Compare commits

...

4 Commits

Author SHA1 Message Date
CIAgent 5838bdeb51 docs(P02): complete ai-tagging phase
---ci---
phase: 2
milestone: v0.6
status: complete
requirements:
  covered: [REQ-6-002]
  partial: []
---/ci---
2026-09-22 01:04:23 +00:00
CIAgent 0a091c2e50 docs(P01): complete postings-backend phase
---ci---
phase: 1
milestone: v0.6
status: complete
requirements:
  covered: [REQ-6-001]
  partial: []
---/ci---
2026-09-22 00:12:47 +00:00
CIAgent 73b493915f chore(P00): phase 0 complete — v0.5.0 shipped, release 886, assets attached
---ci---
phase: 0
milestone: v0.6
status: complete
---/ci---
2026-09-21 22:38:46 +00:00
CIAgent baa9ef5d2d docs(P00): complete pre-execution phase
---ci---
phase: 0
milestone: v0.6
status: complete
---/ci---
2026-09-21 22:38:15 +00:00
41 changed files with 6331 additions and 898 deletions
+15
View File
@@ -8,6 +8,21 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
**v0.4 additions (Distribution & Bootstrap CLI, founder directive D-016):** a new `apps/cli` package — the `nextcraft` bootstrap CLI (`doctor`/`bootstrap`/`verify`/`dev`) compiled to a self-contained linux x64 binary via **Node SEA** (probe-verified: Go/Rust absent, node v24.15.0 SEA-capable), installed by a repo-served one-liner script that resolves the latest Gitea release, downloads binary + sha256 sidecar, verifies, and installs to `~/.local/bin`. Every release from v0.4 onward attaches the binary + checksum as release assets (the "ongoing binaries" requirement). The CLI is a thin wrapper: all orchestration logic stays in `apps/ai-service/scripts/` (bootstrap.sh/dev.sh) — the CLI composes them via subprocess (A-202), duplicating nothing. Previously-planned v0.4 seams (real STT/TTS, KYC, design/sim envs, seq-lease) move to v0.5.
### v0.6 Research Conclusions (Phase 0 RESEARCH)
32. **D-048 Marketplace module layout mirrors D-031** — one new engine module `ai_service/marketplace/` inside apps/ai-service (no new apps, no new ports): `base.py` (pydantic contracts: `Posting`, `Employer`, `PostingTags`, `IngestPosting`), `store.py` (6th D-027 store family, D-049), `ingest.py` (PostingSource adapters, D-050), `tagging.py` (D-052), `embedding/` (protocol + mock + openai_compat + factory, D-051), `search.py` (hybrid facet+cosine ranking, A-405), `enrichment.py` (D-053), `resume.py` (D-054), with the HTTP surface in `ai_service/api/marketplace.py` (`/v1/marketplace/*` router) + `api/marketplace_admin.py` (`/v1/marketplace/admin/*`, D-057) composed via `deps.py` app.state DI (`get_marketplace_store`, `get_embedding_provider`). Boundary rules identical to identity/voice: `marketplace/` never imports `agents/` or `api/`; settings in `config.py` (backend-engineer territory); the G-18 stub MOVES from `api/identity.py` into the new router and becomes real (D-058) — `identity/` keeps only the `require_verified_adult` gate dependency it exports today.
33. **D-049 Sixth store = one SQLite DB, five tables, DefenseStore conventions** — the marketplace store joins the shared `~/.nextcraft/data/nextcraft.db` (all 5 prior stores already share it; `Settings().db_path`). Tables: `posting` (id PK, source, external_id, employer_id, title, description, skills/seniority/category/location/remote/salary JSON+portable columns, status `draft|published|closed`, posted_at, unique constraint on (source, external_id) for idempotent upsert per A-402); `employer` (id PK, ingested raw fields, unique per source); `embedding` (posting_id PK, `vector` BLOB of struct-packed little-endian float32, `model`, `dim` — A-403/A-404 per-row provenance so lazy re-embed on provider change works); `application` (id PK, posting_id, learner_id, status `submitted`, created_at, unique (posting_id, learner_id) for A-408 idempotent apply); `employer_enrichment` (append-only: id PK, employer_id, field, value, source `llm|manual`, model, created_at — A-409). All tables mirror the probe-verified identity/store.py mechanics: WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas, `sa_type=String/JSON` portable columns, `_validate()` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz normalization. `VectorStore` is a protocol method-group on the marketplace store (pgvector-swap-ready, D-019/D-027 pattern).
34. **D-050 PostingSource = protocol + two adapters, no scheduler** — `PostingSource` protocol: `fetch() -> Iterable[IngestPosting]` (pydantic, A-402 shape: source, external_id, employer + posting fields). Two v0.6 implementations: `seeded_corpus` (in-repo JSON seed ported ONCE from `packages/mock-data/jobs.ts` (20 jobs) + `employers.ts` (10 employers) per D-021 convention — shared IDs, mock `matchScore` DROPPED since D-051 computes real ones) and `admin_api` (the founder-authored path; the admin router calls ingest directly rather than an HTTP hop). Ingest upserts idempotent on (source, external_id); a `POST /v1/marketplace/admin/ingest` route triggers a source run explicitly (founder-invoked — no cron/crawler, A-401/A-402). The 3M+ external corpus lands later as a third adapter with zero store/route changes.
35. **D-051 EmbeddingProvider = D-030 mirror with a corrected real path** — protocol `embed(texts: list[str]) -> list[EmbeddingVector]` (frozen pydantic: `values: list[float]`, `model`, `dim`) in `marketplace/embedding/base.py`. Deterministic mock: 256-dim feature-hash of normalized tokens (pure stdlib, similarity deterministic — tests never call the cloud). Real: `OpenAIEmbedProvider` on the shared httpx pool posting `{"model", "input": [texts]}` to `{AI_EMBED_BASE_URL}/embeddings` (OpenAI-compatible; `_sanitize` key redaction mirroring `openai_audio.py`). **Probe correction to A-404:** ollama-cloud does NOT expose `/v1/embeddings` (404 path-not-found; chat/completions auth-gates 401) — so `AI_EMBED_BASE_URL` must point at a different OpenAI-compatible endpoint (local ollama `:11434/v1` supports it, or any embeddings cloud); provider selection `AI_EMBED_PROVIDER=mock|openai-embed` (default mock, G-11 boot-safe fallback with loud log — exact `voice/factory.py` pattern, `AI_EMBED_MODEL`/`AI_EMBED_API_KEY` env-only). Storage per A-403: struct-packed `<{dim}f` float32 BLOB + model + dim per row; cosine in pure Python (sum of products / norm product, ≤10⁴ postings); candidate side embeds competency-profile text on demand, never stored. Confidence in A-404's parenthetical drops it to ~0.5 but the protocol design is unaffected (endpoint-agnostic by config is the D-014 pattern anyway).
36. **D-052 Tagging = D-020 structured_completion, no new agent** — `marketplace/tagging.py` composes the EXISTING `structured_completion(provider, messages, model, schema, schema_hint)` (agents/structured.py, all 4 layers) with a `PostingTags` pydantic schema (skills: list[str] ≤10, seniority enum, category enum) + a versioned prompt constant in `prompts/` (or module-local, matching variants' pattern); no BaseAgent needed — tagging is a one-shot extraction like grading's rubric call. Mock-first for free: `settings.provider` defaults to `mock` and `structured_completion` runs against `MockLLMProvider.chat`, so tests stay cloud-free. Tags persisted as a JSON column on the posting row (D-049); tag facets feed the search filter sidebar (A-405 hard filters); re-tagging on posting update overwrites the tags column (single source of truth, no append-only need — unlike enrichment D-053, tags are derived data).
37. **D-053 Enrichment = append-only provenance table + merge-on-read** — per A-409: `employer_enrichment` rows are append-only `(employer_id, field, value, source: llm|manual, model, created_at)`; read precedence **manual > llm > ingested-raw** computed at API-response time (a small `merge_employer()` over rows, raw fields last); every enriched field carries `{value, source}` provenance markers in the API response (A-304 honesty — LLM copy visibly marked). Trigger: a new employer's first published posting fires a one-shot LLM structured extraction (D-020, same layering as D-052, schema: description, social_links, logo_ref) — founder override = upsert a `manual`-source row, never a mutation of `llm` rows. No employer self-serve anywhere (A-407).
38. **D-054 Resume parse = pypdf (probe-verified) → D-020 draft extraction** — pypdf 6.19.0 pip-reachable through the existing venv channel (probe 1; the ONLY new dependency, pinned `pypdf>=6.19,<7` in pyproject.toml). Pipeline: upload guard (PDF/TXT/MD only, ≤2 MB, A-410) → text extraction (`pypdf.PdfReader` page-text join for PDFs; raw decode for TXT/MD) → empty-extract on a PDF = honest 422 "text layer required" (no OCR) → cap ~100K chars → D-020 structured extraction into a `ParsedProfile` pydantic schema mapped to the competency-profile shape (competencies, skills, experience bands) → response returns as **DRAFT** (`status: "draft"`) — nothing persists to the learner profile until an explicit confirm call (REQ-6-005 review gate). Raw resume bytes never stored, never logged (A-305 pattern; the existing `_scrub_pii_validation` handler precedent applies). Tests use small generated PDFs or TXT fixtures — cloud-free.
39. **D-055 Web migration = client shells + engine-client extension; remove 2 generateStaticParams** — board/detail/employer-profile become one static client shell each fetching at runtime per A-406 (the proven build/defend pattern): `marketplace/page.tsx` keeps its server shell but `JobFilters` receives fetch-backed data (it already owns all filter logic client-side — the facet contract survives unchanged); `jobs/[jobId]` and `employers/[employerId]` drop `generateStaticParams` (the ONLY two removals — learner catalog/learn/build/defend keep theirs, their data stays mock) and render a client shell that fetches then renders (or `notFound()` on 404). `engine-client.ts` gains a marketplace section: `listPostings(filters)`, `getPosting(id)`, `getEmployer(id)`, `searchPostings(query)` → typed against new `packages/types` marketplace wire shapes (data-engineer dual-schema sync); 403 discrimination (verify-CTA vs allowlist) already works. `packages/mock-data` jobs.ts/employers.ts go dormant for the marketplace path (still imported by employer/admin mockups — NOT deleted, A-406/A-407); the seed port (D-050) copies values once. No build-time DB reads — D-039 export build stays repo-only.
40. **D-056 SEO = build-time metadata on static set + client JSON-LD on runtime set + ai-service sitemap/robots** — per A-411, split: (a) build-time-static pages (landing, catalog/stack detail, marketplace board, pricing) get `export const metadata` (probe 7: the static-export-compatible API the app already uses in layout.tsx/enroll) + OG tags + server-rendered JSON-LD (`Organization` on employer-adjacent static, `ItemList` on the board) rendered into the export; (b) runtime-data pages (job detail, employer profile) inject `JobPosting`/`Organization` JSON-LD + title client-side after fetch — accepted JS-crawler limitation, recorded; (c) `/sitemap.xml` + `/robots.txt` as plain FastAPI GET routes on the api app enumerating the static page list + all `published` postings — **probe 6 verified mount order: API routes beat the `StaticFiles("/")` mount**, so they win over the export even in single-port production; do NOT use Next `app/sitemap.ts` (it enumerates at build time and cannot see runtime postings under `output: 'export'`). robots.txt points at `/sitemap.xml`; sitemap URLs use the deployed origin (env-configured `AI_PUBLIC_BASE_URL`-style setting, backend-engineer territory).
41. **D-057 Admin guard = AI_ADMIN_TOKEN header check, env-only, fail-closed** — all `/v1/marketplace/admin/*` routes (postings create + lifecycle transitions draft→published→closed, ingest trigger, enrichment manual-override, applications review) take a FastAPI dependency `require_admin` comparing `X-Admin-Token` (or `Authorization: Bearer`) against `Settings().admin_token` (`AI_ADMIN_TOKEN`, env-only per D-014, probe: no admin token exists in config today — greenfield). Unset token = admin routes 403-fail-closed (never open; a loud boot warning tells the founder to set it, G-11 doctrine). This is the A-401 operator surface; D-043 learner gate composition is untouched — admin routes do NOT pass the learner allowlist/identity gates (different principal class).
42. **D-058 Apply route goes real, composition unchanged** — `POST /v1/marketplace/apply` (moved from api/identity.py into api/marketplace.py, D-048) keeps the exact D-043 order (G-5 allowlist → `require_verified_adult` → rate caps), then: posting must exist and be `published` (404/409 otherwise), insert an `application` row (status `submitted`, unique (posting_id, learner_id) → duplicate apply is idempotent 200-with-existing, A-408), return an honest confirmation payload `{application_id, posting_id, status: "submitted"}` (the `stub/mock` markers die). Founder reviews via `GET /v1/marketplace/admin/applications` (D-057-guarded); no notification infra. Body becomes `{posting_id}` + the learner identity from the verified record rather than a client-asserted `job_id`.
43. **D-059 v0.6 wave order = ROADMAP order confirmed with two couplings** — P1 postings backend must ship WITH the web client shells in the same phase (a live store behind mock-reading pages proves nothing — REQ-6-001's success criterion is surfaces reading real data); P2 tagging before P3 search because facets drive the sidebar filters AND the posting text embedded in P3 should include the normalized tags; P4's two workstreams (enrichment REQ-6-004, resume REQ-6-005) are independent of each other — parallelizable within the phase; P5 SEO depends only on P1 (routes exist to enumerate) but stays last-before-final per ROADMAP since sitemap needs `published` postings and the board metadata should reflect final facets; P6 final review + ship. Tagging/enrichment/resume all route through the same D-020 call site — one prompt-review pass covers all three.
### v0.5 Research Conclusions (Phase 0 RESEARCH)
25. **D-040 Voice real path = `OpenAIAudioProvider` on the shared httpx pool (D-R01)** — implements the D-030 protocol: `transcribe()` = multipart `POST {AI_VOICE_BASE_URL}/audio/transcriptions` (`file` + `model`, response_format=json → `TranscriptSegment`), `synthesize()` = streaming `POST /audio/speech` (JSON body, raw byte stream, `input` ≤4096 chars). Carries `descriptor = VoiceDescriptor(mode="server", ...)` which defense.py:143 already prefers — mode selection drops in with zero API changes. Constructor takes the lifespan `httpx.AsyncClient` (D-017; read=300s already tolerates multi-minute clips). Factory signature becomes `voice_provider_from_settings(settings, http_client)`. Errors sanitized with key redaction (mirror `llm/openai_compat.py:_sanitize`).
+9
View File
@@ -0,0 +1,9 @@
{
"phase": 2,
"stage": "verify",
"milestone": "v0.6",
"milestone_name": "marketplace-engine",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-22T00:30:00Z"
}
+48
View File
@@ -1,3 +1,51 @@
# Nextcraft v0.6 — GRILL.md (Adversarial Review Verdict)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS · **Confidence:** 0.72 · **Date:** 2026-09-21
## Summary
The v0.6 Marketplace Engine plan (46 tasks, 6 phases) is well-researched and tightly scoped, but its central P1 web-migration mechanism — removing `generateStaticParams` from the two marketplace dynamic routes — is **provably build-breaking**: Next 15.5.25 (installed `node_modules/next/dist/build/index.js:1244`) hard-throws error E87 for any dynamic route without `generateStaticParams` under `output: 'export'`, and even a function returning `[]` throws (`length > 0` check). The claimed "proven build/defend pattern" precedent does not exist as described — build/defend routes KEPT their `generateStaticParams`. Four quieter gaps (ingest status semantics, pagination-vs-client-filters, learner-profile sidecar persistence, sitemap origin) each ship a broken happy path or an audit flag. All fixed via binding decisions G-101..G-110 + 8 plan edits applied by the orchestrator before execute.
## Per-Axis Findings
| Axis | Verdict | Rationale |
|------|---------|-----------|
| Feasibility | PASS | Personas/territories match proven patterns (voice factory, identity store, D-020 layer); 46 tasks comparable to shipped v0.5. |
| Scope discipline | PASS | Pricing/employer-auth/scheduler/crawler all absent (MH-M6, A-407/A-412, D-050); only borderline add is admin posting-edit, defensible as the re-tag trigger within A-401. |
| Vertical-slice integrity | FAIL → fixed | P1 backend+shells coupling is right, but the shell mechanism threw E87 at build and runtime-created postings would get no exported page — fixed by G-101 (placeholder param + FastAPI catch-all shell routes). |
| Testability | AT-RISK → fixed | MH-1e pinned a build that could not emit; MH-3d's "matchScore exists nowhere" overclaimed (mock-data retains it for employer/admin mockups) — scoped by G-107/G-110. |
| Security posture | PASS | Fail-closed admin, D-043 gate order, upload bounds, PII sentinel tests verified against existing code; hardening added by G-105/G-108. |
| Performance/capacity | PASS | Brute-force cosine ≤10⁴ honest; SQLite WAL/busy_timeout verified in the identity store; sitemap fine to 50k URLs. |
| Operational readiness | AT-RISK → fixed | D-039 deploy fabric intact (mount order verified), pypdf via bootstrap channel sound; founder workflow breaks at ingest-status (G-102) and sitemap-origin (G-105) — now defined. |
| Dependency risk | PASS | pypdf verified as the only new dep; JSON-LD/sitemap hand-rolled; embedding mock stdlib-only. A-404's stale "ollama-cloud exposes /embeddings" parenthetical annotated as superseded by D-051. |
| Adoption/UX | AT-RISK → fixed | Happy-path steps 2-4 contradicted until ingest status defined (G-102); verify-CTA/404/409 states well-specified; sitemap URL-resolvability pinned (G-109). |
## Binding Decisions (applied to PLAN.md before execute)
- **G-101** — Do NOT remove `generateStaticParams` (E87 build break under `output:'export'`). Keep it returning one placeholder param per route; export one client shell; add FastAPI catch-all `GET /marketplace/jobs/{id}` + `/employers/{id}` (trailing-slash variants) serving the shell — same routes-beat-the-mount mechanism as sitemap. [Vertical-slice integrity]
- **G-102** — Ingest-status contract: seed-port postings enter `published` with original `postedAt`; admin-authored start `draft`. [Adoption/UX]
- **G-103** — Board fetch = paged fetch-all (≤10⁴ documented); API returns `total_published`; "Showing X of Y" over the fetched set with honest truncation notice. [Performance]
- **G-104** — Confirmed learner profiles persist in the marketplace store as a 6th table `learner_profile` (learner_id PK, profile JSON, confirmed_at, model) — NOT a filesystem sidecar. D-049 amended five→six tables. [Scope discipline]
- **G-105** — Unset `AI_PUBLIC_BASE_URL`: loud G-11 boot warning; sitemap serves empty static set + warning (never malformed/relative URLs); robots omits the Sitemap directive; boot never crashes. [Security]
- **G-106** — Match scores appear only in search context; cards without an active query show no score; per-learner profile-driven card scores post-v0.6; REQ-6-003 "scores on job cards" satisfied conditionally — recorded. [Adoption/UX]
- **G-107** — "Mock matchScore absent" scoped to marketplace-path API payloads + marketplace surfaces; grep excludes packages/mock-data and employer/admin route groups. [Testability]
- **G-108** — `admin_token` as `SecretStr`; compare via `secrets.compare_digest`. [Security]
- **G-109** — Every sitemap job/employer URL must serve 200 on the single-port app (G-101 shells); board ItemList JSON-LD enumerates static structure only — never runtime postings. [Operability]
- **G-110** — P1 web tests pin the E87 regression class itself (shells emitted; catch-all serves arbitrary ids; `notFound()` at non-placeholder id via full page load); the honest-501 stub pin at tests/identity/test_identity.py:444 rewritten onto the marketplace router in the same phase. [Testability]
## Plan Edits Applied
1. Task 1-3-04 + MH-1e rewritten per G-101 (placeholder shells + catch-all routes; E87 test class; stub-pin rewrite)
2. Task 1-2-01 + MH-1f ingest-status contract (G-102)
3. Task 1-3-02 paged fetch-all + total_published (G-103)
4. Task 4-B-03 learner_profile table replaces the JSON sidecar (G-104; D-049 → six tables)
5. Task 5-2-01 + MH-5a/5b unset-origin behavior + URL-resolvability (G-105/G-109)
6. Task 3-3-01 + MH-3d matchScore scope + no-query-no-score (G-106/G-107)
7. Task 1-2-03 SecretStr + compare_digest (G-108)
8. PROJECT.md A-404 stale parenthetical annotated as superseded by D-051 probe
---
# Nextcraft v0.3 — GRILL.md (Adversarial Review Verdict)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-CHANGES · **Confidence:** 0.72
+115 -95
View File
@@ -2,13 +2,15 @@
## Persona Roster
> **v0.6 update (RESEARCH, lead-developer assessment):** milestone = Marketplace Engine (D-047). **Activated:** data-engineer (6th D-027 store family — five tables, seed port, TS wire types dual-schema sync, its core constraint territory), ai-engineer full activation (tagging/enrichment/resume D-020 prompts + EmbeddingProvider protocol — LLM-facing work returns), frontend-engineer (client shells, engine-client marketplace extension, generateStaticParams removal ×2, SEO metadata + client JSON-LD), backend-engineer (module wiring, config surface: AI_EMBED_*/AI_ADMIN_TOKEN/AI_PUBLIC_BASE_URL, sitemap/robots routes). **security-auditor re-activated (phase-specific)** for admin-token fail-closed (P1), resume upload bounds + PII-logs (P4), sitemap origin (P5), final review (P6). **Deactivated:** identity-engineer (v0.5 custom — module shipped; only the `require_verified_adult` gate import is reused), voice-engineer, sandbox-engineer (no voice/sandbox/telemetry work), cli-engineer, design-system-engineer (existing primitives cover the marketplace shells).
> **v0.5 update (RESEARCH, lead-developer assessment):** milestone = Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease. **Reactivated:** voice-engineer (real server STT/TTS, its v0.3 territory), sandbox-engineer (design/sim environment kinds + the seq-ack protocol on the fabric/agent it owns), frontend-engineer (defense audio upload, identity enrollment flow, kind-aware build surface). **New custom persona: identity-engineer** (KYC/identity domain — 5th store, provider protocol, age-gate dependencies, PII hygiene). **security-auditor re-activated (phase-specific)** for identity PII + age-gate bypass + audio upload attack surface (phases 1-4 review, final phase). ai-engineer light-touch (no LLM-facing work this milestone). backend-engineer retains settings/factory wiring + turbo/root scripts. cli-engineer inactive (v0.3.6 hotfix shipped; no CLI work planned). design-system-engineer/data-engineer inactive (one TS types extension only — data-engineer light-touch for variants/identity types).
### lead-developer
```yaml
active: true
phase_specific: false
reason: Coordinates the four v0.5 seams across voice/identity/sandbox/telemetry territories; resolves wave-order (D-046: seq-lease first) and identity-gate composition (D-043) boundaries
reason: Coordinates the v0.6 marketplace territory split (store/ingest/embedding/tagging/search/enrichment/resume across data/ai/backend/frontend personas); owns wave-order coupling decisions (D-059: P1 ships backend WITH client shells)
domain: coordination
frameworks:
- next.js
@@ -27,77 +29,59 @@ territory:
- "apps/ai-service/pyproject.toml"
```
### voice-engineer
### data-engineer
```yaml
active: true
phase_specific: false
reason: v0.3 territory reactivated — owns REQ-5-001/002: OpenAIAudioProvider (D-040) on the shared httpx pool, factory signature change, defense route fixes (fmt strip, size guard, media_type), descriptor mode=server, web audio POST
domain: ai-media
reason: v0.6 core activation — owns the 6th D-027 store family (D-049: posting/employer/embedding/application/employer_enrichment tables, WAL/pragmas/portable columns/validate hooks), the seed port from packages/mock-data jobs.ts+employers.ts (D-050, one-time, matchScore dropped), and the TS marketplace wire types (dual-schema sync with pydantic contracts)
domain: data
frameworks:
- httpx
- fastapi
- pytest
- react
constraints:
- provider-agnostic-protocol (D-030 drop-in; descriptor wins selection)
- never-call-cloud-in-tests (MockTransport byte-contract pins)
- key-redaction (mirror openai_compat _sanitize)
- bounded-audio-in-memory (10MB guard before provider call)
territory:
- "apps/ai-service/ai_service/voice/**"
- "apps/ai-service/tests/voice/**"
- "apps/web/components/learner/defense-session.tsx"
```
### identity-engineer
```yaml
active: true
phase_specific: false
reason: v0.5 custom persona (RESEARCH, D-042/43) — owns the new identity module: IdentityProvider protocol + mock, 5th SQLite store (DefenseStore pattern), /v1/identity router, age-gate dependencies (allowlist → identity → rate caps), derived age_band + document refs (PII minimal), caplog sentinel scrub test
domain: identity
frameworks:
- fastapi
- sqlmodel
- pytest
- pydantic
- typescript
constraints:
- pii-never-stored-raw (refs + derived bands only, A-305)
- pii-never-logged (caplog sentinel pin)
- mock-verdict-honesty (mock marker rides every response, A-304)
- gate-composition-order (G-5 allowlist first, identity second, 429 caps last)
- schema-first
- type-safe
- dual-schema-sync (TS/Python changes made in both places)
- idempotent-upserts ((source, external_id) + (posting_id, learner_id) unique constraints)
territory:
- "apps/ai-service/ai_service/identity/**"
- "apps/ai-service/tests/identity/**"
- "apps/ai-service/ai_service/marketplace/base.py"
- "apps/ai-service/ai_service/marketplace/store.py"
- "apps/ai-service/ai_service/marketplace/ingest.py"
- "packages/types/**"
- "packages/mock-data/**"
```
### sandbox-engineer
### ai-engineer
```yaml
active: true
phase_specific: false
reason: v0.3 territory reactivated — owns REQ-5-005/006 (template-layer env registry, starter contents, exec command policy) AND REQ-5-007 (seq-ack frame in ingest + agent spool trim, mid-burst regression test) — both live on the fabric/agent surfaces it built
domain: infra
reason: v0.6 full activation — LLM-facing work returns: tagging prompt+schema (D-052), enrichment extraction (D-053), resume parse extraction (D-054) — all through the existing D-020 structured_completion layer; plus the EmbeddingProvider protocol + deterministic feature-hash mock (D-051). Guards the agent/engine boundaries the marketplace surfaces touch
domain: ai
frameworks:
- python
- linux-namespaces
- pydantic
- httpx
- pytest
constraints:
- stdlib-only-agent (AST-pinned sandbox-agent.py imports)
- acks-are-advisory (gap detection stays authoritative; G-3/G-4 unchanged)
- thread-safe-spool-trim (under _emit_lock, atomic Spool.rewrite)
- digest-kind-agnostic (features derive from event kinds — pinned)
- provider-agnostic-protocol
- never-call-cloud-in-tests
- mock-first (structured_completion runs against MockLLMProvider)
territory:
- "apps/ai-service/ai_service/sandbox/**"
- "apps/ai-service/ai_service/telemetry/**"
- "apps/ai-service/scripts/sandbox-agent.py"
- "apps/ai-service/ai_service/variants/templates.py"
- "apps/ai-service/tests/sandbox/**"
- "apps/ai-service/tests/telemetry/**"
- "apps/ai-service/ai_service/marketplace/tagging.py"
- "apps/ai-service/ai_service/marketplace/enrichment.py"
- "apps/ai-service/ai_service/marketplace/resume.py"
- "apps/ai-service/ai_service/marketplace/embedding/**"
- "apps/ai-service/ai_service/marketplace/search.py"
- "apps/ai-service/ai_service/llm/**"
- "apps/ai-service/ai_service/agents/structured.py"
- "apps/ai-service/ai_service/prompts/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
reason: Owns the settings surface both new providers hang off (voice_base_url/key/models, identity provider selection), factory + main.py lifespan wiring (voice factory gains http_client), .env.example documentation
reason: Owns the v0.6 config surface (AI_EMBED_PROVIDER/BASE_URL/MODEL/API_KEY, AI_ADMIN_TOKEN, AI_PUBLIC_BASE_URL — all env-only), main.py lifespan wiring (marketplace store + embedding provider factory on app.state), the /v1/marketplace/* + admin routers' DI, sitemap/robots routes (D-056), .env.example documentation
domain: backend
frameworks:
- fastapi
@@ -108,12 +92,16 @@ frameworks:
constraints:
- secrets-via-env-only (D-014; keys never in code or commits)
- idempotent-scripts
- fail-closed-admin (unset AI_ADMIN_TOKEN = admin routes 403 + loud boot warning)
territory:
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/voice/factory.py"
- "apps/ai-service/ai_service/api/marketplace.py"
- "apps/ai-service/ai_service/api/marketplace_admin.py"
- "apps/ai-service/ai_service/api/deps.py"
- "apps/ai-service/scripts/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/pyproject.toml"
- "package.json"
- "turbo.json"
```
@@ -122,7 +110,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: Reactivated for v0.5 UX: defense-session audio POST (recorded blob upload), identity enrollment flow (submit → pending → verified states + verify-CTA surfaces), build-surface kind-awareness (variant environment + test_command), engine-client identity + audio functions
reason: v0.6 marketplace surfaces go live: board/detail/employer-profile client shells (D-055, build/defend pattern), engine-client marketplace functions (listPostings/getPosting/getEmployer/searchPostings/apply), generateStaticParams removal on jobs/[jobId]+employers/[employerId] ONLY, apply-flow wiring on the real route, SEO metadata (export const metadata on static set) + client-side JobPosting/Organization JSON-LD on runtime pages
domain: frontend
frameworks:
- react
@@ -131,58 +119,106 @@ frameworks:
constraints:
- component-first
- server-components-default
- honest-state-surfaces (provider badge: mock vs browser vs server; unverified labels)
- honest-state-surfaces (404 on missing posting, verify-CTA on 403, empty boards)
- no-build-time-db-reads (D-039 export build stays repo-only)
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/types/**"
```
### security-auditor
```yaml
active: true
phase_specific: true
reason: v0.5 re-activated — identity PII (storage + logs), age-gate bypass review, audio upload attack surface (10MB guard, format confusion), exec command policy (per-kind allowlist), TTS media_type. Phases 1-4 reviews + final phase
reason: v0.6 re-activated — admin token fail-closed + route coverage (P1), apply gate composition preserved D-043 (P1), embedding provider key redaction (P3), resume upload bounds + raw-bytes-never-logged (P4), enrichment provenance honesty (P4), sitemap origin config (P5), final review (P6)
domain: security
frameworks:
- pytest
- httpx
constraints:
- STRIDE-classified
- pii-never-stored-raw
- pii-never-logged
- bounded-uploads
- fail-closed (unset admin token never opens routes)
- bounded-uploads (resume 2MB/format guard)
- secrets-never-logged (_sanitize key redaction)
territory:
- "apps/ai-service/ai_service/identity/**"
- "apps/ai-service/ai_service/api/defense.py"
- "apps/ai-service/ai_service/api/sandboxes.py"
- "apps/ai-service/ai_service/voice/**"
- "apps/ai-service/ai_service/api/marketplace.py"
- "apps/ai-service/ai_service/api/marketplace_admin.py"
- "apps/ai-service/ai_service/marketplace/resume.py"
- "apps/ai-service/ai_service/marketplace/embedding/openai_compat.py"
- "apps/ai-service/ai_service/api/identity.py"
```
### ai-engineer
### identity-engineer
```yaml
active: true
active: false
phase_specific: false
reason: Light-touch v0.5 — no LLM-facing work (voice STT/TTS is media plumbing, not model work; examiner agent unchanged); guards the agent/engine boundaries the new surfaces touch
domain: ai
reason: v0.5 custom persona — module shipped complete (identity/, gates, /v1/identity/*); v0.6 only REUSES the require_verified_adult import on the marketplace apply route (D-058). No identity-domain work.
domain: identity
frameworks:
- pydantic
- httpx
- fastapi
- sqlmodel
- pytest
constraints:
- pii-never-stored-raw
- pii-never-logged
- mock-verdict-honesty
- gate-composition-order
territory:
- "apps/ai-service/ai_service/identity/**"
- "apps/ai-service/tests/identity/**"
```
### voice-engineer
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete (openai-audio provider, defense routes, browser SR/TTS client); no v0.6 voice work. The EmbeddingProvider protocol MIRRORS the voice pattern (D-051) but the voice module itself is untouched.
domain: ai-media
frameworks:
- httpx
- fastapi
- pytest
- react
constraints:
- provider-agnostic-protocol
- never-call-cloud-in-tests
- key-redaction
- bounded-audio-in-memory
territory:
- "apps/ai-service/ai_service/llm/**"
- "apps/ai-service/ai_service/agents/**"
- "apps/ai-service/ai_service/prompts/**"
- "apps/ai-service/ai_service/voice/**"
- "apps/ai-service/tests/voice/**"
- "apps/web/components/learner/defense-session.tsx"
```
### sandbox-engineer
```yaml
active: false
phase_specific: false
reason: v0.3/v0.5 territory — fabric + env kinds + seq-ack shipped complete; no v0.6 sandbox/telemetry work (marketplace data plane never enters the namespace fabric).
domain: infra
frameworks:
- python
- linux-namespaces
- pytest
constraints:
- stdlib-only-agent
- acks-are-advisory
- thread-safe-spool-trim
- digest-kind-agnostic
territory:
- "apps/ai-service/ai_service/sandbox/**"
- "apps/ai-service/ai_service/telemetry/**"
- "apps/ai-service/scripts/sandbox-agent.py"
- "apps/ai-service/ai_service/variants/templates.py"
- "apps/ai-service/tests/sandbox/**"
- "apps/ai-service/tests/telemetry/**"
```
### cli-engineer
```yaml
active: false
phase_specific: false
reason: v0.3 persona — CLI shipped complete (v0.3.6 daemon surface); no v0.5 CLI work planned. Reactivated if milestone work touches apps/cli.
reason: No CLI work in v0.6 (the binary attaches to every release per the ongoing-binaries requirement; the ship workflow handles it — no apps/cli changes).
domain: cli
frameworks:
- node
@@ -206,7 +242,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work planned in v0.5 (existing primitives — MicControl, GradeBadge, TranscriptViewer — cover the voice surfaces); roster retained.
reason: No design-token or primitive work in v0.6 (existing primitives + JobFilters cover the marketplace shells); roster retained.
domain: frontend
frameworks:
- tailwindcss
@@ -218,28 +254,11 @@ territory:
- "packages/ui/**"
```
### data-engineer
```yaml
active: false
phase_specific: false
reason: Light-touch via frontend-engineer territory (variants/identity TS type extensions); no schema/mock-data work beyond the two typed additions.
domain: data
frameworks:
- typescript
constraints:
- schema-first
- type-safe
- dual-schema-sync (TS/Python changes made in both places)
territory:
- "packages/types/**"
- "packages/mock-data/**"
```
## Phase-Specific Personas
| Persona | Phases | Removed After |
|---------|--------|---------------|
| security-auditor | 1 (seq-ack/agent), 2 (audio upload), 3 (identity PII), 4 (exec policy), 5 (final review) | milestone complete |
| security-auditor | 1 (admin token + apply gates), 3 (embedding key redaction), 4 (resume bounds + PII), 5 (sitemap origin), 6 (final review) | milestone complete |
All other personas span the milestone. Deactivated personas receive no tasks.
@@ -247,8 +266,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
| Conflict | Resolution |
|----------|------------|
| voice-engineer vs backend-engineer (factory.py) | backend-engineer owns factory.py settings wiring + signature; voice-engineer owns openai_audio.py + its factory branch (provider construction), consumes config via DI |
| sandbox-engineer vs frontend-engineer (build-surface) | sandbox-engineer owns engine-side kinds/templates/policy; frontend-engineer owns the web surface + client session hook; wire contract = VariantResponse TS types |
| identity-engineer vs sandbox-engineer (gates) | identity-engineer owns the IdentityGate dependencies; sandbox-engineer owns the sandbox route they mount on (gate order D-043 is a joint review) |
| security-auditor vs identity-engineer | identity-engineer implements; security-auditor reviews + may patch security defects directly in identity/ + api/defense.py (its territory) |
| data-engineer vs backend-engineer (store.py / api routes) | data-engineer owns base.py/store.py/ingest.py schema + persistence; backend-engineer owns config.py/main.py/api routers/DI wiring — the marketplace store is consumed via deps.py, never imported directly by routes |
| data-engineer vs ai-engineer (embedding storage) | data-engineer owns the embedding TABLE + BLOB codec; ai-engineer owns the EmbeddingProvider protocol + mock hash + search cosine math; the store's put/get_vector is the seam |
| ai-engineer vs backend-engineer (factory/config) | backend-engineer owns config surface + factory wiring in main.py; ai-engineer owns provider implementations (openai_compat.py + mock.py) |
| frontend-engineer vs data-engineer (wire types) | data-engineer owns packages/types marketplace shapes (dual-schema sync); frontend-engineer consumes them in engine-client + shells; wire contract = pydantic contracts in marketplace/base.py |
| security-auditor vs backend-engineer (admin routes) | backend-engineer implements require_admin; security-auditor reviews fail-closed behavior + may patch security defects directly in api/marketplace_admin.py + resume.py (its territory) |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
+225 -166
View File
@@ -1,182 +1,241 @@
# Nextcraft v0.5 — PLAN.md
# Nextcraft — PLAN.md (v0.6 Marketplace Engine)
## Overview
**Milestone:** v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease
**Tag line:** v0.4.x patches (P0 → v0.4.0 … P5 → v0.4.5 = milestone release)
**Branch:** milestone/v0.5-real-voice-identity-envs
**Milestone:** v0.6 — Marketplace Engine (D-047): the real marketplace backend behind the existing surfaces.
**Tag line:** v0.5.x patches (P0 → v0.5.0 … P6 → v0.5.6 = milestone release) on the v0.5.x line
**Branch:** milestone/v0.6-marketplace-engine
**Environment facts (probe-verified, apply throughout):** python 3.11.2 (venv at apps/ai-service/.venv), node v24.15.0, pnpm 12.3.4, no docker/podman/sudo, `unshare` userns verified working, SQLite is the only persistence (D-027), tests NEVER call the cloud (mock providers + httpx MockTransport), secrets only in gitignored `.ciagent/.env.secrets` / `.env*` (D-006/D-014). Runtime state lives in `~/.nextcraft/` (D-039) — tests pin to tmp dirs; sandbox workdir fixtures use the bind-mount-safe `sandbox_dir` fixture (overlayfs /tmp breaks userns binds).
**Phase list:** P1 Postings backend + aggregation adapter (REQ-6-001) → P2 AI tagging (REQ-6-002) → P3 Semantic vector search (REQ-6-003) → P4 Enrichment + resume parsing (REQ-6-004/005, two parallel workstreams) → P5 SEO programmatic pages (REQ-6-006) → P6 Final review + ship.
**Wave order (D-046, binding):** P1 seq-lease → P2 voice → P3 identity → P4 environments → P5 final. Seq-lease lands first: it fixes the transport before environment phases add reconnecting telemetry producers; it touches no stores, no web, no settings.
**Wave order (D-059, binding):** P1 ships the backend **WITH the web client shells** in the same phase — a live store behind mock-reading pages proves nothing; REQ-6-001's success criterion is surfaces reading real data. P2 before P3 because tag facets drive the sidebar filters AND the P3 posting embedding text includes the normalized tags. P4's two workstreams (enrichment / resume) are independent — parallelizable within the phase. P5 SEO depends only on P1 (routes exist to enumerate) but stays last-before-final: the sitemap needs `published` postings and the board metadata should reflect final facets. P6 final review + ship.
**Research decisions D-040..D-046 are binding design contracts.** This plan operationalizes them; it does not re-litigate them.
**Research decisions D-048..D-059 are binding design contracts.** This plan operationalizes them; it does not re-litigate them. Assumptions A-401..A-412 are resolved fact.
**Environment facts (unchanged):** python 3.11.2 (venv at apps/ai-service/.venv, pip-only — A-009), node v24.15.0, pnpm 12.3.4, SQLite shared `Settings().db_path` (`~/.nextcraft/data/nextcraft.db`), tests NEVER call the cloud (mock providers + httpx MockTransport), secrets env-only (D-014), runtime state under `~/.nextcraft/` (D-039).
**Verification hooks (every phase, release-blocking):** `pnpm ai:test` (488 backend tests), web `tsx --test` (22), CLI `node:test` (61), `pnpm typecheck` (8 projects), `pnpm ai:lint` (ruff check), `pnpm build` (static export emits). **Hermeticity rule (v0.5 P04 lesson): every new test pins `Settings(db_path=tmp_path/...)` — never `~/.nextcraft`, never the cloud.**
---
## Phase 1: Postings Backend + Aggregation Adapter (REQ-6-001)
**Goal:** The 6th D-027 store family, the PostingSource adapter contract with the seeded corpus, the `/v1/marketplace/*` + admin API, the real apply route — and the web surfaces reading all of it at runtime.
### Wave 1-1: backend core
- **Task 1-1-01** (data-engineer): `ai_service/marketplace/base.py` — pydantic contracts per D-048: `Employer` (id, source, external_id, name + raw ingested fields), `IngestPosting` (A-402 shape: source, external_id, employer + posting fields), `Posting` (full row shape: status `draft|published|closed`, tags JSON column, portable scalar columns, posted_at), `PostingTags` (skills ≤10, seniority, category). Module mirrors D-031 boundary: never imports `agents/` or `api/`.
- **Task 1-1-02** (data-engineer): `ai_service/marketplace/store.py` — `MarketplaceStore` protocol + SQLite impl per D-049 **(amended by G-104: six tables)** (`posting`, `employer`, `embedding`, `application`, `employer_enrichment`, `learner_profile`) on the shared `Settings().db_path`; identity/store.py mechanics exactly: WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas, `sa_type` portable columns, `_validate()` hooks, surfaced IntegrityError, tz normalization. Unique constraints: `(source, external_id)` on posting + `(posting_id, learner_id)` on application (idempotent upserts A-402/A-408); employer unique per `(source, external_id)`; `embedding` table created now (posting_id PK, `vector` BLOB, `model`, `dim`), its codec/methods land in P3 (the data↔ai seam, per territory table); `learner_profile` (learner_id PK, profile JSON, confirmed_at, model — G-104, consumed in P4).
- **Task 1-1-03** (backend-engineer): `config.py` — `admin_token: str = ""` (`AI_ADMIN_TOKEN`), `public_base_url: str = ""` (`AI_PUBLIC_BASE_URL`, placeholder consumed by P5 sitemap); `.env.example` documents both (keys never in code/commits). `api/deps.py` — `get_marketplace_store` (app.state DI; routes NEVER import the store directly — territory rule). `main.py` lifespan — `app.state.marketplace_store`; **loud boot warning when `admin_token` is unset** (G-11; admin routes stay 403-fail-closed regardless).
### Wave 1-2: ingest + routes
- **Task 1-2-01** (data-engineer): `ai_service/marketplace/ingest.py` — `PostingSource` protocol `fetch() -> Iterable[IngestPosting]`; `seeded_corpus` adapter over `marketplace/seed_corpus.json` — **a Python fixture file ported ONCE from `packages/mock-data/jobs.ts` (20 jobs) + `employers.ts` (10 employers)**, shared IDs per D-021 convention, mock `matchScore` DROPPED (D-051 computes real ones); `run(source)` upserts idempotently on `(source, external_id)` (employer rows upserted the same way). **Ingest-status contract (G-102): seed-port postings enter as `published` with their original `postedAt`; admin-authored postings start `draft`** — otherwise the board/facets/P1's success criterion are empty after ingest. No scheduler/crawler — the 3M+ corpus lands later as a third adapter with zero store/route changes.
- **Task 1-2-02** (backend-engineer): `ai_service/api/marketplace.py` — public router: `GET /v1/marketplace/postings` (list, limit/offset, published only), `GET /v1/marketplace/postings/{id}` (404 honest), `GET /v1/marketplace/employers/{id}` (404 honest; raw fields now, merged+provenance in P4). Mounted in main.py. **The G-18 stub route MOVES here from `api/identity.py` and is deleted there** (identity keeps only its gate exports, D-048).
- **Task 1-2-03** (backend-engineer + security-auditor): `ai_service/api/marketplace_admin.py` — `require_admin` FastAPI dependency (X-Admin-Token or Bearer vs `Settings().admin_token` as **`SecretStr`, compared via `secrets.compare_digest` (G-108)**): **unset token → every admin route 403, never open (D-057 fail-closed)**; routes: `POST /admin/postings` (create draft), `POST /admin/postings/{id}/publish` + `/close` (lifecycle `draft→published→closed`, invalid transition → 409), `POST /admin/ingest` (runs the seeded source), `GET /admin/applications` (review list, filterable by posting). Admin routes do NOT pass learner gates (different principal class, D-057).
- **Task 1-2-04** (backend-engineer): apply route per D-058 — `POST /v1/marketplace/apply`, body `{posting_id}`: **gate composition binding (D-043, unchanged order): G-5 allowlist → `require_verified_adult` (imported from identity) → rate caps**; then posting must exist and be `published` (404 / 409); insert `application` row (status `submitted`); duplicate `(posting_id, learner_id)` → idempotent 200-with-existing; honest confirmation `{application_id, posting_id, status: "submitted"}`. All `stub`/`mock` markers die.
### Wave 1-3: web client shells (D-055)
- **Task 1-3-01** (data-engineer): `packages/types/marketplace.ts` — marketplace wire shapes (`PostingSummary`, `PostingDetail`, `EmployerProfile` with `{value, source}` provenance fields ready for P4, `ApplicationConfirmation`, `PostingFacets`) — **dual-schema sync with `marketplace/base.py`**: field-for-field parity, same change in both places.
- **Task 1-3-02** (frontend-engineer): `apps/web/lib/engine-client.ts` marketplace section — `listPostings(filters)` (**paged fetch-all: walks limit/offset pages until exhausted, documented ≤10⁴ bound; response carries `total_published`; "Showing X of Y" computed over the fetched set with an honest truncation notice — G-103**), `getPosting(id)`, `getEmployer(id)`, `applyPosting(posting_id)` typed against the new `packages/types` shapes (`searchPostings` lands P3); 403 discrimination (verify-CTA vs allowlist) already works and is reused.
- **Task 1-3-03** (frontend-engineer): board — `(marketplace)/marketplace/page.tsx` keeps its server shell; the grid + `JobFilters` become fetch-backed (client shell over `listPostings`); honest empty state (zero postings → message, never fake cards); filter option lists stay UI constants this phase (P2 derives them from real tags).
- **Task 1-3-04** (frontend-engineer + backend-engineer): detail + employer shells **(G-101 — do NOT remove `generateStaticParams`; Next 15 hard-throws E87 under `output:'export'` for any dynamic route without it, even returning `[]`)**: keep `generateStaticParams` on `jobs/[jobId]` + `employers/[employerId]` returning ONE placeholder param (`{jobId: '_'}`) so exactly one client-shell page exports per route; add FastAPI catch-all routes `GET /marketplace/jobs/{id}` + `GET /marketplace/employers/{id}` (trailing-slash variants included — `trailingSlash: true`) serving that exported shell file — the same routes-beat-the-mount mechanism P5 relies on for sitemap; runtime client fetch → render, `notFound()` on 404; apply button wired: 403 verify-CTA renders the enrollment prompt, 409 closed renders honestly, success shows the confirmation payload. `packages/mock-data` jobs.ts/employers.ts go dormant for the marketplace path (still imported by employer/admin mockups — NOT deleted). Rewrite `tests/identity/test_identity.py:444`'s honest-501 pin onto the marketplace router in this phase (G-110).
### Wave 1-4: tests
- **Task 1-4-01** (data-engineer): store contract tests (tmp_path db — hermeticity rule): five tables created on the shared file, unique constraints fire on duplicates, pragmas + `_validate` hooks pinned; ingest idempotency (double-run → identical rows, no dupes); **seed-port fidelity check** (20 postings + 10 employers, IDs match the TS fixtures, `matchScore` absent from every API payload).
- **Task 1-4-02** (backend-engineer + security-auditor): route tests — admin token fail-closed matrix (unset → 403 + boot warning in caplog; wrong/absent header → 403; correct → 200); lifecycle 409 on invalid transitions; apply gate composition (allowlist 403 fires FIRST for non-pilot ids, verify-CTA 403 for unverified, 429 at cap, 404/409 states, duplicate → 200 idempotent); 404s on missing postings/employers.
- **Task 1-4-03** (frontend-engineer): web tests (`tsx --test` pattern) — engine-client marketplace functions; shells render fetched data; `notFound()` on 404; empty-board state; apply-flow states.
---
## Phase 2: AI Tagging (REQ-6-002)
**Goal:** Posting text → skills/seniority/category via D-020 structured output (mock-first, cloud-free); tags persisted; facets drive the sidebar.
### Wave 2-1: tagging + facets
- **Task 2-1-01** (ai-engineer): `marketplace/tagging.py` — `PostingTags` schema (skills `list[str]` ≤10, seniority enum, category enum) + versioned prompt constant; `tag_posting(text)` composes the EXISTING `structured_completion(provider, messages, model, schema, schema_hint)` (D-052: no new agent — one-shot extraction like grading's rubric call). Mock-first for free: mock provider default keeps tests cloud-free.
- **Task 2-1-02** (data-engineer): persistence hooks — ingest pipeline tags every posting after upsert; re-tag on posting update **overwrites** the tags column (derived data, single source of truth — D-052; unlike enrichment, no append-only).
- **Task 2-1-03** (backend-engineer): `PUT /v1/marketplace/admin/postings/{id}` (edit title/description/skills → triggers re-tag) + facets in API responses: `GET /v1/marketplace/postings` carries a `facets` payload (skills/seniority/category value counts) computed from stored tags of published postings.
- **Task 2-1-04** (frontend-engineer): `JobFilters` facet lists derive from the API facets payload (static option lists die); counts render honestly.
### Wave 2-2: tests
- **Task 2-2-01** (ai-engineer): tests — tagging via `MockLLMProvider` (cloud-free pin); D-020 schema-violation → single bounded retry pinned; skills ≤10 enforced on an 11-skill mock response; facet derivation test (API facets exactly match stored tags of published-only postings); re-tag overwrite pin (old tags gone after update).
**Verification hooks P2:** all suites green (488+ / web 22+ / CLI 61 / typecheck / ruff / export build). Tagging tests stay mock-provider-only.
---
## Phase 3: Semantic Vector Search (REQ-6-003)
**Goal:** EmbeddingProvider protocol (mock-first, deterministic), SQLite-local vectors, hybrid facet+cosine ranking, computed match scores replacing the mock `matchScore`.
### Wave 3-1: provider + storage
- **Task 3-1-01** (ai-engineer): `marketplace/embedding/base.py` — `EmbeddingProvider` protocol `embed(texts: list[str]) -> list[EmbeddingVector]` (frozen pydantic: `values: list[float]`, `model`, `dim` — D-051, D-030 mirror); `embedding/mock.py` — deterministic 256-dim feature-hash of normalized tokens (pure stdlib; similarity deterministic).
- **Task 3-1-02** (ai-engineer): `embedding/openai_compat.py` — `POST {AI_EMBED_BASE_URL}/embeddings` (`{"model", "input": [texts]}`) on the shared httpx pool (D-017); `_sanitize` key redaction mirroring `openai_audio.py`. **Probe-corrected (D-051): ollama-cloud does NOT expose /embeddings — `AI_EMBED_BASE_URL` must point at another OpenAI-compatible endpoint (e.g. local ollama `:11434/v1`); default stays mock.**
- **Task 3-1-03** (backend-engineer): `config.py` `embed_provider`/`embed_base_url`/`embed_api_key` (SecretStr)/`embed_model` + `embedding/factory.py` wiring in main.py lifespan: `AI_EMBED_PROVIDER=mock|openai-embed`, **G-11 boot-safe — `openai-embed` selected but unconfigured → loud log naming the fix, fallback to mock, boot never crashes**; `app.state.embedding_provider` + `deps.get_embedding_provider`.
- **Task 3-1-04** (data-engineer): store `put_vector`/`get_vector` — struct-packed little-endian float32 BLOB codec (`<{dim}f`), `model`+`dim` persisted per row (A-403/A-404: lazy re-embed on provider change works); roundtrip test (the data↔ai seam per territory table).
### Wave 3-2: pipeline + ranking
- **Task 3-2-01** (ai-engineer): embed-on-publish — the publish transition embeds posting text + tags and stores the vector; **lazy re-embed**: a row whose `model`/`dim` ≠ the provider's is re-embedded on next access; candidate-side competency-profile text is embedded **on demand, never stored**.
- **Task 3-2-02** (ai-engineer): `marketplace/search.py` — pure-Python cosine (sum of products / norm product; ≤10⁴ postings, no numpy — A-403); **hybrid per A-405/D-059: facet hard-filters (SQL on portable columns) first, then cosine ranking over the filtered set; no facets → pure semantic ranking**; `match_score` = cosine(candidate, posting); skill-breakdown = posting-tag overlap with the query + per-skill vector contribution (deterministic feature-hash dot-product shares — testable).
- **Task 3-2-03** (backend-engineer): `GET /v1/marketplace/search` (`q` + facet params → ranked results with `match_score` + `skill_breakdown`); DI-composed, no store import in routes.
### Wave 3-3: web + tests
- **Task 3-3-01** (frontend-engineer): `engine-client.searchPostings(q, filters)`; board search bar wired to the search API; **match scores appear only in search context (G-106): cards without an active query show no score (honest absence, never a fabricated number); per-learner profile-driven card scores are explicitly post-v0.6 — REQ-6-003's "scores on job cards" satisfied conditionally in search context, recorded**; skill-breakdown panel on detail consumes the computed payload.
- **Task 3-3-02** (ai-engineer + security-auditor): tests — deterministic similarity pins (same text → identical vector; related texts > unrelated texts cosine); codec roundtrip; factory fallback + boot-safe; `_sanitize` key-redaction pin (no key in any error); cosine math corner cases (zero vector); hybrid ordering (facet filters before ranking; pure-rank with no facets); embed-on-publish + lazy re-embed on model change.
**Verification hooks P3:** all suites green; embedding tests never leave the process (mock provider + MockTransport for openai_compat byte-contract tests).
---
## Phase 4: Enrichment + Resume Parsing (REQ-6-004, REQ-6-005)
**Goal:** Two independent workstreams, parallelizable (D-059). (A) employer enrichment with append-only provenance; (B) bounded resume upload → draft extraction → learner confirm gate.
### Workstream A: enrichment (REQ-6-004)
- **Task 4-A-01** (ai-engineer): `marketplace/enrichment.py` — `EmployerEnrichment` schema (description, social_links, logo_ref) + prompt; one-shot `enrich_employer()` via structured_completion (D-053, same D-020 layering as tagging; mock-first).
- **Task 4-A-02** (data-engineer): store — `employer_enrichment` rows are **append-only** `(employer_id, field, value, source: llm|manual, model, created_at)`; `merge_employer(employer_id)` read precedence **manual > llm > ingested-raw** (A-409); manual override = new `manual` row, NEVER a mutation of `llm` rows.
- **Task 4-A-03** (backend-engineer): **enrich-on-first-publish** — a publish whose employer has no prior published posting fires the one-shot extraction (re-publishing the same employer does not re-run); employer GET returns merged fields with per-field `{value, source}` provenance markers (A-304 honesty — LLM copy visibly marked); `POST /v1/marketplace/admin/enrichment` (manual-override upsert, D-057-guarded).
- **Task 4-A-04** (frontend-engineer): employer profile shell consumes merged+marked fields; provenance badges visible per field.
- **Task 4-A-05** (security-auditor + ai-engineer): tests — precedence pin (manual beats llm beats raw, each combination); append-only pin (no UPDATE/DELETE path exists on enrichment rows); marker honesty (mock-llm copy carries the llm marker end-to-end); trigger-once pin.
### Workstream B: resume parsing (REQ-6-005)
- **Task 4-B-01** (backend-engineer): pypdf pin — `pypdf>=6.19,<7` in `apps/ai-service/pyproject.toml` + idempotent install via the existing bootstrap venv channel (probe-verified 6.19.0, A-009; the ONLY new dep).
- **Task 4-B-02** (ai-engineer): `marketplace/resume.py` — `ParsedProfile` schema mapped to the competency-profile shape (competencies, skills, experience bands) + prompt; parse via D-020 structured extraction; result carries `status: "draft"`.
- **Task 4-B-03** (backend-engineer): `POST /v1/marketplace/resume` (multipart; **PDF/TXT/MD only, ≤2 MB, text-layer required** — empty pypdf extract → honest 422 "text layer required", NO OCR in v0.6; extracted text capped ~100K chars before the LLM call; raw bytes never stored, never logged — A-305, `_scrub_pii_validation` precedent) and `POST /v1/marketplace/resume/confirm` (learner-gated: G-5 → verified → caps; **persists the confirmed profile JSON to a 6th store table `learner_profile` (learner_id PK, profile JSON, confirmed_at, model — G-104; NOT a filesystem sidecar — the D-027 store family is the only persistence mechanism)**; the draft NEVER persists without confirm).
- **Task 4-B-04** (frontend-engineer): learner dashboard profile section — upload → draft fields review → confirm; bounds errors render honestly (413 oversize / 422 format+text-layer); nothing saves before the confirm click.
- **Task 4-B-05** (security-auditor + ai-engineer): tests — small **generated** PDF + TXT fixtures parse (cloud-free); bounds matrix (format rejection, >2 MB, empty-extract 422, char cap); draft-then-confirm flow; PII sentinel caplog test (resume bytes/sentinel strings in NO log record, NO stored raw form).
**Verification hooks P4:** all suites green; resume + enrichment tests stay mock-provider; resume fixtures generated in tmp (no binaries committed beyond minimal fixtures).
---
## Phase 5: SEO Programmatic Pages (REQ-6-006)
**Goal:** Metadata + JSON-LD + sitemap/robots on the static-export fabric, D-039 unchanged.
### Wave 5-1: web metadata
- **Task 5-1-01** (frontend-engineer): build-time metadata — `export const metadata` (title/description/OG) on the **static set: landing, catalog/stack detail, marketplace board, pricing** (the existing layout.tsx/enroll pattern; static-export-compatible, probe 7).
- **Task 5-1-02** (frontend-engineer): server-rendered JSON-LD on the board — `ItemList` + `Organization` — rendered into the export.
- **Task 5-1-03** (frontend-engineer): client-side JSON-LD on the **runtime set** — `JobPosting` on job detail, `Organization` on employer profile, injected after fetch (A-411 accepted limitation: JS-less crawlers see the shell — recorded).
### Wave 5-2: sitemap/robots + tests
- **Task 5-2-01** (backend-engineer + security-auditor): `GET /sitemap.xml` + `GET /robots.txt` as plain FastAPI routes on the api app — **probe 6 verified: API routes beat the `StaticFiles("/")` mount**, so they win over the export in single-port production; enumerate the static page list (code constant) + all `published` postings; URLs from the `AI_PUBLIC_BASE_URL` origin; robots points at `/sitemap.xml`. **Unset `AI_PUBLIC_BASE_URL` (G-105): loud G-11 boot warning; sitemap serves the empty static-page set (never malformed/relative URLs) and robots omits the Sitemap directive (absolute-URL spec requirement); fail honest at request time; boot never crashes.** **Do NOT use Next `app/sitemap.ts`** (build-time enumeration cannot see runtime postings under `output: 'export'`).
- **Task 5-2-02** (frontend-engineer + backend-engineer): tests — sitemap contents reflect published postings (draft/closed excluded); robots points at the sitemap with the configured origin; metadata/OG present in the exported HTML (`out/` probe); mount-order test (routes beat the static mount); client JSON-LD injection web test.
**Verification hooks P5:** all suites green + export build emits with metadata; sitemap tests pin `db_path=tmp_path` + `public_base_url` explicitly (hermetic, no `~/.nextcraft`).
---
## Phase 6: Final Review + Ship (milestone release v0.5.6)
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — correctness, testing, security, performance, maintainability); **known follow-ups checked for scope creep: seed dormancy verified (mock-data untouched except marketplace path), employer dashboard stays mock (A-407), pricing stays mock (A-412) — zero payments/employer-auth/scheduler code**. P0s fixed in-phase.
- **Task 6-1-02** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
- **Task 6-1-03** (lead-developer → ci-ship): merge milestone → main; tag **v0.5.6** (milestone release); Gitea release with full summary + `nextcraft-linux-x64` + `.sha256` assets (D-036); delete milestone branches; mark REQ-6-001..006 complete; clear checkpoint.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
### Phase 1
- **MH-1a**: Store contract tests (tmp_path db): five tables created on the shared `db_path`; `(source, external_id)` and `(posting_id, learner_id)` unique constraints fire on duplicates; WAL/pragmas/`_validate` hooks pinned.
- **MH-1b**: Ingest idempotency — running the seeded source twice yields identical row counts (no dupes); upsert overwrites in place.
- **MH-1c**: Admin fail-closed matrix — unset `AI_ADMIN_TOKEN` → every admin route 403 + boot warning in caplog; wrong/absent token → 403; correct token → 200. No admin route passes unguarded.
- **MH-1d**: Apply gate composition **provable by test** — G-5 403 fires first, verify-CTA 403 second, 429 at cap; published-only (404 missing / 409 draft+closed); duplicate → 200-with-existing; confirmation payload exact; **G-18 stub markers gone** (grep + response-schema pin).
- **MH-1e**: Web shells read real data — board/detail/employer consume `/v1/marketplace/*` (web tests pin the engine-client functions); `notFound()` on 404; honest empty board; **E87 regression class pinned (G-101/G-110): placeholder shells emitted for both routes (build does not throw), catch-all serves arbitrary ids, hydration + `notFound()` works at a non-placeholder id via full page load**; the honest-501 stub pin in tests/identity/test_identity.py:444 rewritten onto the marketplace router.
- **MH-1f**: Seed fidelity — 20 postings + 10 employers, IDs match the TS fixtures, `matchScore` absent from all marketplace-path payloads; **seed postings enter `published` with original `postedAt` (G-102)**.
### Phase 2
- **MH-2a**: Tagging cloud-free via `MockLLMProvider`; `PostingTags` schema enforced (skills ≤10 pin on an 11-skill response); D-020 single bounded retry pinned on schema violation.
- **MH-2b**: Tag-on-ingest + re-tag-on-update **overwrites** (old tags absent after update — single source of truth).
- **MH-2c**: Facets derive from real stored tags — API facets payload matches published-only tags exactly; `JobFilters` option lists come from the payload (web test).
### Phase 3
- **MH-3a**: Codec roundtrip — struct-packed float32 BLOB via `put_vector`/`get_vector` with `model`+`dim` per row.
- **MH-3b**: Deterministic similarity — same text → identical vector; related > unrelated cosine (pinned numbers).
- **MH-3c**: Factory G-11 boot-safe — `openai-embed` unconfigured → boot survives with loud log + mock fallback; `_sanitize` redaction: no key string in any error (pinned).
- **MH-3d**: Hybrid ordering — facets hard-filter before cosine ranking; no facets → pure semantic rank; `match_score` = computed cosine, **mock `matchScore` absent from marketplace-path API payloads and marketplace surfaces (G-107: the pin's grep excludes `packages/mock-data` and the employer/admin route groups, which stay mock)**; absent score = honest absence (no active query → no score, G-106).
- **MH-3e**: Embed-on-publish + lazy re-embed on model change (pinned); candidate-side embeddings never stored.
- **MH-3f**: Skill-breakdown = tag overlap + vector contribution, deterministic pin.
### Phase 4
- **MH-4a**: Enrichment append-only (no update/delete path) + precedence manual > llm > raw, every pairing pinned.
- **MH-4b**: Provenance markers — every enriched field carries `{value, source}` in the API; mock-llm copy visibly marked end-to-end.
- **MH-4c**: Enrich-on-first-publish fires exactly once per employer (re-publish no-op pin).
- **MH-4d**: Resume bounds matrix — PDF/TXT/MD only (other → 422), >2 MB → 413, empty PDF extract → 422 "text layer required", ~100K char cap.
- **MH-4e**: Draft-then-confirm — parse returns `status: "draft"`, nothing persisted; confirm persists; **PII sentinel caplog test green** (raw bytes never logged/stored).
- **MH-4f**: pypdf pinned `>=6.19,<7` in pyproject + bootstrap installs it; generated PDF fixture parses.
### Phase 5
- **MH-5a**: Sitemap reflects published postings (draft/closed excluded) + static page list; origin from `AI_PUBLIC_BASE_URL`; **every sitemap job/employer URL must serve 200 on the single-port app via the G-101 catch-all shells (URL-resolvability pinned — G-109, never advertise URLs the deploy 404s)**; unset origin → empty set + warning, robots omits the Sitemap directive (G-105).
- **MH-5b**: robots.txt points at `/sitemap.xml`.
- **MH-5c**: Metadata + OG present in the exported HTML (`out/` probe); board ItemList+Organization JSON-LD server-rendered.
- **MH-5d**: Runtime JSON-LD injected client-side after fetch (web test); JS-crawler limitation recorded.
- **MH-5e**: Mount-order — sitemap/robots routes beat the static export in single-port mode (route test).
## Milestone Must-Haves
- **MH-M1**: All six requirements complete with their REQUIREMENTS.md success criteria: REQ-6-001 (store+lifecycle+routes+adapter, surfaces read real data, D-043 gates composed), REQ-6-002 (tags via D-020, facets drive filters), REQ-6-003 (protocol+SQLite vectors, similarity ranking, computed match scores), REQ-6-004 (enrichment with provenance), REQ-6-005 (resume auto-fill with review gate, bounded), REQ-6-006 (metadata+JSON-LD+sitemap/robots, D-039 unchanged).
- **MH-M2**: Zero regressions on existing suites at every phase ship: 488 backend pytest, web tsx --test 22, CLI 61, typecheck 8, ruff, export build.
- **MH-M3**: G-18 stub markers gone (MH-1d) — the one honest-501 marketplace route is now real.
- **MH-M4**: The D-043 gate order provable by test (MH-1d) — allowlist → verified-18+ → caps, unchanged.
- **MH-M5**: Hermeticity — every new test pins `db_path` (and any state dir) to `tmp_path`; no test touches `~/.nextcraft` or the cloud (v0.5 P04 lesson).
- **MH-M6**: Scope discipline — pricing stays mock (A-412), employer dashboard stays mock (A-407), no payments/employer-auth/scheduler/crawler code; seed corpus is the only ingestion source.
## Risks
- **Dual-schema drift (TS wire types ↔ pydantic contracts)** — mitigated by the dual-schema-sync constraint (data-engineer owns both sides of every change) + parity checks in review.
- **Match-score removal breaking non-marketplace mockups** — jobs.ts/employers.ts stay intact for employer/admin mockups; only the marketplace path switches (A-406); pinned by build + web tests.
- **`AI_EMBED_BASE_URL` misconfiguration** (ollama-cloud has no /embeddings — D-051 probe) — default mock; G-11 boot-safe fallback with loud log; endpoint is config, never code.
- **Admin token operational risk** — unset token locks the founder out of admin routes (fail-closed is the intent); loud boot warning names the fix; documented in README quickstart.
- **Brute-force cosine at scale** — pilot-only (≤10⁴ postings, A-403); documented; `VectorStore` protocol keeps the pgvector swap API-compatible.
- **pypdf text-layer variance across real resumes** — honest 422 on empty extract (no OCR); TXT/MD path always works; generated-fixture tests pin both.
- **Client-shipped runtime fetch + JS-crawlers see shells on runtime pages** — accepted limitation (A-411), recorded; static set carries the SEO weight.
- **E87 static-export build break (grill-verified, G-101)** — Next 15 `output:'export'` hard-throws for dynamic routes lacking `generateStaticParams` (even `[]`); mitigation: one placeholder param per route + FastAPI catch-all shell serving (routes beat the mount, probe 6); pinned by release-blocking tests (G-110).
## User-Facing Surface
1. **Voice defense with a provider badge** (`/defend/[competencyId]`): the learner's spoken answers upload as audio and are transcribed server-side when `AI_VOICE_PROVIDER=openai-audio` is configured; examiner questions play as server TTS audio; the mic control shows an honest badge — `server voice`, `browser voice`, or `mock` — derived from the provider descriptor (`VoiceDescriptor.mode`), and degrades visibly (browser/mock fallback) with keys absent.
2. **Identity enrollment flow** (new `/enroll` learner route + marketplace surfaces): submit verification → pending state → verified/rejected state; verified learners proceed to variants/sandboxes/defense; unverified learners hitting gated routes see a structured verify-CTA (403 payload rendered as an actionable prompt, not a dead error). Mock verdicts are labeled `mock` everywhere they surface (A-304 honesty).
3. **Environment-typed build flows** (`/build/[competencyId]`): design competencies open a design environment (SVG/HTML/schematic artifact starter files, Run = validator harness), simulation competencies open a simulation environment (benchmark script + dataset starter files, Run = bounded harness execution); the Run/Test buttons use the variant's real `test_command` instead of hardcoded pytest; the surface, file tree, editor, read-only output panel (CUT-2), and telemetry pulse are unchanged across kinds.
4. **Invisible durability**: mid-connection kill of a build session loses nothing on reconnect (seq-ack protocol) — no visible UI, proven by tests.
1. **Marketplace job board** (`/marketplace`): reads real published postings from `/v1/marketplace/postings`; facet sidebar derives from real AI tags; match scores are computed, not mocked.
2. **Job detail** (`/marketplace/jobs/[jobId]`): real posting fetch; full description, tags, employer info, computed skill-breakdown; honest 404 on missing postings; apply flow live with verify-CTA on 403 and 409 on closed.
3. **Employer profile** (`/marketplace/employers/[employerId]`): merged raw+enriched fields with visible per-field provenance markers.
4. **Learner profile auto-fill**: resume upload → draft review → confirm (nothing saved silently).
5. **Founder/admin surface via curl + engineering docs**: `AI_ADMIN_TOKEN`-guarded posting authoring, lifecycle, ingest trigger, enrichment override, applications review — documented in a README marketplace quickstart addition (curl examples included).
6. **SEO**: sitemap/robots served by the API; metadata + JSON-LD on the page set.
## Happy Path
## Happy Path (end-to-end, written BEFORE execute)
**Defense with real voice:** learner opens `/defend/cmp-*` → DefenseSession starts → examiner question streams (SSE) → learner speaks → MediaRecorder captures webm → POST `/v1/defense/{id}/answer` (multipart audio) → server strips codec param (`webm;codecs=opus` → `webm`), enforces ≤10MB, calls `OpenAIAudioProvider.transcribe` → transcript turn stored (STT latency recorded) → learner clicks the speaker icon on an examiner turn → GET `/v1/defense/{id}/audio/{turn_id}` → server TTS bytes stream back with correct media_type → verdict + integrity signals render unchanged.
**Identity-gated build:** learner completes `/enroll` (submit → mock provider verdict `verified`, age band 18+) → opens a design competency → POST `/v1/variants` passes the identity gate (verified ≥16) → variant carries `environment: "design"` + `test_command` → sandbox created (allowlist ✓ → identity ✓ → rate cap ✓) → starter files written → Run executes the validator harness in the namespace sandbox → telemetry streams with seq-acks → grade digest renders.
1. Founder sets `AI_ADMIN_TOKEN=ops-secret` in `.env`; boots the stack; log shows the marketplace store on the shared DB (no unset-token warning).
2. Founder runs `POST /v1/marketplace/admin/ingest` (X-Admin-Token header) → seeded corpus populates: 20 postings + 10 employers, upsert-idempotent.
3. Founder authors a posting via `POST /v1/marketplace/admin/postings` (draft) → `POST .../publish` → posting is published (auto-tagged, embedded).
4. Learner opens `/marketplace` → board client shell fetches real postings → filters by a facet derived from real tags → opens `/marketplace/jobs/{id}` detail.
5. An 18+ verified, allowlisted learner clicks Apply → passes G-5 → verified-18+ → rate caps → posting published → `application` row (status `submitted`) → honest confirmation; a second click returns the same application (idempotent).
6. Founder reviews via `GET /v1/marketplace/admin/applications` (token-guarded) and contacts the employer out-of-band (no notification infra, A-408).
7. `GET /sitemap.xml` lists the static pages + every published posting.
## UX Acceptance Criteria
1. The mic control badge always tells the truth about which voice path is live (server/browser/mock) — never claims server when mock is wired.
2. Unverified/under-age callers on gated routes get a 403 with an actionable verify-CTA payload (rendered as a prompt with a link to enrollment) — never a bare JSON error in the UI.
3. Mock identity verdicts are visibly labeled `mock` in every surface that shows verification state.
4. Design/sim environments are indistinguishable from build environments in surface mechanics (file tree, editor, Run/Test, output panel) — only starter contents and the Run command differ; no route changes, no new navigation.
5. Run/Test buttons reflect the variant's `test_command` (no hardcoded pytest on a design competency).
6. No durable state is written inside the repo (state in `~/.nextcraft/`; tests in tmp dirs).
7. All existing accessibility baselines hold (WCAG AA contrast on new badge/CTA states).
---
## Phase 1: Seq-Lease + Replay Margin (REQ-5-007)
**Goal:** Close the one-line replay-margin/ACK gap (P07-documented): the capture agent requeues only `_last_sent` on detected disconnect while N frames may be in TCP flight — frames 1..N-1 are lost. Server seq-acks close it.
### Wave 1-1: ingest ack emission
- **Task 1-1-01** (sandbox-engineer): `telemetry/ingest.py` — emit `{"type":"seq_ack","seq":N}` after every successful `store.append` in `IngestSession._append` (N = durable `latest_seq` snapshot already computed; O(1), advisory — gap detection stays authoritative; G-3 flood semantics untouched). Unit test: appended seq N → ack frame N observed on the WS (TestClient portal pattern, `tests/api/test_telemetry_ingest.py`).
- Files: `apps/ai-service/ai_service/telemetry/ingest.py`, `apps/ai-service/tests/telemetry/`
- **MH-1a**: a test proves each successful append emits an ack carrying the post-append `latest_seq`.
### Wave 1-2: agent ack consumption + spool trim
- **Task 1-2-01** (sandbox-engineer): `scripts/sandbox-agent.py` — supervisor loop (currently discards all non-close frames) parses text frames; on `type == "seq_ack"` trims every spool/pending line with `seq <= ack` under `_emit_lock` via atomic `Spool.rewrite`; clears `_last_sent` if its seq ≤ ack; tolerates any frame interleaving (acks/gap_warning/rejected); keepalive pings (binary) unaffected. **Stdlib-only (AST-pinned).**
- **Task 1-2-02** (sandbox-engineer): add an explicit spool bound (max lines, e.g. 4096 — documented; D-R07 correction: no cap existed) — oldest-beyond-bound dropped with a counter; **G-14: overflow is by-design gap creation — unit test proves dropped-counter > 0 → replayed trace exhibits gaps → grader/gap path marks it ungradable (never a silently-truncated-but-gradable trace); document the worst-case arithmetic (~64KB diff cap × 4096 lines ≈ 256MB, under but HALF the 512MB G-2 budget — the spool lives inside the swept workdir)**; document G-2+flood-cap as the outer bound.
- Files: `apps/ai-service/scripts/sandbox-agent.py`, `apps/ai-service/tests/sandbox/test_sandbox_agent.py`
- **MH-1b**: unit test — agent with a scripted WS that acks mid-drain trims its spool to `seq > ack` exactly (no over-trim, no under-trim), stays within the explicit bound, and overflow drops create honest gaps (ungradable, G-14).
- **MH-1c**: `replay_margin()` behavior after acks: requeue window is bounded by unacked in-flight only (repeated reconnect/ack cycles never lose or duplicate a spooled line).
### Wave 1-3: mid-burst regression test (the real proof)
- **Task 1-3-01** (sandbox-engineer): extend `tests/telemetry/test_durability.py` with `test_midburst_disconnect_loses_nothing` — real uvicorn + KillableProxy; sever the connection **immediately after a rapid multi-frame send, WITHOUT waiting for server observation** (the exact scenario the P07 de-flake documented as uncovered); revive; assert every emitted seq stored exactly once, in order; assert spool trimmed to ≤ ack margin; keep `_await_events`/portal patterns (deterministic, no socket surgery).
- Files: `apps/ai-service/tests/telemetry/test_durability.py`
- **MH-1d**: the mid-burst test passes repeatedly (≥3 consecutive runs) with zero loss/dup outside the acked margin.
**Verification strategy P1:** `pnpm ai:test` (413+green), ruff, no web/TS changes, no settings changes. Existing durability + reconnect-flush suites stay green.
**Risks:** mid-burst determinism (mitigated: ack protocol is the fix; wait for server-side observation of the ack itself); `_emit_lock` reentrancy from supervisor thread (trim under the same lock as flush); frame-order tolerance.
---
## Phase 2: Real Server Voice (REQ-5-001, REQ-5-002)
**Goal:** The `openai-audio` VoiceProvider — server STT/TTS for voice defense end-to-end when keys exist; mock/browser unchanged.
### Wave 2-1: settings + factory + provider
- **Task 2-1-01** (backend-engineer): `config.py` — add `voice_base_url: str = ""`, `voice_api_key: str = ""` (never logged), `voice_stt_model: str = "whisper-1"`, `voice_tts_model: str = "tts-1"`, `voice_tts_voice: str = "alloy"`, `voice_tts_format: Literal["mp3","wav","opus"] = "mp3"` (G-16: enum, not free string — it feeds a Content-Type), `voice_max_audio_mb: int = 10`; `.env.example` documents them (keys never in code/commits); unknown format value → fall back to default with a loud log (consistent with G-11).
- **Task 2-1-02** (backend-engineer): `voice/factory.py` — `voice_provider_from_settings(settings, http_client)` (signature change); `openai-audio` branch constructs `OpenAIAudioProvider`; rejects selection when `voice_base_url`/`voice_api_key` empty with an actionable error for direct callers; **G-11: `main.py` lifespan catches the factory error, logs loudly (naming the missing vars), and falls back to the mock provider — a typo'd env in an unattended deploy must never crash the boot (D-039); descriptor then honestly reads `mock`**; lifespan passes `app.state.http_client` (state-injection preserved).
- **Task 2-1-03** (voice-engineer): `voice/openai_audio.py` — `transcribe()`: multipart `POST {base}/audio/transcriptions` (`file` + `model`, response_format=json → `TranscriptSegment`); `synthesize()`: streaming `POST /audio/speech` (JSON body `model/input/voice/response_format`, raw byte chunks); `descriptor = VoiceDescriptor(mode="server", sr_available=True, tts_available=True, hint=...)`; errors sanitized with key redaction (mirror `llm/openai_compat.py:_sanitize`); reuses the shared httpx client (D-017; read=300s).
- Files: `apps/ai-service/ai_service/config.py`, `voice/factory.py`, `voice/openai_audio.py`, `main.py`, `.env.example`
- **MH-2a**: MockTransport byte-contract tests — STT: multipart fields + response parse → `TranscriptSegment`; TTS: JSON body + byte stream → concatenated chunks; failure pins 413/400/429/timeout → sanitized errors, NO key leak (pinned).
- **MH-2b**: factory: `openai-audio` selected + configured → server-mode provider; selected + unconfigured → actionable rejection for direct callers AND app boot survives with mock fallback + loud log naming the fix (G-11); mock/browser unchanged; provider always carries a `descriptor` (a-15 — a missing descriptor would badge the server path as mock). Invert the v0.4 rejection test (`test_real_server_stt_tts_rejected_as_v04_seam`).
### Wave 2-2: defense route fixes + audio upload
- **Task 2-2-01** (voice-engineer): `api/defense.py` — fmt derivation strips codec params (`"webm;codecs=opus"` → `webm`; else real STT 400s); enforce `voice_max_audio_mb` BEFORE provider call (422 empty / 413 oversize; a-9: Content-Length fast path before buffering); TTS route media_type mapped from the `voice_tts_format` enum (G-16); descriptor now comes from the provider (mode=server flows to the client untouched).
- **Task 2-2-02** (frontend-engineer): `engine-client.ts` — `answerDefense` audio variant (FormData: blob + filename + content-type); `defense-session.tsx` — POST the recorded blob instead of discarding it; **G-12: recording bound — auto-stop at a max duration (default 180s) with a visible timer, `recorder.start(timeslice)` for observable size (a-13); a 413 response renders as an honest "answer too long — re-record" prompt, never silent loss**; provider badge from descriptor (`server voice`/`browser voice`/`mock`) with visible degradation states.
- Files: `apps/ai-service/ai_service/api/defense.py`, `apps/web/lib/engine-client.ts`, `apps/web/components/learner/defense-session.tsx`, `apps/web/tests/`
- **MH-2c**: API test — webm;codecs=opus content-type reaches the provider as clean `webm`; oversize audio → 413 without provider call; empty → 422 (existing).
- **MH-2d**: web tests — audio POST path builds correct FormData; badge reflects descriptor mode; auto-stop fires at the bound; 413 renders the re-record prompt (G-12).
**Verification strategy P2:** `pnpm ai:test`, ruff, `pnpm typecheck`, web tests, `pnpm build` (static export still emits). Manual cloud probe recipe documented in `.env.example` comments (never CI). Defense flow tests stay mock-only (cloud-free rule).
**Risks:** full audio bytes buffered in memory (bounded by the 10MB guard); TTS `input` ≤4096 chars (examiner questions are short — enforced with a guard + truncation error); factory signature change touches main.py lifespan (state-injection pattern preserved).
---
## Phase 3: Identity + Age-Gating (REQ-5-003, REQ-5-004)
**Goal:** Identity verification backend behind a provider protocol (mock-first); backend-enforced age gates composed with G-5.
### Wave 3-1: identity module core
- **Task 3-1-01** (identity-engineer): `ai_service/identity/base.py` — `IdentityProvider` protocol: `submit(learner_id, submission) -> submission_id`, `poll(submission_id) -> verdict {status, age_band, provider, mock, refs}`; `mock.py` — deterministic mock (approve-on-policy: age band from a scripted DOB field, reject scripted-bad); verdicts carry `mock: true` marker (A-304).
- **Task 3-1-02** (identity-engineer): `ai_service/identity/store.py` — 5th D-027 store, DefenseStore pattern (WAL, `foreign_keys=ON`, portable columns, `@validates`): `identity_record` table — `id` (submission id, minted once), `learner_id` (indexed), `status: pending|verified|rejected`, `provider`, `provider_verdict` (JSON, mock-marked), `age_band` (derived `16-17`|`18+`, NEVER raw DOB), `document_refs` (JSON refs — raw documents NEVER stored), `submitted_at`, `verified_at`; insert-only + latest-per-learner lookup.
- **Task 3-1-03** (identity-engineer): `main.py` lifespan — `app.state.identity_store` + `app.state.identity_provider` (state-injection overrides preserved); `config.py` — `identity_provider: str = "mock"`, identity store rides the same db_path.
- **MH-3a**: store tests — insert/poll/latest/verdict provenance; constraints fire on invalid bands; same SQLite file (additive table, D-027 family).
### Wave 3-2: API surface + gates
- **Task 3-2-01** (identity-engineer): `api/identity.py` — `/v1/identity/submit` (submission → pending; G-13: one active pending per learner — resubmit while pending → 409 echoing the pending state; per-learner submit rate cap → 429), `/v1/identity/status/{learner_id}` (latest record + mock marker), `/v1/identity/verify/{submission_id}` (poll provider → verified/rejected transition); router mounted in main.py.
- **Task 3-2-02** (identity-engineer): gate dependencies — `require_verified_age(min_age)` FastAPI dependencies; **composition order binding (D-043)**: G-5 allowlist (403 pilot guard) → identity verdict (403 + verify-CTA payload `{reason, min_age, current_status, verify_cta}`) → rate caps (429). Apply: school 16+ on variant generation (`api/variants.py`), sandbox create (`api/sandboxes.py`), defense start (`api/defense.py`); marketplace 18+ via `require_verified_adult` on ONE minimal gated route (`POST /v1/marketplace/apply` — G-18: honest stub; passes the gate composition then returns 501 with `stub: true` + mock-verdict markers, never a fabricated "applied" outcome).
- **Task 3-2-03** (identity-engineer, G-9): conftest `verified_pilot` fixture — seeds an identity record (mock provider, band 18+ or 16+) for test learner ids + allowlist coverage in test settings; MUST land in the same wave as the gates or the existing variant/sandbox/defense suites 403 en masse (those routes are ungated today).
- **Task 3-2-04** (security-auditor, phase-specific): PII review — sentinel scrub test: submit identity with sentinel PII strings → assert they appear in NO log record (caplog) and NO stored raw form (store inspection); API responses expose verdict + mock marker only.
- Files: `apps/ai-service/ai_service/identity/**`, `api/identity.py`, `api/variants.py`, `api/sandboxes.py`, `api/defense.py`, `main.py`, `config.py`, `tests/identity/`, `tests/api/`
- **MH-3b**: gate tests — verified 18+ passes all school gates; 16-17 passes school gates but 403s the marketplace route (honest stub response beyond the gate, G-18); unverified → 403 with verify-CTA payload; under-16 → 403 everywhere gated; allowlist rejection (403) still fires FIRST for non-pilot learner ids; identity submit: pending-resubmit → 409, rate cap → 429 (G-13); **all pre-existing variant/sandbox/defense API suites remain green under the `verified_pilot` fixture (G-9)**.
- **MH-3c**: caplog sentinel test green (PII never logged/stored).
### Wave 3-3: web enrollment flow
- **Task 3-3-01** (frontend-engineer): `/enroll` route — submit → pending → verified/rejected states (honest, mock-labeled); engine-client identity functions; **G-10: client 403 discrimination — `verify_cta` present in the 403 payload → new `VerifyRequiredError` `{reason, min_age, current_status, verify_cta}`; allowlist detail → existing `NotAllowlistedError` (today engine-client.ts collapses every 403 into NotAllowlistedError — a verify-CTA would render as an allowlist lie)**; gated-route 403 CTA rendered as actionable prompt (link to `/enroll`); dashboard learner age badge reflects verified state.
- Files: `apps/web/app/(learner)/enroll/`, `apps/web/lib/engine-client.ts`, `apps/web/components/`, `packages/types/`
- **MH-3d**: web tests — identity client functions; CTA payload shape; enrollment states render; **403 discrimination: verify-CTA → VerifyRequiredError, allowlist detail → NotAllowlistedError (G-10)**. `pnpm build` emits the new route.
- **MH-3e** (CUT-3): identity flow test via `TestClient` against real `create_app` (routers mounted, gates composed, real stores, mock providers) — unverified learner → variants POST → 403 verify-CTA → submit + verify (mock) → variants POST 200. No uvicorn harness (identity is plain JSON; the real-server harness stays where transport matters — P1/P4).
**Verification strategy P3:** `pnpm ai:test`, ruff, typecheck, web tests, `pnpm build`. PII caplog test is release-blocking (security-auditor sign-off).
**Risks:** self-asserted `learner_id` trust level (documented as pilot-scale — same as G-5 today; real auth is post-v0.5); shared-SQLite additive table (safe); mock-verdict honesty must ride every response (pinned).
---
## Phase 4: Design/Sim Environments (REQ-5-005, REQ-5-006)
**Goal:** Environment kinds at the template/variant layer; per-kind starter contents + exec policy; kind flows through telemetry; digest untouched.
### Wave 4-1: registry + wire types
- **Task 4-1-01** (sandbox-engineer): `variants/templates.py` — `TaskTemplate.environment: Literal["build","design","simulation"] = "build"` + per-kind `starter_files` + `harness_command`/`test_command` policy fields; **G-15: command fields validated at definition time (Python, where shlex exists) — must roundtrip `shlex.split` → whitespace-join → `shlex.split` identically (no quotes/globs/metachars; violation is a template-authoring bug caught in tests)**; add one design template (stack-designer c001/c002 — already sanctioned) + one simulation template (stack-science or stack-operator); `variants/generator.py` + `store.py` carry the field through `VariantRecord`.
- **Task 4-1-02** (frontend-engineer): `api/variants.py` — `VariantResponse` gains `environment` + `test_command` (closes the dead-field gap); TS `packages/types/variants.ts` + engine-client types sync (dual-schema rule: both places, same change).
- **MH-4a**: variant tests — design/sim templates generate kind-tagged variants with correct starter files + commands; command fields roundtrip the shlex validator (G-15); wire response carries both fields (a-11: required on the wire, TS required-field parity); TS types match Python field-for-field.
### Wave 4-2: exec command policy
- **Task 4-2-01** (sandbox-engineer + security-auditor): `api/sandboxes.py` exec route — per-kind command policy: **G-15: EXACT argv-token matching** against {template-declared harness/test argv[0]} ∪ a small generic file/nav set — never prefix/substring (trivially bypassed via flags/`-c` passthrough); `sh -c` passthrough DISALLOWED for design/sim kinds (the gaming vector: faking build-style test cycles into a kind-agnostic digest); violation → 422 naming the allowed set; policy table is code (reviewable, versioned); build-kind flows do not regress (existing tests green).
- **MH-4b**: exec tests — design kind rejects pytest-style arbitrary commands not in policy (422); simulation kind accepts its declared harness; build kind flows unchanged.
### Wave 4-3: learner surface kind-awareness
- **Task 4-3-01** (frontend-engineer): `use-sandbox-session.ts` — `test()` uses `variant.test_command`; **G-15: TS splits on whitespace ONLY (no shlex in the browser — safe because templates validated quote-free at authoring, Task 4-1-01)**; `build-surface.tsx` — RunControls commands from the variant; starter-file materialization loop already kind-agnostic (verify against design/sim starter sets); honest busy/denied/error states carry over.
- **MH-4c**: web tests — test command comes from the variant; Run button label/command per kind.
### Wave 4-4: telemetry + grading proof
- **Task 4-4-01** (sandbox-engineer): digest pin test — `compute_digest` over a synthetic design-kind trace (validator harness events) → same feature classes as build traces (kind-agnostic by construction — now pinned); telemetry capture agent unchanged (content-agnostic `_EVENT_KINDS` verified).
- Files: `apps/ai-service/ai_service/variants/templates.py`, `generator.py`, `store.py`, `api/variants.py`, `api/sandboxes.py`, `grading/features.py` (tests only), `apps/web/hooks/use-sandbox-session.ts`, `apps/web/components/learner/build-surface.tsx`, `packages/types/variants.ts`
- **MH-4d** (absorbs former MH-4e per G-17 — no hope-shaped must-haves): design-kind E2E in the real-server harness — design variant → sandbox → starter files → Run validator harness in-ns → telemetry flows → digest computes (mock LLM, real stores) with concrete assertions: stored seqs contiguous 0..N exactly once AND the agent's spool ends ≤ the ack margin (real agent; where a fake agent is used, cite the P1 suite as the ack/trim coverage instead of asserting).
**Verification strategy P4:** `pnpm ai:test`, ruff, typecheck, web tests, `pnpm build`. Grading digest diff vs build traces = zero behavioral drift (pinned).
**Risks:** starter-file materialization is client-driven (missing-file hazard — mitigate: starter sets are small + template-authored; document server-side materialization as a future seam); `test_command` is wire-visible (template-authored, not learner-authored — documented); dual TS/Python schema sync (rule enforced in review).
---
## Phase 5: Final Review + Ship (milestone release v0.4.5)
- **Task 5-1-01** (lead-developer → ci-review personas): multi-persona review of all v0.5 changes (correctness, testing, security, performance, maintainability) — P0s fixed in-phase.
- **Task 5-2-01** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
- **Task 5-3-01** (lead-developer → ci-ship): merge phase/05 → milestone → main; tag **v0.4.5** (milestone release); Gitea release with full summary + `nextcraft-linux-x64` + `.sha256` assets; delete milestone branches; mark requirements complete; clear checkpoint.
---
## Must-Haves (Milestone)
- **MH-M1**: Mid-burst disconnect loses zero events outside the acked margin (P1 regression test, repeatable).
- **MH-M2**: `openai-audio` provider passes byte-contract STT/TTS tests with key-redaction pins; defense flow works server-side end-to-end (manual probe documented); mock/browser paths unchanged.
- **MH-M3**: Identity flow (submit → pending → verified) works under mock; gates enforce 16+/18+ in the binding composition order with verify-CTA payloads; PII never stored raw or logged (caplog sentinel green).
- **MH-M4**: Design + simulation environment kinds provisionable with per-kind starter contents + exec policy; grading digest proven kind-agnostic.
- **MH-M5**: All gates green at every phase ship: `pnpm ai:test`, ruff, `pnpm typecheck`, `pnpm build`, web + cli tests; binary assets on every release (D-036).
- **MH-M6**: v0.1 surface regressions: zero **for verified allowlisted pilot learners** (existing learner/marketplace/employer/admin flows unchanged except the additive enroll route + badges/CTAs; gating unverified/under-age ids is REQ-5-004's purpose, not a regression).
**Out of scope (guarded):** real KYC vendor integration (protocol only), real auth sessions (self-asserted learner_id documented as pilot-scale), marketplace backend beyond the one gated stub route, new isolation tech (D-024 unchanged), server-side starter materialization (documented as future seam).
1. Board shows real postings with an honest empty state (no fake cards when the store is empty).
2. Missing posting id → honest 404 page (no fabricated job).
3. Unverified learner clicking Apply → verify-CTA prompt linking to enrollment (never a dead JSON error or a fake "applied").
4. Match scores are computed cosine values only; no mock `matchScore` anywhere; absent score = absent query context (honest).
5. Tag facets derive from real stored tags (API payload → sidebar), never hardcoded lists.
6. Per-field provenance markers visible on the employer profile (raw/llm/manual).
7. Sitemap lists published postings; robots points at the sitemap; metadata present in the exported HTML.
8. Resume auto-fill never persists without an explicit confirm; oversize/wrong-format/text-less PDFs get honest, actionable errors.
9. All existing accessibility baselines hold (WCAG AA on new states).
+38 -1
View File
@@ -16,7 +16,26 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## Milestone v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (COMPLETE, shipped as v0.4.5)
## Milestone v0.6 — Marketplace Engine (ACTIVE)
**Scope (auto-decided at SPECIFY per full autonomy — founder scope question left unanswered 2026-09-21):**
The marketplace has no backend today: every job board, job detail, employer profile, search, and pricing surface reads `packages/mock-data` directly; the only live route is the G-18 `POST /v1/marketplace/apply` gated stub (18+ verified, honest 501). The guiding outcome — ≥1,000 learners placed in AI-orchestration roles via the Nextcraft marketplace — runs *through* this surface, so the marketplace engine is the highest-leverage next build.
**Scope decision (D-047):** Activate **REQ-F-011..016** as REQ-6-001..006 — the real marketplace backend behind the existing UI:
1. **Job aggregation pipeline** (REQ-6-001, from REQ-F-011): job ingestion from employer postings + a seeded/synthetic corpus (3M+ external jobs is post-MVP — the pipeline ingests at pilot scale with the same adapter contract); postings store (6th D-027 store family); posting lifecycle (draft → published → closed).
2. **AI-powered tagging** (REQ-6-002, from REQ-F-012): skills/seniority/category extraction from posting text via the existing LLM structured-output layer (D-020), provider-agnostic, mock-first.
3. **Semantic vector search** (REQ-6-003, from REQ-F-013): embedding-backed role matching — candidate-competency vs posting-requirement similarity; SQLite-local at pilot scale (no pgvector before Postgres migration), deterministic embedding provider protocol (mock-first, real provider env-configured).
4. **Company enrichment** (REQ-6-004, from REQ-F-014): employer profile enrichment from posting/company data (descriptions, social links, logo refs) — LLM-assisted, human-in-the-loop founder review deferred to post-pilot.
5. **Resume/profile parsing** (REQ-6-005, from REQ-F-015): learner profile auto-fill from an uploaded resume — LLM structured extraction mapping to the competency profile shape.
6. **SEO-optimized programmatic pages** (REQ-6-006, from REQ-F-016): server-rendered job/stack/landing pages with metadata, sitemap, structured data — built on the existing Next.js static-export single-port deploy (D-039-compatible).
**Deferred beyond v0.6 (unchanged):** REQ-F-018 payments/subscriptions, REQ-F-019 human tutor marketplace, REQ-F-020 CIRR placement tracking.
**Success (milestone-level):** the marketplace surfaces read from a real backend (postings, search, matching, employer profiles) instead of mock data; job posting + tagging + search work end-to-end for a pilot employer; the deploy stays one-command on the v0.4.x/v0.5.x fabric.
## Prior Milestone: v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (COMPLETE, shipped as v0.4.5; hotfixes v0.4.6 deploy-static-404, v0.4.7 pre-public hardening)
**Scope (the four seams D-016 deferred out of v0.4, locked at v0.5 Phase 0 SPECIFY):**
@@ -59,6 +78,23 @@ All 8 v0.3 requirements (REQ-3-001..008) are complete and shipped as v0.2.8. See
All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See REQUIREMENTS.md traceability matrix.
## Clarified Assumptions (v0.6 CLARIFY stage, full autonomy — auto-resolved)
| # | Ambiguity | Resolution | Confidence |
|---|-----------|------------|-------------|
| A-401 | Who authors postings at pilot (no employer auth exists)? | **Founder-authored; employer self-serve deferred.** Postings enter via founder/admin API routes (`/v1/marketplace/admin/postings` create + lifecycle transitions draft→published→closed), guarded by an `AI_ADMIN_TOKEN` env-header check (keys env-only, D-014; operator route — D-043 learner gate composition untouched). The employer dashboard's posting-management mockup stays mock; real employer self-serve lands with the deferred B2B employer network milestone. | 0.85 |
| A-402 | Ingestion adapter contract shape? | **`PostingSource` protocol in `ai_service/marketplace/`** (D-031 module, D-014 mock-first): `fetch() -> Iterable[IngestPosting]` — pydantic-typed with `source`, `external_id`, employer + posting fields. Two v0.6 implementations: `seeded_corpus` (in-repo JSON seed, ported once from packages/mock-data jobs.ts/employers.ts per D-021 convention) and `admin_api` (A-401 founder path). Upserts idempotent on `(source, external_id)`. No scheduler/crawler — the 3M+ external corpus drops in later as a new adapter with zero store/route changes. | 0.80 |
| A-403 | Where do embeddings live at SQLite scale (pip-only venv, no pgvector)? | **`embedding` table inside the marketplace SQLite store (6th D-027 family)**: posting_id → BLOB of struct-packed float32 + model + dim columns; the candidate side embeds the learner's competency profile text on demand (not stored). **Brute-force cosine in pure Python** at query time (pilot scale ≤ ~10⁴ postings — no numpy, no FAISS/chromadb, no new deps). `VectorStore` protocol-wrapped so the Postgres migration swaps to pgvector with no API changes (D-027/D-019 pattern). | 0.85 |
| A-404 | Embedding provider + dimensionality? | **`EmbeddingProvider` protocol (mirror of D-030 VoiceProvider)**: deterministic mock (256-dim feature-hash of posting/profile tokens — similarity is deterministic; tests never call the cloud) + real OpenAI-compatible `POST {AI_EMBED_BASE_URL}/embeddings` via `AI_EMBED_MODEL`/`AI_EMBED_API_KEY` (env-only; ollama-cloud exposes /embeddings). Dimension is **not hardcoded** — persisted per row with the model name; on provider/model change, postings re-embed lazily. **[SUPERSEDED by D-051 probe: ollama-cloud does NOT expose /embeddings (404 path-not-found) — `AI_EMBED_BASE_URL` must target another OpenAI-compatible endpoint (e.g. local ollama `:11434/v1`); protocol design unaffected, default stays mock]** | 0.75 |
| A-405 | Does semantic search replace the filter sidebar? | **Hybrid — augments, not replaces.** REQ-6-002 tag facets (skills/seniority/category + location/remote/salary) remain hard filters driving the existing sidebar; the semantic query + similarity compose as a **ranking layer over the facet-filtered set** (no facets selected → pure semantic ranking). Match score on job cards = cosine(candidate-competency vector, posting vector), replacing the mock `matchScore`; skill-breakdown panel derives from tag overlap + per-skill vector contribution. ARCHITECTURE.md's old "filter UI replaced" future-note is superseded by this hybrid. | 0.80 |
| A-406 | What happens to packages/mock-data jobs.ts/employers.ts (runtime data vs static export)? | **Marketplace web surfaces switch from build-time mock imports to runtime client fetch** (A-002 direct-fetch + engine-client pattern, proven by build/defend surfaces): board/detail/employer-profile become client shells fetching `/v1/marketplace/*`; `generateStaticParams` on jobs/[jobId] and employers/[employerId] is removed (one static shell per route, data at runtime). jobs.ts/employers.ts are ported **once** into the Python seed fixture (A-402) and go dormant for the marketplace path (mock-data remains for admin/employer-dashboard mockups + shared types). No build-time DB reads — the D-039 deploy export build stays repo-only. | 0.80 |
| A-407 | Employer auth model at pilot? | **None — employers are data, not principals, in v0.6.** Employer records are ingested/enriched entities keyed by `employer_id` on postings; no employer login, sessions, or accounts. The employer dashboard surface stays the v0.1 mockup; enrichment is founder-triggered/admin-reviewed (A-409), never employer-edited. Real employer auth lands post-payments with the B2B employer network. | 0.85 |
| A-408 | Apply-flow shape beyond the 501 stub? | **The existing `POST /v1/marketplace/apply` route becomes real, D-043 composition unchanged** (G-5 allowlist → verified-18+ verdict → rate caps): body `{posting_id}` → posting must be `published` → insert an `application` row (status `submitted`, identity ref, timestamp) in the marketplace store → honest confirmation payload. Duplicate apply (same learner + posting) is idempotent. **No notification infra**: founder reviews applications via `/v1/marketplace/admin/applications` and contacts employers out-of-band. | 0.75 |
| A-409 | Enrichment provenance rules? | **Separate `employer_enrichment` table — raw fields never overwritten** (REQ-6-004): append-only rows `(employer_id, field, value, source: llm\|manual, model, created_at)`. Read precedence **manual > llm > ingested-raw**; API responses merge and carry per-field provenance markers (A-304 honesty pattern — LLM-authored copy visibly marked). Enrich-on-post: new employer's first published posting triggers LLM structured extraction (D-020, mock-first) of description/social links/logo refs. Founder override = upsert a `manual`-source row, never a mutation of `llm` rows. | 0.85 |
| A-410 | Resume upload format bounds? | **PDF/TXT/MD only, ≤2 MB, text-layer required.** PDF text via `pypdf` (pure-Python, pip-reachable through the A-009 venv-pip channel — the only new dep); extracted text capped ~100K chars before the D-020 structured-extraction call mapping to the competency-profile shape. Image-only/scanned PDFs → honest 422 ("text layer required") — **no OCR in v0.6**. Parsed result returns as a DRAFT: nothing persists to the learner profile until explicit learner confirm (REQ-6-005 review gate); raw resume bytes never stored, never logged (A-305 pattern). | 0.80 |
| A-411 | Which pages get programmatic SEO treatment under static export — and how? | **Build-time-static set: landing, catalog/stack detail, marketplace board, pricing** — full server-component metadata (title/desc/OG) + JSON-LD (Organization, stack/Course, ItemList) rendered at export (D-039 build unchanged). **Runtime-data set: job detail + employer profile** — client shells (A-406) inject `JobPosting`/`Organization` JSON-LD + title client-side after fetch (best-effort per-job SEO at pilot; recorded as an accepted limitation — JS-less crawlers see the shell). **Sitemap/robots: `/sitemap.xml` + `/robots.txt` served dynamically by ai-service** (API routes beat the static mount, D-039), enumerating static pages + published postings; robots points at `/sitemap.xml`. | 0.70 |
| A-412 | Pricing page — real or mock? | **Stays mock — zero backend.** Payments/subscriptions are REQ-F-018, explicitly deferred beyond v0.6 (D-047). The pricing page gains only build-time metadata/OG (A-411 static set); no pricing API routes, no package enforcement — posting is free at pilot (founder-mediated). | 0.95 |
## Clarified Assumptions (v0.5 CLARIFY stage, full autonomy — auto-resolved)
| # | Ambiguity | Resolution | Confidence |
@@ -183,6 +219,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
| D-047 | v0.6 = Marketplace Engine (auto-decided at SPECIFY, full autonomy; founder scope question left unanswered 2026-09-21) | Activates REQ-F-011..016 as REQ-6-001..006: real marketplace backend (aggregation, tagging, vector search, enrichment, resume parsing, SEO pages). Rationale: the guiding outcome (≥1,000 placements via the marketplace) runs through the only surface with zero backend; every prior milestone fed the credential side, placement side is the bottleneck. Confidence 0.80 ≥ threshold 0.60 (D-007). External 3M+ job corpus scaled to pilot (adapter contract preserves the full-scale path). | Milestone scope locked at SPECIFY; tags on v0.5.x line; feature milestone |
| D-039 | Single-port deploy + unattended dev (v0.3.6 hotfix, founder directive) | Only :8420 reachable behind HAProxy; site was down (domain root hit the API 404). Web app = static export served same-origin by the ai-service (`AI_WEB_STATIC_DIR`); `dev -d`/`stop`/`log` daemonize ops; durable state (DB, sandboxes, pid/log) moves to `~/.nextcraft/` out of the repo. | One port serves UI+API; unattended deploy recipe in deploy/README.md |
---
+45 -6
View File
@@ -1,5 +1,33 @@
# Nextcraft — REQUIREMENTS.md
## v0.6 Requirements (ACTIVE — Marketplace Engine, milestone v0.6)
### Job Aggregation & Postings
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-6-001 | Job posting backend (from REQ-F-011, pilot-scaled): postings store (6th D-027 store family — SQLite, insert-safe, protocol-wrapped), employer posting lifecycle (draft → published → closed), ingestion adapter contract (employer-authored postings + seeded corpus at pilot scale; 3M+ external aggregation preserved by the adapter interface), marketplace API routes replacing the mock-data reads on job board/detail/employer surfaces, 18+ verified identity gate on apply/post flows (D-043 composition retained) | critical | 1 | pending |
| REQ-6-002 | AI-powered tagging (from REQ-F-012): skills/seniority/category extraction from posting text via the LLM structured-output layer (D-020) — deterministic mock-first, provider-agnostic; tags persisted with the posting; tag facets drive the search filter sidebar | high | 2 | pending |
### Search & Matching
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-6-003 | Semantic vector search (from REQ-F-013): embedding provider protocol (mock-first, deterministic; real provider env-configured per D-014), vector store at pilot scale (SQLite-local — no pgvector before the Postgres migration), candidate-competency ↔ posting-requirement similarity ranking, match-score surfacing on job cards + candidate matches (replaces mock match scores) | critical | 3 | pending |
### Enrichment & Parsing
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-6-004 | Company enrichment (from REQ-F-014): employer profile enrichment from posting/company data (descriptions, social links, logo refs) via LLM structured output; enrich-on-post with manual founder override path; enriched data stored separately from raw (provenance marked) | medium | 4 | pending |
| REQ-6-005 | Resume/profile parsing (from REQ-F-015): learner profile auto-fill from an uploaded resume — LLM structured extraction mapping to the competency-profile shape, learner-review-before-save (never silently overwrite), upload bounded (size/format guard, house pattern) | medium | 4 | pending |
### SEO & Programmatic Pages
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-6-006 | SEO-optimized programmatic pages (from REQ-F-016): server-rendered job/stack/landing pages with metadata (title/description/OG), sitemap.xml + robots.txt, JSON-LD structured data (JobPosting schema), static-export compatible (D-039 single-port deploy unchanged) | high | 5 | pending |
## v0.5 Requirements (Complete — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease; shipped as v0.4.5)
### Real Server Voice
@@ -182,12 +210,12 @@
| ID | Description | Priority | Milestone | Status |
|----|-------------|----------|-----------|--------|
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.4+ | deferred |
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.4+ | deferred |
| REQ-F-013 | Semantic vector search for role matching | high | v0.4+ | deferred |
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.4+ | deferred |
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.4+ | deferred |
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.4+ | deferred |
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.6 | activated → REQ-6-001 (pilot-scaled; adapter contract preserves full scale) |
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.6 | activated → REQ-6-002 |
| REQ-F-013 | Semantic vector search for role matching | high | v0.6 | activated → REQ-6-003 |
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.6 | activated → REQ-6-004 |
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.6 | activated → REQ-6-005 |
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.6 | activated → REQ-6-006 |
### Platform Infrastructure
@@ -216,6 +244,17 @@
## Traceability Matrix
### v0.6 (active)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-6-001 | 1 | pending |
| REQ-6-002 | 2 | pending |
| REQ-6-003 | 3 | pending |
| REQ-6-004 | 4 | pending |
| REQ-6-005 | 4 | pending |
| REQ-6-006 | 5 | pending |
### v0.5 (complete)
| Requirement | Phase | Status |
+24
View File
@@ -2,6 +2,10 @@
## Overview
**Milestone v0.6 — Marketplace Engine (ACTIVE, started 2026-09-21).** 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`.
**Hotfix v0.4.7 (2026-09-21) — pre-public hardening.** The 4 P1 flags from the v0.5 final review closed: HMAC ingest tokens on the WS grading input (LAN poisoning closed), nonce+counter defense-id mint (same-ms collision closed), browser-mode client SR/TTS per the descriptor contract (gap closed), 8 browser-voice module pins. Deployed via `nextcraft deploy --service`; site 200, service enabled.
**Hotfix v0.4.6 (2026-09-21) — site-down + deploy experience.** The domain root (nextcraft.coreci.dev) served the API's JSON 404 for three releases: the deployed service ran mount-less (web export never built on the host; dev -d auto-wiring silently skipped; nothing detected it). Fixed the class: `nextcraft deploy` (one-command clone/update → install → venv → export build → restart → verify), `deploy --service` (generated systemd unit, reboot-persistent), toolchain discovery (nvm/PNPM_HOME/corepack, incl. split installs — the host's exact layout), loud mount-less boot warning, verify live mount probe. Deployed live on the host via the new command; site 200, service enabled.
**Hotfix v0.3.5/v0.3.6 (2026-09-13) — fresh-box + single-port unattended deploy.**
@@ -24,6 +28,22 @@
## Phase List
### v0.6 (active)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan, grill, MVP/UX check complete; .ciagent/ files updated for v0.6 (shipped as v0.5.0) |
| 1 | Postings backend + aggregation adapter | complete (v0.5.1) | 0 | REQ-6-001 | Postings store + lifecycle + `/v1/marketplace/*` routes live; job board/detail/employer surfaces read real data; 18+ gates composed per D-043; seeded ingestion adapter proven |
| 2 | AI tagging | in-progress | 1 | REQ-6-002 | Posting text → skills/seniority/category tags via LLM structured output (mock-first); tag facets drive search filters; tags persisted |
| 3 | Semantic vector search | pending | 2 | REQ-6-003 | Embedding provider protocol (mock-first) + SQLite-local vectors; competency↔posting similarity ranking; match scores surface on job cards + candidate matches |
| 4 | Enrichment + resume parsing | pending | 3 | REQ-6-004, REQ-6-005 | Employer enrichment from posting data with provenance; resume upload → profile auto-fill with learner review gate; bounded uploads |
| 5 | SEO programmatic pages | pending | 4 | REQ-6-006 | Metadata + JSON-LD + sitemap/robots on job/stack/landing pages; static-export compatible (single-port deploy unchanged) |
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit passes; milestone tagged (final v0.5.x patch); release with binary assets on Gitea; milestone merged to main |
---
## Phase List (v0.5 — complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan, grill complete; .ciagent/ files updated for v0.5 |
@@ -106,6 +126,10 @@
| v0.4.7 | 2026-09-21 | Pre-public hardening (the 4 P1 flags from the v0.5 final review): HMAC ingest tokens on the WS grading input (LAN trace-poisoning closed, per-boot secret, manager mints / endpoint verifies), nonce+counter defense-id mint (same-ms PK collision closed), browser-mode client SR/TTS implemented per the descriptor contract (the v0.5 gap), 8 browser-voice module pins. 488 backend (+10), web 22/22 (+8), CLI 61/61. Deployed via `nextcraft deploy --service`; site 200, service enabled. |
| v0.4.6 | 2026-09-21 | Site-down hotfix + one-command deploy: `nextcraft deploy [--service]` (clone/update → install → venv → export build → restart → verify), systemd unit generation (reboot persistence), toolchain discovery (nvm/PNPM_HOME/corepack + split installs), loud mount-less boot, verify live mount probe, subcommand flag dispatch. Deployed live via the command itself; nextcraft.coreci.dev → 200 HTML. |
## v0.6 (Active — Marketplace Engine)
Real backend behind the marketplace surfaces: postings store + aggregation adapter + marketplace API (REQ-6-001), AI tagging (REQ-6-002), embedding-backed semantic search + match scores (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). 7 phases (P0–P6). Scope D-047 (auto-decided at SPECIFY). Tags v0.5.0+ on the v0.5.x line; final patch = milestone release.
## v0.5 (Complete — Shipped as v0.4.5)
Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease: real server STT/TTS (`openai-audio` VoiceProvider with boot-safe fallback), the identity module (5th D-027 store, provider protocol + mock, D-043 age-gate composition on variants/sandboxes/defense/marketplace), design/simulation environment kinds at the template layer with per-kind exec policy, and the seq-ack protocol closing the P07 replay-margin gap. 6 phases (P0–P5). All 7 requirements (REQ-5-001..007) complete. Tags v0.4.0–v0.4.4 per phase, milestone release v0.4.5.
+3 -3
View File
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.5",
"name": "real-voice-identity-envs",
"version": "v0.6",
"name": "marketplace-engine",
"type": "feature",
"branch": "milestone/v0.5-real-voice-identity-envs"
"branch": "milestone/v0.6-marketplace-engine"
}
}
+11
View File
@@ -74,3 +74,14 @@ AI_VOICE_PROVIDER=mock
# -H 'Content-Type: application/json' \
# -d '{"model":"tts-1","input":"Nextcraft","voice":"alloy"}' \
# -o /tmp/probe.mp3 && file /tmp/probe.mp3 | grep -i audio
# --- Marketplace (REQ-6-001, D-057) ---
# Founder/admin surface guard (/v1/marketplace/admin/*): send as the
# 'X-Admin-Token' header OR 'Authorization: Bearer <token>'; compared
# constant-time (G-108). UNSET = every admin route 403s (fail-closed,
# never open) and the boot log warns loudly. Never commit real tokens.
# AI_ADMIN_TOKEN=
# P5 (D-056) placeholder: the deployed origin for sitemap.xml / robots.txt
# absolute URLs (e.g. https://nextcraft.example). Unset → SEO phase
# serves the empty static-page set with a boot warning (G-105).
# AI_PUBLIC_BASE_URL=
+7
View File
@@ -9,6 +9,7 @@ from ..config import Settings
from ..grading.engine import GradingEngine
from ..grading.store import GradeStore
from ..llm.base import LLMProvider
from ..marketplace.store import MarketplaceStore
from ..sandbox.manager import SandboxManager
from ..sandbox.workdir import SandboxDir
from ..telemetry.ingest import TraceIntegrityMap
@@ -77,3 +78,9 @@ def get_voice_provider(request: Request) -> VoiceProvider:
def get_examiner(request: Request) -> ExaminerAgent:
return request.app.state.examiner_agent
def get_marketplace_store(request: Request) -> MarketplaceStore:
"""v0.6 marketplace (D-049): routes NEVER import the store directly —
the api/ layer composes it via app.state DI (territory rule)."""
return request.app.state.marketplace_store
+7 -36
View File
@@ -7,13 +7,16 @@ response so downstream surfaces never treat mock-verified as real):
POST /v1/identity/verify/{sid} poll provider → terminal transition
Gate dependencies (D-043 binding composition order, mounted by the gated
routes — variants/sandbox-create/defense-start for school 16+; one
marketplace route for 18+ verified):
routes — variants/sandbox-create/defense-start for school 16+; the
marketplace 18+ apply route in api/marketplace.py):
allowlist (403, G-5 pilot guard) → identity verdict (403 + verify-CTA)
→ rate caps (429, owned by the calling routes)
PII (A-305): raw DOB enters via the submission, is used to derive the
band, and is NEVER stored or logged (caplog sentinel test pins it).
v0.6 (D-048/D-058): the G-18 marketplace stub MOVED to api/marketplace.py
and became the real apply route; identity keeps ONLY the gate exports.
"""
from __future__ import annotations
@@ -257,38 +260,6 @@ async def require_verified_age(
#: Convenience alias for the marketplace 18+ composition (D-043's
#: `require_verified_adult` — direct calls use require_verified_age(18, ...)).
#: v0.6 (D-058): the marketplace surface itself moved to api/marketplace.py;
#: identity keeps ONLY this gate export (D-048).
require_verified_adult = require_verified_age
# -- marketplace 18+ gated stub (G-18, REQ-5-004) ------------------------------------
marketplace_router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
class MarketplaceApplyBody(BaseModel):
learner_id: str = Field(min_length=1)
job_id: str = Field(min_length=1)
@marketplace_router.post("/apply", status_code=501)
async def marketplace_apply_stub(
body: MarketplaceApplyBody,
request: Request,
settings: Settings = Depends(get_settings),
) -> dict:
"""The ONE gated marketplace route (D-043): proves the 18+ verified
composition end-to-end. G-18 honesty: after passing the gate it returns
501 with explicit stub + mock markers — the marketplace backend does
not exist yet; this route never fabricates an 'applied' outcome.
"""
if body.learner_id not in settings.learner_allowlist:
raise HTTPException(
status_code=403,
detail=f"learner_id {body.learner_id!r} is not on the sandbox allowlist (G-5)",
)
await require_verified_age(18, body.learner_id, get_identity_store(request))
return {
"detail": "marketplace applications are not live yet",
"stub": True,
"mock": True,
}
@@ -0,0 +1,272 @@
"""/v1/marketplace — public postings API (REQ-6-001, D-048, D-058).
The board's runtime data source (published-only, honest 404s, no
matchScore anywhere — G-106/G-107) + the REAL apply route: the v0.5
placeholder that lived in api/identity.py moved here and became this
module's /apply (D-058) — gate composition UNCHANGED (D-043 binding
order): G-5 allowlist (403) → require_verified_adult (403 + verify-CTA)
→ rate caps (429), then posting exists (404) and is published (409),
then an idempotent insert (A-408: duplicate apply → 200 with the
EXISTING application).
Routes never import the store directly — deps.py app.state DI only.
"""
from __future__ import annotations
import time
from collections import deque
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from ..config import Settings
from ..identity.store import IdentityStore
from ..marketplace.base import Employer, Posting
from .deps import get_marketplace_store, get_settings
from .identity import require_verified_age
router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
def _identity_store(request: Request) -> IdentityStore:
return request.app.state.identity_store
def _enforce_allowlist(learner_id: str, settings: Settings) -> None:
"""G-5 pilot guard — allowlist runs FIRST in the composition (D-043)."""
if learner_id not in settings.learner_allowlist:
raise HTTPException(
status_code=403,
detail=f"learner_id {learner_id!r} is not on the sandbox allowlist (G-5)",
)
# In-memory apply-rate window (monotonic timestamps, process-local) — the
# G-5 creates-per-min pattern from the sandboxes route (module state is
# acceptable: one process, one box, D-019/D-027 precedent).
_APPLY_TIMES: dict[str, deque[float]] = {}
#: Applies per rolling 60s window per learner (A-408 anti-spam; the
#: idempotent duplicate makes honest retries free — only DISTINCT
#: posting applies consume budget).
_APPLY_PER_LEARNER_PER_MIN = 5
def _check_apply_rate(learner_id: str) -> None:
now = time.monotonic()
window = _APPLY_TIMES.setdefault(learner_id, deque())
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= _APPLY_PER_LEARNER_PER_MIN:
raise HTTPException(
status_code=429,
detail="marketplace apply rate exceeded — wait a minute",
)
window.append(now)
# -- response contracts -------------------------------------------------------
class PostingResponse(BaseModel):
"""One posting over HTTP. G-107: `matchScore` is deliberately absent
from the schema — scores are computed search-time context (P3),
never list/detail payload fields."""
id: str
source: str
external_id: str
employer_id: str
title: str
description: str
required_competencies: list[str]
skills: list[str]
seniority: str | None
category: str | None
location: str
remote: bool
salary_min: int | None
salary_max: int | None
status: str
tags: dict | None
posted_at: str | None
class PostingListResponse(BaseModel):
"""G-103: the page + the honest total_published so the board's paged
fetch-all can render "Showing X of Y" with an honest truncation. P2
(MH-2c): facets — value counts over stored tags of published-only
postings, data-driving the filter sidebar."""
postings: list[PostingResponse]
total_published: int
facets: dict
class EmployerResponse(BaseModel):
"""Raw ingested fields (P1). P4 (D-053) merges enrichment on read and
adds per-field {value, source} provenance markers."""
id: str
source: str
external_id: str
name: str
logo: str
description: str
industry: str
size: str
location: str
website: str
social_links: dict[str, str]
culture: str
class ApplyBody(BaseModel):
"""D-058: the learner identity comes from the VERIFIED record, not a
client assertion — but the pilot surface passes learner_id in the
body like every other gated route (G-5 composition unchanged)."""
learner_id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
class ApplyResponse(BaseModel):
"""Honest confirmation (D-058): exactly the application state, no
stub/mock markers, no fabricated outcome."""
application_id: str
posting_id: str
status: str
def _posting_response(posting: Posting) -> PostingResponse:
return PostingResponse(
id=posting.id,
source=posting.source,
external_id=posting.external_id,
employer_id=posting.employer_id,
title=posting.title,
description=posting.description,
required_competencies=posting.required_competencies,
skills=posting.skills,
seniority=posting.seniority,
category=posting.category,
location=posting.location,
remote=posting.remote,
salary_min=posting.salary_min,
salary_max=posting.salary_max,
status=posting.status,
tags=dict(posting.tags) if posting.tags else None,
posted_at=posting.posted_at.isoformat() if posting.posted_at else None,
)
def _employer_response(employer: Employer) -> EmployerResponse:
return EmployerResponse(
id=employer.id,
source=employer.source,
external_id=employer.external_id,
name=employer.name,
logo=employer.logo,
description=employer.description,
industry=employer.industry,
size=employer.size,
location=employer.location,
website=employer.website,
social_links=dict(employer.social_links),
culture=employer.culture,
)
# -- public endpoints ---------------------------------------------------------
@router.get("/postings", response_model=PostingListResponse)
async def list_postings(
store=Depends(get_marketplace_store),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> PostingListResponse:
"""Published postings only (draft/closed never board), posted_at DESC,
with the honest total (G-103) + facets from published-only stored
tags (MH-2c — facets computed once per page request, identical on
every page of the fetch-all)."""
postings, total = store.list_postings(limit=limit, offset=offset)
return PostingListResponse(
postings=[_posting_response(p) for p in postings],
total_published=total,
facets=store.facets(),
)
@router.get("/postings/{posting_id}", response_model=PostingResponse)
async def get_posting(
posting_id: str,
store=Depends(get_marketplace_store),
) -> PostingResponse:
"""Honest 404 on any unknown id — never a fabricated job."""
posting = store.get_posting(posting_id)
if posting is None:
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
return _posting_response(posting)
@router.get("/employers/{employer_id}", response_model=EmployerResponse)
async def get_employer(
employer_id: str,
store=Depends(get_marketplace_store),
) -> EmployerResponse:
"""Honest 404 on unknown employers; raw fields in P1 (merged +
provenance in P4, D-053)."""
employer = store.get_employer(employer_id)
if employer is None:
raise HTTPException(
status_code=404, detail=f"no employer with id {employer_id!r}"
)
return _employer_response(employer)
# -- the real apply route (D-058; the G-18 stub's replacement) ------------------
@router.post("/apply", response_model=ApplyResponse)
async def apply_posting(
body: ApplyBody,
request: Request,
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> ApplyResponse:
"""Apply to a posting. Gate composition (D-043, unchanged order):
G-5 allowlist → require_verified_adult (18+ verified, verify-CTA
403) → rate caps → posting exists (404) + published (409) →
idempotent insert (A-408: a duplicate returns the EXISTING
application with 200)."""
_enforce_allowlist(body.learner_id, settings)
await require_verified_age(18, body.learner_id, _identity_store(request))
_check_apply_rate(body.learner_id)
posting = store.get_posting(body.posting_id)
if posting is None:
raise HTTPException(
status_code=404, detail=f"no posting with id {body.posting_id!r}"
)
if posting.status != "published":
raise HTTPException(
status_code=409,
detail={
"reason": "posting_not_published",
"status": posting.status,
},
)
application, _created = store.insert_application(
posting_id=body.posting_id, learner_id=body.learner_id
)
return ApplyResponse(
application_id=application.id,
posting_id=application.posting_id,
status=application.status,
)
@@ -0,0 +1,339 @@
"""/v1/marketplace/admin — founder/operator surface (REQ-6-001, D-057).
Guard: `require_admin` compares X-Admin-Token OR Authorization: Bearer
against Settings().admin_token via secrets.compare_digest (G-108).
UNSET token → every admin route 403s — fail-closed, NEVER open (a loud
boot warning in main.py names the fix, G-11). Admin routes do NOT pass
the learner gates (different principal class, D-057/D-043 untouched).
Routes: create draft posting, publish/close lifecycle (invalid
transition → 409), seeded-corpus ingest trigger, applications review
(filterable by posting). No cron/crawler — ingest is founder-invoked
(A-401/A-402).
"""
from __future__ import annotations
import logging
import secrets
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from ..config import Settings
from ..llm.base import LLMProvider
from ..marketplace.base import Posting
from ..marketplace.ingest import SeededCorpusSource, run_ingest
from ..marketplace.store import InvalidTransition, MarketplaceStore
from ..marketplace.tagging import TaggingUnavailable, tag_posting
from .deps import get_marketplace_store, get_provider, get_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/marketplace/admin", tags=["marketplace-admin"])
def _extract_admin_token(
x_admin_token: Annotated[str | None, Header(include_in_schema=False)] = None,
authorization: Annotated[str | None, Header(include_in_schema=False)] = None,
) -> str | None:
"""Bearer of the request: X-Admin-Token wins; else Authorization:
Bearer <token>. None when neither header carries a candidate."""
if x_admin_token:
return x_admin_token
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.strip().lower() == "bearer" and token.strip():
return token.strip()
return None
def require_admin(
request_token: Annotated[str | None, Depends(_extract_admin_token)],
settings: Annotated[Settings, Depends(get_settings)],
) -> Settings:
"""D-057 fail-closed guard: constant-time compare (G-108) against the
env-only AI_ADMIN_TOKEN (SecretStr — never repr'd/logged); UNSET → 403
for EVERY request (never open). Returns the settings for the route
handlers."""
configured = settings.admin_token.get_secret_value()
if not configured or request_token is None:
# Fail-closed: no configured token, or no candidate at all — the
# surface is locked, not open (D-057).
raise HTTPException(status_code=403, detail="admin token required")
if not secrets.compare_digest(request_token, configured):
raise HTTPException(status_code=403, detail="invalid admin token")
return settings
# -- contracts ----------------------------------------------------------------
class AdminPostingCreate(BaseModel):
"""Admin-authored posting body. Starts as a DRAFT (G-102: only
seed-port postings enter published); publish is an explicit call."""
employer_id: str = Field(min_length=1)
title: str = Field(min_length=1)
description: str = ""
required_competencies: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
seniority: str | None = None
category: str | None = None
location: str = ""
remote: bool = False
salary_min: int | None = None
salary_max: int | None = None
class AdminPostingResponse(BaseModel):
id: str
source: str
external_id: str
employer_id: str
title: str
status: str
class IngestResponse(BaseModel):
postings_upserted: int
employers_upserted: int
class AdminPostingEdit(BaseModel):
"""P2 (Task 2-1-03): partial content edit; fields omitted stay put.
A successful edit RE-TAGS the posting (tags are derived data,
D-052 — the tagger overwrites)."""
title: str | None = Field(default=None, min_length=1)
description: str | None = None
skills: list[str] | None = None
class AdminApplication(BaseModel):
id: str
posting_id: str
learner_id: str
status: str
created_at: str
class AdminApplicationsResponse(BaseModel):
applications: list[AdminApplication]
# -- routes --------------------------------------------------------------------
@router.post("/postings", status_code=201, response_model=AdminPostingResponse)
async def create_posting(
body: AdminPostingCreate,
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
) -> AdminPostingResponse:
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
adapter — source=admin). Publish is a separate lifecycle call."""
posting = Posting(
id=f"adm-{uuid.uuid4().hex[:16]}",
source="admin",
external_id=f"adm-{uuid.uuid4().hex[:16]}",
employer_id=body.employer_id,
title=body.title,
description=body.description,
required_competencies=body.required_competencies,
skills=body.skills,
seniority=body.seniority, # type: ignore[arg-type]
category=body.category,
location=body.location,
remote=body.remote,
salary_min=body.salary_min,
salary_max=body.salary_max,
status="draft",
)
try:
stored = store.insert_posting(posting)
except Exception as exc: # surfaced IntegrityError from the store
raise HTTPException(status_code=409, detail=str(exc)) from exc
return AdminPostingResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@router.post(
"/postings/{posting_id}/publish", response_model=AdminPostingResponse
)
async def publish_posting(
posting_id: str,
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
) -> AdminPostingResponse:
"""draft → published (the only valid move from draft; anything else
409s). Stamps posted_at NOW when the row has none (G-102: seed-port
postings keep their original postedAt)."""
try:
stored = store.set_posting_status(posting_id, "published")
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if stored is None:
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
return AdminPostingResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@router.post(
"/postings/{posting_id}/close", response_model=AdminPostingResponse
)
async def close_posting(
posting_id: str,
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
) -> AdminPostingResponse:
"""published → closed (the only valid move from published; 409 on
closing a draft or re-closing)."""
try:
stored = store.set_posting_status(posting_id, "closed")
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if stored is None:
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
return AdminPostingResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@router.put("/postings/{posting_id}", response_model=AdminPostingResponse)
async def edit_posting(
posting_id: str,
body: AdminPostingEdit,
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
provider: LLMProvider = Depends(get_provider),
) -> AdminPostingResponse:
"""P2 (Task 2-1-03): edit title/description/skills → RE-TAG the
posting (D-052: tags are derived data — the new extraction
OVERWRITES the old tags column; old tags gone). Tag failure is
honest: the edit persists, tags stay as they were, a warning logs."""
updated = store.update_posting_fields(
posting_id,
title=body.title,
description=body.description,
skills=body.skills,
)
if updated is None:
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
try:
tags = await tag_posting(
provider,
model=settings.model,
title=updated.title,
description=updated.description,
skills_hint=updated.skills,
)
updated = store.set_posting_tags(posting_id, tags) or updated
except TaggingUnavailable:
# Honest: the EDIT landed; tags keep their previous value (the
# warning already logged in tagging.py). Never 500 on tag failure.
pass
return AdminPostingResponse(
id=updated.id,
source=updated.source,
external_id=updated.external_id,
employer_id=updated.employer_id,
title=updated.title,
status=updated.status,
)
@router.post("/ingest", response_model=IngestResponse)
async def ingest(
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
provider: LLMProvider = Depends(get_provider),
) -> IngestResponse:
"""Run the seeded corpus source now (founder-invoked — no scheduler,
A-401/A-402). Idempotent on (source, external_id): a double-run
yields identical rows (MH-1b). Every posting is TAGGED after
upsert (P2, D-052) — a tag failure leaves the posting UNTAGGED with
a warning, never a crashed ingest."""
source = SeededCorpusSource()
result = run_ingest(source, store)
tagged = 0
untagged = 0
for posting in source.fetch():
stored = store.get_posting(posting.id)
if stored is None: # pragma: no cover - upsert just ran
continue
try:
tags = await tag_posting(
provider,
model=settings.model,
title=stored.title,
description=stored.description,
skills_hint=stored.skills,
)
store.set_posting_tags(stored.id, tags)
tagged += 1
except TaggingUnavailable:
untagged += 1
if untagged:
logger.warning(
"marketplace ingest tagged %s/%s postings — %s left untagged "
"(provider failures are honest None, never fabricated)",
tagged,
tagged + untagged,
untagged,
)
return IngestResponse(
postings_upserted=result.postings_upserted,
employers_upserted=result.employers_upserted,
)
@router.get(
"/applications", response_model=AdminApplicationsResponse
)
async def list_applications(
posting_id: str | None = None,
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
) -> AdminApplicationsResponse:
"""Applications review list (newest first), filterable by posting.
The founder contacts employers out-of-band (no notification infra,
A-408)."""
rows: list[Any] = store.list_applications(posting_id=posting_id)
return AdminApplicationsResponse(
applications=[
AdminApplication(
id=a.id,
posting_id=a.posting_id,
learner_id=a.learner_id,
status=a.status,
created_at=a.created_at.isoformat() if a.created_at else "",
)
for a in rows
]
)
@@ -0,0 +1,54 @@
"""G-101 placeholder-shell serving (v0.6).
Next 15's static export emits exactly one placeholder page per dynamic
marketplace route (`out/marketplace/jobs/_/index.html`). Under the
single-port deploy (D-039) these routes serve that shell for ANY id so
runtime-created postings stay reachable and every sitemap URL resolves
(G-109). The shell client-fetches the real data by path id and
notFound()s on 404.
Registered before the StaticFiles mount so the routes win (the same
routes-beat-the-mount mechanism the sitemap relies on). In dev mode
(web_static_dir unset) the Next dev server handles routing itself —
no-op.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, FastAPI
from fastapi.responses import FileResponse
from ..config import Settings
_SHELL_ROUTES = (
("/marketplace/jobs/{job_id}", "marketplace/jobs/_/index.html"),
("/marketplace/jobs/{job_id}/", "marketplace/jobs/_/index.html"),
("/marketplace/employers/{employer_id}", "marketplace/employers/_/index.html"),
("/marketplace/employers/{employer_id}/", "marketplace/employers/_/index.html"),
)
def register_marketplace_shell_routes(app: FastAPI, settings: Settings) -> None:
web_dir = Path(str(settings.web_static_dir or ""))
if str(settings.web_static_dir) in ("", ".") or not web_dir.is_dir():
# Dev/no-mount boots: the Next dev server or a separate web origin
# handles these paths. Nothing to serve here.
return
router = APIRouter()
for path, shell in _SHELL_ROUTES:
def _serve(shell_file: str = shell) -> FileResponse:
page = web_dir / shell_file
if page.is_file():
return FileResponse(page, media_type="text/html")
raise RuntimeError( # pragma: no cover - deploy misconfig
f"marketplace shell missing: {page} — rebuild the web export"
)
router.get(path, include_in_schema=False)(_serve)
app.include_router(router)
+14 -1
View File
@@ -4,7 +4,7 @@ import logging
from pathlib import Path
from typing import Annotated
from pydantic import field_validator
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
# apps/ai-service/ (sandbox dir default is relative to the app, not the CWD)
@@ -156,6 +156,19 @@ class Settings(BaseSettings):
# resubmit) and a per-learner submit rate ceiling.
identity_submits_per_min: int = 3
# v0.6 marketplace (REQ-6-001, D-057): the founder/admin surface is
# guarded by a shared secret — X-Admin-Token or Authorization: Bearer
# compared via secrets.compare_digest (G-108). Env-only (D-014), never
# logged; SecretStr keeps it out of accidental repr/logs. UNSET =
# every /v1/marketplace/admin/* route 403s — fail closed, NEVER open
# (a loud boot warning names the fix, G-11).
admin_token: SecretStr = SecretStr("")
# v0.6 P5 placeholder (D-056): the deployed origin for sitemap.xml /
# robots.txt absolute URLs (consumed by the SEO phase; documented
# now so operators see it beside the admin token).
public_base_url: str = ""
# Voice provider selection (REQ-5-001, D-040): 'mock' (default — the
# no-key path is first-class; tests never call a real voice API),
# 'browser' (client-native SR/TTS; the descriptor tells the web client),
+38 -4
View File
@@ -161,6 +161,24 @@ def create_app(settings: Settings | None = None) -> FastAPI:
if getattr(app.state, "identity_provider", None) is None:
app.state.identity_provider = MockIdentityProvider()
# Marketplace engine (v0.6, REQ-6-001): 6th D-027 store family on
# the same SQLite file. State-injection override preserved.
if getattr(app.state, "marketplace_store", None) is None:
from .marketplace.store import SQLiteMarketplaceStore
app.state.marketplace_store = SQLiteMarketplaceStore(
db_path=settings.db_path
)
# G-11/D-057: admin routes fail CLOSED on an unset token, and the
# founder must be told loudly at boot (never a silent lockout).
if not settings.admin_token.get_secret_value():
logger.warning(
"AI_ADMIN_TOKEN is unset — /v1/marketplace/admin/* routes "
"are 403 (fail-closed). Set AI_ADMIN_TOKEN in .env to "
"author postings, run ingest, and review applications."
)
# Grading persistence + engine (REQ-3-004): GradeStore from the same
# SQLite file as traces (D-027), one GradingEngine singleton wired
# through app.state — the engine receives its stores via constructor
@@ -244,13 +262,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(variants_router)
app.include_router(defense_router)
# v0.5 identity (REQ-5-003/004): verification flow + age-gate deps
# + the one marketplace 18+ gated stub (G-18).
from .api.identity import marketplace_router
# v0.5 identity (REQ-5-003/004): verification flow + age-gate deps.
# v0.6: the G-18 marketplace stub moved to api/marketplace.py and is
# REAL (D-058) — identity keeps only its own router + gate exports.
from .api.identity import router as identity_router
from .api.marketplace import router as marketplace_public_router
from .api.marketplace_admin import router as marketplace_admin_router
app.include_router(identity_router)
app.include_router(marketplace_router)
app.include_router(marketplace_public_router)
app.include_router(marketplace_admin_router)
# A-305/D1: PII-safe 422s — FastAPI echoes the offending `input` in
# validation errors by default; for the identity submit body that
@@ -285,6 +306,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_exception_handler(RequestValidationError, _scrub_pii_validation)
# v0.6 G-101: Next 15 output:'export' hard-throws (E87) for dynamic
# routes without generateStaticParams, so jobs/[jobId] and
# employers/[employerId] export exactly ONE placeholder shell
# (out/marketplace/jobs/_/index.html). These catch-all routes serve
# that shell for ANY id — registered BEFORE the StaticFiles mount so
# they win (the same routes-beat-the-mount mechanism the sitemap
# relies on, G-109). The shell client-fetches the real posting by
# the path id and notFound()s on 404 — a resolvable URL for every
# sitemap entry.
from .api.marketplace_shells import register_marketplace_shell_routes
register_marketplace_shell_routes(app, settings)
# v0.3.6 single-port deploy: serve the exported web app (apps/web/out)
# from the SAME origin as the API when AI_WEB_STATIC_DIR is set. Mounted
# AFTER all routers, so /v1/*, /health, /docs win; StaticFiles(html=True)
@@ -0,0 +1,12 @@
"""Marketplace engine package (REQ-6-001, D-048/D-049).
The 6th engine module inside apps/ai-service (no new apps, no new ports,
D-031 boundary): pydantic contracts (`base.py`), the D-027 store family's
sixth SQLite store (`store.py`), and PostingSource ingest adapters
(`ingest.py`). Tagging (P2), embedding (P3), enrichment + resume (P4)
extend this package; the HTTP surface lives in `api/marketplace*.py`
which composes this module via DI.
Boundary (D-031/D-048): `marketplace/` never imports `agents/` or
`api/`; this package imports config + stdlib only.
"""
@@ -0,0 +1,174 @@
"""Marketplace pydantic contracts (REQ-6-001, D-048, A-402).
The wire + store shapes for the postings backend. `IngestPosting` is the
A-402 adapter shape (what every PostingSource yields); `Posting` is the
full row shape (status lifecycle + tags); `Employer` carries the raw
ingested fields (enrichment + provenance merge is P4's read-time job).
D-051/D-053 note: the mock `matchScore` from the TS fixtures is NOT part
of any contract — P3 computes real scores at search time; absent score =
absent query context (G-106).
Boundary (D-048): mirrors D-031 — never imports `agents/` or `api/`.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
PostingStatus = Literal["draft", "published", "closed"]
#: Lifecycle edge set (D-049/D-057): draft → published → closed. A
#: transition not in this map is invalid (admin route → 409).
VALID_TRANSITIONS: dict[str, str] = {
"draft": "published",
"published": "closed",
}
Seniority = Literal["entry", "mid", "senior", "staff", "principal"]
#: P2 (D-052) enumerates the category taxonomy; the seed corpus carries
#: category=null and P2's tagging pass fills it. The store accepts any
#: short string (validation lands with the tagger); `None` = not yet
#: tagged.
Category = str | None
class PostingTags(BaseModel):
"""Derived tags on a posting row (D-052 schema, persisted as the
posting's JSON `tags` column in P2; the column exists from day one).
`skills` is bounded at 10 by the tagger (D-052); the schema itself
keeps the bound so the store can validate a hand-authored payload.
`extra="forbid"`: a non-tag JSON object (e.g. the mock provider's
canned {"summary":...}) must NOT silently validate as all-default
tags — the untagged signal stays honest (verifier P2-3; P3
embeddings must not treat empty-default parses as real tags).
"""
model_config = ConfigDict(frozen=True, extra="forbid")
skills: list[str] = Field(default_factory=list, max_length=10)
seniority: Seniority | None = None
category: Category = None
class Employer(BaseModel):
"""Raw ingested employer fields (D-049 `employer` table row shape).
P4 (D-053) enriches on read with per-field `{value, source}`
provenance; P1 surfaces the raw fields only.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
source: str = Field(min_length=1)
external_id: str = Field(min_length=1)
name: str = Field(min_length=1)
logo: str = ""
description: str = ""
industry: str = ""
size: str = ""
location: str = ""
website: str = ""
social_links: dict[str, str] = Field(default_factory=dict)
culture: str = ""
class IngestPosting(BaseModel):
"""The A-402 PostingSource payload: (source, external_id) identity +
employer binding + the raw posting fields. Idempotent upsert key is
(source, external_id) — a re-fetch overwrites the same row in place.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
source: str = Field(min_length=1)
external_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
title: str = Field(min_length=1)
description: str = ""
required_competencies: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
seniority: Seniority | None = None
category: Category = None
location: str = ""
remote: bool = False
salary_min: int | None = None
salary_max: int | None = None
#: G-102: seed-port postings carry their ORIGINAL postedAt; the
#: store applies it verbatim (status=published via the ingest
#: contract, not the field).
posted_at: datetime | None = None
class Posting(BaseModel):
"""Full posting row (D-049 `posting` table). Status is the lifecycle
column (draft|published|closed, D-057); `tags` is the P2-derived
JSON column (D-052 — empty until the tagger runs). `matchScore` is
deliberately absent (G-106/G-107).
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
source: str = Field(min_length=1)
external_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
title: str = Field(min_length=1)
description: str = ""
required_competencies: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
seniority: Seniority | None = None
category: Category = None
location: str = ""
remote: bool = False
salary_min: int | None = None
salary_max: int | None = None
status: PostingStatus = "draft"
tags: PostingTags | None = None
posted_at: datetime | None = None
class Application(BaseModel):
"""A learner's application to a posting (D-049 `application` table).
Unique (posting_id, learner_id) → duplicate apply is idempotent
200-with-existing (A-408/D-058). P1 status is always `submitted`;
richer states are post-v0.6.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
status: str = "submitted"
created_at: datetime | None = None
class PostingPage(BaseModel):
"""Published-only list response (G-103): the page + the honest
total so the board can render "Showing X of Y" over a paged
fetch-all without fabrication."""
postings: list[Posting]
total_published: int
class IngestResult(BaseModel):
"""Honest ingest run summary (founder surface)."""
postings_upserted: int = 0
employers_upserted: int = 0
def posting_summary(posting: Posting) -> dict[str, Any]:
"""Serialization helper shared by the list + detail responses — one
shape so the G-107 pin (no `matchScore` in any marketplace-path
payload) covers both surfaces."""
return posting.model_dump(mode="json")
@@ -0,0 +1,97 @@
"""PostingSource adapters + ingest runner (REQ-6-001, D-050, A-402).
`PostingSource` is the adapter port: `fetch() -> Iterable[IngestPosting]`.
v0.6 ships ONE implementation, `SeededCorpusSource` over the in-repo
`seed_corpus.json` (ported ONCE from packages/mock-data/jobs.ts +
employers.ts per D-021 — shared ids, mock matchScore DROPPED: D-051
computes real scores at search time, G-106/G-107). The 3M+ external
corpus lands later as a third adapter with zero store/route changes.
Ingest-status contract (G-102): SEED-PORT postings enter as `published`
with their ORIGINAL postedAt — otherwise the board/facets/P1's success
criterion sit empty after ingest. ADMIN-AUTHORED postings start
`draft` (the admin route's create path, not this runner).
No scheduler/crawler (A-401/A-402): ingest runs when the founder POSTs
/v1/marketplace/admin/ingest; `run_ingest` is idempotent on
(source, external_id) — a double-run yields identical row counts.
Boundary (D-048): never imports `agents/` or `api/`.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Iterable
from pathlib import Path
from typing import Protocol, runtime_checkable
from .base import Employer, IngestPosting, IngestResult
from .store import MarketplaceStore
logger = logging.getLogger(__name__)
#: The ported TS fixture corpus ships IN THE PACKAGE (importlib-free
#: path resolution: this file's parent) — one JSON file, no codegen.
_SEED_PATH = Path(__file__).resolve().parent / "seed_corpus.json"
@runtime_checkable
class PostingSource(Protocol):
"""The ingest port (D-050): yields IngestPosting payloads. Employers
ride the corpus alongside postings (the seeded source exposes both;
a pure-posting adapter simply fetches postings and leaves employers
to a separate source)."""
def fetch(self) -> Iterable[IngestPosting]:
...
def fetch_employers(self) -> Iterable[Employer]:
...
class SeededCorpusSource:
"""The seeded corpus adapter over seed_corpus.json (D-050).
Pure + deterministic: reads the JSON fixture once per fetch; the
runner upserts idempotently. Raw fields map 1:1 from the TS shapes
(camelCase → snake_case port, matchScore dropped — G-102/G-107)."""
def __init__(self, path: Path = _SEED_PATH) -> None:
self._path = path
def _load(self) -> dict:
with self._path.open(encoding="utf-8") as fh:
return json.load(fh)
def fetch(self) -> Iterable[IngestPosting]:
for raw in self._load().get("postings", []):
yield IngestPosting.model_validate(raw)
def fetch_employers(self) -> Iterable[Employer]:
for raw in self._load().get("employers", []):
yield Employer.model_validate(raw)
def run_ingest(source: PostingSource, store: MarketplaceStore) -> IngestResult:
"""Idempotent upsert run: every (source, external_id) overwrites its
row in place — a second run yields identical counts (MH-1b)."""
result = IngestResult()
# Employers first: postings reference employer ids (no FK today, but
# the reference ordering keeps the board honest mid-run).
for employer in source.fetch_employers():
store.upsert_employer(employer)
result.employers_upserted += 1
for posting in source.fetch():
# G-102: seed-port postings enter PUBLISHED with their original
# postedAt (the IngestPosting carries it verbatim).
store.upsert_posting(posting, status="published")
result.postings_upserted += 1
logger.info(
"marketplace ingest: %s postings, %s employers (idempotent upserts)",
result.postings_upserted,
result.employers_upserted,
)
return result
@@ -0,0 +1,695 @@
{
"employers": [
{
"id": "emp-openai",
"source": "seed-corpus",
"external_id": "emp-openai",
"name": "OpenAI Labs",
"logo": "https://placehold.co/80x80/indigo/white?text=OA",
"description": "Mock employer building frontier AI systems. We build and deploy large language models and agentic tools used by millions of developers and consumers.",
"industry": "Artificial Intelligence",
"size": "2,000+",
"location": "San Francisco, CA",
"website": "https://example.com/openai",
"social_links": {
"twitter": "https://example.com/openai/x",
"linkedin": "https://example.com/openai/li"
},
"culture": "Research-driven, shipping-paced, safety-conscious. We pair frontier research with product rigor."
},
{
"id": "emp-anthropic",
"source": "seed-corpus",
"external_id": "emp-anthropic",
"name": "Anthropic Research",
"logo": "https://placehold.co/80x80/amber/white?text=AN",
"description": "Mock employer focused on AI safety and alignment. We build reliable, interpretable, and steerable AI systems.",
"industry": "AI Safety",
"size": "1,000+",
"location": "San Francisco, CA",
"website": "https://example.com/anthropic",
"social_links": {
"twitter": "https://example.com/anthropic/x",
"linkedin": "https://example.com/anthropic/li"
},
"culture": "Safety-first, evidence-based, calm-paced. We value thoroughness over speed when stakes are high."
},
{
"id": "emp-huggingface",
"source": "seed-corpus",
"external_id": "emp-huggingface",
"name": "Hugging Face",
"logo": "https://placehold.co/80x80/yellow/black?text=HF",
"description": "Mock employer building the open-source AI community. We host models, datasets, and demos for the global ML community.",
"industry": "Open Source AI",
"size": "500+",
"location": "New York, NY",
"website": "https://example.com/hf",
"social_links": {
"twitter": "https://example.com/hf/x",
"linkedin": "https://example.com/hf/li"
},
"culture": "Community-first, open-by-default, remote-friendly. We ship in the open with thousands of contributors."
},
{
"id": "emp-scaleai",
"source": "seed-corpus",
"external_id": "emp-scaleai",
"name": "Scale AI",
"logo": "https://placehold.co/80x80/violet/white?text=SC",
"description": "Mock employer providing data and evaluation infrastructure for frontier AI. We power the RLHF and eval pipelines behind many model releases.",
"industry": "AI Infrastructure",
"size": "1,500+",
"location": "San Francisco, CA",
"website": "https://example.com/scale",
"social_links": {
"twitter": "https://example.com/scale/x",
"linkedin": "https://example.com/scale/li"
},
"culture": "Infrastructure-minded, quality-obsessed, enterprise-aware. We bridge research and production data."
},
{
"id": "emp-perplexity",
"source": "seed-corpus",
"external_id": "emp-perplexity",
"name": "Perplexity",
"logo": "https://placehold.co/80x80/teal/white?text=PX",
"description": "Mock employer building an AI-powered answer engine. We combine retrieval, generation, and citations for trustworthy answers.",
"industry": "AI Search",
"size": "300+",
"location": "San Francisco, CA",
"website": "https://example.com/perplexity",
"social_links": {
"twitter": "https://example.com/perplexity/x",
"linkedin": "https://example.com/perplexity/li"
},
"culture": "Answer-focused, fast-iterating, citation-proud. We treat groundedness as a product feature."
},
{
"id": "emp-cohere",
"source": "seed-corpus",
"external_id": "emp-cohere",
"name": "Cohere",
"logo": "https://placehold.co/80x80/pink/white?text=CO",
"description": "Mock employer building enterprise-grade language models. We specialize in retrieval, multilinguality, and data privacy for regulated industries.",
"industry": "Enterprise LLMs",
"size": "400+",
"location": "Toronto, Canada",
"website": "https://example.com/cohere",
"social_links": {
"twitter": "https://example.com/cohere/x",
"linkedin": "https://example.com/cohere/li"
},
"culture": "Enterprise-aware, research-active, multilingual. We ship models that work in 100+ languages."
},
{
"id": "emp-mistral",
"source": "seed-corpus",
"external_id": "emp-mistral",
"name": "Mistral AI",
"logo": "https://placehold.co/80x80/red/white?text=MI",
"description": "Mock employer building open-weight frontier models in Europe. We push efficiency and openness in large language models.",
"industry": "Open-Weight LLMs",
"size": "200+",
"location": "Paris, France",
"website": "https://example.com/mistral",
"social_links": {
"twitter": "https://example.com/mistral/x",
"linkedin": "https://example.com/mistral/li"
},
"culture": "European-rooted, efficiency-driven, open-weight-proud. We ship small models that punch above their size."
},
{
"id": "emp-replicate",
"source": "seed-corpus",
"external_id": "emp-replicate",
"name": "Replicate",
"logo": "https://placehold.co/80x80/orange/white?text=RE",
"description": "Mock employer making ML deployment delightful. We host, serve, and scale models with a few lines of code.",
"industry": "ML Deployment",
"size": "150+",
"location": "San Francisco, CA",
"website": "https://example.com/replicate",
"social_links": {
"twitter": "https://example.com/replicate/x",
"linkedin": "https://example.com/replicate/li"
},
"culture": "Developer-experience-obsessed, pragmatic, remote-friendly. We make shipping models feel like shipping software."
},
{
"id": "emp-pinecone",
"source": "seed-corpus",
"external_id": "emp-pinecone",
"name": "Pinecone",
"logo": "https://placehold.co/80x80/green/white?text=PC",
"description": "Mock employer building the vector database for AI applications. We power retrieval for thousands of production RAG systems.",
"industry": "Vector Databases",
"size": "300+",
"location": "New York, NY",
"website": "https://example.com/pinecone",
"social_links": {
"twitter": "https://example.com/pinecone/x",
"linkedin": "https://example.com/pinecone/li"
},
"culture": "Infra-focused, latency-obsessed, distributed-first. We treat retrieval as the heart of grounded AI."
},
{
"id": "emp-langchain",
"source": "seed-corpus",
"external_id": "emp-langchain",
"name": "LangChain",
"logo": "https://placehold.co/80x80/blue/white?text=LC",
"description": "Mock employer building the orchestration layer for LLM applications. We provide frameworks for agents, retrieval, and evaluation.",
"industry": "AI Orchestration",
"size": "200+",
"location": "San Francisco, CA",
"website": "https://example.com/langchain",
"social_links": {
"twitter": "https://example.com/langchain/x",
"linkedin": "https://example.com/langchain/li"
},
"culture": "Framework-minded, community-driven, abstractions-first. We build primitives others compose into products."
}
],
"postings": [
{
"id": "job-001",
"source": "seed-corpus",
"external_id": "job-001",
"employer_id": "emp-langchain",
"title": "AI Orchestration Engineer",
"description": "Design and operate multi-agent systems that automate complex knowledge workflows. You will own agent topology, tool integration, and evaluation harnesses for production deployments serving millions of queries.",
"required_competencies": [
"stack-orchestration-c001",
"stack-orchestration-c002",
"stack-orchestration-c005"
],
"skills": [
"LangGraph",
"Multi-agent systems",
"RAG",
"Python",
"Evaluation"
],
"seniority": "senior",
"category": null,
"location": "San Francisco, CA",
"remote": true,
"salary_min": 160000,
"salary_max": 240000,
"posted_at": "2026-08-21T10:00:00Z"
},
{
"id": "job-002",
"source": "seed-corpus",
"external_id": "job-002",
"employer_id": "emp-openai",
"title": "LLM Application Developer",
"description": "Build delightful LLM-powered features end-to-end. You will work on retrieval, function calling, streaming UX, and robust output validation for consumer-facing products.",
"required_competencies": [
"stack-orchestration-c003",
"stack-orchestration-c004",
"stack-orchestration-c005"
],
"skills": [
"Python",
"TypeScript",
"RAG",
"Function calling",
"Streaming"
],
"seniority": "mid",
"category": null,
"location": "San Francisco, CA",
"remote": true,
"salary_min": 140000,
"salary_max": 210000,
"posted_at": "2026-08-28T09:30:00Z"
},
{
"id": "job-003",
"source": "seed-corpus",
"external_id": "job-003",
"employer_id": "emp-anthropic",
"title": "AI Safety Researcher",
"description": "Research alignment, interpretability, and robustness of frontier models. Design experiments, run evaluations, and publish findings that improve the safety of deployed AI systems.",
"required_competencies": [
"stack-safety-c001",
"stack-safety-c007",
"stack-safety-c002"
],
"skills": [
"Alignment",
"Interpretability",
"Red teaming",
"Python",
"Research"
],
"seniority": "senior",
"category": null,
"location": "San Francisco, CA",
"remote": false,
"salary_min": 180000,
"salary_max": 280000,
"posted_at": "2026-08-15T12:00:00Z"
},
{
"id": "job-004",
"source": "seed-corpus",
"external_id": "job-004",
"employer_id": "emp-perplexity",
"title": "Prompt Engineer",
"description": "Craft, test, and ship prompt strategies that power answer-engine features. Own prompt libraries, regression suites, and A/B experiments across product surfaces.",
"required_competencies": [
"stack-orchestration-c004",
"stack-orchestration-c001"
],
"skills": [
"Prompt engineering",
"Evaluation",
"A/B testing",
"Python"
],
"seniority": "mid",
"category": null,
"location": "Remote",
"remote": true,
"salary_min": 110000,
"salary_max": 175000,
"posted_at": "2026-09-02T14:15:00Z"
},
{
"id": "job-005",
"source": "seed-corpus",
"external_id": "job-005",
"employer_id": "emp-huggingface",
"title": "AI Product Manager",
"description": "Own the roadmap for AI-powered developer tools. Translate model capabilities into customer value, define success metrics, and ship agentic features with engineering.",
"required_competencies": [
"stack-designer-c001",
"stack-orchestration-c001"
],
"skills": [
"Product strategy",
"AI UX",
"Roadmapping",
"Stakeholder mgmt"
],
"seniority": "senior",
"category": null,
"location": "New York, NY",
"remote": true,
"salary_min": 150000,
"salary_max": 220000,
"posted_at": "2026-08-30T08:00:00Z"
},
{
"id": "job-006",
"source": "seed-corpus",
"external_id": "job-006",
"employer_id": "emp-pinecone",
"title": "RAG Infrastructure Engineer",
"description": "Build the retrieval substrate behind knowledge-grounded AI. Optimize indexing, query latency, and hybrid retrieval across billion-vector workloads.",
"required_competencies": [
"stack-orchestration-c005",
"stack-orchestration-c006"
],
"skills": [
"Vector databases",
"Embeddings",
"Go",
"Distributed systems"
],
"seniority": "mid",
"category": null,
"location": "Remote",
"remote": true,
"salary_min": 130000,
"salary_max": 200000,
"posted_at": "2026-09-05T11:00:00Z"
},
{
"id": "job-007",
"source": "seed-corpus",
"external_id": "job-007",
"employer_id": "emp-scaleai",
"title": "AI Governance Lead",
"description": "Stand up the AI governance program for enterprise customers. Map controls to NIST AI RMF and EU AI Act, run audits, and author model cards at scale.",
"required_competencies": [
"stack-safety-c005",
"stack-safety-c003",
"stack-safety-c013"
],
"skills": [
"AI policy",
"NIST AI RMF",
"Audit",
"Documentation"
],
"seniority": "staff",
"category": null,
"location": "San Francisco, CA",
"remote": true,
"salary_min": 170000,
"salary_max": 230000,
"posted_at": "2026-08-18T16:45:00Z"
},
{
"id": "job-008",
"source": "seed-corpus",
"external_id": "job-008",
"employer_id": "emp-anthropic",
"title": "Human-AI Interaction Designer",
"description": "Design how users collaborate with Claude across products. Own transparency patterns, trust calibration, and agentic interaction for assistant surfaces.",
"required_competencies": [
"stack-designer-c001",
"stack-designer-c003",
"stack-designer-c008"
],
"skills": [
"Figma",
"AI UX",
"Prototyping",
"User research"
],
"seniority": "mid",
"category": null,
"location": "San Francisco, CA",
"remote": true,
"salary_min": 125000,
"salary_max": 190000,
"posted_at": "2026-08-25T10:30:00Z"
},
{
"id": "job-009",
"source": "seed-corpus",
"external_id": "job-009",
"employer_id": "emp-cohere",
"title": "Evaluation Engineer",
"description": "Build and operate the eval platform for LLM-powered products. Design LLM-as-judge pipelines, regression suites, and human-eval panels for production models.",
"required_competencies": [
"stack-orchestration-c007",
"stack-safety-c002"
],
"skills": [
"LLM evaluation",
"Python",
"Statistics",
"Data labeling"
],
"seniority": "mid",
"category": null,
"location": "Toronto, Canada",
"remote": true,
"salary_min": 120000,
"salary_max": 180000,
"posted_at": "2026-09-01T13:20:00Z"
},
{
"id": "job-010",
"source": "seed-corpus",
"external_id": "job-010",
"employer_id": "emp-replicate",
"title": "Robotics Operations Specialist",
"description": "Supervise a fleet of AI-augmented robotic units in a warehouse environment. Interpret anomaly alerts, perform calibrations, and intervene on exceptions.",
"required_competencies": [
"stack-operator-c002",
"stack-operator-c011",
"stack-operator-c003"
],
"skills": [
"Robotics",
"Safety protocols",
"IoT",
"Predictive maintenance"
],
"seniority": "entry",
"category": null,
"location": "Austin, TX",
"remote": false,
"salary_min": 80000,
"salary_max": 115000,
"posted_at": "2026-08-22T09:00:00Z"
},
{
"id": "job-011",
"source": "seed-corpus",
"external_id": "job-011",
"employer_id": "emp-recursion",
"title": "Computational Biologist",
"description": "Apply ML to drug discovery. Build pipelines for phenotype prediction, active learning on assay data, and molecular generation for novel targets.",
"required_competencies": [
"stack-science-c005",
"stack-science-c002",
"stack-science-c011"
],
"skills": [
"Bioinformatics",
"Python",
"PyTorch",
"Drug discovery"
],
"seniority": "senior",
"category": null,
"location": "Boston, MA",
"remote": true,
"salary_min": 145000,
"salary_max": 215000,
"posted_at": "2026-08-12T15:00:00Z"
},
{
"id": "job-012",
"source": "seed-corpus",
"external_id": "job-012",
"employer_id": "emp-deepmind",
"title": "Climate ML Scientist",
"description": "Develop ML models for climate forecasting and energy grid optimization. Work with earth system scientists to downscale GCM output and quantify uncertainty.",
"required_competencies": [
"stack-science-c004",
"stack-science-c014",
"stack-science-c010"
],
"skills": [
"Climate modeling",
"PyTorch",
"Data assimilation",
"PINNs"
],
"seniority": "senior",
"category": null,
"location": "London, UK",
"remote": true,
"salary_min": 135000,
"salary_max": 200000,
"posted_at": "2026-08-27T11:45:00Z"
},
{
"id": "job-013",
"source": "seed-corpus",
"external_id": "job-013",
"employer_id": "emp-langchain",
"title": "Agent Reliability Engineer",
"description": "Own observability and reliability for production agent workloads. Build tracing, alerting, and rollback systems for multi-step agent pipelines.",
"required_competencies": [
"stack-orchestration-c013",
"stack-orchestration-c015",
"stack-orchestration-c008"
],
"skills": [
"Observability",
"Python",
"SRE",
"Distributed tracing"
],
"seniority": "mid",
"category": null,
"location": "Remote",
"remote": true,
"salary_min": 140000,
"salary_max": 205000,
"posted_at": "2026-09-04T09:00:00Z"
},
{
"id": "job-014",
"source": "seed-corpus",
"external_id": "job-014",
"employer_id": "emp-mistral",
"title": "AI Red Team Lead",
"description": "Lead adversarial testing of frontier and open-weight models. Build automated red-team suites, track vulnerabilities, and coordinate disclosure.",
"required_competencies": [
"stack-safety-c002",
"stack-safety-c010",
"stack-safety-c001"
],
"skills": [
"Red teaming",
"Prompt injection",
"Python",
"Leadership"
],
"seniority": "staff",
"category": null,
"location": "Paris, France",
"remote": true,
"salary_min": 160000,
"salary_max": 230000,
"posted_at": "2026-08-19T10:30:00Z"
},
{
"id": "job-015",
"source": "seed-corpus",
"external_id": "job-015",
"employer_id": "emp-huggingface",
"title": "Conversation Designer",
"description": "Design dialogue flows, personas, and repair strategies for AI assistants across open-source products. Partner with ML engineers to align tone with model behavior.",
"required_competencies": [
"stack-designer-c002",
"stack-designer-c010",
"stack-designer-c011"
],
"skills": [
"Conversation design",
"Figma",
"Voice UX",
"Prototyping"
],
"seniority": "mid",
"category": null,
"location": "Remote",
"remote": true,
"salary_min": 105000,
"salary_max": 160000,
"posted_at": "2026-08-29T14:00:00Z"
},
{
"id": "job-016",
"source": "seed-corpus",
"external_id": "job-016",
"employer_id": "emp-anduril",
"title": "Field AI Technician",
"description": "Deploy and maintain AI vision systems on defense hardware in the field. Calibrate sensors, interpret model alerts, and escalate edge cases to engineering.",
"required_competencies": [
"stack-operator-c004",
"stack-operator-c009",
"stack-operator-c001"
],
"skills": [
"Computer vision",
"Sensor calibration",
"Field ops",
"Python"
],
"seniority": "entry",
"category": null,
"location": "Costa Mesa, CA",
"remote": false,
"salary_min": 90000,
"salary_max": 130000,
"posted_at": "2026-08-14T08:30:00Z"
},
{
"id": "job-017",
"source": "seed-corpus",
"external_id": "job-017",
"employer_id": "emp-deepmind",
"title": "Materials ML Engineer",
"description": "Discover novel materials with ML. Train property-prediction models, run active-learning loops over DFT calculations, and validate candidates experimentally.",
"required_competencies": [
"stack-science-c003",
"stack-science-c002",
"stack-science-c006"
],
"skills": [
"Materials science",
"PyTorch",
"DFT",
"Active learning"
],
"seniority": "senior",
"category": null,
"location": "London, UK",
"remote": true,
"salary_min": 140000,
"salary_max": 210000,
"posted_at": "2026-08-26T12:15:00Z"
},
{
"id": "job-018",
"source": "seed-corpus",
"external_id": "job-018",
"employer_id": "emp-openai",
"title": "AI Trust & Safety Analyst",
"description": "Investigate misuse patterns, triage safety incidents, and improve policy enforcement for consumer AI products. Author postmortems and recommend mitigations.",
"required_competencies": [
"stack-safety-c008",
"stack-safety-c006",
"stack-safety-c002"
],
"skills": [
"Trust & safety",
"Incident response",
"Policy",
"Investigation"
],
"seniority": "mid",
"category": null,
"location": "San Francisco, CA",
"remote": true,
"salary_min": 115000,
"salary_max": 170000,
"posted_at": "2026-09-03T10:45:00Z"
},
{
"id": "job-019",
"source": "seed-corpus",
"external_id": "job-019",
"employer_id": "emp-replicate",
"title": "Edge AI Engineer",
"description": "Optimize and deploy models to edge hardware. Quantize, distill, and compile models for low-latency inference on field devices with constrained budgets.",
"required_competencies": [
"stack-operator-c008",
"stack-orchestration-c012",
"stack-orchestration-c014"
],
"skills": [
"Edge ML",
"TensorRT",
"C++",
"Quantization"
],
"seniority": "mid",
"category": null,
"location": "Remote",
"remote": true,
"salary_min": 130000,
"salary_max": 195000,
"posted_at": "2026-08-24T11:30:00Z"
},
{
"id": "job-020",
"source": "seed-corpus",
"external_id": "job-020",
"employer_id": "emp-cohere",
"title": "AI Research Engineer",
"description": "Push the frontier of language model capabilities. Prototype new architectures, run large-scale experiments, and contribute to publications and open-source releases.",
"required_competencies": [
"stack-orchestration-c001",
"stack-science-c010",
"stack-science-c011"
],
"skills": [
"PyTorch",
"Research",
"Transformers",
"Distributed training"
],
"seniority": "senior",
"category": null,
"location": "Berlin, Germany",
"remote": true,
"salary_min": 155000,
"salary_max": 235000,
"posted_at": "2026-08-20T09:00:00Z"
}
]
}
@@ -0,0 +1,695 @@
"""MarketplaceStore — the 6th D-027 store family (D-049, amended G-104).
Six tables on the shared `Settings().db_path` (one SQLite file with the
five prior stores): `posting`, `employer`, `embedding`, `application`,
`employer_enrichment`, `learner_profile`. Mechanics mirror the
probe-verified identity/store.py exactly: WAL / synchronous=NORMAL /
busy_timeout / foreign_keys pragmas at connect time, `sa_type` portable
columns (String/JSON for Postgres parity), `_validate()`/`_normalize()`
hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware →
naive → tz-aware boundary normalization.
P1 scope: posting/employer/application methods + the lifecycle
transitions (draft→published→closed, invalid → ValueError). The
`embedding`, `employer_enrichment`, and `learner_profile` tables are
CREATED here (schema-only) — their methods land in P3/P4 (D-051 codec,
D-053 append-only rows, G-104 confirmed profiles).
Boundary (D-027/D-048): `marketplace/` never imports `agents/`/`api/`;
this module imports config + base only.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Protocol
from sqlalchemy import UniqueConstraint, event
from sqlalchemy.types import JSON, String
from sqlmodel import Field, Session, SQLModel, create_engine
from ..config import Settings
from .base import (
VALID_TRANSITIONS,
Application,
Employer,
IngestPosting,
Posting,
PostingStatus,
PostingTags,
)
_TABLES = (
"posting",
"employer",
"embedding",
"application",
"employer_enrichment",
"learner_profile",
)
_VALID_STATUS = ("draft", "published", "closed")
class PostingRow(SQLModel, table=True):
"""One marketplace posting (D-049). Unique (source, external_id) —
the idempotent ingest upsert key (A-402) — declared via
UniqueConstraint in __table_args__ (sqlmodel metaclass drops bare
Literal/unique annotations; the DefenseStore/IdentityStore pattern
keeps constraints explicit)."""
__tablename__ = "posting"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_posting_source_external"),
)
id: str = Field(primary_key=True)
source: str = Field(sa_type=String)
external_id: str = Field(sa_type=String)
employer_id: str = Field(index=True, sa_type=String)
title: str = Field(sa_type=String)
description: str = Field(default="", sa_type=String)
required_competencies: list[str] = Field(default_factory=list, sa_type=JSON)
skills: list[str] = Field(default_factory=list, sa_type=JSON)
seniority: str | None = Field(default=None, sa_type=String)
category: str | None = Field(default=None, sa_type=String)
location: str = Field(default="", sa_type=String)
remote: bool = Field(default=False)
salary_min: int | None = Field(default=None)
salary_max: int | None = Field(default=None)
#: Bare Literal annotations crash sqlmodel's column inference; explicit
#: sa_type + the validates hook give the same contract (DefenseStore /
#: IdentityStore pattern).
status: str = Field(default="draft", sa_type=String)
#: P2-derived tags (D-052); JSON column exists from day one, empty
#: until the tagger runs. `None` = untagged.
tags: dict[str, Any] | None = Field(default=None, sa_type=JSON)
posted_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if not self.id or not self.source or not self.external_id:
raise ValueError("id, source, and external_id must be non-empty")
if not self.employer_id or not self.title:
raise ValueError("employer_id and title must be non-empty")
if self.status not in _VALID_STATUS:
raise ValueError(f"invalid posting status {self.status!r}")
if self.seniority is not None and self.seniority not in (
"entry",
"mid",
"senior",
"staff",
"principal",
):
raise ValueError(f"invalid seniority {self.seniority!r}")
if self.salary_min is not None and self.salary_min < 0:
raise ValueError("salary_min must be non-negative")
if self.salary_max is not None and self.salary_max < 0:
raise ValueError("salary_max must be non-negative")
def _normalize(self) -> None:
self.posted_at = _as_utc(self.posted_at) if self.posted_at else None
def to_posting(self) -> Posting:
return Posting(
id=self.id,
source=self.source,
external_id=self.external_id,
employer_id=self.employer_id,
title=self.title,
description=self.description,
required_competencies=list(self.required_competencies or []),
skills=list(self.skills or []),
seniority=self.seniority, # type: ignore[arg-type]
category=self.category,
location=self.location,
remote=bool(self.remote),
salary_min=self.salary_min,
salary_max=self.salary_max,
status=self.status, # type: ignore[arg-type]
tags=self.tags, # type: ignore[arg-type]
posted_at=_as_utc(self.posted_at) if self.posted_at else None,
)
class EmployerRow(SQLModel, table=True):
"""Raw ingested employer fields (D-049). Unique (source, external_id);
P4's `employer_enrichment` merges on read with provenance (D-053)."""
__tablename__ = "employer"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_employer_source_external"),
)
id: str = Field(primary_key=True)
source: str = Field(sa_type=String)
external_id: str = Field(sa_type=String)
name: str = Field(sa_type=String)
logo: str = Field(default="", sa_type=String)
description: str = Field(default="", sa_type=String)
industry: str = Field(default="", sa_type=String)
size: str = Field(default="", sa_type=String)
location: str = Field(default="", sa_type=String)
website: str = Field(default="", sa_type=String)
social_links: dict[str, str] = Field(default_factory=dict, sa_type=JSON)
culture: str = Field(default="", sa_type=String)
def _validate(self) -> None:
if not self.id or not self.source or not self.external_id:
raise ValueError("id, source, and external_id must be non-empty")
if not self.name:
raise ValueError("name must be non-empty")
def to_employer(self) -> Employer:
return Employer(
id=self.id,
source=self.source,
external_id=self.external_id,
name=self.name,
logo=self.logo,
description=self.description,
industry=self.industry,
size=self.size,
location=self.location,
website=self.website,
social_links=dict(self.social_links or {}),
culture=self.culture,
)
class EmbeddingRow(SQLModel, table=True):
"""Posting embedding (D-049, A-403/A-404): struct-packed little-endian
float32 BLOB + per-row model/dim so lazy re-embed on provider change
works. posting_id PK. P1 creates the schema; the codec + put/get
methods land in P3 (D-051) — the data↔ai seam."""
__tablename__ = "embedding"
posting_id: str = Field(primary_key=True)
vector: bytes | None = Field(default=None)
model: str = Field(default="", sa_type=String)
dim: int = Field(default=0)
class ApplicationRow(SQLModel, table=True):
"""A learner's application (D-049): unique (posting_id, learner_id)
→ duplicate apply is idempotent 200-with-existing (A-408/D-058)."""
__tablename__ = "application"
__table_args__ = (
UniqueConstraint("posting_id", "learner_id", name="uq_application_posting_learner"),
)
id: str = Field(primary_key=True)
posting_id: str = Field(index=True, sa_type=String)
learner_id: str = Field(index=True, sa_type=String)
status: str = Field(default="submitted", sa_type=String)
created_at: datetime
def _validate(self) -> None:
if not self.id or not self.posting_id or not self.learner_id:
raise ValueError("id, posting_id, and learner_id must be non-empty")
if not self.status:
raise ValueError("status must be non-empty")
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at)
def to_application(self) -> Application:
return Application(
id=self.id,
posting_id=self.posting_id,
learner_id=self.learner_id,
status=self.status,
created_at=_as_utc(self.created_at),
)
class EmployerEnrichmentRow(SQLModel, table=True):
"""Append-only employer enrichment (D-053, A-409): rows are INSERTED,
never mutated; merge-on-read precedence manual > llm > ingested-raw.
P1 creates the schema; methods + the enrich trigger land in P4."""
__tablename__ = "employer_enrichment"
id: str = Field(primary_key=True)
employer_id: str = Field(index=True, sa_type=String)
field: str = Field(sa_type=String)
value: str = Field(default="", sa_type=String)
source: str = Field(sa_type=String) # 'llm' | 'manual'
model: str = Field(default="", sa_type=String)
created_at: datetime | None = Field(default=None)
class LearnerProfileRow(SQLModel, table=True):
"""Confirmed learner profile (G-104): learner_id PK, the confirmed
profile JSON + model. P1 creates the schema; the resume confirm
route persists here in P4 (D-054) — the D-027 store family is the
only persistence mechanism, never a filesystem sidecar."""
__tablename__ = "learner_profile"
learner_id: str = Field(primary_key=True)
profile: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
confirmed_at: datetime | None = Field(default=None)
model: str = Field(default="", sa_type=String)
def _as_utc(ts: datetime) -> datetime:
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027 pattern)."""
if ts.tzinfo is None:
return ts.replace(tzinfo=UTC)
return ts
def _sqlite_connect(dbapi_connection: object, _: object) -> None:
"""Per-connection pragmas — mirrors the other D-027 stores."""
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close() # type: ignore[attr-defined]
class InvalidTransition(ValueError):
"""A lifecycle move outside draft→published→closed (route → 409)."""
class MarketplaceStore(Protocol):
"""Persistence contract for the marketplace backend (D-049)."""
def create_schema(self) -> None:
"""Ensure all six tables exist (idempotent — shared DB file)."""
...
def upsert_posting(self, ingest: IngestPosting, status: PostingStatus) -> Posting:
"""Idempotent on (source, external_id): a re-upsert overwrites
the SAME row's CONTENT in place (A-402), preserving the row id
and the lifecycle status (an admin's publish/close decision
outranks a seed re-run; only a MISSING row takes `status`)."""
...
def insert_posting(self, posting: Posting) -> Posting:
"""Insert a new posting row (admin authoring path, status=draft).
A duplicate id raises IntegrityError (surfaced)."""
...
def get_posting(self, posting_id: str) -> Posting | None:
"""Point lookup by posting id."""
...
def list_postings(
self, limit: int, offset: int
) -> tuple[list[Posting], int]:
"""Published-only page (created_at/title-agnostic order: posted_at
DESC, then id for deterministic ties) + total_published (G-103)."""
...
def set_posting_status(
self, posting_id: str, new_status: PostingStatus
) -> Posting | None:
"""Lifecycle transition with draft→published→closed validation —
InvalidTransition (409 at the route) on any other move. Unknown
id → None. Publishing a draft stamps posted_at NOW when the row
has none (admin-authored postings)."""
...
def upsert_employer(self, employer: Employer) -> Employer:
"""Idempotent on (source, external_id): a re-upsert overwrites in
place, preserving the row id when the payload carries it."""
...
def get_employer(self, employer_id: str) -> Employer | None:
"""Point lookup by employer id (raw fields only in P1)."""
...
def insert_application(
self, posting_id: str, learner_id: str
) -> tuple[Application, bool]:
"""Idempotent apply (A-408): insert status=submitted; a duplicate
(posting_id, learner_id) returns the EXISTING row + created=False
— never a second application."""
...
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
"""Admin review list, newest first; filterable by posting."""
...
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
"""P2 (D-052): persist derived tags — OVERWRITES the tags column
(single source of truth; re-tag replaces, never appends)."""
...
def update_posting_fields(
self,
posting_id: str,
*,
title: str | None = None,
description: str | None = None,
skills: list[str] | None = None,
) -> Posting | None:
"""P2 admin edit path (Task 2-1-03): partial content update; the
caller re-tags after the write (tags are derived data). Unknown id
→ None (route 404s)."""
...
def facets(self) -> dict:
"""P2 (D-052/MH-2c): value counts over the stored tags of
PUBLISHED-ONLY postings: {skills: [{value, count}], seniority:
[...], category: [...]} — count DESC then value ASC. Untagged
postings contribute NOTHING (honest absence)."""
...
class SQLiteMarketplaceStore:
"""SQLite implementation of MarketplaceStore (D-027/D-049)."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path: Path = db_path if db_path is not None else Settings().db_path
self._engine = create_engine(
f"sqlite:///{self._db_path}",
connect_args={"check_same_thread": False},
)
event.listen(self._engine, "connect", _sqlite_connect)
self.create_schema()
def create_schema(self) -> None:
"""Ensure all six tables exist (idempotent — shared DB file)."""
SQLModel.metadata.create_all(self._engine)
# -- postings -----------------------------------------------------------
def upsert_posting(self, ingest: IngestPosting, status: PostingStatus) -> Posting:
if status not in _VALID_STATUS:
raise ValueError(f"invalid posting status {status!r}")
row = PostingRow(
id=ingest.id,
source=ingest.source,
external_id=ingest.external_id,
employer_id=ingest.employer_id,
title=ingest.title,
description=ingest.description,
required_competencies=list(ingest.required_competencies),
skills=list(ingest.skills),
seniority=ingest.seniority,
category=ingest.category,
location=ingest.location,
remote=ingest.remote,
salary_min=ingest.salary_min,
salary_max=ingest.salary_max,
status=status,
posted_at=ingest.posted_at,
)
row._validate()
row._normalize()
with Session(self._engine) as session:
existing = (
session.query(PostingRow)
.filter(
PostingRow.source == ingest.source,
PostingRow.external_id == ingest.external_id,
)
.one_or_none()
)
if existing is None:
session.add(row)
else:
# Overwrite in place, preserving the row id (a re-port with
# a changed id must not orphan employer references). STATUS
# is NOT overwritten: an admin's lifecycle decision (e.g.
# closed) outranks a seed re-run; only a fresh row takes
# the ingest status (G-102 seed postings enter published).
for field in (
"employer_id",
"title",
"description",
"required_competencies",
"skills",
"seniority",
"category",
"location",
"remote",
"salary_min",
"salary_max",
"posted_at",
):
setattr(existing, field, getattr(row, field))
existing._validate()
existing._normalize()
row = existing
session.commit()
session.refresh(row)
return row.to_posting()
def insert_posting(self, posting: Posting) -> Posting:
row = PostingRow(
id=posting.id,
source=posting.source,
external_id=posting.external_id,
employer_id=posting.employer_id,
title=posting.title,
description=posting.description,
required_competencies=list(posting.required_competencies),
skills=list(posting.skills),
seniority=posting.seniority,
category=posting.category,
location=posting.location,
remote=posting.remote,
salary_min=posting.salary_min,
salary_max=posting.salary_max,
status=posting.status,
tags=dict(posting.tags.model_dump()) if posting.tags else None,
posted_at=posting.posted_at,
)
row._validate()
row._normalize()
with Session(self._engine) as session:
session.add(row)
session.commit() # IntegrityError SURFACES (minted-once id)
session.refresh(row)
return row.to_posting()
def get_posting(self, posting_id: str) -> Posting | None:
with Session(self._engine) as session:
row = session.get(PostingRow, posting_id)
if row is None:
return None
session.refresh(row)
return row.to_posting()
def list_postings(
self, limit: int, offset: int
) -> tuple[list[Posting], int]:
with Session(self._engine) as session:
total = (
session.query(PostingRow)
.filter(PostingRow.status == "published")
.count()
)
rows = (
session.query(PostingRow)
.filter(PostingRow.status == "published")
.order_by(PostingRow.posted_at.desc(), PostingRow.id)
.offset(offset)
.limit(limit)
.all()
)
return [r.to_posting() for r in rows], total
def set_posting_status(
self, posting_id: str, new_status: PostingStatus
) -> Posting | None:
if new_status not in _VALID_STATUS:
raise ValueError(f"invalid posting status {new_status!r}")
with Session(self._engine) as session:
row = session.get(PostingRow, posting_id)
if row is None:
return None
expected = VALID_TRANSITIONS.get(row.status)
if expected is None or expected != new_status:
raise InvalidTransition(
f"cannot transition posting {posting_id!r} from "
f"{row.status!r} to {new_status!r} (valid: draft→published→closed)"
)
row.status = new_status
if new_status == "published" and row.posted_at is None:
# Admin-authored draft: publish stamps NOW. Seed-port
# postings already carry their original postedAt (G-102).
row.posted_at = datetime.now(UTC)
row._validate()
session.commit()
session.refresh(row)
return row.to_posting()
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
with Session(self._engine) as session:
row = session.get(PostingRow, posting_id)
if row is None:
return None
row.tags = tags.model_dump(mode="json")
row._validate()
session.commit()
session.refresh(row)
return row.to_posting()
def update_posting_fields(
self,
posting_id: str,
*,
title: str | None = None,
description: str | None = None,
skills: list[str] | None = None,
) -> Posting | None:
with Session(self._engine) as session:
row = session.get(PostingRow, posting_id)
if row is None:
return None
if title is not None:
row.title = title
if description is not None:
row.description = description
if skills is not None:
row.skills = list(skills)
row._validate()
session.commit()
session.refresh(row)
return row.to_posting()
def facets(self) -> dict:
with Session(self._engine) as session:
rows = (
session.query(PostingRow)
.filter(PostingRow.status == "published")
.all()
)
skills: dict[str, int] = {}
seniority: dict[str, int] = {}
category: dict[str, int] = {}
for row in rows:
tags = row.tags or {}
for skill in tags.get("skills", []) or []:
skills[skill] = skills.get(skill, 0) + 1
if tags.get("seniority"):
seniority[tags["seniority"]] = seniority.get(tags["seniority"], 0) + 1
if tags.get("category"):
category[tags["category"]] = category.get(tags["category"], 0) + 1
def _ranked(counts: dict[str, int]) -> list[dict]:
return [
{"value": v, "count": c}
for v, c in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
]
return {
"skills": _ranked(skills),
"seniority": _ranked(seniority),
"category": _ranked(category),
}
# -- employers ----------------------------------------------------------
def upsert_employer(self, employer: Employer) -> Employer:
row = EmployerRow(
id=employer.id,
source=employer.source,
external_id=employer.external_id,
name=employer.name,
logo=employer.logo,
description=employer.description,
industry=employer.industry,
size=employer.size,
location=employer.location,
website=employer.website,
social_links=dict(employer.social_links),
culture=employer.culture,
)
row._validate()
with Session(self._engine) as session:
existing = (
session.query(EmployerRow)
.filter(
EmployerRow.source == employer.source,
EmployerRow.external_id == employer.external_id,
)
.one_or_none()
)
if existing is None:
session.add(row)
else:
for field in (
"id",
"name",
"logo",
"description",
"industry",
"size",
"location",
"website",
"social_links",
"culture",
):
setattr(existing, field, getattr(row, field))
existing._validate()
row = existing
session.commit()
session.refresh(row)
return row.to_employer()
def get_employer(self, employer_id: str) -> Employer | None:
with Session(self._engine) as session:
row = session.get(EmployerRow, employer_id)
if row is None:
return None
session.refresh(row)
return row.to_employer()
# -- applications -------------------------------------------------------
def insert_application(
self, posting_id: str, learner_id: str
) -> tuple[Application, bool]:
row = ApplicationRow(
id=f"app-{uuid.uuid4().hex[:16]}",
posting_id=posting_id,
learner_id=learner_id,
status="submitted",
created_at=datetime.now(UTC),
)
row._validate()
row._normalize()
with Session(self._engine) as session:
existing = (
session.query(ApplicationRow)
.filter(
ApplicationRow.posting_id == posting_id,
ApplicationRow.learner_id == learner_id,
)
.one_or_none()
)
if existing is not None:
# A-408: idempotent — the EXISTING application rides the
# 200, never a second row.
session.refresh(existing)
existing.created_at = _as_utc(existing.created_at)
return existing.to_application(), False
session.add(row)
session.commit()
session.refresh(row)
return row.to_application(), True
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
with Session(self._engine) as session:
query = session.query(ApplicationRow)
if posting_id is not None:
query = query.filter(ApplicationRow.posting_id == posting_id)
rows = query.order_by(ApplicationRow.created_at.desc()).all()
return [r.to_application() for r in rows]
def close(self) -> None:
self._engine.dispose()
@@ -0,0 +1,107 @@
"""Posting text → skills/seniority/category tags (REQ-6-002, D-052).
Composes the EXISTING structured_completion 4-layer pipeline (D-020)
with the PostingTags schema — no new agent, a one-shot extraction like
grading's rubric call. Mock-first for free: the mock provider's
json_object path (or ScriptedJSONProvider) keeps tests cloud-free.
Tags are DERIVED data (D-052): re-tagging overwrites the posting's tags
column — single source of truth; no append-only semantics (unlike P4's
enrichment provenance).
Tag failure is HONEST, never fatal: a provider error leaves the posting
UNTAGGED (tags=None) with a logged warning — facets treat untagged
postings honestly (they simply do not contribute counts).
"""
from __future__ import annotations
import logging
from ..agents.structured import structured_completion
from ..llm.base import LLMProvider
from ..llm.types import Message
from .base import PostingTags
logger = logging.getLogger(__name__)
#: Prompt version — bump on any wording change (prompts are code).
TAGGING_PROMPT_VERSION = 1
TAGGING_PROMPT = """You are a job-posting tagger for an AI-era roles
marketplace. Extract structured tags from the posting.
Rules:
- skills: 3-10 concrete, lowercase technology/role skills actually named
or clearly required by the posting (e.g. "python", "llm evaluation",
"kubernetes"). Prefer the posting's own vocabulary.
- seniority: exactly one of "entry", "mid", "senior", "staff",
"principal" — infer from title + experience language; omit (null) if
genuinely undeterminable.
- category: exactly one of "ai-orchestration", "ai-safety", "product",
"data", "field-operations", "research" — the role's primary domain;
omit (null) if genuinely undeterminable.
Respond with ONLY the JSON object."""
_SCHEMA_HINT = (
'{"skills": ["up to 10 lowercase skill strings"], '
'"seniority": "entry|mid|senior|staff|principal|null", '
'"category": "ai-orchestration|ai-safety|product|data|'
'field-operations|research|null"}'
)
class TaggingUnavailable(Exception):
"""The tagger could not produce valid tags (provider/parse failure).
The caller persists the posting UNTAGGED — never a crash, never a
fabricated tag set (D-052 honesty).
"""
async def tag_posting(
provider: LLMProvider,
*,
model: str,
title: str,
description: str,
skills_hint: list[str] | None = None,
) -> PostingTags:
"""One-shot tag extraction via D-020 structured completion.
`skills_hint` (the posting's declared skills) rides the prompt so
the tagger anchors on the author's own vocabulary; the schema bound
(≤10) is the enforcement — an 11-skill response fails validation,
runs the single bounded retry, and surfaces TaggingUnavailable if
the retry also misses.
"""
hint = ""
if skills_hint:
hint = "\nDeclared skills: " + ", ".join(skills_hint[:15])
messages = [
Message(role="system", content=TAGGING_PROMPT),
Message(
role="user",
content=f"Title: {title}\n\nDescription:\n{description}{hint}",
),
]
try:
return await structured_completion(
provider,
messages,
model=model,
schema=PostingTags,
schema_hint=_SCHEMA_HINT,
)
except Exception as exc:
# Provider failures (RuntimeError from mock/loud providers) and
# parse failures (StructuredOutputError) both land here: honest
# untagged, loud log.
logger.warning(
"marketplace tagging failed for %r: %s — posting stays "
"untagged (honest None, never fabricated)",
title[:60],
exc,
)
raise TaggingUnavailable(str(exc)) from exc
+25 -12
View File
@@ -3,7 +3,8 @@
MH-3a: store contract — insert/poll/latest/verdict provenance, constraints.
MH-3b: gate composition — allowlist (403, first) → identity verdict (403 +
verify-CTA) → caps; 16-17 school-pass/marketplace-block; under-16 blocked;
G-13 submit caps; G-18 honest stub.
G-13 submit caps. v0.6 (D-058): the marketplace apply route is REAL — the
G-18 honest-501 pin is rewritten in tests/marketplace/test_marketplace_api.py.
MH-3c: PII sentinel — raw DOB + document contents appear in NO log record
and NO stored raw form (caplog + store inspection).
MH-3e: identity flow end-to-end via TestClient against real create_app.
@@ -431,28 +432,36 @@ class TestGateComposition:
json={"learner_id": "minor-learner", "competency_id": "stack-orchestration-c007"},
)
assert variants.status_code == 200, variants.text
# marketplace gate (18+ verified): 403 with the age reason
# marketplace gate (18+ verified): 403 with the age reason.
# v0.6 (D-058): the route moved to api/marketplace.py and is
# REAL — body is {learner_id, posting_id}; the gate fires
# BEFORE any posting lookup, so the posting id is irrelevant.
apply = c.post(
"/v1/marketplace/apply",
json={"learner_id": "minor-learner", "job_id": "job-001"},
json={"learner_id": "minor-learner", "posting_id": "job-001"},
)
assert apply.status_code == 403
detail = apply.json()["detail"]
assert detail["reason"] == "age_gate_18_plus"
assert detail["min_age"] == 18
def test_verified_adult_marketplace_stub_is_honest_501(
def test_verified_adult_marketplace_apply_is_real(
self, gated_client: TestClient
) -> None:
"""G-18: the gate passes; the route NEVER fabricates 'applied'."""
"""G-110 (was the G-18 honest-501 pin): the route is REAL now —
after the gate it answers with the honest application
confirmation, never a fabricated 'applied'. The marketplace
router's full behavioral matrix (404/409/idempotent-duplicate)
lives in tests/marketplace/test_marketplace_api.py; this pin
guards the IDENTITY side: a verified 18+ adult passes the gate."""
resp = gated_client.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "job_id": "job-001"},
json={"learner_id": "pilot-learner", "posting_id": "job-001"},
)
assert resp.status_code == 501
body = resp.json()
assert body["stub"] is True
assert body["mock"] is True
# gate passed: the answer is the real route's (404 posting-
# unknown in this fixture's empty store), NEVER the stub 501.
assert resp.status_code != 501
assert resp.status_code == 404 # honest: no such posting stored
def test_mh3e_flow_unverified_then_enrolled(
self, identity_store: SQLiteIdentityStore, tmp_path: Path
@@ -567,14 +576,18 @@ class TestVerifierHardening:
"/v1/variants",
json={"learner_id": lid, "competency_id": "stack-orchestration-c007"},
)
# v0.6 (D-058): the REAL marketplace route — the gate
# fires before the posting lookup, so the empty store's
# 404 (not the stub-era 501) is what a passed gate looks
# like here.
market = c.post(
"/v1/marketplace/apply",
json={"learner_id": lid, "job_id": "job-001"},
json={"learner_id": lid, "posting_id": "job-001"},
)
assert (school.status_code == 200) is expect_school, (
f"band={band!r} school gate: {school.status_code}"
)
assert (market.status_code == 501) is expect_market, (
assert (market.status_code == 404) is expect_market, (
f"band={band!r} marketplace gate: {market.status_code}"
)
@@ -0,0 +1,120 @@
"""Marketplace test fixtures (hermeticity rule: db_path pins tmp_path —
never ~/.nextcraft, never the cloud).
Mirrors the tests/identity pattern: a tmp-path marketplace store + a
seeded verified-18+ identity store, injected into a real create_app via
state-injection (the G-9 precedent) — the gated apply route sees the
exact composition production runs.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.identity.store import SQLiteIdentityStore
from ai_service.main import create_app
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ..conftest import SUITE_LEARNERS, seed_verified_identity
#: The pilot learner roster for gated-route tests (G-5 allowlist coverage
#: + verified 18+ identity records — the verified_pilot pattern).
MARKETPLACE_LEARNERS = ["pilot-learner", "pilot-learner-2"]
ADMIN_TOKEN = "test-admin-token"
@pytest.fixture()
def marketplace_store(tmp_path: Path) -> SQLiteMarketplaceStore:
"""Hermetic store: the shared-DB pattern on a pytest tmp file."""
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
yield store
store.close()
@pytest.fixture()
def identity_store(tmp_path: Path) -> SQLiteIdentityStore:
store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
seed_verified_identity(store, MARKETPLACE_LEARNERS)
yield store
store.close()
@pytest.fixture()
def settings(tmp_path: Path) -> Settings:
return Settings(
provider="mock",
model="gemma4:31b",
port=8421,
db_path=tmp_path / "nextcraft-test.db",
sandbox_dir=tmp_path / "sandboxes",
learner_allowlist=SUITE_LEARNERS,
)
def make_client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
admin_token: str | None = ADMIN_TOKEN,
) -> TestClient:
"""Full app with state-injected stores; admin_token=None exercises
the D-057 fail-closed matrix."""
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=admin_token or "",
)
app = create_app(settings)
app.state.identity_store = identity_store # state-injection (G-9)
app.state.marketplace_store = marketplace_store
return TestClient(app)
@pytest.fixture(autouse=True)
def _reset_apply_rate_window():
"""The apply rate cap is module-level process state (the D-019
precedent); tests must not inherit each other's windows."""
from ai_service.api.marketplace import _APPLY_TIMES
_APPLY_TIMES.clear()
yield
_APPLY_TIMES.clear()
@pytest.fixture()
def client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
) -> TestClient:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
yield c
@pytest.fixture()
def seeded_client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
) -> TestClient:
"""Client over an INGESTED store (20 published seed postings)."""
from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest
run_ingest(SeededCorpusSource(), marketplace_store)
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
yield c
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@@ -0,0 +1,220 @@
"""Ingest tests (MH-1b idempotency, MH-1f seed fidelity, G-102).
Hermeticity: tmp_path db, the in-repo seed_corpus.json fixture — zero
network, zero ~/.nextcraft.
Pins: double-run yields identical row counts (no dupes, upserts
overwrite in place); 20 postings + 10 employers with ids matching the
TS fixtures (D-021 shared ids); matchScore absent from every serialized
posting; seed postings enter `published` with their ORIGINAL postedAt.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ai_service.marketplace.base import IngestPosting, posting_summary
from ai_service.marketplace.ingest import (
PostingSource,
SeededCorpusSource,
run_ingest,
)
from ai_service.marketplace.store import SQLiteMarketplaceStore
#: The TS fixture ids (D-021 shared ids — jobs.ts + employers.ts).
TS_JOB_IDS = [f"job-{i:03d}" for i in range(1, 21)]
TS_EMPLOYER_IDS = sorted(
[
"emp-openai",
"emp-anthropic",
"emp-huggingface",
"emp-scaleai",
"emp-perplexity",
"emp-cohere",
"emp-mistral",
"emp-replicate",
"emp-pinecone",
"emp-langchain",
]
)
class TestSeededCorpusSource:
def test_source_satisfies_the_protocol(self) -> None:
assert isinstance(SeededCorpusSource(), PostingSource)
def test_seed_fidelity_counts_and_ids(self) -> None:
source = SeededCorpusSource()
postings = list(source.fetch())
employers = list(source.fetch_employers())
assert len(postings) == 20 # jobs.ts fixture count
assert len(employers) == 10 # employers.ts fixture count
assert sorted(p.id for p in postings) == TS_JOB_IDS
assert sorted(e.id for e in employers) == TS_EMPLOYER_IDS
# Employer bindings match the TS fixtures EXACTLY — including
# the three dangling references (emp-deepmind/-recursion/-anduril
# appear in jobs.ts but never in employers.ts; the mock web
# surfaces render them as 'Unknown employer'). The port is
# faithful; the API answers those detail pages with the same
# honest 404 the web's notFound() shows.
assert {p.employer_id for p in postings} == {
"emp-openai",
"emp-anthropic",
"emp-huggingface",
"emp-scaleai",
"emp-perplexity",
"emp-cohere",
"emp-mistral",
"emp-replicate",
"emp-pinecone",
"emp-langchain",
"emp-deepmind",
"emp-recursion",
"emp-anduril",
}
employer_ids = {e.id for e in employers}
assert employer_ids < {p.employer_id for p in postings} # 10 defined
# every DEFINED employer is referenced by at least one posting
assert employer_ids <= {p.employer_id for p in postings}
def test_seed_fixture_fields_match_ts_port(self) -> None:
postings = {p.id: p for p in SeededCorpusSource().fetch()}
job_001 = postings["job-001"]
assert job_001.title == "AI Orchestration Engineer"
assert job_001.employer_id == "emp-langchain"
assert job_001.seniority == "senior"
assert job_001.remote is True
assert job_001.salary_min == 160000
assert job_001.salary_max == 240000
assert "LangGraph" in job_001.skills
# G-102: the original postedAt rides the port verbatim.
assert job_001.posted_at is not None
assert job_001.posted_at.isoformat() == "2026-08-21T10:00:00+00:00"
def test_match_score_absent_from_serialized_postings(self) -> None:
"""G-107/MH-1f: matchScore is absent from EVERY serialized
posting — no camelCase, no snake_case, no variant."""
for posting in SeededCorpusSource().fetch():
blob = json.dumps(posting_summary(posting)).lower()
assert "matchscore" not in blob
assert "match_score" not in blob
def test_seed_json_file_has_no_match_score(self) -> None:
path = (
Path(__file__).resolve().parents[2]
/ "ai_service"
/ "marketplace"
/ "seed_corpus.json"
)
assert "matchScore" not in path.read_text(encoding="utf-8")
data = json.loads(path.read_text(encoding="utf-8"))
assert len(data["postings"]) == 20
assert len(data["employers"]) == 10
class TestRunIngest:
def test_double_run_identical_rows(self, marketplace_store) -> None:
"""MH-1b: running the seeded source twice yields identical row
counts — upserts overwrite in place, no dupes."""
first = run_ingest(SeededCorpusSource(), marketplace_store)
second = run_ingest(SeededCorpusSource(), marketplace_store)
assert first.postings_upserted == second.postings_upserted == 20
assert first.employers_upserted == second.employers_upserted == 10
from sqlalchemy import text
with marketplace_store._engine.connect() as conn: # noqa: SLF001
postings = conn.execute(text("SELECT COUNT(*) FROM posting")).fetchone()[0]
employers = conn.execute(text("SELECT COUNT(*) FROM employer")).fetchone()[0]
assert postings == 20
assert employers == 10
def test_seed_postings_enter_published_with_original_posted_at(
self, marketplace_store
) -> None:
"""G-102: seed-port postings enter `published` carrying their
ORIGINAL postedAt — the board is populated after ingest."""
run_ingest(SeededCorpusSource(), marketplace_store)
job_001 = marketplace_store.get_posting("job-001")
assert job_001 is not None
assert job_001.status == "published"
assert job_001.posted_at is not None
assert job_001.posted_at.year == 2026 and job_001.posted_at.month == 8
def test_published_total_after_ingest(self, marketplace_store) -> None:
run_ingest(SeededCorpusSource(), marketplace_store)
_, total = marketplace_store.list_postings(limit=100, offset=0)
assert total == 20
def test_ingest_result_counts(self, marketplace_store) -> None:
result = run_ingest(SeededCorpusSource(), marketplace_store)
assert result.postings_upserted == 20
assert result.employers_upserted == 10
class TestCustomSource:
def test_run_ingest_with_a_scripted_source(self, marketplace_store) -> None:
"""The adapter port: a third source (the 3M+ corpus lands later)
runs through the same runner with ZERO store/route changes."""
class TwoJobSource:
def fetch(self):
yield IngestPosting(
id="ext-1",
source="external",
external_id="ext-1",
employer_id="emp-openai",
title="External One",
)
yield IngestPosting(
id="ext-2",
source="external",
external_id="ext-2",
employer_id="emp-openai",
title="External Two",
)
def fetch_employers(self):
return iter([])
result = run_ingest(TwoJobSource(), marketplace_store)
assert result.postings_upserted == 2
assert result.employers_upserted == 0
rows, _total = marketplace_store.list_postings(limit=10, offset=0)
assert {r.id for r in rows} == {"ext-1", "ext-2"}
@pytest.mark.parametrize("db_filename", ["one.db"])
def test_store_can_share_a_db_file_with_other_stores(tmp_path: Path, db_filename) -> None:
"""D-049: the marketplace store coexists on one shared SQLite file
with another store family member (identity) — six new tables join
the file without touching the existing one."""
from ai_service.identity.store import SQLiteIdentityStore
shared = tmp_path / "nextcraft.db"
identity = SQLiteIdentityStore(db_path=shared)
marketplace = SQLiteMarketplaceStore(db_path=shared)
run_ingest(SeededCorpusSource(), marketplace)
from sqlalchemy import text
with marketplace._engine.connect() as conn: # noqa: SLF001
tables = {
row[0]
for row in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)
}
marketplace.close()
identity.close()
assert {
"identity_record",
"posting",
"employer",
"embedding",
"application",
"employer_enrichment",
"learner_profile",
} <= tables
@@ -0,0 +1,854 @@
"""Marketplace API tests (MH-1c admin matrix, MH-1d apply gates, G-110).
Hermeticity: every client pins db_path to tmp_path (see conftest).
Pins:
- Admin fail-closed matrix: unset token → 403 on EVERY admin route +
the G-11 boot warning in caplog; wrong/absent header → 403; correct
token → 200. No admin route passes unguarded.
- Lifecycle: draft→published→closed over HTTP; invalid transition 409;
unknown posting 404.
- Public surfaces: published-only list with total_published (G-103),
honest 404s, no matchScore in any payload (G-107).
- Apply gate composition (D-043 order, MH-1d): G-5 allowlist 403
FIRST; verify-CTA 403 for unverified; age_gate 403 for 16-17; 429 at
the rate cap; 404 missing posting; 409 draft/closed; duplicate →
idempotent 200-with-existing; confirmation payload exact.
- G-110: the honest-501 stub pin (tests/identity/test_identity.py) is
REWRITTEN here — the apply route is REAL (response schema pinned,
stub/mock markers absent from route + response).
"""
from __future__ import annotations
import inspect
import logging
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from ai_service.api import marketplace as marketplace_api
from ai_service.config import Settings
from ai_service.identity.store import IdentityRecord, SQLiteIdentityStore
from ai_service.main import create_app
from ai_service.marketplace.base import IngestPosting, Posting
from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ..conftest import seed_verified_identity
from .conftest import ADMIN_TOKEN, admin_headers, make_client
class TestAdminFailClosed:
"""MH-1c: unset AI_ADMIN_TOKEN → every admin route 403 + boot
warning; wrong token → 403; correct token → 200."""
def test_unset_token_every_admin_route_403_plus_boot_warning(
self, tmp_path, caplog
) -> None:
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
seed_verified_identity(identity, ["pilot-learner"])
marketplace = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
run_ingest(SeededCorpusSource(), marketplace)
settings = Settings(
provider="mock",
learner_allowlist=["pilot-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token="", # UNSET — the fail-closed case
)
app = create_app(settings)
app.state.identity_store = identity
app.state.marketplace_store = marketplace
with caplog.at_level(logging.WARNING):
with TestClient(app) as c:
# G-11: the boot warns LOUDLY (naming the fix).
warnings = [
r.getMessage()
for r in caplog.records
if "AI_ADMIN_TOKEN" in r.getMessage()
]
assert warnings, "expected an unset-admin-token boot warning"
assert "AI_ADMIN_TOKEN" in warnings[0]
# EVERY admin route 403s — never open (D-057).
assert c.post("/v1/marketplace/admin/ingest").status_code == 403
assert (
c.post(
"/v1/marketplace/admin/postings",
json={"employer_id": "emp-openai", "title": "T"},
).status_code
== 403
)
assert (
c.post(
"/v1/marketplace/admin/postings/job-001/publish"
).status_code
== 403
)
assert (
c.post(
"/v1/marketplace/admin/postings/job-001/close"
).status_code
== 403
)
assert (
c.get("/v1/marketplace/admin/applications").status_code == 403
)
# ...even WITH a candidate token (unset config locks all).
assert (
c.post(
"/v1/marketplace/admin/ingest",
headers={"X-Admin-Token": "anything"},
).status_code
== 403
)
identity.close()
marketplace.close()
def test_wrong_or_absent_token_403_correct_token_200(
self, tmp_path, identity_store, marketplace_store
) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
ingest = "/v1/marketplace/admin/ingest"
# absent header → 403
assert c.post(ingest).status_code == 403
# wrong X-Admin-Token → 403
assert (
c.post(ingest, headers=admin_headers("wrong-token")).status_code
== 403
)
# wrong Bearer → 403
assert (
c.post(
ingest,
headers={"Authorization": "Bearer nope"},
).status_code
== 403
)
# correct X-Admin-Token → 200
ok = c.post(ingest, headers=admin_headers(ADMIN_TOKEN))
assert ok.status_code == 200, ok.text
assert ok.json() == {
"postings_upserted": 20,
"employers_upserted": 10,
}
# correct Bearer form → 200 too (G-108 accepts both carriers)
ok2 = c.post(
ingest, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}
)
assert ok2.status_code == 200
assert ok2.json()["postings_upserted"] == 20 # idempotent re-run
class TestAdminLifecycle:
def test_create_publish_close_over_http(
self, tmp_path, identity_store, marketplace_store
) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
created = c.post(
"/v1/marketplace/admin/postings",
headers=admin_headers(),
json={
"employer_id": "emp-openai",
"title": "Admin Authored Role",
"skills": ["Python"],
"seniority": "senior",
},
)
assert created.status_code == 201, created.text
body = created.json()
assert body["status"] == "draft" # G-102: admin path starts draft
posting_id = body["id"]
# draft is NOT on the public board
assert c.get(f"/v1/marketplace/postings/{posting_id}").status_code == 404 or (
c.get(f"/v1/marketplace/postings/{posting_id}").json()["status"]
== "draft"
)
published = c.post(
f"/v1/marketplace/admin/postings/{posting_id}/publish",
headers=admin_headers(),
)
assert published.status_code == 200
assert published.json()["status"] == "published"
closed = c.post(
f"/v1/marketplace/admin/postings/{posting_id}/close",
headers=admin_headers(),
)
assert closed.status_code == 200
assert closed.json()["status"] == "closed"
def test_invalid_transitions_409(
self, tmp_path, identity_store, marketplace_store
) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
created = c.post(
"/v1/marketplace/admin/postings",
headers=admin_headers(),
json={"employer_id": "emp-openai", "title": "T"},
).json()
pid = created["id"]
# draft → close is invalid → 409
assert (
c.post(
f"/v1/marketplace/admin/postings/{pid}/close",
headers=admin_headers(),
).status_code
== 409
)
c.post(
f"/v1/marketplace/admin/postings/{pid}/publish",
headers=admin_headers(),
)
# published → publish (re-publish) is invalid → 409
assert (
c.post(
f"/v1/marketplace/admin/postings/{pid}/publish",
headers=admin_headers(),
).status_code
== 409
)
c.post(
f"/v1/marketplace/admin/postings/{pid}/close",
headers=admin_headers(),
)
# closed is terminal → both moves 409
assert (
c.post(
f"/v1/marketplace/admin/postings/{pid}/publish",
headers=admin_headers(),
).status_code
== 409
)
def test_lifecycle_404_on_unknown_posting(
self, tmp_path, identity_store, marketplace_store
) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
assert (
c.post(
"/v1/marketplace/admin/postings/ghost/publish",
headers=admin_headers(),
).status_code
== 404
)
assert (
c.post(
"/v1/marketplace/admin/postings/ghost/close",
headers=admin_headers(),
).status_code
== 404
)
def test_applications_review_list_filterable(
self, tmp_path, identity_store, marketplace_store
) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
run_ingest(SeededCorpusSource(), marketplace_store)
# two applications on distinct postings
for posting_id in ("job-001", "job-002"):
resp = c.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": posting_id},
)
assert resp.status_code == 200, resp.text
all_apps = c.get(
"/v1/marketplace/admin/applications", headers=admin_headers()
)
assert all_apps.status_code == 200
assert len(all_apps.json()["applications"]) == 2
filtered = c.get(
"/v1/marketplace/admin/applications",
headers=admin_headers(),
params={"posting_id": "job-001"},
)
rows = filtered.json()["applications"]
assert len(rows) == 1
assert rows[0]["posting_id"] == "job-001"
assert rows[0]["learner_id"] == "pilot-learner"
assert rows[0]["status"] == "submitted"
class TestPublicSurfaces:
def test_list_published_only_with_total(
self, seeded_client: TestClient
) -> None:
resp = seeded_client.get("/v1/marketplace/postings", params={"limit": 5})
assert resp.status_code == 200
body = resp.json()
assert body["total_published"] == 20
assert len(body["postings"]) == 5
# G-102: everything on the board is published
assert all(p["status"] == "published" for p in body["postings"])
def test_list_pagination_walks_all_pages(
self, seeded_client: TestClient
) -> None:
seen: list[str] = []
offset = 0
while True:
body = seeded_client.get(
"/v1/marketplace/postings",
params={"limit": 7, "offset": offset},
).json()
seen.extend(p["id"] for p in body["postings"])
if offset + 7 >= body["total_published"]:
break
offset += 7
assert len(seen) == 20
assert len(set(seen)) == 20 # no dupes across the walk (G-103)
def test_get_posting_honest_404(self, seeded_client: TestClient) -> None:
assert (
seeded_client.get("/v1/marketplace/postings/does-not-exist").status_code
== 404
)
def test_get_employer_honest_404(self, seeded_client: TestClient) -> None:
assert (
seeded_client.get("/v1/marketplace/employers/ghost-emp").status_code
== 404
)
def test_employer_payload_raw_fields(self, seeded_client: TestClient) -> None:
employer = seeded_client.get("/v1/marketplace/employers/emp-openai").json()
assert employer["name"] == "OpenAI Labs"
assert employer["social_links"]["twitter"] == "https://example.com/openai/x"
assert employer["culture"].startswith("Research-driven")
def test_match_score_absent_from_all_payloads(
self, seeded_client: TestClient
) -> None:
"""G-107/MH-1f: matchScore is absent from list + detail + employer
payloads — computed scores are search-context-only (P3/G-106)."""
listing = seeded_client.get("/v1/marketplace/postings", params={"limit": 20})
blob = listing.text.lower()
assert "matchscore" not in blob and "match_score" not in blob
detail = seeded_client.get("/v1/marketplace/postings/job-001")
assert "matchscore" not in detail.text.lower()
def test_posting_payload_shape(self, seeded_client: TestClient) -> None:
"""G-110 response-schema pin (replaces the honest-501 stub pin)."""
posting = seeded_client.get("/v1/marketplace/postings/job-001").json()
assert posting["id"] == "job-001"
assert posting["title"] == "AI Orchestration Engineer"
assert posting["employer_id"] == "emp-langchain"
assert posting["status"] == "published"
assert posting["posted_at"].startswith("2026-08-21T10:00:00")
assert "LangGraph" in posting["skills"]
# the response schema itself carries no stub-era field
schema_fields = set(marketplace_api.PostingResponse.model_fields)
assert "match_score" not in schema_fields
assert "matchScore" not in schema_fields
class TestApplyGateComposition:
"""MH-1d: the D-043 order is PROVABLE — allowlist 403 fires first,
verify-CTA 403 second, 429 at cap; then posting states; then the
idempotent duplicate."""
def test_allowlist_403_fires_first(
self, seeded_client: TestClient
) -> None:
"""A NON-allowlisted learner gets the G-5 403 even though the
posting id is garbage — the allowlist precedes everything."""
resp = seeded_client.post(
"/v1/marketplace/apply",
json={"learner_id": "stranger-danger", "posting_id": "totally-bogus"},
)
assert resp.status_code == 403
assert "allowlist" in resp.json()["detail"]
def test_unverified_allowlisted_gets_verify_cta(
self, tmp_path, marketplace_store
) -> None:
"""Allowlisted but NEVER identity-verified → 403 with the
verify-CTA payload (the second gate, A-306)."""
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
# NO seed — fresh learner has no identity record
settings = Settings(
provider="mock",
learner_allowlist=["fresh-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
)
app = create_app(settings)
app.state.identity_store = identity
app.state.marketplace_store = marketplace_store
with TestClient(app) as c:
resp = c.post(
"/v1/marketplace/apply",
json={"learner_id": "fresh-learner", "posting_id": "job-001"},
)
assert resp.status_code == 403
detail = resp.json()["detail"]
assert detail["reason"] == "identity_verification_required"
assert detail["min_age"] == 18
assert detail["current_status"] == "none"
assert detail["verify_cta"] == "/enroll"
identity.close()
def test_verified_16_17_blocked_with_age_reason(
self, tmp_path, marketplace_store
) -> None:
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
identity.insert(
IdentityRecord(
id="seed-minor",
learner_id="minor-learner",
status="verified",
provider="mock",
verdict={"status": "verified", "age_band": "16-17", "mock": True},
age_band="16-17",
submitted_at=datetime.now(UTC),
verified_at=datetime.now(UTC),
)
)
settings = Settings(
provider="mock",
learner_allowlist=["minor-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
)
app = create_app(settings)
app.state.identity_store = identity
app.state.marketplace_store = marketplace_store
with TestClient(app) as c:
resp = c.post(
"/v1/marketplace/apply",
json={"learner_id": "minor-learner", "posting_id": "job-001"},
)
assert resp.status_code == 403
detail = resp.json()["detail"]
assert detail["reason"] == "age_gate_18_plus"
assert detail["min_age"] == 18
identity.close()
def test_rate_cap_429(self, tmp_path, marketplace_store) -> None:
"""Distinct applies within the window exhaust the cap → 429
(honest retries of the SAME posting stay idempotent-200 and do
not consume budget)."""
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
seed_verified_identity(identity, ["pilot-learner"])
settings = Settings(
provider="mock",
learner_allowlist=["pilot-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
)
app = create_app(settings)
app.state.identity_store = identity
app.state.marketplace_store = marketplace_store
run_ingest(SeededCorpusSource(), marketplace_store)
with TestClient(app) as c:
# 5 distinct postings = the cap (APPLY_PER_LEARNER_PER_MIN)
for i in range(1, 6):
resp = c.post(
"/v1/marketplace/apply",
json={
"learner_id": "pilot-learner",
"posting_id": f"job-{i:03d}",
},
)
assert resp.status_code == 200, resp.text
# 6th distinct apply within the window → 429
sixth = c.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": "job-006"},
)
assert sixth.status_code == 429
# a RE-apply of an already-applied posting is idempotent —
# and the rate window check happens BEFORE the duplicate
# short-circuit, so it 429s under the same window too.
identity.close()
def test_apply_404_missing_posting(self, seeded_client: TestClient) -> None:
resp = seeded_client.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": "no-such-job"},
)
assert resp.status_code == 404
def test_apply_409_draft(self, tmp_path, identity_store, marketplace_store) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
created = c.post(
"/v1/marketplace/admin/postings",
headers=admin_headers(),
json={"employer_id": "emp-openai", "title": "Still Draft"},
).json()
resp = c.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": created["id"]},
)
assert resp.status_code == 409
detail = resp.json()["detail"]
assert detail["reason"] == "posting_not_published"
assert detail["status"] == "draft"
def test_apply_409_closed(
self, tmp_path, identity_store, marketplace_store
) -> None:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
created = c.post(
"/v1/marketplace/admin/postings",
headers=admin_headers(),
json={"employer_id": "emp-openai", "title": "To Close"},
).json()
c.post(
f"/v1/marketplace/admin/postings/{created['id']}/publish",
headers=admin_headers(),
)
c.post(
f"/v1/marketplace/admin/postings/{created['id']}/close",
headers=admin_headers(),
)
resp = c.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": created["id"]},
)
assert resp.status_code == 409
assert resp.json()["detail"]["status"] == "closed"
def test_apply_success_then_idempotent_duplicate(
self, seeded_client: TestClient
) -> None:
first = seeded_client.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": "job-001"},
)
assert first.status_code == 200, first.text
body = first.json()
assert body["posting_id"] == "job-001"
assert body["status"] == "submitted"
assert body["application_id"].startswith("app-")
duplicate = seeded_client.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": "job-001"},
)
assert duplicate.status_code == 200 # A-408: 200, never 409
assert duplicate.json() == body # the EXISTING application rides
class TestStubMarkersGone:
"""G-110/MH-3: the honest-501 placeholder is REPLACED — the apply
route is REAL; every placeholder/mock marker is gone from the route
declarations AND the responses."""
def test_apply_route_is_real_not_a_placeholder(self) -> None:
"""The route decorator carries NO status_code=501 and its body
returns the honest confirmation (never a not-live-yet payload)."""
apply_route = marketplace_api.router.routes[
[getattr(r, "path", "") for r in marketplace_api.router.routes].index(
"/v1/marketplace/apply"
)
]
# no explicit status_code override → 200 (the confirmation is the
# success path); the placeholder era pinned 501 on the decorator.
assert getattr(apply_route, "status_code", None) is None
body = inspect.getsource(marketplace_api.apply_posting)
assert "501" not in body
assert "not live yet" not in body
def test_identity_module_has_no_marketplace_route(self) -> None:
"""D-048: identity keeps ONLY the gate exports — no marketplace
router, no placeholder body."""
from ai_service.api import identity as identity_api
source = inspect.getsource(identity_api)
assert "marketplace_router" not in source
assert "/apply" not in source
# the gate exports survive (D-043 composition consumers)
assert hasattr(identity_api, "require_verified_adult")
assert hasattr(identity_api, "require_verified_age")
def test_apply_response_has_no_placeholder_markers(
self, seeded_client: TestClient
) -> None:
body = seeded_client.post(
"/v1/marketplace/apply",
json={"learner_id": "pilot-learner", "posting_id": "job-003"},
).json()
assert set(body) == {"application_id", "posting_id", "status"} # exact payload
assert "stub" not in body and "mock" not in body
def test_openapi_schema_pins_the_real_apply_route(
self, seeded_client: TestClient
) -> None:
"""The G-110 response-schema pin: /v1/marketplace/apply is a
200-route with the honest confirmation schema — no 501 anywhere."""
spec = seeded_client.get("/openapi.json").json()
apply_op = spec["paths"]["/v1/marketplace/apply"]["post"]
assert "200" in apply_op["responses"]
assert "501" not in apply_op["responses"]
ref = apply_op["responses"]["200"]["content"]["application/json"]["schema"]
assert ref["$ref"].endswith("ApplyResponse")
class TestShellRoutesServeArbitraryIds:
"""G-101/G-109: the exported placeholder shell is served for ANY job/
employer id on the single-port app, so runtime-created postings stay
reachable and every sitemap URL resolves."""
def _client_with_shell(self, tmp_path) -> TestClient:
# Minimal export tree with the placeholder shells.
web_dir = tmp_path / "out"
jobs_dir = web_dir / "marketplace" / "jobs" / "_"
employers_dir = web_dir / "marketplace" / "employers" / "_"
jobs_dir.mkdir(parents=True)
employers_dir.mkdir(parents=True)
(jobs_dir / "index.html").write_text("<html>job shell</html>")
(employers_dir / "index.html").write_text("<html>employer shell</html>")
settings = Settings(
provider="mock",
learner_allowlist=["pilot-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token="",
web_static_dir=web_dir,
)
return TestClient(create_app(settings))
def test_arbitrary_job_id_serves_the_placeholder_shell(
self, tmp_path
) -> None:
with self._client_with_shell(tmp_path) as client:
r = client.get("/marketplace/jobs/job-latest-ai-role")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "job shell" in r.text
def test_arbitrary_employer_id_serves_the_placeholder_shell(
self, tmp_path
) -> None:
with self._client_with_shell(tmp_path) as client:
r = client.get("/marketplace/employers/emp-newco/")
assert r.status_code == 200
assert "employer shell" in r.text
def test_api_routes_still_win_over_the_mount(self, tmp_path) -> None:
"""The catch-all registration must not shadow /v1/* or /health."""
with self._client_with_shell(tmp_path) as client:
assert client.get("/health").status_code == 200
assert (
client.get("/v1/marketplace/postings").status_code == 200
)
class TestFacetsPayload:
"""P2 (MH-2c): the list response carries facets computed from stored
tags of PUBLISHED-ONLY postings; draft/closed contribute nothing."""
def test_facets_present_and_empty_before_tagging(
self, seeded_client: TestClient
) -> None:
body = seeded_client.get("/v1/marketplace/postings").json()
assert body["facets"] == {"skills": [], "seniority": [], "category": []}
def test_facets_match_stored_tags_published_only(
self, seeded_client: TestClient, marketplace_store
) -> None:
from ai_service.marketplace.base import PostingTags
marketplace_store.set_posting_tags(
"job-001", PostingTags(skills=["python"], seniority="mid", category="data")
)
marketplace_store.set_posting_tags(
"job-002", PostingTags(skills=["python", "rag"], seniority=None, category=None)
)
# A DRAFT with tags must NOT contribute facets.
draft_id = "adm-draft-1"
marketplace_store.insert_posting(
Posting(
id=draft_id,
source="admin",
external_id=draft_id,
employer_id="emp-openai",
title="Draft",
)
)
marketplace_store.set_posting_tags(
draft_id, PostingTags(skills=["secret-draft-skill"], seniority=None, category=None)
)
body = seeded_client.get("/v1/marketplace/postings").json()
facets = body["facets"]
assert facets["skills"] == [
{"value": "python", "count": 2},
{"value": "rag", "count": 1},
]
assert facets["seniority"] == [{"value": "mid", "count": 1}]
assert facets["category"] == [{"value": "data", "count": 1}]
assert all(f["value"] != "secret-draft-skill" for f in facets["skills"])
class TestAdminEditRoute:
"""P2 (Task 2-1-03): PUT /admin/postings/{id} — admin-guarded edit
that re-tags (overwrite semantics, D-052)."""
def test_edit_requires_admin_token(self, client: TestClient) -> None:
r = client.put(
"/v1/marketplace/admin/postings/job-001",
json={"title": "New"},
)
assert r.status_code == 403
def test_edit_unknown_posting_404s(self, client: TestClient) -> None:
r = client.put(
"/v1/marketplace/admin/postings/missing",
json={"title": "New"},
headers=admin_headers(),
)
assert r.status_code == 404
def test_edit_retags_and_old_tags_gone(
self, seeded_client: TestClient, marketplace_store
) -> None:
from ai_service.marketplace.base import PostingTags
# Pre-existing tags that the edit's re-tag must OVERWRITE.
marketplace_store.set_posting_tags(
"job-001",
PostingTags(skills=["stale-skill"], seniority="entry", category="research"),
)
# The app's provider is the plain MockProvider — its json_object
# reply is a fixed non-matching payload, so the re-tag fails
# honestly and tags STAY. Assert the honest-failure contract via
# the route: the edit persists, the old tags remain.
r = seeded_client.put(
"/v1/marketplace/admin/postings/job-001",
json={"title": "Edited title"},
headers=admin_headers(),
)
assert r.status_code == 200
assert r.json()["title"] == "Edited title"
stored = marketplace_store.get_posting("job-001")
assert stored is not None and stored.title == "Edited title"
# extra="forbid" (verifier P2-3): the plain mock's canned
# {"summary":...} does NOT validate as PostingTags → the tag
# call fails honestly → the edit lands, tags UNCHANGED (old
# tags kept — never fabricated, never erased by a bad parse).
assert stored.tags is not None
assert stored.tags.skills == ["stale-skill"]
def test_edit_with_scripted_provider_retags(
self, tmp_path, marketplace_store, identity_store
) -> None:
"""With a SCRIPTED provider the edit re-tags and OVERWRITES."""
from ai_service.config import Settings
from ai_service.llm.mock import ScriptedJSONProvider
from ai_service.main import create_app
from ai_service.marketplace.base import PostingTags
marketplace_store.upsert_posting(
IngestPosting(
id="job-edit-1", source="test", external_id="job-edit-1",
employer_id="emp-openai", title="Old",
),
status="published",
)
marketplace_store.set_posting_tags(
"job-edit-1", PostingTags(skills=["old-skill"], seniority=None, category=None)
)
settings = Settings(
provider="mock",
learner_allowlist=["pilot-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=ADMIN_TOKEN,
)
app = create_app(settings)
app.state.identity_store = identity_store
app.state.marketplace_store = marketplace_store
app.state.provider = ScriptedJSONProvider(
{"skills": ["new-skill"], "seniority": "senior", "category": "product"}
)
with TestClient(app) as c:
r = c.put(
"/v1/marketplace/admin/postings/job-edit-1",
json={"title": "New"},
headers={"X-Admin-Token": ADMIN_TOKEN},
)
assert r.status_code == 200
stored = marketplace_store.get_posting("job-edit-1")
assert stored is not None
assert stored.tags is not None
assert stored.tags.skills == ["new-skill"]
assert "old-skill" not in stored.tags.skills
assert stored.tags.seniority == "senior"
class TestIngestTags:
"""P2 (D-052): the admin ingest route TAGS every posting after
upsert; tag failures leave postings UNTAGGED (honest) — never a
crashed ingest."""
def test_ingest_with_plain_mock_leaves_untagged(
self, seeded_client: TestClient, marketplace_store
) -> None:
"""The app's MockProvider json_object reply does not match
PostingTags → every posting stays untagged; the ingest still
reports the full upsert counts (honest failure semantics)."""
# seeded_client already ran run_ingest via fixtures WITHOUT
# tagging (fixture uses the store directly) — run the ROUTE now:
r = seeded_client.post(
"/v1/marketplace/admin/ingest", headers=admin_headers()
)
assert r.status_code == 200
assert r.json()["postings_upserted"] == 20
# extra="forbid" (verifier P2-3): the plain mock's canned
# {"summary":...} does NOT validate as PostingTags → the tag
# pass fails honestly per posting → UNTAGGED (tags=None), never
# a fabricated tag set, ingest completes with full counts.
stored = marketplace_store.get_posting("job-001")
assert stored is not None
assert stored.tags is None
def test_ingest_with_scripted_provider_tags(
self, tmp_path, marketplace_store, identity_store
) -> None:
from ai_service.config import Settings
from ai_service.llm.mock import ScriptedJSONProvider
from ai_service.main import create_app
settings = Settings(
provider="mock",
learner_allowlist=["pilot-learner"],
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=ADMIN_TOKEN,
)
app = create_app(settings)
app.state.identity_store = identity_store
app.state.marketplace_store = marketplace_store
app.state.provider = ScriptedJSONProvider(
{"skills": ["python"], "seniority": "mid", "category": "data"}
)
with TestClient(app) as c:
r = c.post(
"/v1/marketplace/admin/ingest", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert r.status_code == 200
assert r.json()["postings_upserted"] == 20
stored = marketplace_store.get_posting("job-001")
assert stored is not None
assert stored.tags is not None
assert stored.tags.skills == ["python"]
# Facets now reflect the tagged corpus (published-only).
facets = marketplace_store.facets()
assert facets["skills"] == [{"value": "python", "count": 20}]
@@ -0,0 +1,340 @@
"""Marketplace store contract tests (MH-1a, D-049/G-104).
Hermeticity: every fixture pins db_path to tmp_path — never
~/.nextcraft, never the cloud (MH-M5).
Pins: six tables created on the shared file; unique constraints fire on
duplicates ((source, external_id) posting + employer, (posting_id,
learner_id) application); WAL/synchronous=NORMAL/busy_timeout/
foreign_keys pragmas; _validate/_normalize hooks; tz normalization;
lifecycle transitions draft→published→closed with InvalidTransition on
every other move.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from ai_service.marketplace.base import Employer, IngestPosting, Posting
from ai_service.marketplace.store import InvalidTransition, SQLiteMarketplaceStore
def _posting(
external_id: str = "job-x",
source: str = "seed-corpus",
posting_id: str | None = None,
**overrides,
) -> IngestPosting:
"""A valid IngestPosting; `posting_id` defaults to external_id (the
D-021 shared-id convention: id == external_id for seed ports)."""
payload = dict(
id=posting_id or external_id,
source=source,
external_id=external_id,
employer_id="emp-openai",
title="Test Posting",
description="d",
skills=["Python"],
seniority="mid",
location="Remote",
remote=True,
salary_min=100,
salary_max=200,
posted_at=datetime(2026, 9, 1, tzinfo=UTC),
)
payload.update(overrides)
return IngestPosting(**payload)
class TestSchema:
def test_six_tables_created_on_shared_db(self, tmp_path) -> None:
store = SQLiteMarketplaceStore(db_path=tmp_path / "shared.db")
with store._engine.connect() as conn: # noqa: SLF001
names = {
row[0]
for row in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)
}
store.close()
for table in (
"posting",
"employer",
"embedding",
"application",
"employer_enrichment",
"learner_profile",
):
assert table in names, f"table {table} missing"
def test_pragmas_pinned(self, marketplace_store) -> None:
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys — the
identity/DefenseStore per-connection contract (D-027)."""
with marketplace_store._engine.connect() as conn: # noqa: SLF001
journal = conn.execute(text("PRAGMA journal_mode")).fetchone()[0]
sync = conn.execute(text("PRAGMA synchronous")).fetchone()[0]
busy = conn.execute(text("PRAGMA busy_timeout")).fetchone()[0]
fk = conn.execute(text("PRAGMA foreign_keys")).fetchone()[0]
assert str(journal).lower() == "wal"
assert sync == 1 # NORMAL
assert busy == 5000
assert fk == 1
def test_create_schema_idempotent(self, marketplace_store) -> None:
marketplace_store.create_schema() # second call is a no-op
marketplace_store.create_schema()
class TestPostingConstraints:
def test_unique_source_external_id_fires(self, marketplace_store) -> None:
"""Direct INSERT of a second (source, external_id) pair raises —
the constraint exists beneath the upsert's backpressure."""
marketplace_store.upsert_posting(_posting(), status="published")
with pytest.raises(IntegrityError):
with marketplace_store._engine.begin() as conn: # noqa: SLF001
conn.execute(
text(
"INSERT INTO posting (id, source, external_id, "
"employer_id, title, status) VALUES "
"('other-id', 'seed-corpus', 'job-x', 'emp-openai', 'Dup', 'draft')"
)
)
def test_upsert_posting_idempotent_same_row(self, marketplace_store) -> None:
first = marketplace_store.upsert_posting(_posting(), status="published")
second = marketplace_store.upsert_posting(
_posting(title="Updated Title"), status="published"
)
# Same row overwritten in place — identical id, updated field,
# still exactly ONE posting for the key.
assert first.id == second.id
assert second.title == "Updated Title"
with marketplace_store._engine.connect() as conn: # noqa: SLF001
count = conn.execute(
text("SELECT COUNT(*) FROM posting WHERE external_id='job-x'")
).fetchone()[0]
assert count == 1
def test_upsert_preserves_admin_lifecycle_status(self, marketplace_store) -> None:
"""An admin's close decision outranks a seed re-run: the
re-upsert overwrites CONTENT, never status."""
marketplace_store.upsert_posting(_posting(), status="published")
marketplace_store.set_posting_status("job-x", "closed")
again = marketplace_store.upsert_posting(
_posting(title="Seed re-run"), status="published"
)
assert again.status == "closed"
assert again.title == "Seed re-run"
def test_insert_posting_duplicate_id_integrity_error(
self, marketplace_store
) -> None:
posting = Posting(
id="adm-1",
source="admin",
external_id="adm-1",
employer_id="emp-openai",
title="Admin Draft",
)
marketplace_store.insert_posting(posting)
with pytest.raises(IntegrityError):
marketplace_store.insert_posting(
posting.model_copy(update={"title": "Second"})
)
def test_validate_hooks(self, marketplace_store) -> None:
with pytest.raises(ValueError, match="invalid posting status"):
marketplace_store.upsert_posting(
_posting(), status="banana" # type: ignore[arg-type]
)
with pytest.raises(ValueError, match="employer_id"):
marketplace_store.upsert_posting(
_posting(employer_id=""), status="draft"
)
# the STORE's seniority hook fires beneath pydantic's Literal:
# a raw-row write path (future admin edit) cannot smuggle a
# non-canonical value past the validates hook. (ORM-level hooks
# run on model instances; the raw-SQL insert here must satisfy
# NOT NULL columns first, so exercise the hook via a
# constructed row instead.)
from ai_service.marketplace.store import PostingRow
bad = PostingRow(
id="bad-sen",
source="s",
external_id="bad-sen",
employer_id="emp-openai",
title="T",
seniority="wizard",
status="draft",
)
with pytest.raises(ValueError, match="invalid seniority"):
bad._validate()
def test_tz_normalization_roundtrip(self, marketplace_store) -> None:
"""Naive-when-stored, tz-aware-when-read (the D-027 boundary)."""
marketplace_store.upsert_posting(_posting(), status="published")
got = marketplace_store.get_posting("job-x")
assert got is not None
assert got.posted_at is not None
assert got.posted_at.tzinfo is not None
assert got.posted_at.utcoffset().total_seconds() == 0
class TestLifecycle:
def test_draft_publish_closed_happy_path(self, marketplace_store) -> None:
posting = marketplace_store.insert_posting(
Posting(
id="adm-9",
source="admin",
external_id="adm-9",
employer_id="emp-openai",
title="Lifecycle",
)
)
assert posting.status == "draft"
assert posting.posted_at is None
published = marketplace_store.set_posting_status("adm-9", "published")
assert published is not None and published.status == "published"
# Admin publish stamps NOW when the row has no posted_at.
assert published.posted_at is not None
closed = marketplace_store.set_posting_status("adm-9", "closed")
assert closed is not None and closed.status == "closed"
def test_invalid_transitions_raise(self, marketplace_store) -> None:
marketplace_store.upsert_posting(_posting("job-t"), status="draft")
# draft → closed is invalid
with pytest.raises(InvalidTransition):
marketplace_store.set_posting_status("job-t", "closed")
# draft → published ok; published → published invalid
marketplace_store.set_posting_status("job-t", "published")
with pytest.raises(InvalidTransition):
marketplace_store.set_posting_status("job-t", "published")
marketplace_store.set_posting_status("job-t", "closed")
# closed → anything invalid (terminal)
for target in ("draft", "published"):
with pytest.raises(InvalidTransition):
marketplace_store.set_posting_status("job-t", target)
def test_unknown_posting_returns_none(self, marketplace_store) -> None:
assert marketplace_store.set_posting_status("nope", "published") is None
assert marketplace_store.get_posting("nope") is None
class TestListPostings:
def test_published_only_with_total(self, marketplace_store) -> None:
for i in range(3):
marketplace_store.upsert_posting(
_posting(f"job-p{i}"), status="published"
)
marketplace_store.insert_posting(
Posting(
id="adm-draft",
source="admin",
external_id="adm-draft",
employer_id="emp-openai",
title="Hidden Draft",
)
)
rows, total = marketplace_store.list_postings(limit=2, offset=0)
assert total == 3 # G-103: draft excluded from the honest count
assert len(rows) == 2
page2, total2 = marketplace_store.list_postings(limit=2, offset=2)
assert total2 == 3
assert len(page2) == 1
def test_order_posted_at_desc(self, marketplace_store) -> None:
for day, pid in ((1, "old"), (5, "new")):
marketplace_store.upsert_posting(
_posting(pid, posted_at=datetime(2026, 9, day, tzinfo=UTC)),
status="published",
)
rows, _ = marketplace_store.list_postings(limit=10, offset=0)
assert [r.id for r in rows] == ["new", "old"]
class TestEmployers:
def test_employer_roundtrip_and_idempotent_upsert(self, marketplace_store) -> None:
employer = Employer(
id="emp-openai",
source="seed-corpus",
external_id="emp-openai",
name="OpenAI Labs",
website="https://example.com/openai",
social_links={"twitter": "https://example.com/openai/x"},
)
first = marketplace_store.upsert_employer(employer)
second = marketplace_store.upsert_employer(
employer.model_copy(update={"name": "OpenAI Labs Updated"})
)
assert first.id == second.id
assert second.name == "OpenAI Labs Updated"
got = marketplace_store.get_employer("emp-openai")
assert got is not None
assert got.social_links["twitter"].endswith("/x")
assert marketplace_store.get_employer("nope") is None
def test_employer_unique_source_external_id_fires(self, marketplace_store) -> None:
marketplace_store.upsert_employer(
Employer(
id="emp-a",
source="seed-corpus",
external_id="emp-a",
name="A",
)
)
with pytest.raises(IntegrityError):
with marketplace_store._engine.begin() as conn: # noqa: SLF001
conn.execute(
text(
"INSERT INTO employer (id, source, external_id, name) "
"VALUES ('emp-b', 'seed-corpus', 'emp-a', 'Dup')"
)
)
def test_employer_validate_hook(self, marketplace_store) -> None:
"""The store's own hook guards beneath pydantic (pydantic
validates the API path; the validates hook is the second line
for constructed rows)."""
from ai_service.marketplace.store import EmployerRow
bad = EmployerRow(id="emp-bad", source="s", external_id="emp-bad", name="")
with pytest.raises(ValueError, match="non-empty"):
bad._validate()
class TestApplications:
def test_apply_idempotent_returns_existing(self, marketplace_store) -> None:
first, created = marketplace_store.insert_application("job-1", "pilot-learner")
assert created is True
assert first.status == "submitted"
second, created2 = marketplace_store.insert_application(
"job-1", "pilot-learner"
)
assert created2 is False
assert second.id == first.id # A-408: the EXISTING row rides the 200
def test_unique_posting_learner_fires(self, marketplace_store) -> None:
marketplace_store.insert_application("job-1", "pilot-learner")
with pytest.raises(IntegrityError):
with marketplace_store._engine.begin() as conn: # noqa: SLF001
conn.execute(
text(
"INSERT INTO application (id, posting_id, learner_id, "
"status, created_at) VALUES "
f"('app-x', 'job-1', 'pilot-learner', 'submitted', "
f"'{datetime.now(UTC).isoformat()}')"
)
)
def test_list_applications_filter(self, marketplace_store) -> None:
marketplace_store.insert_application("job-1", "learner-a")
marketplace_store.insert_application("job-2", "learner-a")
marketplace_store.insert_application("job-1", "learner-b")
all_rows = marketplace_store.list_applications()
assert len(all_rows) == 3
only_job1 = marketplace_store.list_applications(posting_id="job-1")
assert {a.learner_id for a in only_job1} == {"learner-a", "learner-b"}
@@ -0,0 +1,248 @@
"""P2 tagging tests (REQ-6-002, D-052, MH-2a/2b/2c) — cloud-free.
The mock provider's json_object path returns a canned non-matching JSON
(deterministic), so every tag test uses ScriptedJSONProvider with an
explicit payload — the byte-contract idiom from the voice/llm suites.
"""
from __future__ import annotations
import logging
import pytest
from ai_service.llm.mock import MockProvider, ScriptedJSONProvider
from ai_service.marketplace.base import IngestPosting, PostingTags
from ai_service.marketplace.ingest import SeededCorpusSource
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ai_service.marketplace.tagging import TaggingUnavailable, tag_posting
MODEL = "test-model"
def _valid_payload() -> dict:
return {
"skills": ["python", "llm evaluation", "rag"],
"seniority": "senior",
"category": "ai-orchestration",
}
class TestTagPosting:
async def test_mock_scripted_tag_extraction(self) -> None:
"""MH-2a: cloud-free tagging via a scripted provider."""
provider = ScriptedJSONProvider(_valid_payload())
tags = await tag_posting(
provider,
model=MODEL,
title="LLM Evaluation Engineer",
description="Build eval harnesses for LLM systems.",
skills_hint=["python"],
)
assert tags == PostingTags.model_validate(_valid_payload())
async def test_over_ten_skills_rejected_then_retry_path(self) -> None:
"""MH-2a: the ≤10 bound is the enforcement — an 11-skill response
fails schema validation (bounded retry runs); if the retry
repeats the same shape the tagger surfaces TaggingUnavailable."""
provider = ScriptedJSONProvider(
{"skills": [f"skill-{i}" for i in range(11)], "seniority": None, "category": None}
)
with pytest.raises(TaggingUnavailable):
await tag_posting(
provider,
model=MODEL,
title="T",
description="D",
)
async def test_retry_recovers_on_second_pass(self) -> None:
"""D-020 layer 4: a provider whose FIRST response is invalid but
whose retry validates produces tags (single bounded retry)."""
attempts = {"n": 0}
class RetryThenValid(MockProvider):
def _reply_for(self, messages, response_format): # type: ignore[override]
if response_format is not None and response_format.get("type") == "json_object":
attempts["n"] += 1
if attempts["n"] == 1:
return '{"skills": ["' + '", "'.join(f"s{i}" for i in range(11)) + '"]}'
import json
return json.dumps(_valid_payload())
return super()._reply_for(messages, response_format)
tags = await tag_posting(RetryThenValid(), model=MODEL, title="T", description="D")
assert tags.skills == ["python", "llm evaluation", "rag"]
assert attempts["n"] == 2
async def test_provider_failure_raises_tagging_unavailable(self) -> None:
"""Honest failure: a dead provider surfaces TaggingUnavailable —
never a crash, never fabricated tags."""
provider = MockProvider()
provider.fail_before_first_token = True
with pytest.raises(TaggingUnavailable):
await tag_posting(provider, model=MODEL, title="T", description="D")
async def test_failure_logs_warning(self, caplog) -> None:
provider = MockProvider()
provider.fail_before_first_token = True
with caplog.at_level(logging.WARNING):
with pytest.raises(TaggingUnavailable):
await tag_posting(provider, model=MODEL, title="A title", description="D")
assert any("untagged" in r.getMessage() for r in caplog.records)
class TestTagOnIngest:
def test_ingest_tags_postings_and_facets_match(self, tmp_path) -> None:
"""MH-2c: tag-on-ingest + facets exactly match the stored tags of
published-only postings (a scripted deterministic tag set)."""
class DeterministicTagger(MockProvider):
"""json_object responses derived from the posting content —
deterministic per posting."""
def _reply_for(self, messages, response_format): # type: ignore[override]
import json
if response_format is not None and response_format.get("type") == "json_object":
user = next(m.content for m in reversed(messages) if m.role == "user")
if "Title: T1" in user:
return json.dumps(
{"skills": ["python"], "seniority": "mid", "category": "data"}
)
return json.dumps(
{"skills": ["python", "rag"], "seniority": "senior", "category": None}
)
return super()._reply_for(messages, response_format)
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
# Two hand-seeded published postings + one draft (facets must
# exclude drafts).
for pid, title in (("job-001", "T1"), ("job-002", "T2")):
store.upsert_posting(
IngestPosting(
id=pid,
source="test",
external_id=pid,
employer_id="emp-x",
title=title,
),
status="published",
)
store.upsert_posting(
IngestPosting(
id="job-003",
source="test",
external_id="job-003",
employer_id="emp-x",
title="T1",
),
status="draft",
)
import asyncio
async def _tag_all() -> None:
for pid in ("job-001", "job-002"):
posting = store.get_posting(pid)
tags = await tag_posting(
DeterministicTagger(),
model=MODEL,
title=posting.title,
description=posting.description,
)
store.set_posting_tags(pid, tags)
asyncio.run(_tag_all())
facets = store.facets()
# published-only: job-001 (python/mid/data) + job-002 (python+rag/senior)
assert facets["skills"] == [
{"value": "python", "count": 2},
{"value": "rag", "count": 1},
]
assert facets["seniority"] == [
{"value": "mid", "count": 1},
{"value": "senior", "count": 1},
]
assert facets["category"] == [{"value": "data", "count": 1}]
def test_untagged_store_facets_are_empty(self, tmp_path) -> None:
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
store.upsert_posting(
IngestPosting(
id="job-001",
source="test",
external_id="job-001",
employer_id="emp-x",
title="T",
),
status="published",
)
assert store.facets() == {
"skills": [],
"seniority": [],
"category": [],
}
class TestReTagOverwrites:
def test_retag_overwrites_old_tags(self, tmp_path) -> None:
"""MH-2b: tags are derived data — a re-tag OVERWRITES (old tags
gone, single source of truth; no append-only)."""
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
store.upsert_posting(
IngestPosting(
id="job-001", source="test", external_id="job-001",
employer_id="emp-x", title="T",
),
status="published",
)
old = PostingTags(skills=["stale-skill"], seniority="entry", category="research")
store.set_posting_tags("job-001", old)
new = PostingTags(skills=["fresh-skill"], seniority="principal", category="product")
stored = store.set_posting_tags("job-001", new)
assert stored is not None and stored.tags == new
fetched = store.get_posting("job-001")
assert fetched is not None
assert fetched.tags == new
assert "stale-skill" not in (fetched.tags.skills if fetched.tags else [])
def test_update_posting_fields_then_retag(self, tmp_path) -> None:
"""The admin edit path: fields update, then the route's re-tag
replaces the tags (store layer contract)."""
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
store.upsert_posting(
IngestPosting(
id="job-001", source="test", external_id="job-001",
employer_id="emp-x", title="Old title",
),
status="published",
)
updated = store.update_posting_fields(
"job-001", title="New title", skills=["kubernetes"]
)
assert updated is not None and updated.title == "New title"
store.set_posting_tags(
"job-001",
PostingTags(skills=["kubernetes", "docker"], seniority=None, category=None),
)
fetched = store.get_posting("job-001")
assert fetched is not None
assert fetched.title == "New title"
assert fetched.skills == ["kubernetes"]
assert fetched.tags is not None and fetched.tags.skills == ["kubernetes", "docker"]
def test_update_unknown_posting_returns_none(self, tmp_path) -> None:
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
assert store.update_posting_fields("missing", title="X") is None
assert store.set_posting_tags("missing", PostingTags()) is None
class TestSeededCorpusTaggable:
def test_seed_postings_have_taggable_content(self) -> None:
"""Every seed posting carries title + description + skills the
tagger can consume (no empty-string descriptions hiding)."""
empty = [p for p in SeededCorpusSource().fetch() if not p.description.strip()]
assert empty == []
@@ -1,192 +1,17 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, Briefcase, Github, Globe, Linkedin, MapPin, Twitter, Users } from 'lucide-react';
import { employers, jobs } from '@nextcraft/mock-data';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import { formatSalaryRange, initials as initialsOf, relativeTime } from '../../../../../lib/format';
interface PageProps {
params: Promise<{ employerId: string }>;
}
const CULTURE_CARDS = [
{ label: 'Open Workspace', tone: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300' },
{ label: 'Team Collaboration', tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' },
{ label: 'Innovation Lab', tone: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
{ label: 'Learning Culture', tone: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' },
{ label: 'Global Team', tone: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300' },
{ label: 'Mission First', tone: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300' },
] as const;
import { EmployerProfileClient } from '../../../../../components/marketplace/employer-profile-client';
/**
* G-101 (Task 1-3-04): generateStaticParams STAYS with ONE placeholder
* param — Next 15 hard-throws E87 under `output:'export'` for any
* dynamic route without it (even returning `[]`). Exactly one client
* shell page exports here; the FastAPI catch-all serves it for every
* runtime id, and the client fetches the real employer (notFound() on
* 404 — honest absence, never a fabricated profile).
*/
export function generateStaticParams() {
return employers.map((e) => ({ employerId: e.id }));
return [{ employerId: '_' }];
}
export default async function EmployerProfilePage({ params }: PageProps) {
const { employerId } = await params;
const employer = employers.find((e) => e.id === employerId);
if (!employer) notFound();
const openPositions = jobs.filter((j) => j.employerId === employer.id);
const aboutParagraphs = [
employer.description,
`Headquartered in ${employer.location}, ${employer.name} operates in the ${employer.industry} space with a team of ${employer.size} people. Our culture is best described as: ${employer.culture}`,
`We are actively hiring across roles that pair AI engineering rigor with real customer outcomes. Browse our open positions below and apply through the Nextcraft marketplace to connect directly with our hiring team.`,
];
return (
<div className="flex flex-col gap-6">
<div>
<Link
href="/marketplace"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to job board
</Link>
</div>
{/* Header */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-4">
<span
aria-hidden
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 text-lg font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
>
{initialsOf(employer.name)}
</span>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{employer.name}
</h1>
<p className="text-sm font-medium text-primary-700 dark:text-primary-300">
{employer.industry}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{employer.size}
</span>
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{employer.location}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<SocialIcon href={employer.socialLinks.twitter ?? '#'} label="Twitter">
<Twitter className="h-4 w-4" />
</SocialIcon>
<SocialIcon href={employer.socialLinks.linkedin ?? '#'} label="LinkedIn">
<Linkedin className="h-4 w-4" />
</SocialIcon>
<SocialIcon href={employer.socialLinks.github ?? '#'} label="GitHub">
<Github className="h-4 w-4" />
</SocialIcon>
<SocialIcon href={employer.website} label="Website">
<Globe className="h-4 w-4" />
</SocialIcon>
</div>
</div>
</CardBody>
</Card>
{/* About */}
<Card>
<CardBody className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">About</h2>
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{aboutParagraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</CardBody>
</Card>
{/* Culture */}
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Culture</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{CULTURE_CARDS.map((c, i) => (
<div
key={c.label}
className={`flex h-24 flex-col items-center justify-center gap-2 rounded-lg border border-slate-200 text-center dark:border-slate-800 ${c.tone}`}
>
<span className="text-xs font-medium uppercase tracking-wide opacity-70">#{i + 1}</span>
<span className="text-sm font-semibold">{c.label}</span>
</div>
))}
</div>
</CardBody>
</Card>
{/* Open positions */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Open positions
</h2>
<Badge variant="default">
<Briefcase className="h-3 w-3" />
{openPositions.length}
</Badge>
</div>
{openPositions.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No current openings.</p>
) : (
<div className="flex flex-col gap-3">
{openPositions.map((job) => (
<Link
key={job.id}
href={`/marketplace/jobs/${job.id}`}
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-4 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
{job.title}
</span>
<span className="text-xs text-slate-600 dark:text-slate-400">
{job.location} · {formatSalaryRange(job.salaryMin, job.salaryMax)} · Posted{' '}
{relativeTime(job.postedAt)}
</span>
</div>
<span className="inline-flex h-8 w-fit items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-800 dark:border-slate-700 dark:text-slate-100">
View
</span>
</Link>
))}
</div>
)}
</CardBody>
</Card>
</div>
);
}
function SocialIcon({
href,
label,
children,
}: {
href: string;
label: string;
children: React.ReactNode;
}) {
return (
<a
href={href}
aria-label={label}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{children}
</a>
);
export default function EmployerProfilePage() {
return <EmployerProfileClient />;
}
@@ -1,315 +1,17 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
import { employers, jobs } from '@nextcraft/mock-data';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import type { Job } from '@nextcraft/types';
import {
formatSalaryRange,
matchBarClasses,
matchBadgeClasses,
matchTone,
relativeTime,
seniorityLabel,
initials as initialsOf,
} from '../../../../../lib/format';
interface PageProps {
params: Promise<{ jobId: string }>;
}
import { JobDetailClient } from '../../../../../components/marketplace/job-detail-client';
/**
* Derive a stable mock "AI-matched skills" breakdown from a job's skill list.
* The first skill is given the highest score (close to the job's matchScore)
* and subsequent skills taper slightly so the visualization reads naturally.
* G-101 (Task 1-3-04): generateStaticParams STAYS with ONE placeholder
* param — Next 15 hard-throws E87 under `output:'export'` for any
* dynamic route without it (even returning `[]`). Exactly one client
* shell page exports here; the FastAPI catch-all serves it for every
* runtime id, and the client fetches the real posting (notFound() on
* 404 — honest absence, never a fabricated job).
*/
function aiMatchedSkills(job: Job): Array<{ skill: string; score: number }> {
const top = job.skills.slice(0, 5);
return top.map((skill, idx) => {
const taper = Math.round(idx * 4);
const score = Math.max(55, Math.min(99, job.matchScore - taper));
return { skill, score };
});
}
export function generateStaticParams() {
return jobs.map((j) => ({ jobId: j.id }));
return [{ jobId: '_' }];
}
export default async function JobDetailPage({ params }: PageProps) {
const { jobId } = await params;
const job = jobs.find((j) => j.id === jobId);
if (!job) notFound();
const employer = employers.find((e) => e.id === job.employerId);
const employerName = employer?.name ?? 'Unknown employer';
const related = jobs
.filter((j) => j.id !== job.id && j.employerId === job.employerId)
.slice(0, 3);
const relatedFallback =
related.length > 0
? related
: jobs
.filter((j) => j.id !== job.id && j.seniority === job.seniority)
.slice(0, 3);
const relatedJobs = (related.length > 0 ? related : relatedFallback).slice(0, 3);
const matchedSkills = aiMatchedSkills(job);
// Build out 3–4 paragraph rich description.
const descriptionParagraphs = [
job.description,
`${employerName} is looking for a ${seniorityLabel(job.seniority)}-level ${
job.title.toLowerCase().includes('engineer') ||
job.title.toLowerCase().includes('designer') ||
job.title.toLowerCase().includes('researcher')
? 'practitioner'
: 'professional'
} who can own outcomes end-to-end. You will partner with product, research, and platform teams to ship reliable AI-powered features, instrumented with the evaluation harnesses this role is responsible for.`,
`Day-to-day you will design systems around ${job.skills
.slice(0, 3)
.join(', ')}, write production code, review pull requests, run evaluations, and contribute to a blameless postmortem culture. We expect strong written communication and comfort working in a fast-moving, evidence-driven environment.`,
`This role is ${job.remote ? 'remote-friendly' : `on-site in ${job.location}`} and reports into the ${employer?.industry ?? 'AI'} practice. Compensation ranges from ${formatSalaryRange(
job.salaryMin,
job.salaryMax,
)} plus equity and benefits. We are an equal-opportunity employer and actively seek candidates from non-traditional backgrounds who have built real AI systems.`,
];
return (
<div className="flex flex-col gap-6">
{/* Back link */}
<div>
<Link
href="/marketplace"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to job board
</Link>
</div>
{/* Header */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-4">
<span
aria-hidden
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-100 text-base font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
>
{initialsOf(employerName)}
</span>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{job.title}
</h1>
<Link
href={`/marketplace/employers/${job.employerId}`}
className="text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
{employerName}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{job.location}
</span>
{job.remote && <Badge variant="success">Remote</Badge>}
<span className="inline-flex items-center gap-1">
<Wallet className="h-3.5 w-3.5" />
{formatSalaryRange(job.salaryMin, job.salaryMax)}
</span>
<Badge variant="default">{seniorityLabel(job.seniority)}</Badge>
<span>Posted {relativeTime(job.postedAt)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`inline-flex h-12 w-12 items-center justify-center rounded-full text-sm font-bold ring-2 ${matchBadgeClasses(
job.matchScore,
)}`}
title={`${job.matchScore}% AI match`}
>
{job.matchScore}%
</span>
<Button size="lg" type="button">
Apply Now
</Button>
</div>
</div>
</CardBody>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="flex flex-col gap-6 lg:col-span-2">
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Job description
</h2>
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{descriptionParagraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</CardBody>
</Card>
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Required competencies
</h2>
<ul className="flex flex-col gap-2">
{job.requiredCompetencies.map((c) => (
<li
key={c}
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
>
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
<Check className="h-3 w-3" />
</span>
<span>{humanizeCompetency(c)}</span>
</li>
))}
</ul>
</CardBody>
</Card>
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
AI-Matched Skills
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
How your competency profile maps to this role, computed by Nextcraft&apos;s match
engine.
</p>
<div className="flex flex-col gap-3">
{matchedSkills.map(({ skill, score }) => (
<div key={skill} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-slate-800 dark:text-slate-200">{skill}</span>
<span className="text-slate-600 dark:text-slate-400">{score}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className={`h-full rounded-full ${matchBarClasses(score)}`}
style={{ width: `${score}%` }}
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${skill} match ${score} percent`}
/>
</div>
</div>
))}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
<span
className={`inline-block h-2 w-2 rounded-full ${
matchTone(job.matchScore) === 'high'
? 'bg-emerald-500'
: matchTone(job.matchScore) === 'mid'
? 'bg-amber-500'
: 'bg-orange-500'
}`}
/>
Overall match score {job.matchScore}%
</div>
</CardBody>
</Card>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-6">
<Card>
<CardBody className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Employer
</h2>
<div className="flex items-center gap-3">
<span
aria-hidden
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
>
{initialsOf(employerName)}
</span>
<div className="flex flex-col">
<span className="font-semibold text-slate-900 dark:text-slate-100">
{employerName}
</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{employer?.industry ?? 'AI'}
</span>
</div>
</div>
<dl className="flex flex-col gap-1.5 text-sm">
{employer && (
<>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Size</dt>
<dd className="text-slate-800 dark:text-slate-200">{employer.size}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Location</dt>
<dd className="text-slate-800 dark:text-slate-200">{employer.location}</dd>
</div>
</>
)}
</dl>
{employer && (
<Link href={`/marketplace/employers/${employer.id}`}>
<Button variant="outline" size="sm" icon={<Building2 className="h-3.5 w-3.5" />} type="button">
View Company
</Button>
</Link>
)}
</CardBody>
</Card>
<Card>
<CardBody className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Related jobs
</h2>
<div className="flex flex-col gap-3">
{relatedJobs.map((r) => (
<Link
key={r.id}
href={`/marketplace/jobs/${r.id}`}
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-3 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800"
>
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
{r.title}
</span>
<span className="text-xs text-slate-600 dark:text-slate-400">{r.location}</span>
<span className="text-xs text-slate-600 dark:text-slate-400">
{formatSalaryRange(r.salaryMin, r.salaryMax)} · {r.matchScore}% match
</span>
</Link>
))}
</div>
</CardBody>
</Card>
</div>
</div>
</div>
);
}
/** Turn a competency code like "stack-orchestration-c001" into readable prose. */
function humanizeCompetency(code: string): string {
const parts = code.split('-');
if (parts.length >= 3) {
const stack = parts[1];
const capLabel = `Capability ${parts[parts.length - 1].toUpperCase()}`;
const stackPretty = stack.charAt(0).toUpperCase() + stack.slice(1);
return `${stackPretty} · ${capLabel}`;
}
return code;
export default function JobDetailPage() {
return <JobDetailClient />;
}
@@ -1,26 +1,10 @@
import { Badge } from '@nextcraft/ui';
import { employers, jobs } from '@nextcraft/mock-data';
import { JobFilters } from '../../../components/marketplace/job-filters';
import { BoardClient } from '../../../components/marketplace/board-client';
/**
* Marketplace board (D-055, Task 1-3-03): server shell only — the grid
* + filters live in the client shell over listPostings (real published
* postings, honest empty state, G-103 truncation notice).
*/
export default function MarketplaceHomePage() {
const employerNames: Record<string, string> = Object.fromEntries(
employers.map((e) => [e.id, e.name]),
);
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Marketplace · Job Board</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
AI-Native Job Board
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
AI-credentialed talent meets AI-era employers. Every listing shows a live AI match
score based on the skills recruiters need most right now.
</p>
</header>
<JobFilters jobs={jobs} employerNames={employerNames} />
</div>
);
return <BoardClient />;
}
@@ -0,0 +1,108 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge } from '@nextcraft/ui';
import type { EmployerProfile, PostingFacets, PostingSummary } from '@nextcraft/types';
import { listPostings, getEmployer, type PostingsFetchAll } from '../../lib/engine-client';
import { JobFilters } from './job-filters';
/**
* Board client shell (D-055, Task 1-3-03): fetches real published
* postings (paged fetch-all, G-103) + employer names, then renders the
* grid + JobFilters over the fetched set. Honest at every state: zero
* postings → an empty-board message (never fake cards), a truncation
* notice when the page bound stopped the fetch-all early. P2: facets
* from the list payload data-drive the sidebar options (MH-2c).
*/
export function BoardClient() {
const [postings, setPostings] = useState<PostingSummary[] | null>(null);
const [employerNames, setEmployerNames] = useState<Record<string, string>>({});
const [totalPublished, setTotalPublished] = useState(0);
const [truncated, setTruncated] = useState(false);
const [facets, setFacets] = useState<PostingFacets | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const body: PostingsFetchAll = await listPostings(controller.signal);
if (controller.signal.aborted) return;
setPostings(body.postings);
setTotalPublished(body.totalPublished);
setTruncated(body.truncated);
setFacets(body.facets);
// Employer names: one getEmployer per distinct employer_id (the
// pilot corpus is 10 employers; a board-level employers index
// route is post-v0.6).
const names: Record<string, string> = {};
const seen = new Set<string>();
for (const p of body.postings) {
if (seen.has(p.employer_id)) continue;
seen.add(p.employer_id);
try {
const e: EmployerProfile = await getEmployer(p.employer_id, controller.signal);
names[p.employer_id] = e.name;
} catch {
names[p.employer_id] = 'Unknown employer';
}
}
if (!controller.signal.aborted) setEmployerNames(names);
} catch (err) {
if (controller.signal.aborted) return;
setPostings([]);
setError(err instanceof Error ? err.message : 'Failed to load the job board.');
}
})();
return () => controller.abort();
}, []);
const emptyBoard = postings !== null && postings.length === 0 && !error;
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Marketplace · Job Board</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
AI-Native Job Board
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
AI-credentialed talent meets AI-era employers. Every listing is a live posting
from the Nextcraft marketplace.
</p>
</header>
{error && (
<div
role="alert"
className="rounded-lg border border-red-300 bg-red-50 px-6 py-8 text-center dark:border-red-800 dark:bg-red-900/30"
>
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
</div>
)}
{emptyBoard && (
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
No postings are published yet. The board updates the moment a posting goes live —
nothing is shown here that isn&apos;t real.
</div>
)}
{postings !== null && postings.length > 0 && (
<>
{truncated && (
<div
role="status"
className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
>
Showing the first {postings.length} of {totalPublished} postings — the board
caps its load for responsiveness. Use the search API (coming in v0.6 search)
to reach the full set.
</div>
)}
<JobFilters postings={postings} employerNames={employerNames} facets={facets} />
</>
)}
</div>
);
}
@@ -0,0 +1,261 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import {
ArrowLeft,
Briefcase,
Github,
Globe,
Linkedin,
MapPin,
Twitter,
Users,
} from 'lucide-react';
import type { EmployerProfile, PostingSummary } from '@nextcraft/types';
import { Badge, Card, CardBody } from '@nextcraft/ui';
import { formatSalaryRange, initials as initialsOf, relativeTime } from '../../lib/format';
import {
EngineError,
getEmployer,
listPostings,
} from '../../lib/engine-client';
import { segmentFromPath } from '../../lib/route-id';
/**
* Employer profile client shell (D-055/G-101, Task 1-3-04): fetches the
* real employer at runtime, notFound() on 404, and lists that
* employer's published postings from the board's fetched set. Raw
* ingested fields in P1 — P4 (D-053) merges enrichment with per-field
* `{value, source}` provenance markers.
*/
export function EmployerProfileClient() {
const [employer, setEmployer] = useState<EmployerProfile | null>(null);
const [openPositions, setOpenPositions] = useState<PostingSummary[]>([]);
const [missing, setMissing] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
const employerId = segmentFromPath(window.location.pathname);
try {
const e = await getEmployer(employerId, controller.signal);
if (controller.signal.aborted) return;
setEmployer(e);
try {
const board = await listPostings(controller.signal);
if (!controller.signal.aborted) {
setOpenPositions(board.postings.filter((p) => p.employer_id === e.id));
}
} catch {
/* positions are a nice-to-have; the profile still renders */
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof EngineError && err.status === 404) {
setMissing(true);
return;
}
setLoadError(err instanceof Error ? err.message : 'Failed to load the employer.');
}
})();
return () => controller.abort();
}, []);
if (missing) {
notFound();
}
if (!employer) {
return (
<div className="flex flex-col gap-6">
<div>
<Link
href="/marketplace"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to job board
</Link>
</div>
<div
role="alert"
className="rounded-lg border border-red-300 bg-red-50 px-6 py-8 text-center dark:border-red-800 dark:bg-red-900/30"
>
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{loadError ?? 'Loading employer…'}
</p>
</div>
</div>
);
}
const aboutParagraphs = [
employer.description,
`Headquartered in ${employer.location}, ${employer.name} operates in the ${employer.industry} space with a team of ${employer.size} people.`,
];
return (
<div className="flex flex-col gap-6">
<div>
<Link
href="/marketplace"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to job board
</Link>
</div>
{/* Header */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-4">
<span
aria-hidden
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 text-lg font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
>
{initialsOf(employer.name)}
</span>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{employer.name}
</h1>
<p className="text-sm font-medium text-primary-700 dark:text-primary-300">
{employer.industry}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{employer.size}
</span>
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{employer.location}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{employer.social_links.twitter && (
<SocialIcon href={employer.social_links.twitter} label="Twitter">
<Twitter className="h-4 w-4" />
</SocialIcon>
)}
{employer.social_links.linkedin && (
<SocialIcon href={employer.social_links.linkedin} label="LinkedIn">
<Linkedin className="h-4 w-4" />
</SocialIcon>
)}
{employer.social_links.github && (
<SocialIcon href={employer.social_links.github} label="GitHub">
<Github className="h-4 w-4" />
</SocialIcon>
)}
{employer.website && (
<SocialIcon href={employer.website} label="Website">
<Globe className="h-4 w-4" />
</SocialIcon>
)}
</div>
</div>
</CardBody>
</Card>
{/* About */}
<Card>
<CardBody className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">About</h2>
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{aboutParagraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</CardBody>
</Card>
{/* Culture */}
{employer.culture && (
<Card>
<CardBody className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Culture</h2>
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{employer.culture}
</p>
</CardBody>
</Card>
)}
{/* Open positions */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Open positions
</h2>
<Badge variant="default">
<Briefcase className="h-3 w-3" />
{openPositions.length}
</Badge>
</div>
{openPositions.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
No current openings from this employer.
</p>
) : (
<div className="flex flex-col gap-3">
{openPositions.map((posting) => (
<Link
key={posting.id}
href={`/marketplace/jobs/${posting.id}`}
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-4 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
{posting.title}
</span>
<span className="text-xs text-slate-600 dark:text-slate-400">
{posting.location}
{posting.salary_min != null && posting.salary_max != null && (
<> · {formatSalaryRange(posting.salary_min, posting.salary_max)}</>
)}
{posting.posted_at && <> · Posted {relativeTime(posting.posted_at)}</>}
</span>
</div>
<span className="inline-flex h-8 w-fit items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-800 dark:border-slate-700 dark:text-slate-100">
View
</span>
</Link>
))}
</div>
)}
</CardBody>
</Card>
</div>
);
}
function SocialIcon({
href,
label,
children,
}: {
href: string;
label: string;
children: React.ReactNode;
}) {
return (
<a
href={href}
aria-label={label}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{children}
</a>
);
}
@@ -0,0 +1,335 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
import type { ApplicationConfirmation, PostingDetail, EmployerProfile } from '@nextcraft/types';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import {
formatSalaryRange,
relativeTime,
seniorityLabel,
initials as initialsOf,
} from '../../lib/format';
import {
EngineError,
VerifyRequiredError,
applyPosting,
getEmployer,
getPosting,
} from '../../lib/engine-client';
import { segmentFromPath } from '../../lib/route-id';
/**
* Job detail client shell (D-055/G-101, Task 1-3-04): fetches the real
* posting at runtime, renders the detail markup, notFound() on 404.
* Apply states: 403 verify-CTA → enrollment prompt (the build/defend
* G-10 pattern), 409 → honest closed message, success → the honest
* confirmation payload. No match scores anywhere (G-106): an absent
* score is an absent query context, honestly.
*/
export function JobDetailClient() {
const [posting, setPosting] = useState<PostingDetail | null>(null);
const [employer, setEmployer] = useState<EmployerProfile | null>(null);
const [missing, setMissing] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [confirmation, setConfirmation] = useState<ApplicationConfirmation | null>(null);
const [applyError, setApplyError] = useState<string | null>(null);
const [verifyCta, setVerifyCta] = useState<string | null>(null);
const [closed, setClosed] = useState(false);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const p = await getPosting(
segmentFromPath(window.location.pathname),
controller.signal,
);
if (controller.signal.aborted) return;
setPosting(p);
try {
const e = await getEmployer(p.employer_id, controller.signal);
if (!controller.signal.aborted) setEmployer(e);
} catch {
/* employer optional for the detail view */
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof EngineError && err.status === 404) {
setMissing(true);
return;
}
setLoadError(err instanceof Error ? err.message : 'Failed to load the posting.');
}
})();
return () => controller.abort();
}, []);
async function onApply() {
if (!posting) return;
setApplying(true);
setApplyError(null);
setVerifyCta(null);
setClosed(false);
try {
const c = await applyPosting(posting.id);
setConfirmation(c);
} catch (err) {
if (err instanceof VerifyRequiredError) {
setVerifyCta(err.verifyCta);
} else if (err instanceof EngineError && err.status === 409) {
setClosed(true);
} else if (err instanceof EngineError && err.status === 404) {
setApplyError('This posting no longer exists.');
} else {
setApplyError(err instanceof Error ? err.message : 'Apply failed.');
}
} finally {
setApplying(false);
}
}
if (missing) {
notFound();
}
if (!posting) {
return (
<div className="flex flex-col gap-6">
<div>
<Link
href="/marketplace"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to job board
</Link>
</div>
<div
role="alert"
className="rounded-lg border border-red-300 bg-red-50 px-6 py-8 text-center dark:border-red-800 dark:bg-red-900/30"
>
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{loadError ?? 'Loading posting…'}
</p>
</div>
</div>
);
}
const employerName = employer?.name ?? 'Unknown employer';
const descriptionParagraphs = posting.description
? [posting.description]
: ['No description provided yet.'];
return (
<div className="flex flex-col gap-6">
{/* Back link */}
<div>
<Link
href="/marketplace"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to job board
</Link>
</div>
{/* Header */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-4">
<span
aria-hidden
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-100 text-base font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
>
{initialsOf(employerName)}
</span>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{posting.title}
</h1>
<Link
href={`/marketplace/employers/${posting.employer_id}`}
className="text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
{employerName}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{posting.location}
</span>
{posting.remote && <Badge variant="success">Remote</Badge>}
{posting.salary_min != null && posting.salary_max != null && (
<span className="inline-flex items-center gap-1">
<Wallet className="h-3.5 w-3.5" />
{formatSalaryRange(posting.salary_min, posting.salary_max)}
</span>
)}
{posting.seniority && <Badge variant="default">{seniorityLabel(posting.seniority)}</Badge>}
{posting.posted_at && <span>Posted {relativeTime(posting.posted_at)}</span>}
{posting.status === 'closed' && <Badge variant="warning">Closed</Badge>}
</div>
</div>
</div>
<div className="flex flex-col items-start gap-3 md:items-end">
{/* Apply flow (D-058): every state renders honestly. */}
{confirmation ? (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
>
<span className="font-semibold">Application submitted</span>
<span className="text-xs">
application {confirmation.application_id} · status {confirmation.status}
</span>
</div>
) : posting.status !== 'published' ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
This posting is {posting.status} — applications are closed.
</p>
) : (
<Button size="lg" type="button" loading={applying} onClick={() => void onApply()}>
Apply Now
</Button>
)}
{verifyCta && (
<div
role="alert"
className="flex flex-col gap-1 rounded-lg border border-blue-300 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-200"
>
<span>
You must be 18+ with a verified identity to apply — it takes one step in
enrollment.
</span>
<a href={verifyCta} className="font-medium underline">
Verify your identity to continue →
</a>
</div>
)}
{closed && (
<p role="alert" className="text-sm text-amber-700 dark:text-amber-300">
This posting is closed — it is no longer accepting applications.
</p>
)}
{applyError && (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{applyError}
</p>
)}
</div>
</div>
</CardBody>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="flex flex-col gap-6 lg:col-span-2">
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Job description
</h2>
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{descriptionParagraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</CardBody>
</Card>
{posting.required_competencies.length > 0 && (
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Required competencies
</h2>
<ul className="flex flex-col gap-2">
{posting.required_competencies.map((c) => (
<li
key={c}
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
>
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
<Check className="h-3 w-3" />
</span>
<span>{c}</span>
</li>
))}
</ul>
</CardBody>
</Card>
)}
{posting.skills.length > 0 && (
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Skills</h2>
<div className="flex flex-wrap gap-1.5">
{posting.skills.map((skill) => (
<span
key={skill}
className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"
>
{skill}
</span>
))}
</div>
</CardBody>
</Card>
)}
</div>
{/* Sidebar */}
<div className="flex flex-col gap-6">
<Card>
<CardBody className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Employer
</h2>
<div className="flex items-center gap-3">
<span
aria-hidden
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
>
{initialsOf(employerName)}
</span>
<div className="flex flex-col">
<span className="font-semibold text-slate-900 dark:text-slate-100">
{employerName}
</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{employer?.industry ?? 'AI'}
</span>
</div>
</div>
{employer && (
<dl className="flex flex-col gap-1.5 text-sm">
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Size</dt>
<dd className="text-slate-800 dark:text-slate-200">{employer.size}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Location</dt>
<dd className="text-slate-800 dark:text-slate-200">{employer.location}</dd>
</div>
</dl>
)}
<Link href={`/marketplace/employers/${posting.employer_id}`}>
<Button variant="outline" size="sm" icon={<Building2 className="h-3.5 w-3.5" />} type="button">
View Company
</Button>
</Link>
</CardBody>
</Card>
</div>
</div>
</div>
);
}
+75 -55
View File
@@ -2,25 +2,20 @@
import { useMemo, useState } from 'react';
import { Bookmark, Search, X } from 'lucide-react';
import type { Job, Seniority } from '@nextcraft/types';
import type { PostingSummary, Seniority } from '@nextcraft/types';
import { Button, Input } from '@nextcraft/ui';
import {
formatSalaryK,
matchBadgeClasses,
relativeTime,
seniorityLabel,
} from '../../lib/format';
const POPULAR_SKILLS = [
'LLM',
'RAG',
'Agents',
'Python',
'TypeScript',
'React',
'Vector DB',
'Fine-tuning',
] as const;
// P2 (MH-2c): skills/seniority/category option lists are DATA-DRIVEN
// from the API facets payload (stored tags of published postings);
// location/remote/salary stay UI constants — they are posting fields,
// not tags. Fallback to the classic option set when facets are absent
// (pre-ingest boards show the honest empty state anyway).
const FALLBACK_SKILLS = ['LLM', 'RAG', 'Agents', 'Python'] as const;
const SENIORITY_OPTIONS: Array<Seniority | 'all'> = [
'all',
@@ -32,8 +27,14 @@ const SENIORITY_OPTIONS: Array<Seniority | 'all'> = [
];
export interface JobFiltersProps {
jobs: Job[];
postings: PostingSummary[];
employerNames: Record<string, string>;
/** P2: facet value counts from the API — drives the sidebar options. */
facets?: {
skills: Array<{ value: string; count: number }>;
seniority: Array<{ value: string; count: number }>;
category: Array<{ value: string; count: number }>;
};
}
interface Filters {
@@ -54,58 +55,70 @@ const EMPTY_FILTERS: Filters = {
salaryMax: '',
};
function matchesJob(job: Job, f: Filters, employerName: string): boolean {
function matchesPosting(
posting: PostingSummary,
f: Filters,
employerName: string,
): boolean {
// Text query — match title, company, or any skill.
if (f.query.trim()) {
const q = f.query.trim().toLowerCase();
const haystack = [
job.title,
posting.title,
employerName,
job.location,
...job.skills,
posting.location,
...posting.skills,
]
.join(' ')
.toLowerCase();
if (!haystack.includes(q)) return false;
}
// Skill filters — job must include every selected skill (case-insensitive).
// Skill filters — posting must include every selected skill (case-insensitive).
if (f.skills.length > 0) {
const jobSkillsLower = job.skills.map((s) => s.toLowerCase());
const postingSkillsLower = posting.skills.map((s) => s.toLowerCase());
const ok = f.skills.every((sel) =>
jobSkillsLower.some((s) => s.includes(sel.toLowerCase())),
postingSkillsLower.some((s) => s.includes(sel.toLowerCase())),
);
if (!ok) return false;
}
// Seniority.
if (f.seniority !== 'all' && job.seniority !== f.seniority) return false;
if (f.seniority !== 'all' && posting.seniority !== f.seniority) return false;
// Remote only.
if (f.remoteOnly && !job.remote) return false;
if (f.remoteOnly && !posting.remote) return false;
// Salary range.
// Salary range (null salary_max = unbounded upper — matches any min filter;
// null salary_min matches any max filter).
if (f.salaryMin.trim()) {
const min = Number(f.salaryMin);
if (!Number.isNaN(min) && job.salaryMax < min) return false;
if (!Number.isNaN(min) && (posting.salary_max ?? Infinity) < min) return false;
}
if (f.salaryMax.trim()) {
const max = Number(f.salaryMax);
if (!Number.isNaN(max) && job.salaryMin > max) return false;
if (!Number.isNaN(max) && (posting.salary_min ?? 0) > max) return false;
}
return true;
}
export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
export function JobFilters({ postings, employerNames, facets }: JobFiltersProps) {
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
// P2: facet-driven skill options (top 8 by count); honest fallback when
// the API carried no facets (untagged store).
const skillOptions = useMemo(() => {
const fromFacets = (facets?.skills ?? []).slice(0, 8).map((f) => f.value);
return fromFacets.length > 0 ? fromFacets : [...FALLBACK_SKILLS];
}, [facets]);
const filtered = useMemo(
() =>
jobs.filter((job) =>
matchesJob(job, filters, employerNames[job.employerId] ?? 'Unknown employer'),
postings.filter((posting) =>
matchesPosting(posting, filters, employerNames[posting.employer_id] ?? 'Unknown employer'),
),
[jobs, filters, employerNames],
[postings, filters, employerNames],
);
function toggleSkill(skill: string) {
@@ -162,7 +175,7 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
</Button>
)}
<span className="ml-auto text-sm text-slate-500 dark:text-slate-400">
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of {jobs.length} jobs
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of {postings.length} jobs
</span>
</div>
</div>
@@ -194,8 +207,9 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Popular skills</h3>
<div className="flex flex-wrap gap-2">
{POPULAR_SKILLS.map((skill) => {
{skillOptions.map((skill) => {
const active = filters.skills.includes(skill);
const count = facets?.skills.find((f) => f.value === skill)?.count;
return (
<button
key={skill}
@@ -210,6 +224,11 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
].join(' ')}
>
{skill}
{count != null && (
<span className={active ? 'ml-1 opacity-80' : 'ml-1 text-slate-400'}>
{count}
</span>
)}
</button>
);
})}
@@ -277,11 +296,11 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{filtered.map((job) => (
<JobCard
key={job.id}
job={job}
employerName={employerNames[job.employerId] ?? 'Unknown employer'}
{filtered.map((posting) => (
<PostingCard
key={posting.id}
posting={posting}
employerName={employerNames[posting.employer_id] ?? 'Unknown employer'}
/>
))}
</div>
@@ -292,7 +311,9 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
);
}
function JobCard({ job, employerName }: { job: Job; employerName: string }) {
/** G-106: no match badge on cards outside search context — an absent
* score is an honest absence, never a fabricated number. */
function PostingCard({ posting, employerName }: { posting: PostingSummary; employerName: string }) {
return (
<article className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-start justify-between gap-3">
@@ -305,40 +326,39 @@ function JobCard({ job, employerName }: { job: Job; employerName: string }) {
</span>
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{job.title}
{posting.title}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{employerName}</p>
</div>
</div>
<span
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
job.matchScore,
)}`}
title={`${job.matchScore}% AI match`}
>
{job.matchScore}%
</span>
{posting.status === 'closed' && (
<span className="inline-flex h-11 w-fit shrink-0 items-center rounded-full bg-slate-100 px-3 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-400">
Closed
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<span className="text-slate-400">📍</span>
{job.location}
{posting.location}
</span>
{job.remote && (
{posting.remote && (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
Remote
</span>
)}
<span className="inline-flex items-center gap-1">
<span className="text-slate-400">$</span>
{formatSalaryK(job.salaryMin)}–{formatSalaryK(job.salaryMax)}
</span>
<span>{relativeTime(job.postedAt)}</span>
{posting.salary_min != null && posting.salary_max != null && (
<span className="inline-flex items-center gap-1">
<span className="text-slate-400">$</span>
{formatSalaryK(posting.salary_min)}–{formatSalaryK(posting.salary_max)}
</span>
)}
{posting.posted_at && <span>{relativeTime(posting.posted_at)}</span>}
</div>
<div className="flex flex-wrap gap-1.5">
{job.skills.slice(0, 5).map((skill) => (
{posting.skills.slice(0, 5).map((skill) => (
<span
key={skill}
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"
@@ -350,7 +370,7 @@ function JobCard({ job, employerName }: { job: Job; employerName: string }) {
<div className="mt-auto pt-1">
<a
href={`/marketplace/jobs/${job.id}`}
href={`/marketplace/jobs/${posting.id}`}
className="inline-flex h-9 w-full items-center justify-center rounded-md border border-slate-300 bg-transparent text-sm font-medium text-slate-800 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
>
View Details
+119
View File
@@ -20,6 +20,11 @@ import type {
DefenseFinish,
DefenseSession,
TelemetryEvent,
PostingSummary,
PostingDetail,
EmployerProfile,
ApplicationConfirmation,
PostingFacets,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -448,4 +453,118 @@ export async function proctorSignals(
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}
// -- marketplace (REQ-6-001, D-055/G-103) ----------------------------------------
/** A 404 from a marketplace detail route — the shells render notFound()
* on it (honest absence, never a fabricated posting/employer). */
export class NotFoundError extends EngineError {
constructor(message = 'Not found.') {
super(message, 404);
this.name = 'NotFoundError';
}
}
/** The result of the board's paged fetch-all (G-103): every published
* posting fetched so far + the server's honest total_published, so the
* board can render "Showing X of Y" with a truncation notice when the
* page bound stopped the walk early. */
export interface PostingsFetchAll {
postings: PostingSummary[];
totalPublished: number;
/** True when the page-bound stopped the walk before exhaustion. */
truncated: boolean;
/** P2 (MH-2c): facet value counts from published-only stored tags —
* data-drives the filter sidebar's skills/seniority/category options.
* The LAST page's value wins (identical on every page by contract). */
facets?: PostingFacets;
}
/** Paged fetch-all page bound: 100 postings/page × 50 pages = 5,000
* fetched rows (the documented ≤10⁴ pilot corpus, A-403, fits with
* headroom). Past the bound the walk stops and `truncated` goes true
* so the board shows the honest truncation notice (G-103). */
export const POSTINGS_MAX_PAGES = 50;
export const POSTINGS_PAGE_SIZE = 100;
/**
* Walk limit/offset pages until exhausted (G-103). Each response carries
* `total_published` — the LAST page's value wins (one SQL COUNT per page,
* monotone within a request window). The UI filters client-side over the
* fetched set (grill G-103: filters are client-side this phase; facet
* server-filters land P2/P3).
*/
export async function listPostings(signal?: AbortSignal): Promise<PostingsFetchAll> {
const postings: PostingSummary[] = [];
let totalPublished = 0;
let truncated = false;
let facets: PostingFacets | undefined;
for (let page = 0; page < POSTINGS_MAX_PAGES; page++) {
const body = await jsonFetch<{
postings: PostingSummary[];
total_published: number;
facets?: PostingFacets;
}>(
`/v1/marketplace/postings?limit=${POSTINGS_PAGE_SIZE}&offset=${page * POSTINGS_PAGE_SIZE}`,
{ signal },
);
totalPublished = body.total_published;
facets = body.facets;
postings.push(...(body.postings ?? []));
if (postings.length >= totalPublished) return { postings, totalPublished, truncated, facets };
if ((body.postings ?? []).length < POSTINGS_PAGE_SIZE) {
// Fewer rows than requested but the count disagrees (concurrent
// publish race) — treat as exhausted only when nothing new arrives.
if ((body.postings ?? []).length === 0) return { postings, totalPublished, truncated, facets };
}
}
truncated = postings.length < totalPublished;
return { postings, totalPublished, truncated, facets };
}
/** One posting by id — typed 404 (NotFoundError) on unknown ids. */
export async function getPosting(id: string, signal?: AbortSignal): Promise<PostingDetail> {
try {
return await jsonFetch<PostingDetail>(
`/v1/marketplace/postings/${encodeURIComponent(id)}`,
{ signal },
);
} catch (err) {
if (err instanceof EngineError && err.status === 404) {
throw new NotFoundError(err.message);
}
throw err;
}
}
/** One employer by id — typed 404 (NotFoundError) on unknown ids. */
export async function getEmployer(id: string, signal?: AbortSignal): Promise<EmployerProfile> {
try {
return await jsonFetch<EmployerProfile>(
`/v1/marketplace/employers/${encodeURIComponent(id)}`,
{ signal },
);
} catch (err) {
if (err instanceof EngineError && err.status === 404) {
throw new NotFoundError(err.message);
}
throw err;
}
}
/** Apply to a posting (D-058). Gate composition server-side (D-043):
* 403 NotAllowlisted → 403 VerifyRequired (verify-CTA discrimination
* reused) → 429 RateLimited → 404 missing → 409 closed → 200 honest
* confirmation (a duplicate returns the EXISTING application). */
export async function applyPosting(
postingId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<ApplicationConfirmation> {
return jsonFetch('/v1/marketplace/apply', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, posting_id: postingId }),
signal,
});
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Runtime id extraction for the G-101 placeholder shells.
*
* The static export emits one page per dynamic route (the `_`
* placeholder param); the catch-all serves it at EVERY runtime URL.
* The page's RSC payload therefore carries the placeholder param —
* `useParams()` returns `_` even at `/marketplace/jobs/job-001/` — so
* the shells read the REAL id from the browser path (client-effect
* only; never during SSR/hydration).
*/
/** Last non-empty path segment, URI-decoded (trailingSlash-safe). */
export function segmentFromPath(pathname: string): string {
const segments = pathname.split('/').filter(Boolean);
const raw = segments[segments.length - 1] ?? '';
try {
return decodeURIComponent(raw);
} catch {
// Malformed percent-encoding — use the raw segment verbatim.
return raw;
}
}
+437
View File
@@ -0,0 +1,437 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
/**
* Task 1-4-03 / MH-1e (REQ-6-001, G-101/G-103/G-106): marketplace web
* client shells. Fetch stubs pin the wire shapes without a server.
*
* - engine-client: listPostings paged fetch-all + truncation flag,
* getPosting/getEmployer typed 404s, applyPosting body + 403/409
* discrimination (the G-10 taxonomy reused).
* - shells: source-level behavior pins (the 'use client' shells use
* window/navigation APIs node:test cannot mount; behavior is pinned
* by the fetch-backed contracts + the honest-state markup they
* render, verified by typecheck + the export build).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
function wirePosting(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "job-001",
source: "seed",
external_id: "job-001",
employer_id: "emp-1",
title: "AI Platform Engineer",
description: "Build the platform.",
required_competencies: ["stack-orchestration-c001"],
skills: ["Python", "LLM"],
seniority: "senior",
category: null,
location: "San Francisco",
remote: true,
salary_min: 160000,
salary_max: 240000,
status: "published",
tags: null,
posted_at: "2026-09-01T00:00:00Z",
...overrides,
};
}
test("listPostings: single page fetch-all carries total_published (G-103)", async () => {
const calls: string[] = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
return new Response(
JSON.stringify({
postings: [wirePosting()],
total_published: 1,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
try {
const { listPostings } = await import(`../lib/engine-client.ts${QS}`);
const result = await listPostings();
assert.equal(result.postings.length, 1);
assert.equal(result.totalPublished, 1);
assert.equal(result.truncated, false, "exhausted set must not be truncated");
assert.ok(
calls[0].endsWith("/v1/marketplace/postings?limit=100&offset=0"),
`unexpected first page URL: ${calls[0]}`,
);
} finally {
globalThis.fetch = realFetch;
}
});
test("listPostings: walks limit/offset pages until exhausted (G-103)", async () => {
const calls: string[] = [];
const realFetch = globalThis.fetch;
// Page 1: 100 rows (full page → another page may exist); page 2: 1 row
// + total 101. The walk must stop after page 2 (postings >= total).
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
const offset = Number(String(input).split("offset=")[1]);
if (offset === 0) {
const rows = Array.from({ length: 100 }, (_, i) =>
wirePosting({ id: `job-${i}`, external_id: `job-${i}` }),
);
return new Response(
JSON.stringify({ postings: rows, total_published: 101 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(
JSON.stringify({
postings: [wirePosting({ id: "job-100", external_id: "job-100" })],
total_published: 101,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
try {
const { listPostings } = await import(`../lib/engine-client.ts${QS}`);
const result = await listPostings();
assert.equal(calls.length, 2, "exactly two page fetches");
assert.ok(calls[0].endsWith("offset=0"));
assert.ok(calls[1].endsWith("offset=100"));
assert.equal(result.postings.length, 101);
assert.equal(result.totalPublished, 101);
assert.equal(result.truncated, false);
} finally {
globalThis.fetch = realFetch;
}
});
test("listPostings: 50-page bound stops the walk with truncated=true (G-103)", async () => {
const realFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
const rows = Array.from({ length: 100 }, (_, i) =>
wirePosting({ id: `job-${calls}-${i}`, external_id: `x-${calls}-${i}` }),
);
// total stays above everything fetched → the walk only stops at the bound
return new Response(
JSON.stringify({ postings: rows, total_published: 1_000_000 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
try {
const { listPostings, POSTINGS_MAX_PAGES } = await import(`../lib/engine-client.ts${QS}`);
assert.equal(POSTINGS_MAX_PAGES, 50, "documented page bound (≤10⁴ corpus)");
const result = await listPostings();
assert.equal(calls, 50, "the walk stops at the 50-page bound");
assert.equal(result.postings.length, 5000);
assert.equal(result.truncated, true, "honest truncation flag over the fetched set");
assert.ok(result.totalPublished > result.postings.length);
} finally {
globalThis.fetch = realFetch;
}
});
test("getPosting: right URL + typed NotFoundError on 404 (honest absence)", async () => {
const { getPosting, NotFoundError, EngineError } = await import(`../lib/engine-client.ts${QS}`);
const realFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
return new Response(JSON.stringify({ detail: "no posting with id 'nope'" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
try {
await assert.rejects(
() => getPosting("nope"),
(err: unknown) => {
assert.ok(err instanceof NotFoundError, "404 must surface as NotFoundError");
assert.ok(err instanceof EngineError, "NotFoundError extends EngineError");
assert.equal((err as InstanceType<typeof EngineError>).status, 404);
return true;
},
);
assert.ok(calls[0].endsWith("/v1/marketplace/postings/nope"));
} finally {
globalThis.fetch = realFetch;
}
});
test("getEmployer: right URL + wire shape + typed 404", async () => {
const { getEmployer, NotFoundError } = await import(`../lib/engine-client.ts${QS}`);
const realFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
if (String(input).endsWith("/v1/marketplace/employers/emp-1")) {
return new Response(
JSON.stringify({
id: "emp-1",
source: "seed",
external_id: "emp-1",
name: "OpenAI Labs",
logo: "",
description: "Research-driven.",
industry: "AI Research",
size: "500-1000",
location: "San Francisco",
website: "https://example.com",
social_links: { twitter: "https://example.com/x" },
culture: "Research-driven, shipping-paced.",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({ detail: "no employer with id 'nope'" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
try {
const e = await getEmployer("emp-1");
// base.py Employer field-for-field parity (dual-schema sync)
assert.equal(e.id, "emp-1");
assert.equal(e.name, "OpenAI Labs");
assert.equal(e.social_links.twitter, "https://example.com/x");
assert.equal(e.culture, "Research-driven, shipping-paced.");
await assert.rejects(
() => getEmployer("nope"),
(err: unknown) => err instanceof NotFoundError,
);
assert.ok(calls[0].endsWith("/v1/marketplace/employers/emp-1"));
assert.ok(calls[1].endsWith("/v1/marketplace/employers/nope"));
} finally {
globalThis.fetch = realFetch;
}
});
test("applyPosting: posts {learner_id, posting_id} to /v1/marketplace/apply", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), init });
return new Response(
JSON.stringify({ application_id: "app-1", posting_id: "job-001", status: "submitted" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
try {
const { applyPosting, MOCK_LEARNER_ID } = await import(`../lib/engine-client.ts${QS}`);
const c = await applyPosting("job-001");
assert.deepEqual(c, { application_id: "app-1", posting_id: "job-001", status: "submitted" });
assert.ok(calls[0].url.endsWith("/v1/marketplace/apply"));
assert.equal(calls[0].init?.method, "POST");
assert.deepEqual(JSON.parse(String(calls[0].init?.body)), {
learner_id: MOCK_LEARNER_ID,
posting_id: "job-001",
});
} finally {
globalThis.fetch = realFetch;
}
});
test("applyPosting: verify-CTA 403 → VerifyRequiredError (the G-10 pattern reused)", async () => {
const { applyPosting, VerifyRequiredError } = await import(`../lib/engine-client.ts${QS}`);
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "age_gate_18_plus",
min_age: 18,
current_status: "none",
verify_cta: "/enroll",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
try {
await assert.rejects(
() => applyPosting("job-001"),
(err: unknown) => {
assert.ok(err instanceof VerifyRequiredError);
const v = err as InstanceType<typeof VerifyRequiredError>;
assert.equal(v.minAge, 18);
assert.equal(v.verifyCta, "/enroll");
return true;
},
);
} finally {
globalThis.fetch = realFetch;
}
});
test("applyPosting: 409 closed posting surfaces the EngineError (honest closed state)", async () => {
const { applyPosting, EngineError } = await import(`../lib/engine-client.ts${QS}`);
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({ detail: { reason: "posting_not_published", status: "closed" } }),
{ status: 409, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
try {
await assert.rejects(
() => applyPosting("job-001"),
(err: unknown) => {
assert.ok(err instanceof EngineError);
assert.equal((err as InstanceType<typeof EngineError>).status, 409);
return true;
},
);
} finally {
globalThis.fetch = realFetch;
}
});
// -- shell behavior pins (source-level: 'use client' shells use
// window/navigation node:test cannot mount) ----------------------------------
test("board shell: empty board renders an honest empty state (never fake cards)", async () => {
const fs = await import("node:fs");
const src = fs.readFileSync("components/marketplace/board-client.tsx", "utf8");
// Honest empty state: zero postings → a message, not fabricated cards.
assert.match(src, /No postings are published yet/);
// The empty state fires ONLY on a fetched empty set (not on error).
assert.match(src, /postings\.length === 0 && !error/);
// Fetch-backed: listPostings drives the board (D-055).
assert.match(src, /await listPostings/);
});
test("board shell: truncation notice renders over the fetched set (G-103)", async () => {
const fs = await import("node:fs");
const src = fs.readFileSync("components/marketplace/board-client.tsx", "utf8");
assert.match(src, /truncated &&/, "truncation flag gates the notice");
assert.match(src, /Showing the first \{postings\.length\} of \{totalPublished\}/);
});
test("job detail shell: notFound on 404 + honest apply states (403 CTA / 409 closed / confirmation)", async () => {
const fs = await import("node:fs");
const src = fs.readFileSync("components/marketplace/job-detail-client.tsx", "utf8");
// 404 → the honest not-found signal (never a fabricated job).
assert.match(src, /status === 404[\s\S]{0,200}setMissing\(true\)/);
// Apply states: verify-CTA prompt (learner build/defend pattern), 409
// closed message, success confirmation payload.
assert.match(src, /VerifyRequiredError/);
assert.match(src, /verifyCta/, "verify-CTA link renders on 403");
assert.match(src, /posting is closed/);
assert.match(src, /Application submitted/);
assert.match(src, /confirmation\.application_id/, "honest confirmation payload renders");
});
test("employer shell: fetch-backed + notFound on 404 + raw ingested fields", async () => {
const fs = await import("node:fs");
const src = fs.readFileSync(
"components/marketplace/employer-profile-client.tsx",
"utf8",
);
assert.match(src, /await getEmployer/);
assert.match(src, /status === 404[\s\S]{0,400}setMissing\(true\)/);
// Raw ingested fields render (P4 adds {value, source} provenance).
assert.match(src, /employer\.culture/);
assert.match(src, /social_links\.twitter/);
});
test("E87 regression class (G-101): generateStaticParams STAYS with ONE placeholder param", async () => {
const fs = await import("node:fs");
const jobs = fs.readFileSync(
"app/(marketplace)/marketplace/jobs/[jobId]/page.tsx",
"utf8",
);
const employers = fs.readFileSync(
"app/(marketplace)/marketplace/employers/[employerId]/page.tsx",
"utf8",
);
for (const [name, src] of [
["jobs/[jobId]", jobs],
["employers/[employerId]", employers],
] as const) {
assert.match(src, /export function generateStaticParams/, `${name}: must keep generateStaticParams`);
assert.match(
src,
/\[{ jobId: '_' }\]|\[{ employerId: '_' }\]/,
`${name}: exactly one placeholder param`,
);
// The page must NOT be a client component (Next forbids 'use client' +
// generateStaticParams in one file — the shell is a separate client file).
assert.ok(!src.includes("'use client'"), `${name}: server shell (client lives in the component)`);
}
});
test("G-106: no matchScore on marketplace-path surfaces (honest absent score)", async () => {
const fs = await import("node:fs");
const surfaces = [
"components/marketplace/board-client.tsx",
"components/marketplace/job-detail-client.tsx",
"components/marketplace/employer-profile-client.tsx",
"components/marketplace/job-filters.tsx",
];
for (const f of surfaces) {
const src = fs.readFileSync(f, "utf8");
assert.ok(!/matchScore/.test(src), `${f} must not reference matchScore`);
}
const typesSrc = fs.readFileSync("../../packages/types/marketplace.ts", "utf8");
// Only the WIRE shapes (dual-schema-sync block) are pinned — the old
// mock Job/Candidate types stay intact for the dormant employer/admin
// mockups (G-107 scope: mock-data excluded). Comments may NAME the
// constraint; no wire FIELD may carry it.
const wireBlock = typesSrc.slice(typesSrc.indexOf("export type PostingStatus"));
const wireFields = wireBlock.split("\n").filter((l) => /^\s*\w+\??:/.test(l));
const scoreFields = wireFields.filter((l) => /matchScore/i.test(l));
assert.equal(scoreFields.length, 0, `wire shapes must not carry matchScore fields: ${scoreFields}`);
});
test("dual-schema sync: PostingSummary mirrors marketplace/base.py field-for-field", async () => {
const fs = await import("node:fs");
const typesSrc = fs.readFileSync("../../packages/types/marketplace.ts", "utf8");
// posting_summary parity (G-107): the exact base.py Posting fields.
const fields = [
"id:", "source:", "external_id:", "employer_id:", "title:",
"description:", "required_competencies:", "skills:", "seniority:",
"category:", "location:", "remote:", "salary_min:", "salary_max:",
"status:", "tags:", "posted_at:",
];
const block = typesSrc.slice(
typesSrc.indexOf("export interface PostingSummary"),
typesSrc.indexOf("export interface PostingDetail"),
);
for (const f of fields) {
assert.ok(block.includes(f), `PostingSummary missing base.py field ${f}`);
}
});
test("G-101 shells extract the runtime id from the browser path (trailingSlash-safe)", async () => {
const { segmentFromPath } = await import("../lib/route-id.ts");
// trailingSlash: true — the real id is the last non-empty segment.
assert.equal(segmentFromPath("/marketplace/jobs/job-001/"), "job-001");
assert.equal(segmentFromPath("/marketplace/jobs/job-001"), "job-001");
assert.equal(segmentFromPath("/marketplace/employers/emp-openai/"), "emp-openai");
// URI-encoded ids (e.g. sources with slashes/specials) decode safely;
// malformed sequences fall back to the raw segment (never a crash).
assert.equal(segmentFromPath("/marketplace/jobs/job%2F001/"), "job/001");
assert.equal(segmentFromPath("/marketplace/jobs/job%2/"), "job%2");
// No segments at all → empty id (the shell fetch 404s honestly).
assert.equal(segmentFromPath("/"), "");
assert.equal(segmentFromPath(""), "");
});
test("P2 facets: engine-client surfaces facets; JobFilters derives options from them (MH-2c)", async () => {
const src = readFileSync("lib/engine-client.ts", "utf8");
// The fetch-all carries the facets payload (dual-schema: PostingFacets).
assert.ok(/facets\?: PostingFacets/.test(src), "PostingsFetchAll must carry facets");
assert.ok(/facets = body\.facets/.test(src), "listPostings must propagate facets");
const filters = readFileSync("components/marketplace/job-filters.tsx", "utf8");
// Skill options derive from the facets payload (counts render honestly),
// with the fallback only when facets are absent — no hardcoded-only list.
assert.ok(/facets\?\.skills/.test(filters), "JobFilters must derive options from facets");
assert.ok(/\.slice\(0, 8\)\.map\(\(f\) => f\.value\)/.test(filters), "top-8 facet values drive options");
assert.ok(/FALLBACK_SKILLS/.test(filters), "honest fallback when facets absent");
const board = readFileSync("components/marketplace/board-client.tsx", "utf8");
assert.ok(/facets=\{facets\}/.test(board), "BoardClient must pass facets to JobFilters");
// Wire types: PostingPage carries facets (dual-schema with base.py PostingListResponse).
const wire = readFileSync("../../packages/types/marketplace.ts", "utf8");
assert.ok(/export interface PostingFacets/.test(wire), "PostingFacets type exists");
assert.ok(/facets: PostingFacets;/.test(wire), "PostingPage carries facets");
});
+94 -1
View File
@@ -3,6 +3,14 @@
*
* AI-era job board + talent marketplace. Jobs, employers, candidates,
* postings, talent matches, and search filters.
*
* The `Posting*` / `EmployerProfile` / `ApplicationConfirmation` wire
* shapes below are the dual-schema-sync mirror of
* `apps/ai-service/ai_service/marketplace/base.py` (Task 1-3-01):
* field-for-field parity, snake_case as emitted by the API — every
* change happens in both places. `matchScore` is deliberately absent
* (G-106/G-107): scores are computed search-time context (P3), never
* list/detail payload fields.
*/
export type Seniority = 'entry' | 'mid' | 'senior' | 'staff' | 'principal';
@@ -73,4 +81,89 @@ export interface SearchFilter {
remote: boolean | null;
salaryMin: number | null;
salaryMax: number | null;
}
}
// -- wire shapes (dual-schema sync with marketplace/base.py — 1-3-01) -----------
export type PostingStatus = 'draft' | 'published' | 'closed';
/** Derived tags on a posting row (base.py PostingTags; skills ≤10). */
export interface PostingTags {
skills: string[];
seniority: Seniority | null;
category: string | null;
}
/** One posting over HTTP — base.py `posting_summary` parity (G-107:
* `matchScore` deliberately absent). */
export interface PostingSummary {
id: string;
source: string;
external_id: string;
employer_id: string;
title: string;
description: string;
required_competencies: string[];
skills: string[];
seniority: Seniority | null;
category: string | null;
location: string;
remote: boolean;
salary_min: number | null;
salary_max: number | null;
status: PostingStatus;
tags: PostingTags | null;
posted_at: string | null;
}
/** Full posting detail — same shape as the summary (one serialization
* helper covers list + detail on the server; mirrored here). */
export interface PostingDetail extends PostingSummary {}
/** Raw ingested employer fields (base.py Employer / api EmployerResponse).
* P4 (D-053) merges enrichment on read with per-field
* `{value, source}` provenance markers. */
export interface EmployerProfile {
id: string;
source: string;
external_id: string;
name: string;
logo: string;
description: string;
industry: string;
size: string;
location: string;
website: string;
social_links: Record<string, string>;
culture: string;
}
/** Honest apply confirmation (base.py Application / api ApplyResponse):
* exactly the application state, no fabricated outcome. */
export interface ApplicationConfirmation {
application_id: string;
posting_id: string;
status: string;
}
/** Published-only list response (G-103): the page + the honest
* total_published so the board's paged fetch-all can render
* "Showing X of Y" without fabrication. P2 (MH-2c): facets — value
* counts over stored tags of published-only postings (dual-schema sync
* with api/marketplace.py PostingListResponse.facets). */
export interface FacetCount {
value: string;
count: number;
}
export interface PostingFacets {
skills: FacetCount[];
seniority: FacetCount[];
category: FacetCount[];
}
export interface PostingPage {
postings: PostingSummary[];
total_published: number;
facets: PostingFacets;
}