HR-ATS-Portal/docs/architecture/07-implementation-plan.md

136 KiB
Raw Blame History

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 04, 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.71.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, ~2025 peak concurrency (assumption, _decisions.md deployment section) No scale work in Phases 04. 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 12 d · M 35 d · L 610 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 610 days of one developer's productive time. At 4.0 dev-days/week for Talha it therefore occupies 1.52.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.511.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 24 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 23 wk (1725 dd) 20 — 4L 9M 6S 1XS 57.597.5 6.811.5 wk Talha 34 dd ÷ 4.0 = 8.5 wk 912 weeks Medium-high
1 One real CV, end to end, on real infrastructure, with an explainable score 1014 wk (85119 dd) 36 — 16L 17M 3S 150251 17.629.5 wk Talha 93 dd ÷ 4.0 = 23.3 wk 2430 weeks Medium
2 Turn the slice into a working recruiter workflow 710 wk (6085 dd) 17 — 8L 8M 1S 73122 8.614.4 wk Ahmed 40 dd ÷ 4.5 = 8.9 wk 914 weeks Medium-low
3 Offers, assessments, reporting, and the fairness gate before ranking goes live 710 wk (6085 dd) 13 — 5L 7M 1S 5287 6.110.2 wk Talha 27 dd ÷ 4.0 = 6.8 wk 710 weeks Low
4 Outbound publishing, full assistant, remaining AI capabilities, admin surfaces 58 wk (4268 dd) 11 — 5L 4M 2S 4474 5.28.7 wk Talha 27 dd ÷ 4.0 = 6.8 wk 79 weeks Low

Total roll-up: 376.5631.5 dev-days. Published total: 5675 weeks — the sum of the per-phase published ranges, roughly 13 to 17 months of two-person delivery for the full platform, against the 3145 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.
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 (ProposedAccepted) 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, <Chart/> 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: 912 weeks (57.597.5 dev-days). This replaces the 23 weeks (1725 dev-days) inherited from _decisions.md. The correction is 3.43.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: 1725 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 23 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 <img src=x onerror=alert(1)> 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:

