# 07 — Implementation Plan ## Status / Scope of this document Binding delivery plan for the Utopia Brands internal HR Recruitment & ATS platform. It converts the architecture and database decisions in `_decisions.md` into a phased, owner-assigned, dependency-ordered schedule for **exactly two developers** — Talha Ahmed (senior) and Ahmed Mujtaba (junior). It covers Phases 0–4, the task split, development order, review gates, testing strategy, week-by-week milestones through Phase 0, Definition of Done, and a risk register. Scope boundaries, stated so nothing here is over-read: - Requirements are taken from the assignment brief and `_decisions.md`. **There is no meeting transcript in this repository** (findings §B) — nothing here derives from one. - The repository is a static browser-only prototype: no backend, no database, no auth, no tests, no build step, no Docker, no env files (findings §A, §B). Every backend line item below is greenfield. The retained assets are `css/styles.css`, `js/ui.js` and `js/charts.js` (findings §G). - Two kinds of effort figure appear and they are not interchangeable. **Task sizes (§10) are single-person effort in dev-days including review rework** — see the §1 size key. **Phase ranges (§2) are calendar weeks for the two-person team**, derived from the task sizes and the per-person weekly budgets. Where a range is wide, that is the honest estimate, not padding. - **The §2 phase ranges are larger than the ones in `_decisions.md`, by roughly 1.7–1.8x on the total.** That is not a scope change — it is this plan's own bottom-up task roll-up contradicting the inherited calendar figure, resolved in favour of the roll-up. Both numbers are published side by side in §2 and the divergence is recorded as §17 divergence 5. If you read only one section of this document, read §2. - The schedule is anchored to an **assumed kickoff of Monday 2026-08-03**. Anchor date is an assumption; all dates shift as one block if kickoff moves. - Terminology: this document uses the Part 1 module names (`requisition`, `application`, `scoring`) for code boundaries and the Part 2 table names (`job`, `job_version`, `job_application`, `ats_result`) for schema objects. That divergence exists inside `_decisions.md` itself and is flagged in §17. --- ## 1. The delivery constraints that shape every number below | Constraint | Consequence for the plan | |---|---| | Two developers, one of whom is junior | No parallel workstreams beyond two. Every plan line is either Talha's or Ahmed's; there is no "the team will" | | One reviewer | Talha is the sole reviewer of Ahmed's work *and* has no reviewer for his own. Review is a scheduled capacity cost, not free | | Greenfield backend | Phase 0 is not optional setup — it is the first time this system has a database, a migration, an auth check or a test | | 66 named seats, ~20–25 peak concurrency (assumption, `_decisions.md` deployment section) | No scale work in Phases 0–4. No Kubernetes, Kafka, microservices, Elasticsearch, second database or separate AI service | | Attacker-supplied input is a **Phase 1** input, not a later one | CV files and inbound mail are the two Phase 1 intake channels, so the XSS remediation (findings §E) and untrusted-file hardening are Phase 0/1 scheduled work, not backlog | | Unresolved open questions gate Phase 3+ | Model hosting (OQ-1), historic outcome data (OQ-2), jurisdictions (OQ-4). Confidence is stated lower for those phases for that reason | ### Capacity model (assumption, used for every effort figure) | Person | Nominal | Absorbed by review / architecture / ops / stakeholder time | Productive dev-days per week | |---|---|---|---| | Talha | 5 | ~1.0 (PR review, ADRs, IT liaison, deploys) | **4.0** | | Ahmed | 5 | ~0.5 (ramp, rework after review) | **4.5** | | **Combined** | 10 | — | **~8.5 dev-days / week** | Size key used throughout: **XS** ≤ 0.5 d · **S** 1–2 d · **M** 3–5 d · **L** 6–10 d · **XL** > 10 d (an XL task must be split before it enters a sprint). Three properties of that key matter, because §2 rolls the §10 task tables up against it: 1. **The sizes are single-person effort in dev-days, not elapsed calendar.** A task sized L is 6–10 days of one developer's productive time. At 4.0 dev-days/week for Talha it therefore occupies 1.5–2.5 of *his* weeks. Nothing in the §10 tables is an elapsed-time figure; where a task's real constraint is waiting rather than working (T-07, the Entra ID request) the size covers only the work, and the waiting is handled as a dependency and a risk, never as size. 2. **The bands are conservative envelopes, not point estimates.** This has an asymmetric consequence for roll-ups. The sum of the band *minima* is a **hard floor** — every task must consume at least its minimum, so the phase cannot finish faster. The sum of the band *maxima* is a **pessimistic envelope**, not a p90: independent tasks do not all land on their worst case simultaneously. Read the published lower bounds in §2 as commitments and the upper bounds as envelopes. 3. **Where a task-level estimate elsewhere in this document is tighter than its band, the tighter number wins for planning and the band is retained for the roll-up.** There is exactly one such case: §8 estimates A-01 → A-05 at ~4.5 dev-days in total from a step-by-step breakdown, while their bands sum to 6.5–11.5. §8's figure is derived from the enumerated 34 sites and is the better number; the §12 weekly derivation uses band midpoints for consistency, which makes that stretch of the schedule the most likely to come in early. Roll-up convention: **min** = XS 0.5 / S 1 / M 3 / L 6; **midpoint** (used in §12) = XS 0.5 / S 1.5 / M 4 / L 8; **max** = XS 0.5 / S 2 / M 5 / L 10. --- ## 2. Phase overview **Two independent numbers exist for every phase and they disagree.** The **inherited range** is the calendar figure carried over from `_decisions.md`. The **bottom-up roll-up** is the sum of the §10 task sizes against the §1 size key. Both are published below, because the correction is large enough that hiding it would be the single most misleading thing this document could do. Where the two came from, stated plainly: the inherited dev-day column in the previous version of this table was not an estimate at all — it was the inherited calendar range multiplied by the 8.5 dev-days/week capacity figure. The §10 task sizes were assigned afterwards, per task, and were never reconciled against it. In Phases 2–4 the two happen to agree within the bands. **In Phases 0 and 1 they do not, and Phase 1's bottom-up minimum exceeds its inherited maximum by 20%** — a bottom-up floor above a published ceiling is not a wide estimate, it is a wrong one. **The reconciliation resolves in favour of the roll-up.** The roll-up is derived from 96 enumerated tasks with named owners, dependencies and review gates; the inherited range was derived before that enumeration existed. The inherited column is retained rather than deleted so the size of the correction stays visible. | Phase | Objective in one line | Inherited (`_decisions.md`) | §10 tasks | Roll-up dev-days | Roll-up ÷ 8.5 | Busier stream's floor | **Published range** | Confidence | |---|---|---|---|---|---|---|---|---| | **0** | Make the prototype safe and stand up a real engineering platform | 2–3 wk (17–25 dd) | 20 — 4L 9M 6S 1XS | 57.5–97.5 | 6.8–11.5 wk | Talha 34 dd ÷ 4.0 = **8.5 wk** | **9–12 weeks** | Medium-high | | **1** | One real CV, end to end, on real infrastructure, with an explainable score | 10–14 wk (85–119 dd) | 36 — 16L 17M 3S | 150–251 | 17.6–29.5 wk | Talha 93 dd ÷ 4.0 = **23.3 wk** | **24–30 weeks** | Medium | | **2** | Turn the slice into a working recruiter workflow | 7–10 wk (60–85 dd) | 17 — 8L 8M 1S | 73–122 | 8.6–14.4 wk | Ahmed 40 dd ÷ 4.5 = **8.9 wk** | **9–14 weeks** | Medium-low | | **3** | Offers, assessments, reporting, and the fairness gate before ranking goes live | 7–10 wk (60–85 dd) | 13 — 5L 7M 1S | 52–87 | 6.1–10.2 wk | Talha 27 dd ÷ 4.0 = **6.8 wk** | **7–10 weeks** | Low | | **4** | Outbound publishing, full assistant, remaining AI capabilities, admin surfaces | 5–8 wk (42–68 dd) | 11 — 5L 4M 2S | 44–74 | 5.2–8.7 wk | Talha 27 dd ÷ 4.0 = **6.8 wk** | **7–9 weeks** | Low | Total roll-up: **376.5–631.5 dev-days**. Published total: **56–75 weeks** — the sum of the per-phase published ranges, roughly 13 to 17 months of two-person delivery for the full platform, against the 31–45 weeks previously stated. Note that the published lower bound (56 weeks) is **above** the roll-up ÷ 8.5 figure (44.3 weeks), and the gap is not an error — it is the cost of the load imbalance. Dividing by combined capacity assumes both developers are always busy on that phase's work; dividing each stream by its own budget does not. The 12-week gap between 44 and 56 is the amount of Talha-bound sequential work that Ahmed cannot absorb, and §12.2 shows it concretely as four weeks of his slack inside Phase 0 alone. Anyone quoting 44 weeks is quoting a number that requires a third developer to be true. That correction is the honest number. Any commitment materially shorter than it is a commitment to cut scope, and §4.2.1 now names the specific tasks that would be cut. **How each published bound is derived, so it can be checked and challenged:** | Bound | Basis | Why | |---|---|---| | Lower | The **busier developer's** band-minimum ÷ that person's own weekly budget (Talha 4.0, Ahmed 4.5) | A phase cannot finish faster than its busiest stream. The combined-capacity figure (roll-up ÷ 8.5) silently assumes perfect load balance and is optimistic wherever the streams are unequal — which is every phase except 2 | | Upper | Roll-up band-maximum ÷ 8.5 combined | Deliberately the *combined* figure. It is an envelope, not a p90 (§1), and it too assumes perfect balance | **The load imbalance is the finding behind the finding.** Talha holds 93 of Phase 1's 150 floor dev-days — 62% of the work at the lower of the two weekly budgets. Applying the same stream-level arithmetic to the *upper* band gives Talha 57 dd (14 wk) in Phase 0 and 155 dd (39 wk) in Phase 1, both above the published ceilings. The published ceilings are therefore only reachable if load is actively rebalanced. Two levers exist and both are used in this plan rather than left as advice: 1. **Rebalance.** `procrastinate` queue configuration moves from Talha to Ahmed as **A-10b** (§10.1) — one senior-owned module rotated per phase, which is R-04's stated mitigation rather than a new idea. Phase 1 has no comparable candidate: every Talha item there is schema, constraints, parsing, authorization or an integration. 2. **Cut.** The §4.2 Phase 1 cut line is extended with a named, sized set of tasks that brings Phase 1's floor back under the inherited 119 dev-days if the calendar is fixed rather than the scope. ```mermaid gantt title Utopia Brands ATS phase schedule — earliest-finish chain plus slip bands dateFormat YYYY-MM-DD axisFormat %b %Y excludes weekends section Phase 0 Hardening and platform foundations :p0, 2026-08-03, 45d Slip band :p0b, after p0, 15d Phase 0 demo D0 earliest :milestone, m0, 2026-10-05, 0d section Phase 1 Vertical slice intake to score :p1, after p0, 120d Slip band :p1b, after p1, 30d Phase 1 demo D1 earliest :milestone, m1, 2027-03-22, 0d section Phase 2 Recruiter workflow :p2, after p1b, 45d Slip band :p2b, after p2, 25d section Phase 3 Offers assessments reporting fairness :p3, after p2b, 35d Slip band :p3b, after p3, 15d section Phase 4 Outbound and full assistant :p4, after p3b, 35d Slip band :p4b, after p4, 10d ``` Read the chart as: the solid bar is the published lower bound (the busier stream's floor), the slip band is the remainder up to the published upper bound. Bands are **cumulative** — a Phase 1 slip moves everything after it, which is why Phase 2 is drawn from the end of the Phase 1 band rather than from the end of the Phase 1 bar. The two milestones are **earliest** dates on the no-slip chain; D0 lands in week 9, not week 3, and §12 derives that week by week. --- ## 3. Phase 0 — Safety and platform foundations **Objective.** Two independent outcomes: (a) the existing prototype can no longer execute attacker-supplied strings, so a stakeholder demo pointed at real data is survivable; and (b) the team has a repository, a database, migrations, CI, a deploy path, an authorization decision point and an audit trail — none of which exist today (findings §B, §D). ### 3.1 Included | # | Item | Why in Phase 0 | |---|---|---| | 1 | **XSS/CSP remediation of the prototype** — see §8 for the ordered plan | 34 unescaped `innerHTML` sites (findings §E); Phase 1 intake is attacker-supplied by design | | 2 | Monorepo layout (`api/`, `web/`, `db/migrations/`), docker compose (Postgres 16, Redis, MinIO) | No Dockerfile, no compose file, no env file exists (findings §B) | | 3 | CI pipeline: ruff, mypy, `import-linter` contracts, pytest against a real Postgres service container, ESLint `react/no-danger` as error, stylelint token rule, `tsc --noEmit`, migration-drift gate, unescaped-interpolation grep gate, evidence-citation gate (`tools/check_evidence_citations.py`) | `.github/` is absent (findings §B). Boundary rules must be machine-enforced from commit one | | 4 | `db/migrations` bootstrap: schemas `app`/`ref`/`audit`/`ai`/`staging`, extensions `pg_trgm`, `unaccent`, `btree_gist`, `pgcrypto`; **ADR 0017 signed off** (`Proposed` → `Accepted`) | Resolves the ORM-vs-plain-SQL conflict in §17 before any table exists, with one ruling instead of six recommendations. Merge blocker on migration `001` | | 5 | `identity`: `app_user`, `role`, `permission`, scoped `role_assignment`, sessions, and the single `iam.can(actor, action, resource)` decision point with DRF permission classes | Today the RBAC matrix gates nothing (`js/rbac.js:78`, `js/rbac.js:111-112`); retrofitting authz across 25 modules is far more expensive later | | 6 | `audit`: partitioned `audit.audit_event`, generic change trigger, `SET LOCAL` actor bridge, hash chain, nightly verifier | History and audit are cheap now, unrecoverable later — you cannot backfill what was never captured | | 7 | `config`: reference-data seed migrations plus Django admin registration for the 7 controlled vocabularies | Removes the entire Settings UI from Phase 1 scope | | 8 | `pii_classification` table plus a CI completeness check | Three later jobs read it (purge, subject access, non-prod anonymisation) | | 9 | React shell scaffold: Vite + TS + React, `git mv css/styles.css` **verbatim**, `` wrapper over the retained `js/charts.js`, the 10 `js/ui.js` primitives ported as typed components keeping class names | The design system is the asset worth keeping (findings §G) | | 10 | Deployment: staging + production on the managed container platform, one image / two entrypoints, secret store, auto-deploy to staging on merge | A deploy path that exists in week 2 is worth more than a better one in week 20 | | 11 | procrastinate queue configuration: named queues, retry policy, job-status endpoint | Transactional enqueue must exist before intake writes exist | | 12 | **File the Entra ID app registration and Graph `Mail.Read` admin-consent request, plus the dedicated recruiting mailbox request, in week 1** | Outside the team's control; the single most likely Phase 1 blocker | | 13 | ADRs for the binding decisions in `_decisions.md` | Bus-factor mitigation, and the junior's reference material | ### 3.2 EXCLUDED — the Phase 0 cut line Nothing below is started in Phase 0, even if it looks quick: - No candidate, application, requisition or intake tables. **Only** `identity`, `audit`, `config` and the reference vocabularies get schema in Phase 0. - No CV parsing, no mail polling, no scoring, no model provider client. - No React screen wired to real data. The shell renders the design system and a login; that is all. - No migration of prototype screens beyond the shell — the prototype is frozen after the patch, security fixes only. - No SSO cutover to production Entra ID; local users plus a dev tenant are sufficient. - No monitoring/alerting stack beyond platform logs and the audit verifier. - No RLS, no partition automation (`pg_partman` is Phase 2), no pgvector. ### 3.3 Dependencies - **External:** corporate IT for the Entra ID app registration, `Mail.Read` admin consent and the recruiting mailbox (item 12). Cloud subscription and budget approval for the container platform, managed Postgres, blob storage and secret store. - **Internal:** none. Phase 0 is deliberately buildable with zero external unblocks — the only external item is *filed* in Phase 0 and *needed* in Phase 1. ### 3.4 Risks specific to Phase 0 | Risk | Handling | |---|---| | Cloud subscription/budget approval slips | Phase 0 runs entirely on docker compose; only items 10 and 12 need the subscription. Escalate on day 3 if not approved | | The CSP breaks the prototype because inline handlers remain | Ordering is enforced: escaping → delegated listeners → CSP. See §8 | | CI pipeline becomes a week-long yak shave | Timeboxed to 5 dev-days. Ship a pipeline with 6 jobs that all pass, not 12 that are half-wired | | Ahmed's first authorization/migration exposure lands with no scaffolding | Talha lands `identity` and the first migration before Ahmed's schema tasks (A-09, A-10) start, so there is a pattern to copy | ### 3.5 Acceptance criteria 1. `docker compose up` on a clean machine yields a running API, worker, Postgres, Redis and object store, with migrations applied, in one command. 2. CI is green on `main` and **red** for: a new unescaped `${` inside an HTML template literal, a `dangerouslySetInnerHTML`, a cross-module import that bypasses `service.py`, a model change without a migration, and a CSS rule using a raw colour instead of a `var(--…)` token. 3. `iam.can()` is the only authorization implementation; a test asserts that a deny-by-default endpoint with no permission declared returns 403, not 200. 4. Every write to a classified table produces an `audit.audit_event` row with a correct actor, and the nightly hash-chain verifier passes. 5. The prototype: all 34 `innerHTML` interpolation sites escaped, zero inline event handlers remain, CSP active without `unsafe-inline` for scripts, and a stored XSS payload in a candidate name renders as literal text in both themes. 6. A merge to `main` auto-deploys to staging without manual steps. ### 3.6 Effort and confidence **Effort: 9–12 weeks (57.5–97.5 dev-days).** This replaces the 2–3 weeks (17–25 dev-days) inherited from `_decisions.md`. The correction is 3.4–3.9x and it is not a re-scoping — the §3.1 item list is unchanged. The 20 tasks in §10.1 sum to 57.5 dev-days at their band *minima*; Talha's own stream is 34 of those days at 4.0 dev-days/week, which puts the floor at 8.5 weeks before any risk materialises. See §2 for the full derivation and §17 divergence 5 for the consequence for `_decisions.md`'s one-month statement. Why the inherited figure was wrong is worth naming, because the same mistake is easy to repeat: 17–25 dev-days buys roughly **two** of Phase 0's L tasks. Phase 0 contains four (`identity` with scoped role assignments and `iam.can()`; partitioned `audit` with a hash chain and verifier; a staging-plus-production deploy path with a secret store; and porting ten design-system primitives to typed React), plus nine M tasks, on a repository that today has no backend, no migration, no test and no pipeline (findings §B). The 2–3 week figure was a calendar wish that predated the task enumeration. **Confidence: Medium-high** (was High). *Scope* discovery risk really is almost absent: the work is enumerable (34 known sites, 5 modules with no business logic, one CI pipeline, one compose file), depends on no external system, and is work Talha has done before. Confidence drops one notch only because the published range is itself a correction that has not yet been validated by delivery, and because the floor is set by one person's stream — so it is sensitive to a single absence in a way the combined-capacity figure hid (R-25). The residual technical uncertainty is unchanged: subscription approval, the size of the CI yak shave, and how fast Ahmed absorbs the migration and delegated-event patterns — which is why his Phase 0 list is deliberately front-loaded with the escaping pass (mechanical, high-volume, easy to review) before anything schema-shaped. ### 3.7 Demonstration milestone — **D0** **Timing: end of week 12 on the no-slip chain, not week 3.** §12.2 derives it week by week. The content below is unchanged — only the date moved, and it moved because the effort figure was corrected, not because anything was added to the demo. Note that D0 is the *end* of a chain of earlier demonstrable moments rather than the first one: the hardened prototype is showable in W2, the CI gate set in W4, authorization in W7, the audit trail in W9 and staging CD in W11 (§12). Stakeholders should see those five, in those weeks, precisely so that a 12-week Phase 0 does not read as twelve weeks of silence. Live, on staging, in one sitting: log in with a scoped role; open the Candidates screen of the **hardened prototype** with a seeded record whose name field is `` and show it rendering as text; open the browser console and show the CSP blocking an injected inline script; show the React shell rendering the same design tokens and one chart from the retained `charts.js`; show CI failing on a deliberately unescaped interpolation and a boundary-violating import; show `audit.audit_event` rows for the login and the role change with correct actors. --- ## 4. Phase 1 — The vertical slice that makes the product real **Objective.** Deliver the end-to-end flow, on real infrastructure, with real data: ```mermaid flowchart LR A["Inbound email
(Outlook via Graph)"] --> C B["Manual CV upload
or careers form"] --> C C["raw_intake
(immutable landing row)"] --> D["raw_intake_attachment
+ virus scan + blob store"] D --> E["intake_parse_attempt
(append-only, versioned parser)"] E --> F{"Parse outcome"} F -->|"succeeded / partial"| G["Recruiter triage:
intake_resolution"] F -->|"failed"| H["needs_review / rejected_unusable
/ quarantined — NO candidate"] G --> I["candidate
(identity only)"] G --> J["job_application
(candidate x job_version)"] J --> K["ats_result + criteria
(pinned versions)"] K --> L["Score explanation panel"] L --> M["Recruiter review + stage transition"] M --> N["Pipeline stage history"] ``` Every arrow above is Phase 1 scope. The requirement that raw intake exists before any candidate is enforced structurally, not by convention: `candidate.created_from_raw_intake_id` and `job_application.raw_intake_id` are both `NOT NULL` against non-deferrable FKs, and the manual-upload path is not an exception — the UI writes a `raw_intake` row on channel `manual_ui` first. ### 4.1 Included Grouped as three sub-slices, delivered in this order: Sub-slice week spans below are stated against the **24-week floor** of the published Phase 1 range (§2), not the inherited 14 weeks; they overlap because the two streams run in parallel. **1A — Intake and identity (weeks 1–8 of the phase)** `files`; `document_parsing` (PDF, DOCX, OCR fallback, per-field confidence, `ParseIssue`); `raw_intake` / `intake_channel` / `raw_intake_attachment` / `intake_parse_attempt` / `intake_resolution` with all five invariant layers; `candidate` plus child tables (`candidate_email`, `candidate_phone`, `candidate_skill`, `candidate_employment`, `candidate_education`, `candidate_link`, `candidate_document`, `candidate_consent`, `candidate_tag`, `candidate_note`); `candidate_search_index` with trigger maintenance; manual upload and careers-portal channels; the intake triage UI and the parse-review panel. **1B — Requisition and application (weeks 7–15)** `job` / `job_version` / `job_requirement` with `INSERT`+`SELECT`-only grants, immutability triggers and the deferrable weight-sum trigger; `job_posting`; `job_application` with generated `state`, the reapplication partial unique index, `attempt_no`, stage and status history via triggers, and `transition()` refusing terminal-negative transitions from a non-human actor; `assignment` interval tables with `EXCLUDE` constraints and the partial unique index on the current primary recruiter; **`approval_request` + `approval_decision` in their single-approver form, with `publish_version()` refusing a version that has no approval decision from a user holding `job.approve` (REQ-JOB-07, `00` §2.4)**; requisition screens with the weighted requirements editor and visible approval state. The approval gate is in Phase 1 and not later because REQ-JOB-07 is a **confirmed** Phase 1 requirement, and because Phase 1 is the phase in which `job_version` publishing first exists. Shipping publishing without its gate would mean a recruiter can unilaterally publish a requisition version — which is precisely the control that OBD-12 and the job-board cost bands (OBD-13) assume is already there. Only the *multi-step* chain (`approval_route`, `approval_route_step`) is deferred; see §4.2 and `08` GAP-01. **1C — Scoring, authorization and mail (weeks 12–24)** `ai_orchestration` framework (AiRun ledger, prompt/model version pinning, `invoke(capability, context, actor)` checked against the human actor); `scoring` (`scoring_config` / `scoring_config_version` / `scoring_config_criterion` / `job_scoring_assignment` / `ats_result` / `ats_result_criterion` with stored `weight_applied` and `contribution`, `input_fingerprint`, `explain()`, batch rescore); duplicate **detection** with the six signals and the canonical-pair constraint; the Outlook Graph delta adapter **plus the minimal outbound-mail slice — one `outbound_message` row per send, `Mail.Send` through the same `MailProvider` port, the `outbound:{public_id}` + `sent_at IS NULL` idempotency guard, NDR classification against the thread token, and one seeded transactional template (T-16b)**; authorization enforced on every endpoint; the score explanation panel and AI provenance badges; the API layer conventions, OpenAPI schema and generated TS client; the five Playwright journeys. React screens in Phase 1, in migration order: shell + login (Phase 0), then the untrusted-data screens — **Intake triage (Inbox), Manual CV upload, Candidate list, Candidate profile** — then **Requisitions** and a minimal application list with a transition drawer. ### 4.2 EXCLUDED — the Phase 1 cut line This is the most important cut line in the document, because Phase 1 is where scope creep is fatal. | Excluded from Phase 1 | Phase | Why it is safe to defer | |---|---|---| | Drag-and-drop pipeline board | 2 | A stage transition is demonstrable from a list plus a drawer. The board is UX polish over an already-working transition service | | Duplicate **merge** and unmerge | 2 | Detection plus a flagged queue delivers the value; merge is the highest-risk logic in the schema and deserves a dedicated phase | | Interviews, scorecards, calendar sync | 2 | Nothing in the intake→score thread needs them | | Assessments, offers | 3 | — | | **Multi-step** approval chains (`approval_route`, `approval_route_step`, most-specific-match route selection, per-step SLA and delegation) | 2 | The **single-approver** case is **in** Phase 1 as T-17b — REQ-JOB-07 is confirmed at Phase 1 (`00` §2.4) and is one `approval_request` row plus one `approval_decision`. What is deferred is the route table, the ordered steps and the grade/threshold branching from OBD-12. Deferring the whole engine, as the previous version of this line did, left Phase 1 publishing ungated | | `notifications` **delivery pipeline** — template authoring UI, retry with backoff, complaint handling, digests, per-user preferences, in-app notification centre | 2 | **The minimal send slice is *in* Phase 1 as T-16b**, because Phase 1 owns inbound email and an NDR with no `outbound_message` row to attach to cannot be classified at all — deferring the row defers a Phase 1 correctness property (`04` §9.1 row 5; `08` §7 finding 10). What is safe to defer is everything around that one `Mail.Send`: the pipeline's costly parts are sender-domain configuration, template approval and bounce policy, none of which Phase 1 needs. **Internal** notifications remain in-app rows only in Phase 1 (OBD-21, revised from Phase 3 to Phase 2) | | Analytics dashboards and the report library | 2 / 3 | Phase 1 has too little real data for a dashboard to be honest | | The chatbot (any form) | 2 | It must call an authorization-checked service layer that does not exist until 1C completes | | `fairness_evaluation` | 3 | **Hard gate: ranking is not released to recruiters as a ranking until the fairness evaluation passes.** Phase 1 shows a score with components on a single application, not a ranked shortlist | | Job-board inbound (4 boards), outbound publishing (8 platforms) | 3 / 4 | Outlook plus careers portal plus manual upload are the channels with real Phase 1 volume | | Talent pool, saved segments | 3 | — | | Settings UI, Help | 4 | Django admin covers the vocabularies | | pgvector / semantic matching | 2+ | Postgres FTS plus trigram is sufficient at this volume | | `worker-untrusted` queue split, RLS | 2 | Phase 1 mitigations are timeouts, memory caps, a restricted OS user and no egress from the parse step — weaker, and explicitly logged as a risk | | Auto-creation of candidates without human review | never in Phase 1 | Every `create_candidate` resolution in Phase 1 is `decision_mode = 'human'` | #### 4.2.1 The second cut line — what moves out if the calendar is fixed instead of the scope The table above is the cut line for *scope creep*. This one is the cut line for a **fixed calendar**, and it exists because §2's reconciliation leaves Phase 1 at 24–30 weeks against an inherited target of 10–14. If the business fixes the calendar rather than the scope, these are the tasks that move — named, sized, and in this order, so the conversation is about a list rather than about optimism. The order is the existing de-scope order from §4.4 and R-08, extended past the point where it ran out. | # | Task | Size cut | Moves to | What is actually lost | |---|---|---|---|---| | 1 | **T-16** Outlook Graph + careers-portal adapters, **and T-16b with it** — the minimal outbound slice sends through the same port and cannot survive its removal | L + M (9–15) | 2 | Both automated channels. Manual upload carries the whole slice. Already the top of the de-scope order because it is the item most likely to be externally blocked anyway (R-02). Losing T-16b restores the hole `04` §9.1 row 5 identified: a recruiter answers every parse failure from Outlook by hand, outside the audit trail. That is tolerable **only** because cutting T-16 already means Phase 1 has no mailbox to answer from | | 2 | **T-22** Duplicate detection | L (6–10) | 2 | Duplicates are found by recruiters, not by the system, until Phase 2. The global unique index on `candidate_email.address_normalised` still prevents silent identity collisions, so nothing becomes *incorrect* — only manual | | 3 | **T-13b** OCR fallback | M (3–5) | 2 | Scanned PDFs land in `needs_review` instead of parsing. Defensible: they were never going to parse well (R-03), and the *hardening* half of T-13b (timeout, memory cap, restricted user, no egress) is **not** cuttable and stays in Phase 1 | | 4 | **A-14** Candidate profile — timeline, tags, notes | L → M (−3 to −5) | 2 | Identity, applications and documents ship; the social layer waits | | 5 | **A-21** Service-level API test suite — narrow to the intake, candidate and application facades | L → M (−3 to −5) | 2 | Coverage of the facades that carry the invariants is kept; the rest waits | | 6 | **A-16** Requisition screens — drop the version diff view | L → M (−3 to −5) | 2 | Versions still mint correctly and old scores still pin; the reader compares two versions by opening both | | 7 | **A-24** Playwright journeys — 5 down to 2 (ingest→parse, promote→score) | M → S (−2 to −3) | 2 | The two journeys that assert the raw-intake-before-candidate invariant are kept | | 8 | **A-20** Error/loading/empty-state system — one shared pattern instead of a system | M → S (−2 to −3) | 2 | Screens still have the four states (DoD 15); they are less consistent | | 9 | **A-19** AI provenance badges | S (1–2) | 2 | The score panel still shows pinned versions as text (A-18); the badge component waits | Cumulative cut: **32–53 dev-days**, bringing Phase 1 to **118–198 dev-days** — the same endpoint as before T-16b was added, because T-16b enters Phase 1 and leaves again with cut 1. That puts the floor at 118 — just inside the inherited 119 — and Talha's own stream at 75 dd ÷ 4.0 = **18.8 weeks**. So the honest statement even after the full cut is: **a 14-week Phase 1 is not reachable; a 19-week one is, in the optimistic half of the band.** The gap between 14 and 19 is not recoverable by cutting further, because everything left is either an invariant, the authorization layer, or the scoring engine. Three §4.5 acceptance criteria move with these cuts and must be restated rather than quietly dropped: criterion 1 (a CV arriving **by email**) becomes a Phase 2 criterion under cut 1, with the Phase 1 form being "a CV arriving by manual upload or the careers form"; **criterion 14 (a parse failure resolvable in-product) moves to Phase 2 under the same cut, because it sends through the port cut 1 removes**; criterion 12 narrows from five Playwright journeys to two under cut 7. Every other criterion, including criterion 13, survives the full cut set intact — which is the test of whether a cut list is honest. **Never cut, at any calendar pressure:** the five intake invariant layers; candidate/application separation; per-application `ats_result` with pinned versions; job and scoring-config versioning; the T-17b approval gate; `iam.can()` on every endpoint; the A-26 forbidden-write suite; the no-AI-terminal-negative guard. Each of these is either a non-negotiable constraint or is unrecoverably expensive to retrofit — cutting them does not buy time, it converts schedule debt into permanent data-model debt. ### 4.3 Dependencies | Dependency | Blocks | Mitigation if late | |---|---|---| | Entra ID app registration + `Mail.Read` **and `Mail.Send`** admin consent + recruiting mailbox | The Outlook channel (T-16) and the minimal outbound slice (T-16b) | The slice is demonstrable end-to-end on manual upload and the careers form. Mail is the *last* channel wired, deliberately, so it cannot block the thread. **Request both scopes in the same T-07 conversation in Phase 0 week 1** — asking for `Mail.Send` separately, later, means a second admin-consent cycle at the exact point Phase 1 needs it, and `04` §9.3 already rates that dependency as weeks not days. T-16b is testable against `FakeMailProvider` while consent is pending | | A real CV corpus (30–50 anonymised or synthetic-but-realistic CVs including scanned PDFs) | Parser accuracy work, the malformed-CV test corpus | Talha assembles a synthetic corpus in week 1 of the phase; the business supplies real ones when available | | A decision on the model provider (OQ-1) | `ai_orchestration` beyond the ledger, AI-assisted field extraction | Scoring v1 is deterministic and rule-based over parsed fields plus `job_requirement` weights. It does **not** need a model. This is why scoring is not blocked by OQ-1 | | Phase 0 complete | Everything | — | | A published `job_version` with weighted requirements summing to 1.0 | Any score at all | Talha seeds one by migration in 1B so scoring work can start before the requisition UI is finished | ### 4.4 Risks specific to Phase 1 | Risk | Handling | |---|---| | Parsing accuracy falls far short of "no recruiter re-keying" | Design mitigation already chosen: low-confidence fields are left **empty**, never guessed, and the parse-review panel makes the review step explicit and fast. The business must accept a review step; OQ-6 (behaviour on parse failure) must be answered in this phase | | Untrusted files parsed inside our own worker (RCE, zip bombs, resource exhaustion) | Per-document CPU and wall-clock timeout, memory cap, restricted OS user, no outbound network from the parse step, virus scan before parse. Full isolation is the Phase 2 `worker-untrusted` queue. This is the split trigger most likely to fire early | | The global unique index on `candidate_email.address_normalised` backs up the review queue | Real violations exist: shared family addresses, agency mailboxes, `info@` referral forms. Decide the non-identifying-address exclusion list **inside Phase 1**, before go-live | | Authorization is brand new and there is no database-level backstop until RLS in Phase 2 | One centralised module, no repository access from views, and A-22's endpoint × role matrix test asserting every candidate-reading endpoint routes through `iam.can()` | | Ahmed blocked waiting on Talha's schema and services | Ahmed's Phase 1 stream is deliberately sequenced so that at any moment he has one UI task against a shipped API and one test/validation task that needs no new API | | Review throughput becomes the bottleneck | PR size cap of ~400 changed lines, two fixed review windows per day, Ahmed keeps two branches in flight | | The inherited 14-week upper bound is exceeded | **Already exceeded on paper, before the phase starts** — the §10 task roll-up floors Phase 1 at 150 dev-days against 119 (§2). Treated as a certainty rather than a risk: the published range is 24–30 weeks, and the §4.2.1 cut list is the instrument if the calendar is fixed instead. The forward-looking risk is now "Phase 1 exceeds **30** weeks" — see R-08 | | The published 24–30 week range is itself exceeded | Re-forecast at the end of sub-slice 1A against real parsing throughput, when T-13a is the first task with actual measured effort behind it. Apply §4.2.1 in order, top down, and stop at the first cut that closes the gap. Scoring, the invariants, the T-17b approval gate and authorization are never cut | | Talha's stream is the binding constraint and has no second reviewer | Talha holds 93 of the 150 floor dev-days (62%) at the lower weekly budget (§2). No Phase 1 item is transferable — all of it is schema, constraints, parsing, authorization or integration — so the mitigation is not rebalancing but reducing his queue via §4.2.1 cuts 1–3, all of which are his. R-25 tracks this | ### 4.5 Acceptance criteria 1. A real PDF CV arrives by **email**, appears as a `raw_intake` row within the polling interval, and is idempotent under redelivery (same `external_message_id` or same `payload_sha256` produces no second row). 2. A CV that cannot be parsed reaches a terminal state (`rejected_unusable` or `quarantined`) with **no** `candidate` and **no** `job_application` row, and is visible and retryable in the triage UI. Nothing is silently dropped. 3. Attempting to insert a `candidate` without a `raw_intake` row fails at the database. Attempting to commit a candidate with no email and no phone fails at COMMIT. Attempting a malformed email address fails the CHECK. All three are asserted by tests. 4. Two applications from the same person to two different jobs produce **one** candidate and **two** applications with **two independent** `ats_result` rows. 5. A second live application to the same job is refused by the partial unique index; a reapplication after a terminal outcome plus cooling-off succeeds with `attempt_no = 2`. 6. Editing a requirement mints a new `job_version`; the previously computed score is unchanged and still displays its original pinned criteria and contributions. 7. `UPDATE` against `job_version`, `job_requirement`, `scoring_config_version` and `ats_result` (outside the granted column list) raises an exception. Asserted by tests. 8. The score panel shows, per criterion: requirement, matched evidence, `weight_applied`, `contribution`, and the pinned `job_version` / `scoring_config_version` / `algorithm_code_version` / parse attempt. 9. No code path can move an application to a terminal-negative status without a human actor. `review_outcome` cannot be set without `reviewed_by_user_id`. Asserted by tests. 10. Every endpoint returns 403 for a role without the permission, and the permission matrix test covers every endpoint × every role. 11. A recruiter moves an application through two stages; `job_application_stage_history` shows the actor, reason and non-overlapping intervals. 12. Playwright journeys 1–5 pass in CI against a real Postgres. 13. **A recruiter cannot publish a `job_version`.** `publish_version()` raises unless an `approval_decision` row exists against that version's `approval_request` with `decision = 'approved'` and an `approver_user_id` holding the `job.approve` permission in scope — and the requesting recruiter approving their own version is refused as self-approval. Asserted as a **forbidden-write test in A-26**, not only through the UI, so the guarantee survives a direct API or `psql` path. The requisition screen shows the version's approval state (`draft` → `pending_approval` → `approved` / `rejected` → `published`) and the approver, and the approve/reject action is invisible to a role without the permission (REQ-JOB-07, `00` §2.4; closes `08` GAP-01). 14. **A parse failure is resolvable without leaving the product.** From the triage UI, a recruiter asks the sender for an unprotected copy; the send writes one `outbound_message` row **before** the provider call; replaying the job finds `provider_message_id` set and is a no-op (no second email); and a synthetic NDR for that message is matched by `X-Utopia-Thread` and sets the bounce state on the same row. An NDR that matches nothing lands in `needs_review` rather than being discarded. Asserted against `FakeMailProvider`, so it runs in CI without a mailbox (T-16b; `04` §4.3, §9.1 row 5; closes `08` §7 finding 10). ### 4.6 Effort and confidence **Effort: 24–30 weeks (150–251 dev-days).** This replaces the 10–14 weeks (85–119 dev-days) inherited from `_decisions.md`. The 36 tasks in §10.2 sum to 150 dev-days at their band *minima* — above the inherited *maximum* of 119 — and Talha's stream alone is 93 of those days at 4.0 dev-days/week, which sets the 23.3-week floor. §2 derives this; §4.2.1 is the cut list if the calendar is fixed instead of the scope. **Seven of the 150 floor days are tasks this revision added, each closing a hole that had no implementing task in any phase:** T-17b (M) and A-16b (S) — the REQ-JOB-07 approval gate — and T-16b (M), the minimal outbound-mail slice that makes a Phase 1 parse failure resolvable in-product (`04` §9.1 row 5; `08` §7 finding 10). All three are the kind of item that is invisible in a phase plan until someone tries to run the phase. **Confidence: Medium.** Medium applies to the *published* 24–30 week range, not to the inherited one, which is now known to be unachievable rather than uncertain. The reasoning, item by item: the schema is already decided in detail so there is little *design* discovery left, and the invariants are expressible in Postgres features Talha has chosen deliberately — that part is Medium-High. Three things pull the whole phase down to Medium. (1) **Real CV parsing has irreducible discovery risk**: you cannot estimate the long tail of scanned, two-column, table-based and non-English CVs until you have run a real corpus through it, and the estimate for T-13 could be wrong by a factor of two in either direction. (2) **The Outlook dependency is outside the team's control** and historically these consents take weeks, not days. (3) **This is the first authorization layer this system has ever had**, being built at the same time as the domain it protects, by the same person, with no second reviewer. Against that, the phase is structured so the *demonstrable thread* does not depend on the two riskiest items — manual upload substitutes for mail, and rule-based scoring substitutes for a model — which is what keeps the confidence at Medium rather than Low. ### 4.7 Demonstration milestone — **D1** On staging, with real data, in this order: send a real CV to the recruiting mailbox from a phone; watch it land in the triage queue; show the parse result with per-field confidence and one field deliberately left empty because confidence was low; send a second, deliberately broken file and show it terminate in `rejected_unusable` with no candidate created; promote the good one to a candidate and create an application against a published requisition version; **attempt to publish a requisition version as the recruiter who drafted it and show it refused, then log in as the hiring manager, approve it, and show it publish** — with the `approval_decision` row and its approver on screen; show the score with its per-criterion contributions and pinned versions; edit a requirement, publish a new version, and show the old score unchanged beside the new one; move the application two stages and show the history with actor and reason; log in as an interviewer and show the same candidate is not visible; show the audit trail for all of it. --- ## 5. Phase 2 — Recruiter workflow **Objective.** Turn a working thread into a working job: a recruiter can run a requisition end to end without leaving the product, duplicates can be resolved reversibly, and the first honest numbers appear. ### 5.1 Included Drag-and-drop pipeline board over the existing transition service, with allowed transitions driven by `pipeline.is_allowed()`; `interview` scheduling with UTC instant plus wall-clock intent plus IANA zone, `EXCLUDE` double-booking on participants, scorecards that lock on submit; **duplicate merge with reversible unmerge** (`candidate_merge`, `candidate_merge_operation` undo log, descending-seq replay, stack discipline, retention interlock); the **full** `notifications` delivery pipeline — template authoring and versioning UI, retry with backoff, complaint handling, digests, per-user preferences and the in-app notification centre — **built on the minimal send path Phase 1 already shipped as T-16b**, so the provider, the `MailProvider` port and `outbound_message` exist and only the pipeline around them is new (`08` §7 finding 10; internal notification email arrives here, OBD-21); `worklist` with rule-driven tasks; analytics read-model views plus **seven** of REQ-ANL-01's eight dashboard KPIs on the retained `charts.js` — Open Jobs, Closed Jobs, Total Candidates, Interviews Today, Offers Accepted, Time to Hire, Time to Fill (**Cost per Hire is excluded, see §5.2**); read-only assistant over whitelisted typed query intents with `on_behalf_of_user_id` audit; `worker-untrusted` queue split; `pg_partman` (or a scheduled SQL function) for audit partitions; pgvector enabled for Phase 2 hybrid retrieval. ### 5.2 EXCLUDED — the Phase 2 cut line - No assistant **write** actions and no tool-calling beyond read intents. - No offers, no assessments, no approval chains. - No fairness evaluation, and therefore **no ranked shortlist surfaced to recruiters** — scores remain per-application, not a league table. - No report library or saved reports; dashboard KPIs only. - **No Cost per Hire KPI tile.** Seven of REQ-ANL-01's eight KPIs ship; the eighth does not, because no entity in the design holds recruiting cost. Its only possible source, `app.job_posting_metric.spend_amount`, is Phase 4, and the prototype's figure is the literal constant `costPerHire: 4280` at `js/data.js:247`. **Reinstated in Phase 4** alongside `job_posting_metric`, scoped honestly as *cost per hire from measurable channel spend* — agency fees, referral bonuses and recruiter time are out of scope unless a `hiring_cost` table is confirmed. Ruled in `08` GAP-02; reversible by Finance via `_open-items.md` OPEN-02. **Say this to stakeholders in the D2 walkthrough** rather than letting an absent tile read as an oversight — shipping the tile against a constant would be the worse outcome. - No job-board inbound or outbound. - No custom per-requisition pipeline configuration (default stages only; custom is Phase 3). - No RLS. Authorization remains application-layer only. - No talent pool. ### 5.3 Dependencies Phase 1 complete, specifically: `application.transition()`, duplicate detection with `confirmed_distinct` memory, the authorization layer, and `ai_orchestration.invoke()`. Calendar integration needs the same Entra tenant as the mail channel. The assistant needs OQ-1 resolved. ### 5.4 Risks specific to Phase 2 | Risk | Handling | |---|---| | **Merge reversal is the highest-risk logic in the schema.** A table carrying `candidate_id` that merge re-parents but does not record becomes silently unreversible, discovered months later | Enumerate every `candidate_id`-carrying table in one place; a test asserts the merge routine records an operation for each; property test merge→unmerge→assert byte-identical child-row parentage | | Stack discipline blocks a legitimate reversal with no in-product resolution | Accepted deliberately over silent cross-contamination. The error message must **name the blocking merge** so the recruiter knows what to reverse first | | The pipeline board's optimistic updates diverge from server-side transition rules | The board never decides legality; it calls `is_allowed()` and rolls back visually on rejection. Asserted by a Playwright test that attempts an illegal drag | | Timezone divergence between `starts_at` and `local_start_wall` | A single scheduling service function is the only writer, plus a nightly reconciliation job reporting mismatches | | Assistant leaks data through a query intent that forgets the actor check | No service account exists; `invoke()` takes the human actor. A test asserts a low-privilege user's question cannot return a record the REST API would deny them | ### 5.5 Acceptance criteria 1. Merge two candidates who share an email address; both applications survive, nothing is deleted, the loser's `public_id` still resolves via 301, and the funnel report count is unchanged (no double counting). 2. Unmerge restores every re-parented row; rows created after the merge stay with the survivor and were listed on the confirmation screen before the recruiter confirmed. 3. An out-of-order unmerge is refused with an error naming the blocking merge. 4. Two interviews cannot be scheduled for the same interviewer in overlapping slots — the database refuses it, not the UI. 5. A reschedule across a DST boundary preserves the organiser's wall-clock intent. 6. The assistant answers a recruiter's question about their own requisition and refuses the same question from an interviewer, with both attempts in the access audit log. 7. Dashboard KPIs match a hand-written SQL query over the same window. ### 5.6 Effort, confidence, demonstration **Effort: 7–10 weeks (60–85 dev-days). Confidence: Medium-low.** Two reasons, both specific: merge/unmerge is genuinely hard concurrent state manipulation whose failure mode is silent and delayed, so its estimate has a long tail; and the assistant's scope is the easiest thing in the whole plan for stakeholders to inflate, because all 15 AI capabilities are already visible in the prototype UI as an interface preview. **Demonstration — D2:** run one requisition front to back in a single session — board, interview scheduled across two timezones, scorecard submitted and locked, a duplicate found, merged, then unmerged in front of the audience, and the dashboard KPI moving as a consequence. **One thing to say out loud in D2, not to leave for someone to notice:** the dashboard shows **seven** KPIs, not eight. Cost per Hire is deliberately absent because no entity holds recruiting cost until Phase 4 — the prototype's `$4,280` was a hardcoded constant, and a tile computed from a constant is worse than no tile. It returns in Phase 4 with `job_posting_metric`, covering measurable channel spend. If Finance wants it at Phase 2, that is a small cost-entry table and a recruiter screen, and it is their call to make (`_open-items.md` OPEN-02) — but it should be made before the tile is promised, not after. --- ## 6. Phase 3 — Offers, assessments, reporting, and the fairness gate **Objective.** Close the hiring loop, and pass the gate that lets ranking be used as ranking. ### 6.1 Included `assessment` (templates, assignment, results); `offer` with immutable `offer_version` rows, approval chain, money as amount+currency pairs with the minor-unit rounding trigger, and `issue()` requiring explicit human confirmation; the report library and saved reports; **`fairness_evaluation` as an activation gate — no `scoring_config_version` becomes active without a passing `EvaluationRun` reference**; `talent_pool` as tags plus saved segments with rematch; job-board **inbound** adapters (4 boards); the retention purge as pseudonymisation with `retention_hold`, plus the subject-access export; custom per-requisition pipeline configuration. ### 6.2 EXCLUDED — the Phase 3 cut line - No outbound publishing to external platforms. - No assistant write actions or full tool-use. - No Settings or Help UI (Django admin remains the vocabulary surface). - No special-category data (diversity, health, accommodation). Its exclusion is a decision, not an omission — it needs separate access control, aggregate-only reads and a distinct lawful basis. - No self-hosted model inference. ### 6.3 Dependencies and the honest gating problem `fairness_evaluation` depends on **OQ-2: historic hiring outcome data**, which is unconfirmed. If it does not exist, the evaluation can only be run against face-plausibility checks and synthetic distributions, which means **the gate may block the ranking release with no engineering fix available**. That is not a schedule risk; it is a scope risk that has to be escalated to the business in Phase 1, not discovered in Phase 3. Offers also depend on OQ-4 (jurisdictions) for letter templates and retention periods. ### 6.4 Risks and acceptance criteria | Risk | Handling | |---|---| | Fairness gate cannot be satisfied without outcome data (OQ-2) | Raise in Phase 1. Fallback: release scores as per-application decision support with ranking explicitly disabled in the UI, and say so to stakeholders | | Retention pseudonymisation permanently blocks merge reversal | Exclude candidates in an unreversed merge from purge for a defined window, or require explicit acknowledgement that reversibility is being surrendered | | Offer approval chains grow into a workflow engine | No generic engine. Offer transitions live in `offer.service` until three modules demonstrably need the same machinery | | Six jurisdictions of offer letters and retention rules | Templates are `config` reference data per jurisdiction; retention is per-record, never per-region storage | Acceptance: an offer cannot be issued by a background job; an amount in JPY with decimal places is refused by the rounding trigger; a salary range with mismatched currencies is refused; activating a scoring config without a passing evaluation is refused; a retention purge pseudonymises a candidate while leaving `ats_result` scores and stage history intact; a subject-access export returns everything the `pii_classification` registry says it should. **Effort: 7–10 weeks (60–85 dev-days). Confidence: Low** — not because the engineering is hard but because two of the four workstreams (fairness, retention/jurisdiction) are gated by unanswered business and legal questions, and an estimate for work whose acceptance criteria are not yet decidable is not a real estimate. **Demonstration — D3:** an offer drafted, approved through a two-step chain, issued by a human with confirmation, and the letter produced; a fairness evaluation run shown to a non-engineering audience with a pass/fail verdict; a retention purge run against a test candidate showing the pseudonymised record with intact history. --- ## 7. Phase 4 — Outbound, full assistant, and admin surfaces **Objective.** Everything that only makes sense once the internal product works. **Included.** `integrations_outbound` publishing `job_version`s to the 8 external platforms with state reconciliation and `HR-admin`-gated publish (it has cost implications); the full tool-using assistant with write actions behind explicit confirmation; the remaining P2 AI capabilities; the Settings UI split correctly between `identity` (security, users, roles) and `config` (vocabularies, templates, branding); Help as static docs with client-side search; the referral/agency/campus/walk-in inbound forms; RLS for the AI query path if ad-hoc querying proves genuinely necessary; the final accessibility regression sweep across all migrated screens; **retirement of the prototype** — the last prototype screens are deleted as their React counterparts land. **EXCLUDED — the Phase 4 cut line.** No self-hosted inference. No mobile app. No candidate-facing portal beyond the token-gated status page. No multi-tenant or per-region anything (excluded by constraint). No microservice extraction unless a **hard** split trigger from `_decisions.md` has actually fired. **Dependencies.** Commercial accounts and API credentials for 8 external platforms — procurement, not engineering, and it can slip independently. Phase 3 complete. **Risks.** Eight third-party publishing APIs is eight independent breakage surfaces maintained by two people — mitigate by treating platforms as individually deferrable/degradable, with `PublishAttempt` failures surfaced rather than retried forever. Assistant write actions are the largest new authorization surface in the product; every write goes through the same domain service as the UI, with confirmation, and never through a new path. **Acceptance.** Publishing a requisition version to a platform and unpublishing it reconciles state; an assistant write action requires confirmation and produces an audit event with `actor_kind = 'ai_agent'` and `on_behalf_of_user_id` set; AI Studio shows true per-capability availability rather than a coming-soon grid; the accessibility sweep passes WCAG 2.1 AA across every migrated route in both themes; no prototype screen remains reachable. **Effort: 5–8 weeks (42–68 dev-days). Confidence: Low** — dominated by third-party API variance and procurement timing, neither of which the team controls. **Demonstration — D4:** publish a requisition to two external platforms and show the applications arriving back through `raw_intake`; ask the assistant to perform a write action and show the confirmation, the audit entry, and the refusal when the asking user lacks the permission. --- ## 8. The P0 XSS remediation — scheduled work, in order This is scheduled Phase 0 work with a named owner, a review gate and acceptance criteria, because **real CV data cannot be rendered safely until it is done** (findings §E). Today nothing is exploitable — the data is generated locally by a seeded PRNG (`js/data.js:8-10`) — but the two Phase 1 intake sources are attacker-supplied by design, and the storage decisions deliberately preserve unsanitised originals (`full_name_original`, `candidate_email.address_original`, `raw_intake.payload`, `intake_parse_attempt.parsed`), which guarantees attacker-controlled strings reach the rendering layer. Sanitising on write is the wrong fix and would violate the preserve-the-original rule; escaping on output is the fix. **The order is load-bearing** — doing it in any other order breaks the prototype: | Step | Work | Why this position | |---|---|---| | 1 | Add `UI.esc()` to `js/ui.js` and apply it at every interpolation of a data-derived value across the 34 `innerHTML` sites in 14 files. Start with the untrusted-data screens: `js/candidates.js` (e.g. `${c.name}`, `${c.currentTitle}`, `${c.location}` at `js/candidates.js:68`), `js/inbox.js`, `js/import.js` | Highest-risk sinks first, so partial completion still reduces exposure | | 2 | Replace inline handlers such as `onclick="Candidates.openProfile('${c.id}')"` (`js/candidates.js:121`) with delegated listeners reading `data-*` attributes | **Must precede step 3.** A CSP without `unsafe-inline` blocks inline handlers, so enabling the policy first would break every interactive control | | 3 | Add the `Content-Security-Policy` header (and a meta fallback for the static `devserver.py` path) with no `unsafe-inline` for scripts. Verify no inline `