flowchart LR
  A["Inbound email<br/>(Outlook via Graph)"] --> C
  B["Manual CV upload<br/>or careers form"] --> C
  C["raw_intake<br/>(immutable landing row)"] --> D["raw_intake_attachment<br/>+ virus scan + blob store"]
  D --> E["intake_parse_attempt<br/>(append-only, versioned parser)"]
  E --> F{"Parse outcome"}
  F -->|"succeeded / partial"| G["Recruiter triage:<br/>intake_resolution"]
  F -->|"failed"| H["needs_review / rejected_unusable<br/>/ quarantined — NO candidate"]
  G --> I["candidate<br/>(identity only)"]
  G --> J["job_application<br/>(candidate x job_version)"]
  J --> K["ats_result + criteria<br/>(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 18 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 715) 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 1224) 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 2430 weeks against an inherited target of 1014. 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 (915) 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 (610) 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 (35) 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 (12) 2 The score panel still shows pinned versions as text (A-18); the badge component waits

Cumulative cut: 3253 dev-days, bringing Phase 1 to 118198 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 (3050 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 2430 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 2430 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 13, 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 15 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 (draftpending_approvalapproved / rejectedpublished) 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: 2430 weeks (150251 dev-days). This replaces the 1014 weeks (85119 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 2430 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: 710 weeks (6085 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: 710 weeks (6085 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_versions 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: 58 weeks (4268 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 <script> block remains among the 22 script tags at index.html:264-285 before enabling Only safe once step 2 is complete
4 Add the CI gate: fail on a new unescaped ${ inside an HTML template literal, and fail on dangerouslySetInnerHTML in the React tree Makes the guarantee survive the 612 months during which the migration runs
5 Add the poisoned-CV fixture corpus (XSS payloads in name, title, employer, filename and email display fields) and an assertion test that each renders as literal text in both themes Turns a one-time patch into a permanent regression test, and seeds the malformed-CV corpus used in Phase 1

Owner: Ahmed (tasks A-01 → A-05). Review gate: G-SEC, Talha, with a mandatory manual pass over all 34 sites — a grep is not a review. Effort: 23 dev-days for steps 13, plus ~1.5 days for steps 45. Acceptance: criterion 5 in §3.5.

Standing rule for the migration window: real candidate data is only ever wired to the React app. The prototype is frozen after this patch, security fixes only, and each migrated screen deletes its prototype counterpart in the same PR.


9. Development order

The order is derived from what physically cannot be built before something else, then from what de-risks the most per week.

flowchart TD
  S1["1. Repo + compose + CI + migration runner"] --> S2["2. identity + iam.can()"]
  S1 --> S3["3. Prototype XSS patch + CSP + CI gate"]
  S2 --> S4["4. audit + SET LOCAL actor bridge"]
  S1 --> S5["5. React shell + frozen CSS + UI primitives"]
  S4 --> S6["6. config + reference data + pii_classification"]
  S6 --> S7["7. files + blob store + virus scan hook"]
  S7 --> S8["8. document_parsing in the worker"]
  S7 --> S9["9. raw_intake + attachments + parse_attempt"]
  S9 --> S8
  S9 --> S10["10. candidate + child tables + search index"]
  S10 --> S11["11. intake_resolution + promote_to_candidate"]
  S11 --> S12["12. Intake triage UI + manual upload + parse review"]
  S6 --> S13["13. job + job_version + job_requirement"]
  S13 --> S13b["13b. approval_request + approval_decision<br/>single approver; publish_version() gate"]
  S2 --> S13b
  S13 --> S14["14. job_application + history triggers + reapplication rule"]
  S10 --> S14
  S14 --> S15["15. assignment interval tables"]
  S13 --> S16["16. scoring_config + ats_result + explain()"]
  S13b --> S16
  S14 --> S16
  S8 --> S16
  S2 --> S17["17. ai_orchestration ledger + invoke(actor)"]
  S17 --> S16
  S16 --> S18["18. Score explanation panel + provenance badges"]
  S2 --> S19["19. Authorization on every endpoint + permission matrix tests"]
  S10 --> S20["20. Duplicate detection"]
  S9 --> S21["21. Outlook Graph adapter"]
  S19 --> S22["Phase 2 onwards"]
  S18 --> S22
  S20 --> S22
  S21 --> S22

Four ordering decisions worth defending:

  1. Authorization before the domain it protects. identity and iam.can() land in Phase 0, before a single candidate table exists. Retrofitting authorization onto 25 modules costs multiples of building it first, and the current codebase has literally none (findings §D).
  2. Versioning and history in Phase 1, even though nothing visibly needs them yet. Both are cheap to design in and expensive to retrofit, because retrofitting means backfilling history that was never captured. Every prototype gap in findings §F becomes permanent if deferred.
  3. The Outlook channel last within Phase 1. It is the item most likely to be blocked by someone outside the team, so it must not sit upstream of anything demonstrable.
  4. The approval gate in the same step as publishing, never after it (13b). publish_version() and the approval that authorises it are built together, because a publish_version() that ships ungated and is gated later is not a refactor — it is a period during which requisitions were published without the control REQ-JOB-07 requires, and there is no way to retroactively establish who approved what. It sits downstream of identity (2) because the gate is a permission check, and upstream of scoring (16) because the only legitimate way to obtain a published job_version to score against is through it. The one exception is the migration-seeded version in §4.3, which is created by migration rather than by publish_version() and is flagged as such in its change_reason.

10. Task split

Rules applied: Talha owns architecture, database and constraints, integrations, parsing, matching, duplicate detection, routing, access control, chatbot backend, security reviews, deployment and all code review. Ahmed's stream is deliberately varied across frontend, backend APIs, validation, testing, AI UX, dashboards and workflow logic — never only CRUD, never only data cleaning. Every Ahmed task names a Talha review checkpoint.

Review gate key (each is a named Talha checkpoint with its own checklist):

Gate Name Checklist focus
G-ARCH Boundary & architecture review service.py-only imports, import-linter contracts pass, no cross-module table access
G-SCHEMA Migration & constraint review Every invariant expressed in the DB; forbidden-write test exists; grants correct; migration is forward-only and reviewed as SQL
G-API API contract review OpenAPI diff, error envelope, pagination, Zod/serializer parity, generated client regenerated
G-SEC Security review Escaping, CSP, untrusted-input handling, no secret in code, authz on every path
G-AUTHZ Permission review Deny-by-default, iam.can() is the only decision point, matrix test updated
G-AI AI provenance review AiRun linked, versions pinned, no auto-reject path, explanation renders real stored values
G-UX Design-system & a11y review Token-only CSS, class names match the frozen stylesheet, keyboard reachable, contrast AA, 44px targets, loading/error/empty states present
G-TEST Test-quality review Real Postgres, meaningful assertions, forbidden-write tests, no DB mocking
G-REL Release readiness Migration replay on a staging clone, rollback plan, audit verifier green

10.1 Phase 0

ID Owner Description Depends on Review gate Size
T-01 Talha Monorepo layout (api/, web/, db/migrations/) + docker compose (Postgres 16, Redis, MinIO) G-ARCH M
T-02 Talha Django 5 + DRF skeleton: per-env settings, drf-spectacular, single error envelope, request-id middleware, SET LOCAL actor middleware T-01 G-ARCH M
T-03 Talha CI pipeline: ruff, mypy, import-linter, pytest on a real Postgres container, ESLint react/no-danger, stylelint token rule, tsc --noEmit, and both migration-drift gates — makemigrations --check (models vs state) and the SQL-level pg_dump --schema-only + trigger/column-privilege catalogue diff against the committed expected dump (§17). Also wires in the documentation gates, including tools/check_evidence_citations.py, which already exists in the repository and passes — it needs no services, so it belongs in the fast lint job rather than the Postgres job T-01 G-ARCH M
T-04 Talha Sign off ADR 0017 (adr/0017-plain-sql-migrations-as-schema-authority.md, ProposedAccepted) — the single ruling that supersedes the six scattered ORM-vs-plain-SQL recommendations (§17; 00 §10, 02 §15 I1, 03 §32.1, 04 §9.1, 05 §9.1 I-1) — plus db/migrations bootstrap (schemas, extensions) and db/schema-ignore.toml. Merge blocker: migration 001 does not merge while ADR 0017 is still Proposed. T-01 G-SCHEMA M
T-05 Talha identity: users, roles, permissions, scoped role assignments, sessions, iam.can(), DRF permission classes T-04 G-AUTHZ L
T-06 Talha audit: partitioned audit_event, generic change trigger, SET LOCAL bridge, hash chain, nightly verifier T-05 G-SCHEMA L
T-07 Talha File Entra ID app registration, Graph Mail.Read and Mail.Send admin consent (both in one request — T-16b needs send in Phase 1, and a second consent cycle later costs weeks, 04 §9.3) and recruiting-mailbox requests with IT (week 1) G-SEC S
T-08 Talha ADRs for the binding decisions in _decisions.md. docs/architecture/adr/NNNN-slug.md filenames are the authoritative register — there is no parallel numbering, and 02-system-architecture.md §13 is an index generated from the directory, never a second numbering scheme. A new ADR takes the next free file number; a decision cited as an ADR anywhere in the package must have a file, because the R-04 bus-factor mitigation is worthless if the index points at documents that do not exist G-ARCH M
T-09 Talha Deployment: staging + prod on the container platform, one image / two entrypoints, secret store, auto-deploy on merge T-01, T-03 G-REL L
A-01 Ahmed UI.esc() + escape every data-derived interpolation across the 34 innerHTML sites in 14 files G-SEC M
A-02 Ahmed Replace all inline onclick handlers with delegated data-* listeners (js/candidates.js:121 pattern) A-01 G-SEC S
A-03 Ahmed CSP without unsafe-inline for scripts; verify no inline <script> remains A-02 G-SEC S
A-04 Ahmed CI gate: fail on new unescaped ${ in an HTML template literal, and on dangerouslySetInnerHTML A-01, T-03 G-SEC XS
A-05 Ahmed Poisoned-CV fixture corpus + assertion that payloads render as literal text in both themes A-03 G-TEST S
A-06 Ahmed React shell: Vite + TS, git mv css/styles.css verbatim, stylelint token-only rule, 23-route table from js/app.js:7-16 T-01 G-UX M
A-07 Ahmed Port the 10 js/ui.js primitives to typed React components keeping existing class names A-06 G-UX L
A-08 Ahmed <Chart/> wrapper around the retained js/charts.js canvas engine A-06 G-UX S
A-09 Ahmed config reference-data seed migrations + Django admin registration for the 7 controlled vocabularies T-04, T-05 G-SCHEMA M
A-10 Ahmed pii_classification table + CI completeness check over candidate-touching columns T-04 G-SCHEMA S
A-10b Ahmed procrastinate configuration: named queues, retry policy, periodic tasks, job-status endpoint. Reassigned from Talha (was T-10). This is the one senior-owned Phase 0 module rotated to Ahmed under R-04's stated mitigation, and it is what brings Talha's Phase 0 stream from 49.5 to 45.5 midpoint dev-days (§2). It is also genuinely varied work for him — queue topology and retry semantics, not CRUD — and it has no dependency on any candidate table, so it fits the W6 slot where he would otherwise be blocked waiting on T-05 T-02, T-04 G-ARCH M

10.2 Phase 1

ID Owner Description Depends on Review gate Size
T-11 Talha intake_channel, raw_intake, raw_intake_attachment, intake_parse_attempt, intake_resolution + all five malformed-email invariant layers T-04, T-06 G-SCHEMA L
T-12 Talha files: object storage, sha256, virus-scan hook, signed URLs, retention classes T-04 G-SEC M
T-13a Talha document_parsing: PDF + DOCX text and layout extraction, ParsedDocument, per-field confidence, ParseIssue T-12 G-SEC L
T-13b Talha OCR fallback for scanned CVs + hardening: per-document CPU/wall timeout, memory cap, restricted OS user, no egress T-13a G-SEC M
T-14 Talha candidate + 10 child tables + promotion rule + candidate_search_index trigger maintenance + FTS/trigram indexes T-11 G-SCHEMA L
T-15 Talha intake.ingest() (idempotent on channel + external ref, and on payload sha256) and promote_to_candidate() resolution service T-11, T-14 G-ARCH L
T-16 Talha integrations_inbound: Outlook Graph delta adapter + careers-portal webhook adapter + dead-letter handling T-15, T-07 G-SEC L
T-16b Talha Minimal outbound-mail slice (04 §9.1 row 5; 08 §7 finding 10). app.outbound_message written before the provider call; Mail.Send through the same MailProvider port as T-16, so the Graph adapter is shared and FakeMailProvider makes it testable with no mailbox; the outbound:{public_id} idempotency key plus the sent_at IS NULL in-transaction guard (the only thing preventing a duplicate candidate email); notify.classify_bounce matching an inbound NDR to its outbound_message by the X-Utopia-Thread token, with unmatched NDRs going to needs_review, never discarded; one seeded message_template_version for the "send an unprotected copy of your CV" reply. Not built: template authoring UI, retry policy beyond the queue default, complaint handling, digests, notification_preference, the notification centre — all Phase 2 (T-29, A-31, A-36). 04 §9.1 row 5 sizes this 46 dev-days; carried as M for the roll-up, with the 6-day case inside the phase envelope T-16, T-11 G-SEC M
T-17 Talha job, job_version, job_requirement, job_posting: immutability grants + triggers, deferrable weight-sum trigger T-04, A-09 G-SCHEMA L
T-17b Talha Requisition approval gate (REQ-JOB-07). app.approval_request + app.approval_decision (03 §10) in their single-approver form: typed nullable job_version_id subject column with the num_nonnulls = 1 CHECK (P7), uq_approval_open_per_subject, append-only grants on approval_decision, approver_user_id NOT NULL. publish_version() refuses a job_version with no approved decision from a holder of job.approve in scope, and refuses self-approval by the requesting user. Adds job_version.approval_request_id. Not built: approval_route / approval_route_step, ordered multi-step chains, the OBD-12 grade/threshold branching, per-step SLA and delegation — all Phase 2. Route selection is hardcoded to "the requisition's hiring manager, else department head" and stored on the request, so the Phase 2 route engine cannot rewrite Phase 1 history T-17, T-05 G-SCHEMA M
T-18 Talha job_application: generated state, reapplication index, attempt_no, stage/status history triggers, transition() human-actor guard T-14, T-17 G-SCHEMA L
T-19 Talha assignment: job_assignment + job_application_assignment interval tables, GiST EXCLUDE, primary-recruiter partial unique index, owners_at() T-18 G-SCHEMA M
T-20a Talha scoring_config + scoring_config_version + scoring_config_criterion + job_scoring_assignment with overlap EXCLUDE T-17 G-SCHEMA M
T-20b Talha ats_result + ats_result_criterion: version pinning, stored weight_applied/contribution, input_fingerprint, column-level grants T-20a, T-18 G-SCHEMA L
T-20c Talha Scoring engine v1 (deterministic, rule-based over parsed fields × weighted requirements), explain(), batch rescore on version publish T-20b, T-13a G-AI L
T-21 Talha ai_orchestration framework: AiRun ledger, prompt/model version rows, invoke(capability, context, actor) calling iam.can() with the human actor T-05, T-06 G-AI L
T-22 Talha Duplicate detection: duplicate_candidate_pair, six explainable signals, canonical-pair constraint, confirmed_distinct memory, nightly rescan T-14 G-SCHEMA L
T-23 Talha Authorization rollout: every endpoint through iam.can(), scoped queryset helpers, deny-by-default, live views for soft delete T-05, T-18 G-AUTHZ L
T-24 Talha API layer conventions, OpenAPI schema, generated TypeScript client, per-user rate limits T-02 G-API M
A-11 Ahmed Intake triage queue UI: all eight raw_intake states including needs_review, rejected_unusable and quarantined; retry action; nothing silently hidden T-15, A-07 G-UX L
A-12 Ahmed Manual CV upload screen: multi-file drag-drop, progress, per-file error states, writes a manual_ui raw_intake row first T-12, T-15 G-UX M
A-13 Ahmed Parse-result review panel: field-by-field confidence, low-confidence fields shown empty not guessed, accept/edit per field T-13a, A-11 G-AI M
A-14 Ahmed Candidate profile screens: identity vs applications separation, documents, contacts, timeline, tags, notes T-14, T-18 G-UX L
A-15 Ahmed Candidate list on TanStack Table against server-side DRF pagination, filtering and sorting T-14, T-24 G-UX M
A-16 Ahmed Requisition screens: draft, weighted requirements editor with live sum-to-1.0 validation, version diff view, publish flow. The version's approval state is a first-class element of this screendraftpending_approvalapproved / rejectedpublished, showing the named approver and, once decided, the decision timestamp and comment. The publish control is disabled with a stated reason (not hidden) while the version is unapproved, so the gate is legible rather than mysterious T-17, T-17b, A-07 G-UX L
A-16b Ahmed Requisition approve / reject action (REQ-JOB-07): submit-for-approval from the draft, the approver's approve/reject with a mandatory comment on reject, wired to the endpoints 06 §2.6 already defines — POST /jobs/{id}/versions/{vid}/submit-for-approval (job.edit), GET /jobs/{id}/approvals (job.read), POST /jobs/{id}/approvals/{step_id}/approve and /reject (job.approve) — with optimistic state and rollback on a 403. The action is absent from the DOM — not merely disabled — for a role without job.approve in scope, and A-22's matrix gains a row for each new endpoint. Pairs with T-17b and is Ahmed's first task against a permission-gated write, which is deliberate: it is small, demonstrable on its own, and exercises the authorization layer from the UI side before A-22 tests it from the API side T-17b, A-16 G-AUTHZ S
A-17 Ahmed Zod + DRF serializer validation pairs for intake, candidate, requisition and application; shared error-code vocabulary T-24 G-API M
A-18 Ahmed Score explanation panel: per-criterion requirement, evidence, weight_applied, contribution, band, and all pinned versions T-20c G-AI M
A-19 Ahmed AI provenance badges (model version, prompt version, run id, reviewer) + the AiReview verdict action T-21, A-18 G-AI S
A-20 Ahmed Error / loading / empty-state system: skeletons, retry, offline banner, error-envelope mapping, toast policy — applied across every Phase 1 screen A-07, T-24 G-UX M
A-21 Ahmed Service-level API test suite: one suite per module facade, against a real Postgres, no DB mocking T-15, T-18 G-TEST L
A-22 Ahmed Permission test matrix: every endpoint × every role → expected allow/deny, plus an assertion that every candidate-reading endpoint routes through iam.can() T-23 G-AUTHZ M
A-23 Ahmed Malformed-CV pipeline test corpus + expected-outcome assertions per case (see §11.4) T-13b, A-05 G-TEST M
A-24 Ahmed The 5 Playwright smoke journeys in CI A-11, A-14, A-16 G-TEST M
A-25 Ahmed Application list + stage transition drawer with mandatory reason (pre-board interim) T-18, A-14 G-UX S
A-26 Ahmed Constraint test suite: attempt each forbidden write (UPDATE on job_version, candidate without intake, contactless candidate, malformed email, second live application, AI terminal-negative transition, publish_version() on a job_version with no approved approval_decision, and on one whose only approval decision was made by the requesting user or by a user without job.approve in scope — §4.5 criterion 13) and assert the exception. Assert the exact literal, not just "it raised": for the terminal-negative case, pytest.raises(TerminalTransitionRequiresHumanActor, match=r"actor_kind = 'user'"), plus a parametrised case per non-'user' actor kind (system, integration, ai_agent) and a positive case proving actor_kind='user' is permitted. Rationale: a bare "an exception was raised" assertion passes identically against actor_type != 'human', actor_kind = 'human' and allowed_actor_kind = 'user_or_system' — the four spellings _decisions.md RULING-01 exists to eliminate — and the 'human' spelling fails closed on every legitimate recruiter rejection. Also assert ck_allowed_actor_kinds rejects a rule row containing any value outside ('user','system','integration','ai_agent'), and that ck_terminal_negative_user_only rejects is_terminal_negative = true with allowed_actor_kinds <> ARRAY['user'] T-11, T-17, T-17b, T-18, T-20b G-TEST M

10.3 Phase 2

ID Owner Description Depends on Review gate Size
T-25a Talha Merge engine: candidate_merge, candidate_merge_operation undo log, additive re-parenting, suppression via partial unique indexes T-22 G-SCHEMA L
T-25b Talha Reversal: descending-seq replay, stack-discipline trigger, retention interlock, post-merge-row semantics T-25a G-SCHEMA L
T-26 Talha worker-untrusted queue split: restricted OS user, no egress, CPU/memory caps, per-document limits T-13b, T-09 G-SEC M
T-27 Talha Read-only assistant: whitelisted parameterised typed query intents, streaming endpoint, on_behalf_of_user_id access audit, no SQL access T-21, T-23 G-AI L
T-28 Talha interview backend: UTC instant + wall-clock intent + IANA zone, participant GiST EXCLUDE, calendar sync, reconciliation job T-18 G-SCHEMA L
T-29 Talha notifications delivery: outbound provider, retry, bounce handling, template rendering via config A-09, A-10b G-ARCH M
T-30 Talha analytics read-only SQL views declared in migrations + nightly refresh + pg_partman for audit partitions T-18, T-19 G-SCHEMA M
A-27 Ahmed Pipeline board: drag-and-drop across 7 stages, legality from pipeline.is_allowed(), optimistic update with visual rollback on rejection T-18, A-25 G-UX L
A-28 Ahmed Duplicate review UI: side-by-side comparison with each signal shown and its value, per-field survivor choice, confirmed_distinct action T-22, T-25a G-UX L
A-29 Ahmed Merge undo UI: confirmation screen listing exactly which post-merge rows will stay, plus blocked-reason messaging naming the blocking merge T-25b G-UX M
A-30 Ahmed Interview scheduling UI: timezone-aware slot picker showing organiser wall clock and viewer local time, conflicts surfaced from the DB refusal T-28 G-UX L
A-31 Ahmed Communication template UI: versioned templates, variable palette, preview with escaping proven on a hostile variable value A-09, T-29 G-UX M
A-32 Ahmed Dashboard KPI components on the retained charts.js: funnel, time-to-hire, source performance, recruiter workload T-30, A-08 G-UX M
A-33 Ahmed worklist module: task rules, SLA items, overdue sweep, plus the recruiter task UI (workflow logic, not CRUD) T-18, T-29 G-ARCH M
A-34 Ahmed Chatbot UI dock: streaming answers, citations to permitted records, an explicit "cannot answer / not permitted" state T-27 G-UX L
A-35 Ahmed Integration tests for the intake channels against a dedicated test mailbox and a sandbox careers form, including redelivery idempotency T-16 G-TEST M
A-36 Ahmed Notification preferences + in-app notification centre T-29 G-UX S

10.4 Phase 3

ID Owner Description Depends on Review gate Size
T-31 Talha fairness_evaluation: EvaluationRun, disparate-impact metrics, and the activation gate blocking scoring_config_version activation without a pass T-20c G-AI L
T-32 Talha offer: immutable offer_version, approval chain, money pairs + minor-unit rounding trigger, issue() with mandatory human confirmation T-18, A-09 G-SCHEMA L
T-33 Talha assessment backend: templates, assignment, results, expiry sweep T-18 G-ARCH M
T-34 Talha Job-board inbound adapters (4 boards) into intake.ingest() T-16 G-SEC L
T-35 Talha Retention purge as pseudonymisation + retention_hold + retention_action log + subject-access export A-10, T-25b G-SEC L
A-37 Ahmed Talent-pool UI: pools, saved segments, rematch results against a requisition version, add-from-anywhere T-14, T-20c G-UX M
A-38 Ahmed Offer drafting + approval-chain UI with amount+currency validation and range/currency-mismatch errors T-32, A-17 G-UX L
A-39 Ahmed Assessment assign/record UI + candidate-facing token-gated result submission T-33 G-UX M
A-40 Ahmed Report library UI + saved reports over the analytics read models T-30 G-UX M
A-41 Ahmed Fairness results screen written for a legal/business audience, not an engineering one T-31 G-AI M
A-42 Ahmed Retention and consent admin screens: holds, due dates, purge history T-35 G-UX S
A-43 Ahmed Load/performance harness for search and list screens against a seeded 100k-candidate dataset, measuring against the §11 thresholds T-14 G-TEST M
A-44 Ahmed Custom per-requisition pipeline configuration UI T-18 G-UX M

10.5 Phase 4

ID Owner Description Depends on Review gate Size
T-36a Talha integrations_outbound core: JobPosting, PublishAttempt, publish/unpublish/reconcile, HR-admin gate T-17 G-SEC L
T-36b Talha Platform adapters for the 8 external destinations, individually degradable T-36a G-SEC L
T-37 Talha Full tool-using assistant: write actions through existing domain services behind explicit confirmation T-27, T-23 G-AI L
T-38 Talha Remaining P2 AI capabilities, each with its own AiCapability availability state T-21 G-AI L
T-39 Talha RLS evaluation and, if justified, a dedicated AI-path role with row and column policies T-27 G-SEC M
A-45 Ahmed Job-board publishing UI with cost-band warnings and per-platform state T-36a G-UX M
A-46 Ahmed Settings UI split correctly: security/users/roles → identity, vocabularies/templates/branding → config T-05, A-09 G-UX L
A-47 Ahmed Assistant write-action confirmation UX + undo affordances + permission-denied messaging T-37 G-UX M
A-48 Ahmed Full accessibility regression sweep across every migrated route × 2 themes, matching the prototype's verified AA baseline all UI G-UX M
A-49 Ahmed Prototype retirement: delete the last prototype screens, remove the transitional grep-gate scaffolding, update the route table A-48 G-SEC S
A-50 Ahmed Help: static docs + client-side search G-UX S

10.6 Is Ahmed's stream actually varied? (self-check against the constraint)

Category Ahmed tasks
Security hardening A-01, A-02, A-03, A-04, A-49
Frontend screens / workflow UI A-11, A-12, A-14, A-15, A-16, A-25, A-27, A-30, A-31, A-36, A-37, A-38, A-39, A-42, A-44, A-45, A-46, A-50
AI / explainability UX A-13, A-18, A-19, A-34, A-41, A-47
Backend / schema / workflow logic A-09, A-10, A-10b, A-33
Authorization-gated workflow A-16b
Validation A-17
Testing A-05, A-21, A-22, A-23, A-24, A-26, A-35, A-43
Dashboards & data viz A-08, A-32, A-40
Design system & accessibility A-06, A-07, A-20, A-48

8 test tasks, 6 AI-UX tasks, 4 design-system tasks, and two genuinely non-trivial state machines (A-28/A-29 merge and unmerge, A-27 board legality). No task is "clean this data", and no phase gives him only forms.

Two tasks were added or moved by the §2 reconciliation and the REQ-JOB-07 fix, and both improve the variety rather than dilute it. A-10b (procrastinate queues, moved from Talha) is his first piece of infrastructure work — queue topology, retry semantics and a job-status endpoint — and it lands in Phase 0, where his stream was otherwise entirely frontend and security. A-16b is his first authorization-gated write: a small, independently demonstrable action whose whole point is that it must be refused for the wrong role, which is a category he previously only touched from the test side in A-22. Both come with a Talha review checkpoint (G-ARCH and G-AUTHZ respectively), and neither is CRUD.


11. Testing strategy

There are no tests, no test runner and no CI in the repository today (findings §B, §H), so this is entirely additive and can be shaped correctly from the start. Two principles: the module facade is the test seam (which is what makes the boundary rule pay for itself), and the database is never mocked — the design depends on jsonb, partial and expression unique indexes, deferrable constraint triggers, GiST EXCLUDE, FTS, pg_trgm and LISTEN/NOTIFY, none of which SQLite has. CI runs a real Postgres service container.

No coverage-percentage gate. Coverage targets reward testing getters. The gate is the layer checklist below plus Talha's G-TEST review.

11.1 Unit tests (fast, no I/O)

Target Examples
Scoring arithmetic Criterion normalisation, weighted aggregation, band boundaries, input_fingerprint stability, contribution sum equals overall score
Parsing helpers Date-range arithmetic to total_experience_months, section detection, email/phone extraction, e164 normalisation, name normalisation (unaccent + lower)
Validation Email regex boundary cases, currency minor-unit rounding, salary range ordering, cooling-off calculation
Duplicate signals Trigram similarity thresholds, canonical pair ordering, signal weighting
Frontend Vitest on the ported js/ui.js primitives, the score explanation panel's rendering of stored values, and the error/loading/empty state machine

11.2 Integration / service-level tests (real Postgres)

One suite per module facade. Each asserts the facade's contract, not its internals. Mandatory members of this layer:

  • Forbidden-write tests. Every trigger and grant gets a test that attempts the forbidden write and asserts the exception: UPDATE on job_version, job_requirement, scoring_config_version, audit_event, ats_result outside the granted columns; a candidate insert with no raw_intake; a commit with no contact channel; a malformed email; a second live application to the same job; an overlapping assignment interval; an overlapping interview slot for one participant.
  • Idempotency tests. Redelivering the same mail message or webhook payload produces no second raw_intake row, by both external_message_id and payload_sha256.
  • History-completeness tests. Every state transition produces exactly one history row with non-overlapping intervals, and a transition performed without the SET LOCAL actor bridge produces actor_unknown = true rather than a wrong attribution.
  • Merge/unmerge property test (Phase 2). For a generated pair of candidates with child rows across every candidate_id-carrying table: merge, then unmerge, then assert every child row is back under its original parent. Plus a test asserting the merge routine records a candidate_merge_operation for every such table — the enumeration is maintained in one place and the test fails when a new table is added without it.
  • Migration replay test. Every migration applies cleanly from empty to head, and from a staging-shaped clone, in CI.
  • Audit hash-chain test. Tampering with a row is detected by the verifier.

11.3 Permission tests

Treated as a first-class layer, because Phase 1 has no database-level backstop (RLS is Phase 2) and the codebase has no authorization at all today (findings §D).

Test Assertion
Endpoint × role matrix (A-22) Every endpoint, for every one of the 7 roles (05 §2.1), returns the expected allow/deny. A new endpoint without a matrix row fails CI
Single decision point Static assertion that no view module queries a repository directly, and every candidate-reading endpoint routes through iam.can()
Deny by default An endpoint declaring no permission returns 403, not 200
Scope enforcement An interviewer sees only candidates on their own assigned interviews; a recruiter sees only their own workload where scoped
Chatbot parity (Phase 2) For a matrix of questions × roles, any answer containing data the same user's REST call would be denied is a test failure. No service account exists to be tested
Delegated access audit Every chatbot answer writes an audit_event with actor_kind = 'ai_agent' and on_behalf_of_user_id set
Token surfaces An expired, revoked or consumed candidate_access_token is refused; knowledge of a public_id alone grants nothing

11.4 Pipeline tests against malformed CVs

The intake pipeline's contract is: nothing is silently lost, and nothing malformed ever produces a candidate. That is tested against a versioned corpus in the repository, each file with a declared expected outcome. A new parser version must re-run the whole corpus.

Corpus case Expected outcome
Clean text PDF parsed, high confidence, promotable
Scanned image-only PDF parsed via OCR with low confidence, or needs_review — never a guessed field
Two-column / table-heavy layout parsed or partial; no field silently interleaved from the wrong column
Password-protected / encrypted PDF failedneeds_review, with a specific ParseIssue reason
0-byte file, truncated file, corrupt header rejected_unusable, no candidate
Extension/MIME mismatch (.pdf that is a ZIP) quarantined, no parse attempted
DOCX containing macros quarantined by the scan hook before parse
200-page PDF; 500 MB file; ZIP bomb Refused or killed by the size/time/memory cap; recorded as failed with a timeout reason, never an OOM that takes the worker down
CJK, Arabic/RTL, accented Latin names parsed with correct normalisation; name_normalised unaccented and lowercased
CV with no email and no phone needs_review. The deferrable contactability trigger prevents any candidate
CV with two email addresses (work + personal) One candidate, two candidate_email rows, provenance per address
Email address already on another candidate Resolution forced into duplicate review by the global partial unique index; no ghost candidate
Shared/agency/info@ address needs_review today; exercises the non-identifying-address exclusion decision
XSS payloads in name, title, employer, filename Stored verbatim (preserve-the-original), rendered as literal text. Asserted in Playwright, both themes
Same CV delivered twice One raw_intake row
Mail with 5 attachments, 2 of them CVs One intake, 5 attachment rows, 2 parse attempts, recruiter chooses

11.5 End-to-end (Playwright, 5 journeys in CI)

Login; ingest a CV and see it parsed; promote a submission to a candidate and application; create and publish a requisition version; move an application through two stages. Each journey asserts DOM text rather than screenshots, so a stored XSS payload appearing as markup is a failure.

11.6 Performance checks (Phase 3, A-43)

Against a seeded 100k-candidate dataset: p95 candidate search < 500 ms on the tuned FTS + trigram path, p95 list screens < 300 ms, batch rescore of a 500-application requisition version completing inside the worker's SLA. These are the numbers that would justify the escalation ladder in _decisions.md (index tuning → read replica → materialised search table → BM25 extension) — and, per that decision, the search-service triggers are three to four orders of magnitude away and very likely never fire.


12. Weekly demonstration milestones — Phase 0 week by week

Anchored to the assumed 2026-08-03 kickoff. Each week ends with something a non-engineer can watch. These are demos on staging or locally, not releases.

This section was re-derived from scratch against the capacity model, and the result changed materially. The previous version assigned W1 four Talha items (T-01 M, T-04 M, T-07 S, T-03 started) — 712+ dev-days of work against a 4.0 dev-day week — and put the full D0 demo in W3. Both were consequences of the §2 sizing error rather than deliberate compression. The table below is built by filling each person's week to their budget and no further: Talha 4.0 dev-days, Ahmed 4.5, using the §1 midpoints (XS 0.5 / S 1.5 / M 4 / L 8). Every cell states the dev-days consumed so the arithmetic is checkable, and no cell exceeds the budget.

Talha's Phase 0 stream is 45.5 midpoint dev-days (after A-10b moved to Ahmed) = 11.4 weeks at 4.0/week. Ahmed's is 32.0 = 7.1 weeks at 4.5/week. Phase 0 therefore completes in W12, and D0 is a week-12 demo, not a week-3 one.

12.1 Month one — weeks 1 to 4

Week Dates Talha (4.0 dd) Ahmed (4.5 dd) Demonstrable at end of week
W1 Aug 37 T-07 filed day 1 (1.5) · T-01 started (2.5 of 4) = 4.0 A-01 (4.0) · A-02 started (0.5 of 1.5) = 4.5 The repo layout and a partial compose stack. The prototype's Candidates, Inbox and Import screens render a hostile name string as literal text — the three highest-risk sinks done first, per §8 step 1
W2 Aug 1014 T-01 complete (1.5) · T-04 started (2.5 of 4) = 4.0 A-02 complete (1.0) · A-03 (1.5) · A-05 (1.5) · A-06 started (0.5 of 4) = 4.5 docker compose up brings up API + worker + Postgres + Redis + object store on a clean machine. CSP blocks an injected inline script in the browser console, and the poisoned-CV fixtures assert as literal text in both themes. §8 steps 13 and 5 are complete in W2, which satisfies §3.5 criterion 5 — step 4, the CI grep gate, waits on T-03 and lands in W4
W3 Aug 1721 T-04 complete (1.5) · T-03 started (2.5 of 4) = 4.0 A-06 complete (3.5) · A-08 started (1.0 of 1.5) = 4.5 The first migration applies from empty: schemas app/ref/audit/ai/staging and the four extensions, with the migration-runner ADR (T-04) settling the §17 divergence 1 conflict in writing before any table exists
W4 Aug 2428 T-03 complete (1.5) · T-02 started (2.5 of 4) = 4.0 A-08 complete (0.5) · A-04 (0.5) · A-10 (1.5) · A-07 started (2.0 of 8) = 4.5 Month-one demo. CI green on main and red on each of: an unescaped ${ in a template literal, a dangerouslySetInnerHTML, a boundary-violating import, a model change with no migration, a raw colour in CSS, and an unclassified candidate-touching column. The React shell renders the frozen stylesheet in both themes with a chart from the retained charts.js

Dependency check on the above — every prerequisite lands before its dependent starts: A-04 needs T-03 (complete W4) and is scheduled in W4; A-10 needs T-04 (complete W3) and is scheduled in W4; A-06 needs T-01 (complete W2) and starts W2. The previous version scheduled A-04 in W2, three weeks before T-03 completed.

12.2 Weeks 5 to 12 — the rest of Phase 0, to D0

Week Talha (4.0 dd) Ahmed (4.5 dd) Milestone
W5 T-02 complete (1.5) · T-05 started (2.5 of 8) = 4.0 A-07 (4.5 of 8; cum 6.5) = 4.5
W6 T-05 (4.0; cum 6.5 of 8) = 4.0 A-07 complete (1.5) · A-10b started (3.0 of 4) = 4.5
W7 T-05 complete (1.5) · T-06 started (2.5 of 8) = 4.0 A-10b complete (1.0) · 3.5 dd of genuine slack = 4.5 Log in with a scoped role and get a 403 from an endpoint the role lacks — the first time authorization in this system decides anything (findings §D)
W8 T-06 (4.0; cum 6.5 of 8) = 4.0 A-09 (4.0) · 0.5 slack = 4.5 Django admin editing the 7 controlled vocabularies, removing the Settings UI from Phase 1 scope
W9 T-06 complete (1.5) · T-09 started (2.5 of 8) = 4.0 Phase 0 complete for Ahmed — rolls onto Phase 1 preparatory work (see below) audit.audit_event rows with correct actors for the login and a role change; the nightly hash-chain verifier green; tampering with a row detected
W10 T-09 (4.0; cum 6.5 of 8) = 4.0 Phase 1 preparatory work
W11 T-09 complete (1.5) · T-08 started (2.5 of 4) = 4.0 Phase 1 preparatory work Merge to main auto-deploys to staging with no manual step
W12 T-08 complete (1.5) · 2.5 dd into T-11/T-12 = 4.0 Phase 1 preparatory work Phase 0 demo D0 in full (§3.7). Phase 1 begins mid-week

Ahmed's W7 slack and W9W12 gap are real and are stated rather than papered over. He finishes Phase 0 in W8 while Talha finishes in W12 — the load imbalance from §2, visible as calendar. The work that fills it, in priority order, is chosen to need no API that does not yet exist: the Vitest harness for the ported js/ui.js primitives (required by §11.1 regardless), extending the A-05 poisoned-CV corpus toward the §11.4 case list, drafting the T-08 ADRs from Talha's decision notes for Talha to review rather than write, and starting A-17's Zod schemas against the error-envelope and pagination conventions fixed by T-02 in W5. None of that is filler, but none of it is on the critical path either — which is the argument for R-25 and for §4.2.1's cuts 13, all of which come off Talha's queue and none off Ahmed's.

What month one does and does not deliver — corrected. _decisions.md states that one month yields "Phase 0 plus one vertical slice". By this document's own task sizing that is not achievable, and §17 divergence 5 records the conflict. Month one delivers the hardened prototype (complete, in W2), the local platform, the full CI gate set, the first migration and the React shell with the design system intact. It does not deliver authorization (W7), the audit trail (W9), staging CD (W11) or any intake row — and the first end-to-end thread lands in Phase 1, not in week 4. §15 is written to that corrected boundary.


13. Definition of Done

A task is Done when all of the following are true. There is no partial Done, and "Done except tests" is not a state.

Every task

  1. Merged to main via a PR reviewed by Talha under its named review gate (§10). No direct pushes to main.
  2. PR is ≤ ~400 changed lines, or was split. Oversized PRs are returned unreviewed.
  3. CI green on every job: ruff, mypy, import-linter contracts, pytest on real Postgres, migration-drift gate, ESLint (react/no-danger error), stylelint token rule, tsc --noEmit, unescaped-interpolation grep gate.
  4. Deployed to staging automatically on merge, and exercised there — not only locally.
  5. No new TODO referencing a missing invariant, permission or test.

Backend / schema tasks additionally

  1. Every invariant is expressed in the database (constraint, trigger, index or grant), not only in application code.
  2. A forbidden-write test exists and passes for each new constraint.
  3. Migration applies forward from empty and from a staging-shaped clone. No down-migration is written; recovery is forward-fix plus PITR.
  4. Cross-module access goes through service.py only; import-linter contract updated if a legitimate new edge was added.
  5. Every state change produces an audit_event with a correct actor, and history rows with non-overlapping intervals.

API tasks additionally

  1. OpenAPI schema regenerated, TS client regenerated, error envelope and pagination conventions followed, Zod schema and DRF serializer agree on every field and message.
  2. A row exists in the permission matrix test for every new endpoint.

Frontend tasks additionally

  1. No dangerouslySetInnerHTML. All user- or candidate-derived text renders through JSX escaping.
  2. CSS uses only existing var(--…) tokens from the frozen styles.css; class names match the design system.
  3. Loading, empty, error and permission-denied states all exist and were demonstrated.
  4. Keyboard reachable, focus visible, contrast AA in both themes, 44px touch targets, usable at 320px — matching the prototype's verified baseline (findings §G).
  5. If it replaces a prototype screen, the prototype counterpart is deleted in the same PR.

AI-touching tasks additionally

  1. Every invocation writes an AiRun row before its output is usable; model, prompt and config versions are pinned on any stored result.
  2. No path exists by which an AI actor produces a terminal-negative outcome; a test asserts the refusal.
  3. The user-facing surface shows provenance (model version, run id) and an explanation built from stored values, never recomputed at read time.

14. Risk register

Likelihood and impact are H/M/L. Owner is the person accountable for the mitigation, not necessarily the person who executes it.

# Risk L I Mitigation Owner Phase
R-01 Stored XSS reaches a recruiter session. 34 unescaped innerHTML sites (findings §E) and storage that deliberately preserves unsanitised originals M H The §8 remediation in Phase 0 in the stated order; CSP without unsafe-inline; CI grep gate; poisoned-CV corpus test; written rule that real data is only wired to the React app Ahmed (exec) / Talha (gate) 0
R-02 Microsoft Graph consent and mailbox provisioning blocked by corporate IT, stalling inbound channel #1 for weeks H M File in Phase 0 week 1 (T-07); sequence the Outlook adapter last in Phase 1 so nothing demonstrable sits behind it; manual upload and careers form carry the demo Talha 01
R-03 CV parsing accuracy falls short of "no recruiter re-keying" on mixed-quality and scanned PDFs H M Leave low-confidence fields empty rather than guessing; make the parse-review step explicit and fast (A-13); versioned corpus (§11.4) as the accuracy contract; get OQ-6 answered in Phase 1; set the expectation with the business in D1, not at go-live Talha 1
R-04 Bus factor of one. Talha owns architecture, authz, AI, scoring, parsing, deployment — and reviews his own work with no second reviewer H H ADRs for every decision (T-08); pair on identity and scoring; deliberately rotate one senior-owned module per phase to Ahmed with Talha reviewing; keep runbooks in the repo, not in one head Talha all
R-05 Review throughput caps delivery. Every Ahmed PR needs Talha, who is also the sole implementer of the critical path H M 400-line PR cap; two fixed review windows per day; Ahmed keeps two branches in flight; mechanical gates (import-linter, stylelint, grep, matrix test) so review is about design, not policing Talha all
R-06 Untrusted-file parsing runs inside our own worker — RCE and resource-exhaustion surface by design M H Phase 1: virus scan before parse, per-document CPU/wall timeout, memory cap, restricted OS user, no egress. Phase 2: worker-untrusted queue (T-26). This is the split trigger most likely to fire early Talha 12
R-07 Merge reversal is silently incomplete — a candidate_id-carrying table re-parented but not recorded in the undo log M H Single enumeration of every such table; test asserting an operation is recorded for each; merge→unmerge property test; stack discipline refuses rather than guesses Talha 2
R-08 Phase 1 exceeds the inherited 14 weeks. Re-rated from M to H, and the rating is now a statement about arithmetic rather than about uncertainty: the §10.2 roll-up floors Phase 1 at 150 dev-days against the inherited 119 (§2), so exceeding 14 weeks is not a risk that might materialise — it has already materialised on paper, before kickoff. The three long-tail items (parsing, authz, Graph) are on top of that, not the cause of it H M Published range corrected to 2430 weeks (§4.6). The §4.2.1 cut list is the instrument if the calendar is fixed instead of the scope, applied top-down and stopped at the first cut that closes the gap; even the full cut set only reaches ~19 weeks, and that ceiling is stated to the business at D0 rather than discovered at D1. Re-forecast at the end of sub-slice 1A with real parsing data Talha 1
R-08b Phase 1 exceeds the corrected 30 weeks. The forward-looking twin of R-08: the 30-week ceiling assumes perfect load balance across two streams, whereas Talha's own worst case is 155 dd ÷ 4.0 = 39 weeks (§2) M H The ceiling is only reachable if Talha's queue is actively reduced — §4.2.1 cuts 13 (T-16 with T-16b, T-22, T-13b) are all his and are the first lever. Impact is H rather than M because a Phase 1 overrun pushes every later phase (the gantt bands are cumulative) and Phase 1 is the phase that contains every non-negotiable invariant Talha 1
R-24 Phase 0 exceeds the inherited 23 weeks — R-08's missing twin, and the more damaging of the two because it is the one stakeholders have already been told. The §10.1 roll-up floors Phase 0 at 57.5 dev-days against the inherited 1725, a 3.43.9x correction, and Talha's stream alone floors it at 8.5 weeks. _decisions.md's statement that one month yields "Phase 0 plus one vertical slice" is not achievable on this plan's own sizing H H Published range corrected to 912 weeks (§3.6) and §12 re-derived week by week to D0 in W12. Impact is H because this is the number stakeholders anchor on and the §15 first-month expectation was written against the wrong one; the correction must be delivered at kickoff, not at the end of week 3 when the D0 demo does not happen. Unlike Phase 1 there is no cut list: every Phase 0 item is either the XSS remediation, the authorization decision point, the audit trail or the platform, and all four are cheap now and unrecoverably expensive later (§9 ordering decision 1) Talha 0
R-25 The plan is Talha-bound, and combined-capacity arithmetic hides it. Talha holds 62% of Phase 1's floor dev-days at the lower of the two weekly budgets (4.0 vs 4.5). Every phase except 2 is bounded by his stream, so the 8.5 dev-days/week figure used throughout §2 is optimistic wherever it is applied to a phase rather than to a person — and a single week of his absence costs more than a week, because review stops too (A-3, R-04) H M Published lower bounds are derived per stream, not from combined capacity (§2). One senior-owned module rotated to Ahmed per phase — A-10b in Phase 0, executed rather than recommended. Phase 1 has no transferable item, so the lever there is cutting his queue (§4.2.1) rather than moving it. Ahmed's W7W12 slack is named explicitly in §12.2 rather than hidden as "ramp", because unowned slack is what turns into invented scope Talha all
R-09 OQ-1 model hosting unresolved. If legal requires self-hosting, Phase 1 gains GPU infra, model serving and MLOps that two developers cannot absorb M H Scoring v1 is deterministic and needs no model, so the Phase 1 thread is not blocked; AI capabilities beyond the ledger are deferred until answered; escalate for a decision by the end of Phase 1 sub-slice 1A Talha 13
R-10 OQ-2 no historic outcome data, so the Phase 3 fairness gate cannot be satisfied and may block the ranking release with no engineering fix M H Raise in Phase 1, not Phase 3. Fallback: release scores as per-application decision support with ranking explicitly disabled in the UI and stated to stakeholders Talha 1 (raise) / 3
R-11 OQ-4 jurisdictions vs one database. A legal requirement for in-jurisdiction storage conflicts directly with the one-database constraint L H One region plus per-record retention and deletion; if legal disagrees, it needs an explicit business exception, not an architectural workaround. Escalate in Phase 0 Talha 0 (raise) / 3
R-12 Scope creep from the 15 AI capabilities already visible in the prototype UI, which reads as nearly-done to stakeholders H M Only AI-1/2/3 in Phase 1; AI Studio must show true per-capability availability, never a coming-soon grid; every demo states explicitly what is and is not wired Talha all
R-13 Two frontends coexist for 612 months and drift — a fix applied to one and not the other H M Prototype frozen after the Phase 0 patch, security fixes only; each migrated screen deletes its prototype counterpart in the same PR (DoD 17); A-49 closes the window Ahmed 14
R-14 plpgsql volume is a skills and maintenance risk for a two-person team with a junior M M Talha owns all trigger and constraint code; Ahmed owns migrations, reference data, the pii_classification CI check, search tuning and the constraint test suite; every trigger has a forbidden-write test Talha 12
R-15 Migration tooling collision — RESOLVED, retained for traceability. Part 1 chose Django with built-in migrations and a makemigrations --check gate, Part 2 mandates plain-SQL up-only migrations and rejects ORM-generated schema. Six documents carried six separately-worded recommendations, which is a contradiction with six votes and no ruling H M Settled by ADR 0017 (adr/0017-plain-sql-migrations-as-schema-authority.md), which supersedes the recommendations previously scattered across 00 §10, 02 §15 I1, 03 §32.1, 04 §9.1, 05 §9.1 I-1 and §17 of this document. Signed off as part of T-04, Phase 0 week 1, before migration 001 exists, and a merge blocker on the first migration PR — that PR does not merge until ADR 0017 is Accepted. Residual risk is now execution, not indecision Talha 0
R-16 The global unique index on candidate_email.address_normalised backs up the review queue — shared family, agency and info@ addresses H M Decide the non-identifying-address exclusion list inside Phase 1, before go-live; corpus case in §11.4 exercises it Talha 1
R-17 Trigger-written history is attributed to system wherever middleware forgets SET LOCAL app.actor_user_id (jobs, imports, psql fixes) H L actor_unknown = true makes the gap visible; a dashboard query on actor_unknown counts treated as a data-quality alarm, not noise Talha 1
R-18 Ahmed blocked waiting on Talha's schema and services M M Sequence his stream so he always holds one UI task against a shipped API plus one test/validation task needing no new API; the Phase 0 escaping pass and the React shell are entirely independent of the backend Talha all
R-19 No high availability. One app host; a platform restart is minutes of downtime, and six jurisdictions narrow the maintenance window M L Accepted deliberately for an internal tool. Stated to the business before the first deploy that overlaps Singapore hours, not discovered during it Talha 0
R-20 Provider model deprecation breaks score reproducibility required by the pinning design M M Model version pinned on every ats_result; a forced provider migration is a new ScoringConfigVersion plus a rescore batch plus an audit entry — never a silent in-place rescore Talha 14
R-21 Retention pseudonymisation permanently blocks merge reversal, so reversibility erodes silently as purge cadence increases M M Exclude candidates in an unreversed merge from purge for a defined window, or require explicit acknowledgement that reversibility is being surrendered Talha 3
R-22 Cloud subscription, budget or procurement delay (Phase 0 hosting; Phase 4 eight platform accounts) M M Phase 0 runs entirely on docker compose; escalate on day 3. Phase 4 platforms are individually deferrable Talha 0, 4
R-23 Stakeholders judge the product against the prototype's 23 finished-looking routes rather than against the phase plan H M Every demo opens with the cut line for that phase; the phase table in §2 is shown at each milestone; the prototype is never demoed against real data Talha all

15. What can realistically be demonstrated in the first month — and what cannot

Stated plainly, because this is the section most likely to be read by someone deciding whether the plan is credible.

This section was rewritten against the corrected §12 derivation. The previous version promised authorization, the audit trail, continuous deployment and a real CV landing as a raw_intake row — all inside month one — because it was written against the 23 week Phase 0 figure. On the §10.1 task sizing those land in W7, W9, W11 and Phase 1 respectively. The lists below are what four weeks of two developers at 4.0 and 4.5 dev-days actually buys. Nothing was removed from the plan; four items moved from "month one" to "weeks 712", which is where §12.2 now shows them.

15.1 Can be demonstrated in month one (high confidence)

  • The prototype hardened — complete, in week 2. All 34 innerHTML interpolation sites escaped, zero inline handlers, an active CSP without unsafe-inline, and a hostile candidate name rendering as literal text in both themes, with the poisoned-CV fixture corpus asserting it. This is the highest-value item in month one and it lands earliest, deliberately: it is the one thing that must be true before real CV data exists anywhere near this system (findings §E).
  • A working local environment from one command — API, worker, Postgres 16, Redis and object storage under docker compose up, with the first migration applied, where today the repository has no compose file, no migration and no backend of any kind (findings §B).
  • A CI pipeline that is red for the right reasons — a new unescaped ${ in a template literal, a dangerouslySetInnerHTML, a cross-module import bypassing service.py, a model change with no migration, a raw colour instead of a var(--…) token, and an unclassified candidate-touching column. Six mechanical gates, all passing on main, where today .github/ does not exist.
  • The schema foundation — schemas app/ref/audit/ai/staging and the four extensions applying from empty, with the migration-runner ADR settling the §17 divergence 1 conflict in writing before the first table exists.
  • The React shell rendering the preserved design system in both themes, with the ported js/ui.js primitives and a chart from the retained js/charts.js — the findings §G assets demonstrably intact after the move.

15.2 Lands in weeks 712, not month one (was previously promised for month one)

Moved rather than cut. Each has a named week in §12.2:

Item Week Why it cannot be earlier
Real authentication and real authorization — login, scoped roles, a 403 from an endpoint the role lacks W7 identity (T-05) is an L task depending on T-04, and T-04 depends on T-01. That is three sequential tasks on Talha's stream at 4.0 dev-days/week
An append-only audit trail with correct actor attribution and a passing hash-chain verifier W9 T-06 is an L task depending on T-05. Partitioning, the generic change trigger, the SET LOCAL bridge, the hash chain and the nightly verifier are not a week of work
Continuous deployment — merge to main reaches staging with no manual step W11 T-09 is an L task and also the one gated on cloud subscription approval (R-22)
The 7 controlled vocabularies editable in Django admin W8 A-09 depends on T-05

15.3 Cannot be demonstrated in the first month

Listed so nobody expects them. The first three moved here from §15.1 and §15.2 of the previous version, and that is the substance of the correction:

  • Any intake row at all. files (T-12) and raw_intake (T-11) are Phase 1 and start mid-W12. A real CV file landing as an immutable raw_intake row with a sha256 in blob storage — previously a month-one promise — is a Phase 1 deliverable.
  • A malformed file terminating in rejected_unusable with no candidate. Same reason. This is the single most important invariant in the product and it is demonstrated at D1, not D0.
  • The first thin end-to-end thread (manual CV → intake → parse → human promotion → candidate → application → one ats_result). Previously a "stretch, only if W1W3 run clean" row in §12. It is not a stretch goal against a 4-week month; it is Phase 1 work sitting behind eight sequential Talha tasks. _decisions.md states that one month yields "Phase 0 plus one vertical slice" — on this document's own sizing it does not, and §17 divergence 5 records that rather than restating the claim.
  • Requirement editing minting a new job_version with the old score unchanged. Needs T-17 and T-20b, both Phase 1.
  • Inbound email. Depends on an Entra ID app registration and Mail.Read admin consent from corporate IT, requested on day 1 of week 1 and outside the team's control.
  • Accurate parsing across real CV variety. Month one does not prove the pipeline exists at all, let alone field-level accuracy on scanned, two-column or non-English CVs. That takes the full Phase 1 corpus cycle.
  • Any AI capability. The AiRun ledger is Phase 1, not Phase 0, and scoring v1 is deliberately rule-based. No model is called in month one.
  • The pipeline board, interviews, scorecards, offers, assessments, notifications, dashboards, reports, talent pool, the chatbot, and job-board publishing. Phases 24.
  • Duplicate merge or unmerge. Detection is Phase 1; merge is Phase 2. Month one has neither.
  • A ranked shortlist. Ranking is gated on the Phase 3 fairness evaluation. Month one shows no score at all.
  • Production readiness. Local only in month one; staging from W11. No production data, no SSO cutover, no HA, no monitoring beyond platform logs and the audit verifier.
  • Anything migrated beyond the shell. 23 routes will not be ported in a month, and the intake screens are Phase 1; the remaining prototype screens stay frozen and unwired.

15.4 The single most important thing to say to stakeholders

The prototype already looks finished — 23 routes, 100 candidates, charts, an AI studio. None of it is real: the data is generated in-browser by a seeded PRNG at load (js/data.js:8-10), nothing persists, there are no network calls anywhere in the codebase (findings §C), and the ATS score is int(52,98) (js/data.js:123). Month one replaces a much smaller amount of surface with genuinely real behaviour. Progress in months 13 will look like the product getting smaller and slower, because a real intake row with a real constraint is worth more than a screen full of convincing fiction. That expectation has to be set at kickoff, not defended at D1.

And the second most important thing, which must be said in the same conversation: the phase ranges in §2 are a correction of the ones in _decisions.md, upward, by roughly 1.71.8x on the total — 5675 weeks rather than 3145, with Phase 0 at 912 weeks rather than 23. That correction comes from summing this plan's own 96 enumerated tasks against its own size key; it is not new information about the problem, it is the first time the arithmetic was done. Delivering it at kickoff costs one uncomfortable meeting. Delivering it in week 3, when the D0 demo does not happen, costs the plan's credibility and every number in it — including the ones that are right.


16. Assumptions register

Every item here is an assumption, not an established fact, and each would change the plan if wrong.

# Assumption If wrong
A-1 Kickoff is Monday 2026-08-03 All dates shift as one block
A-2 Capacity is ~8.5 productive dev-days/week combined — and, more bindingly, Talha 4.0 and Ahmed 4.5 individually (§1) Every effort figure scales inversely. The per-person split matters more than the combined figure: because Talha's stream sets the floor in four of five phases (§2, R-25), a change to his 4.0 moves the schedule roughly 1.5x more than the same proportional change to Ahmed's 4.5
A-3 Both developers are available continuously with no extended leave in Phase 01 A two-week absence by Talha costs more than two weeks, because review stops too
A-4 Azure is the target cloud and Utopia runs M365, so Entra ID SSO and Graph land in one tenant A different identity provider or mail host adds integration work to Phase 1
A-5 Volume is 20k60k applications/year and 200600 documents/day at peak Bulk job-board feeds or high-volume seasonal hiring would invalidate the search, audit and worker sizing
A-6 Candidate population reaches order 10⁴10⁵ over several years The 2M-row search-service trigger is 34 orders away and very likely never fires; if volume is far higher, re-derive rather than inherit
A-7 Peak concurrency ~2025 of 66 named seats Sizing and the no-HA decision would need revisiting
A-8 A contracted API model provider under a DPA is acceptable (OQ-1) Self-hosting adds GPU infra and MLOps that break the phase ranges (R-09)
A-9 The business will accept a human review step on parsed CV fields R-03 becomes a scope conflict rather than a UX detail
A-10 3050 realistic CVs including scanned documents can be obtained in Phase 1 week 1 Parser accuracy work slips and the corpus is synthetic-only
A-11 Scoring v1 can be deterministic and rule-based over parsed fields × weighted requirements If the business requires model-based matching in Phase 1, the phase becomes gated on OQ-1
A-12 No special-category data (diversity, health, accommodation) is in scope through Phase 4 Adds separate access control, aggregate-only reads and a distinct lawful basis
A-13 Interviewers (24 of 66 seats) need only their own assigned interviews The interviewer scope model and its permission tests change

17. Consistency check against _decisions.md

Six divergences found. Per the instruction in _decisions.md, they are recorded here rather than silently dropped. Divergence 1 is settled by a single ruling quoted verbatim below; 24 remain recorded as risks; divergences 5 and 6 are resolved against _decisions.md — the only two places in this document where that happens, and the reasoning is given in full for both.

1. Migration tooling — a direct contradiction, and the one that had to be settled first. Part 1 chooses Django 5 partly because "migrations are built in and there is no migration tooling to inherit", and the CI decision requires a makemigrations --check gate. Part 2 mandates "ordered, up-only plain-SQL migration files under db/migrations", states "the ORM… never generates it", and explicitly rejects "ORM-first migrations (Django/Prisma/TypeORM autogenerate)". Part 2's own risk list flags this as a cross-document dependency to reconcile. It is scheduled as T-04 in Phase 0 week 1, before any table exists. Resolution: T-04's output is ADR 0017, and its content is already written — the canonical migration authority ruling in 02-system-architecture.md §12.4, reproduced verbatim here so this plan cannot drift from it. T-04 is therefore a sign-off task, not a design task, which is why it fits inside Phase 0 week 1.

Migration authority ruling — canonical text (ADR 0017). Quote it; do not paraphrase it.

db/migrations/NNN_*.sql is the schema authority. Every Django migration is SeparateDatabaseAndState(database_operations=[RunSQL(<that file>)], state_operations=[…]), so Django owns ordering and the applied-state ledger and authors no DDL. Every model stays managed = True; managed = False is used on no table, because it would remove exactly the tables that carry invariants from the one gate watching them. makemigrations --check compares models against declared migration state — never against the live database — so it is kept as the model-vs-state gate, and it is kept quiet not by a flag but by declaring every object Django can model in Meta.constraints / Meta.indexes (CheckConstraint, UniqueConstraint(condition=…), Index(Lower(…)), ExclusionConstraint) and mirroring those same declarations in state_operations. Objects Django cannot model at all — triggers, column-level GRANT/REVOKE, RANGE partitions and their attach/detach, generated columns, DEFERRABLE INITIALLY DEFERRED constraint triggers, and procrastinate's vendor-managed migrations — are named in an explicit, reviewed db/schema-ignore.toml, and are covered instead by a second, SQL-level gate: CI builds a database by running every migration, captures pg_dump --schema-only --no-owner plus a catalogue query for triggers and column privileges, and diffs that against the committed expected dump; any difference fails the build, and updating the expected dump is a reviewed part of the migration PR. Two gates, two failure modes, neither one silently lying: the ORM gate catches a model that has drifted from state, the SQL gate catches a database object that no migration created — or that a migration created and nobody reviewed. Signed off as ADR 0017 before migration 001 is written; it restates the mechanism already binding in adr/0002-primary-relational-database.md §3.

Plan consequences: T-04 must land the db/schema-ignore.toml file and the second CI gate — the pg_dump/catalogue diff script and the committed expected dump — before migration 001, so the gate exists from the first table rather than being retrofitted over 60 tables. Budget ~1 developer-day for the script and the ignore list on top of the ADR itself; it is a clean, independently demonstrable Ahmed task with a Talha review, and it is the reason the earlier "managed = False where the ORM's view would drift" recommendation was dropped: that flag would have removed the invariant-bearing tables from both gates instead of one.

2. pgvector phasing. Part 1's deployment decision provisions the database with "pgvector and pg_trgm enabled"; Part 2 lists pgvector as "Phase 2 only". Harmless but worth stating: this plan enables pg_trgm, unaccent, btree_gist and pgcrypto in Phase 0 and defers the pgvector extension creation and any embedding table to Phase 2, so no Phase 1 code can quietly start depending on vectors. If the managed instance is provisioned with pgvector available-but-not-created, both statements hold.

3. Entity naming. Part 1 names the module requisition with Requisition / RequisitionVersion / Application / ApplicationScore / ApplicationStageHistory; Part 2 names the tables job / job_version / job_application / ats_result / job_application_stage_history. Same entities, two vocabularies. This plan uses Part 1 for module and service names and Part 2 for table names, and a glossary mapping the two should be added to the ADR set (T-08) before Phase 1 — otherwise the junior will reasonably assume they are different things, and API field names will end up mixed.

4. Duplicate-review phasing granularity. Part 1's module table says "1 detect / 2 merge UI", while Part 2 specifies the full merge, undo-log and reversal machinery without a phase. This plan reads the intent as: detection plus the flagged queue in Phase 1 (T-22), and the merge engine, undo log and reversal in Phase 2 (T-25a/b) — because reversal correctness is the highest-risk logic in the schema and should not be compressed into the phase that is already carrying parsing, authorization and the Graph integration.

5. Phase effort ranges — this plan's own task sizing contradicts the inherited ranges, and the plan follows the sizing. This is the largest divergence in the set and the only one where this document declines to inherit. _decisions.md's phase table gives Phase 0 at 23 weeks, Phase 1 at 1014, and a total of 3145 weeks, and states as honest delivery that "one month yields Phase 0 plus one vertical slice, not a platform". Summing this plan's 96 §10 tasks against its own §1 size key gives 376.5631.5 dev-days — 5675 weeks — with Phase 0 at 912 and Phase 1 at 2430. The two figures for Phases 2, 3 and 4 agree within their bands; Phases 0 and 1 do not, and Phase 1's bottom-up floor sits above the inherited ceiling.

Resolution, and why it goes this way: the roll-up wins. It is derived from enumerated tasks with owners, dependencies, sizes and review gates; the inherited range predates that enumeration and, as §2 sets out, its dev-day column was the calendar range multiplied by 8.5 rather than an independent estimate. Adjusting the 96 task sizes downward to fit the calendar would have produced a document that agreed with itself and lied about the work — the one failure mode this plan exists to prevent. So §2 publishes both columns, §3.6 and §4.6 restate the phase effort, §12 is re-derived week by week against the per-person budgets, §15 is rewritten to the corrected month-one boundary, R-08 is re-rated to H and joined by R-08b, R-24 and R-25, and §4.2.1 adds the cut list for the case where the business fixes the calendar rather than the scope.

What this means for _decisions.md: its phase table and its one-month statement are the two figures that need revising, and this is the input for that revision rather than a unilateral override — _decisions.md remains the binding document, and the divergence is recorded here per its own instruction. Nothing else in _decisions.md is affected: no scope moved between phases as a result of this correction, no architectural decision changed, and no hard constraint is touched. The only scope changes anywhere in this revision are the addition of T-17b and A-16b, which pull the REQ-JOB-07 requisition-approval gate into Phase 1 where 00 §2.4 already confirmed it belonged and where no task previously implemented it (08 GAP-01), and T-16b under divergence 6 below.

6. Module 5 (notifications) is phased 2 in _decisions.md; this plan puts a minimal send slice in Phase 1. _decisions.md module 5 gives notificationsNotification, NotificationPreference, OutboundMessage, digest send, delivery retry, bounce handling — a single phase number: 2. Taken literally that leaves Phase 1 owning inbound email and CV parsing with no way to send anything, and the package had drifted into four different readings of it (04 §9.1 row 5 at Phase 1, 07 §5.1 and 03 §5 / §33 at 2, 00 DEF-07 at 23, 00 OBD-21 at 3).

Resolution, and why it goes this way: the module number is right about the pipeline and wrong about the row. Phase 1 takes T-16b — one outbound_message written before the provider call, Mail.Send through the MailProvider port T-16 already builds, the idempotency guard, NDR classification, one seeded template (M, 46 dev-days per 04 §9.1 row 5). Phase 2 keeps everything else: template UI, retry, complaint handling, digests, preferences, the notification centre (T-29, A-31, A-36). The deciding argument is not feature value but correctness — an NDR arriving with no outbound_message row to attach to cannot be classified at all, so a Phase 2 module number silently makes a Phase 1 failure path unresolvable inside the product and outside the audit trail. The counter-argument, which is why nothing more moves, is that the pipeline's costly parts (sender domain, template approval, bounce policy, digest semantics) depend on other people's work items and buy Phase 1 nothing.

What this means for _decisions.md: module 5's phase cell needs to read "1 minimal send / 2 full pipeline", exactly as 03 §5 now does. No table, column or architectural decision changes — the same eight tables ship; only the phase, and therefore the migration number, moves. 03 §33 splits the area into 011a (Phase 1, seven tables) and 018 (Phase 2, notification_preference), and 03 §33.1 records why the Phase 1 half had to be renumbered below 017b rather than keep a split phase label on 018. Recorded as 08 §7 finding 10; OBD-21's superseded Phase 3 answer and DEF-07's 23 target are amended in 00.

No divergence was found on the hard constraints: single relational database, no multi-tenancy, no per-region databases, raw intake before candidate, candidate identity separate from applications, per-application scores, versioned jobs/requirements/scoring configs, duplicate detection with manual review and reversible merge, flexible historical recruiter assignment, current state plus history, explainable and versioned AI that never auto-rejects, and a chatbot that cannot bypass access controls.