136 KiB
07 — Implementation Plan
Status / Scope of this document
Binding delivery plan for the Utopia Brands internal HR Recruitment & ATS platform. It
converts the architecture and database decisions in _decisions.md into a phased,
owner-assigned, dependency-ordered schedule for exactly two developers — Talha Ahmed
(senior) and Ahmed Mujtaba (junior). It covers Phases 0–4, the task split, development
order, review gates, testing strategy, week-by-week milestones through Phase 0, Definition
of Done, and a risk register.
Scope boundaries, stated so nothing here is over-read:
- Requirements are taken from the assignment brief and
_decisions.md. There is no meeting transcript in this repository (findings §B) — nothing here derives from one. - The repository is a static browser-only prototype: no backend, no database, no auth, no
tests, no build step, no Docker, no env files (findings §A, §B). Every backend line item
below is greenfield. The retained assets are
css/styles.css,js/ui.jsandjs/charts.js(findings §G). - Two kinds of effort figure appear and they are not interchangeable. Task sizes (§10) are single-person effort in dev-days including review rework — see the §1 size key. Phase ranges (§2) are calendar weeks for the two-person team, derived from the task sizes and the per-person weekly budgets. Where a range is wide, that is the honest estimate, not padding.
- The §2 phase ranges are larger than the ones in
_decisions.md, by roughly 1.7–1.8x on the total. That is not a scope change — it is this plan's own bottom-up task roll-up contradicting the inherited calendar figure, resolved in favour of the roll-up. Both numbers are published side by side in §2 and the divergence is recorded as §17 divergence 5. If you read only one section of this document, read §2. - The schedule is anchored to an assumed kickoff of Monday 2026-08-03. Anchor date is an assumption; all dates shift as one block if kickoff moves.
- Terminology: this document uses the Part 1 module names (
requisition,application,scoring) for code boundaries and the Part 2 table names (job,job_version,job_application,ats_result) for schema objects. That divergence exists inside_decisions.mditself and is flagged in §17.
1. The delivery constraints that shape every number below
| Constraint | Consequence for the plan |
|---|---|
| Two developers, one of whom is junior | No parallel workstreams beyond two. Every plan line is either Talha's or Ahmed's; there is no "the team will" |
| One reviewer | Talha is the sole reviewer of Ahmed's work and has no reviewer for his own. Review is a scheduled capacity cost, not free |
| Greenfield backend | Phase 0 is not optional setup — it is the first time this system has a database, a migration, an auth check or a test |
66 named seats, ~20–25 peak concurrency (assumption, _decisions.md deployment section) |
No scale work in Phases 0–4. No Kubernetes, Kafka, microservices, Elasticsearch, second database or separate AI service |
| Attacker-supplied input is a Phase 1 input, not a later one | CV files and inbound mail are the two Phase 1 intake channels, so the XSS remediation (findings §E) and untrusted-file hardening are Phase 0/1 scheduled work, not backlog |
| Unresolved open questions gate Phase 3+ | Model hosting (OQ-1), historic outcome data (OQ-2), jurisdictions (OQ-4). Confidence is stated lower for those phases for that reason |
Capacity model (assumption, used for every effort figure)
| Person | Nominal | Absorbed by review / architecture / ops / stakeholder time | Productive dev-days per week |
|---|---|---|---|
| Talha | 5 | ~1.0 (PR review, ADRs, IT liaison, deploys) | 4.0 |
| Ahmed | 5 | ~0.5 (ramp, rework after review) | 4.5 |
| Combined | 10 | — | ~8.5 dev-days / week |
Size key used throughout: XS ≤ 0.5 d · S 1–2 d · M 3–5 d · L 6–10 d · XL > 10 d (an XL task must be split before it enters a sprint).
Three properties of that key matter, because §2 rolls the §10 task tables up against it:
- The sizes are single-person effort in dev-days, not elapsed calendar. A task sized L is 6–10 days of one developer's productive time. At 4.0 dev-days/week for Talha it therefore occupies 1.5–2.5 of his weeks. Nothing in the §10 tables is an elapsed-time figure; where a task's real constraint is waiting rather than working (T-07, the Entra ID request) the size covers only the work, and the waiting is handled as a dependency and a risk, never as size.
- 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.
- Where a task-level estimate elsewhere in this document is tighter than its band, the tighter number wins for planning and the band is retained for the roll-up. There is exactly one such case: §8 estimates A-01 → A-05 at ~4.5 dev-days in total from a step-by-step breakdown, while their bands sum to 6.5–11.5. §8's figure is derived from the enumerated 34 sites and is the better number; the §12 weekly derivation uses band midpoints for consistency, which makes that stretch of the schedule the most likely to come in early.
Roll-up convention: min = XS 0.5 / S 1 / M 3 / L 6; midpoint (used in §12) = XS 0.5 / S 1.5 / M 4 / L 8; max = XS 0.5 / S 2 / M 5 / L 10.
2. Phase overview
Two independent numbers exist for every phase and they disagree. The inherited range is
the calendar figure carried over from _decisions.md. The bottom-up roll-up is the sum of the
§10 task sizes against the §1 size key. Both are published below, because the correction is large
enough that hiding it would be the single most misleading thing this document could do.
Where the two came from, stated plainly: the inherited dev-day column in the previous version of this table was not an estimate at all — it was the inherited calendar range multiplied by the 8.5 dev-days/week capacity figure. The §10 task sizes were assigned afterwards, per task, and were never reconciled against it. In Phases 2–4 the two happen to agree within the bands. In Phases 0 and 1 they do not, and Phase 1's bottom-up minimum exceeds its inherited maximum by 20% — a bottom-up floor above a published ceiling is not a wide estimate, it is a wrong one.
The reconciliation resolves in favour of the roll-up. The roll-up is derived from 96 enumerated tasks with named owners, dependencies and review gates; the inherited range was derived before that enumeration existed. The inherited column is retained rather than deleted so the size of the correction stays visible.
| Phase | Objective in one line | Inherited (_decisions.md) |
§10 tasks | Roll-up dev-days | Roll-up ÷ 8.5 | Busier stream's floor | Published range | Confidence |
|---|---|---|---|---|---|---|---|---|
| 0 | Make the prototype safe and stand up a real engineering platform | 2–3 wk (17–25 dd) | 20 — 4L 9M 6S 1XS | 57.5–97.5 | 6.8–11.5 wk | Talha 34 dd ÷ 4.0 = 8.5 wk | 9–12 weeks | Medium-high |
| 1 | One real CV, end to end, on real infrastructure, with an explainable score | 10–14 wk (85–119 dd) | 36 — 16L 17M 3S | 150–251 | 17.6–29.5 wk | Talha 93 dd ÷ 4.0 = 23.3 wk | 24–30 weeks | Medium |
| 2 | Turn the slice into a working recruiter workflow | 7–10 wk (60–85 dd) | 17 — 8L 8M 1S | 73–122 | 8.6–14.4 wk | Ahmed 40 dd ÷ 4.5 = 8.9 wk | 9–14 weeks | Medium-low |
| 3 | Offers, assessments, reporting, and the fairness gate before ranking goes live | 7–10 wk (60–85 dd) | 13 — 5L 7M 1S | 52–87 | 6.1–10.2 wk | Talha 27 dd ÷ 4.0 = 6.8 wk | 7–10 weeks | Low |
| 4 | Outbound publishing, full assistant, remaining AI capabilities, admin surfaces | 5–8 wk (42–68 dd) | 11 — 5L 4M 2S | 44–74 | 5.2–8.7 wk | Talha 27 dd ÷ 4.0 = 6.8 wk | 7–9 weeks | Low |
Total roll-up: 376.5–631.5 dev-days. Published total: 56–75 weeks — the sum of the per-phase published ranges, roughly 13 to 17 months of two-person delivery for the full platform, against the 31–45 weeks previously stated.
Note that the published lower bound (56 weeks) is above the roll-up ÷ 8.5 figure (44.3 weeks), and the gap is not an error — it is the cost of the load imbalance. Dividing by combined capacity assumes both developers are always busy on that phase's work; dividing each stream by its own budget does not. The 12-week gap between 44 and 56 is the amount of Talha-bound sequential work that Ahmed cannot absorb, and §12.2 shows it concretely as four weeks of his slack inside Phase 0 alone. Anyone quoting 44 weeks is quoting a number that requires a third developer to be true.
That correction is the honest number. Any commitment materially shorter than it is a commitment to cut scope, and §4.2.1 now names the specific tasks that would be cut.
How each published bound is derived, so it can be checked and challenged:
| Bound | Basis | Why |
|---|---|---|
| Lower | The busier developer's band-minimum ÷ that person's own weekly budget (Talha 4.0, Ahmed 4.5) | A phase cannot finish faster than its busiest stream. The combined-capacity figure (roll-up ÷ 8.5) silently assumes perfect load balance and is optimistic wherever the streams are unequal — which is every phase except 2 |
| Upper | Roll-up band-maximum ÷ 8.5 combined | Deliberately the combined figure. It is an envelope, not a p90 (§1), and it too assumes perfect balance |
The load imbalance is the finding behind the finding. Talha holds 93 of Phase 1's 150 floor dev-days — 62% of the work at the lower of the two weekly budgets. Applying the same stream-level arithmetic to the upper band gives Talha 57 dd (14 wk) in Phase 0 and 155 dd (39 wk) in Phase 1, both above the published ceilings. The published ceilings are therefore only reachable if load is actively rebalanced. Two levers exist and both are used in this plan rather than left as advice:
- Rebalance.
procrastinatequeue 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. - 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 (Proposed → Accepted) |
Resolves the ORM-vs-plain-SQL conflict in §17 before any table exists, with one ruling instead of six recommendations. Merge blocker on migration 001 |
| 5 | identity: app_user, role, permission, scoped role_assignment, sessions, and the single iam.can(actor, action, resource) decision point with DRF permission classes |
Today the RBAC matrix gates nothing (js/rbac.js:78, js/rbac.js:111-112); retrofitting authz across 25 modules is far more expensive later |
| 6 | audit: partitioned audit.audit_event, generic change trigger, SET LOCAL actor bridge, hash chain, nightly verifier |
History and audit are cheap now, unrecoverable later — you cannot backfill what was never captured |
| 7 | config: reference-data seed migrations plus Django admin registration for the 7 controlled vocabularies |
Removes the entire Settings UI from Phase 1 scope |
| 8 | pii_classification table plus a CI completeness check |
Three later jobs read it (purge, subject access, non-prod anonymisation) |
| 9 | React shell scaffold: Vite + TS + React, git mv css/styles.css verbatim, <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,configand 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_partmanis Phase 2), no pgvector.
3.3 Dependencies
- External: corporate IT for the Entra ID app registration,
Mail.Readadmin 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
docker compose upon a clean machine yields a running API, worker, Postgres, Redis and object store, with migrations applied, in one command.- CI is green on
mainand red for: a new unescaped${inside an HTML template literal, adangerouslySetInnerHTML, a cross-module import that bypassesservice.py, a model change without a migration, and a CSS rule using a raw colour instead of avar(--…)token. iam.can()is the only authorization implementation; a test asserts that a deny-by-default endpoint with no permission declared returns 403, not 200.- Every write to a classified table produces an
audit.audit_eventrow with a correct actor, and the nightly hash-chain verifier passes. - The prototype: all 34
innerHTMLinterpolation sites escaped, zero inline event handlers remain, CSP active withoutunsafe-inlinefor scripts, and a stored XSS payload in a candidate name renders as literal text in both themes. - A merge to
mainauto-deploys to staging without manual steps.
3.6 Effort and confidence
Effort: 9–12 weeks (57.5–97.5 dev-days). This replaces the 2–3 weeks (17–25 dev-days)
inherited from _decisions.md. The correction is 3.4–3.9x and it is not a re-scoping — the
§3.1 item list is unchanged. The 20 tasks in §10.1 sum to 57.5 dev-days at their band minima;
Talha's own stream is 34 of those days at 4.0 dev-days/week, which puts the floor at 8.5 weeks
before any risk materialises. See §2 for the full derivation and §17 divergence 5 for the
consequence for _decisions.md's one-month statement.
Why the inherited figure was wrong is worth naming, because the same mistake is easy to repeat:
17–25 dev-days buys roughly two of Phase 0's L tasks. Phase 0 contains four (identity with
scoped role assignments and iam.can(); partitioned audit with a hash chain and verifier; a
staging-plus-production deploy path with a secret store; and porting ten design-system primitives
to typed React), plus nine M tasks, on a repository that today has no backend, no migration, no
test and no pipeline (findings §B). The 2–3 week figure was a calendar wish that predated the task
enumeration.
Confidence: Medium-high (was High). Scope discovery risk really is almost absent: the work is enumerable (34 known sites, 5 modules with no business logic, one CI pipeline, one compose file), depends on no external system, and is work Talha has done before. Confidence drops one notch only because the published range is itself a correction that has not yet been validated by delivery, and because the floor is set by one person's stream — so it is sensitive to a single absence in a way the combined-capacity figure hid (R-25). The residual technical uncertainty is unchanged: subscription approval, the size of the CI yak shave, and how fast Ahmed absorbs the migration and delegated-event patterns — which is why his Phase 0 list is deliberately front-loaded with the escaping pass (mechanical, high-volume, easy to review) before anything schema-shaped.
3.7 Demonstration milestone — D0
Timing: end of week 12 on the no-slip chain, not week 3. §12.2 derives it week by week. The content below is unchanged — only the date moved, and it moved because the effort figure was corrected, not because anything was added to the demo.
Note that D0 is the end of a chain of earlier demonstrable moments rather than the first one: the hardened prototype is showable in W2, the CI gate set in W4, authorization in W7, the audit trail in W9 and staging CD in W11 (§12). Stakeholders should see those five, in those weeks, precisely so that a 12-week Phase 0 does not read as twelve weeks of silence.
Live, on staging, in one sitting: log in with a scoped role; open the Candidates screen of
the hardened prototype with a seeded record whose name field is
<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 1–8 of the phase)
files; document_parsing (PDF, DOCX, OCR fallback, per-field confidence, ParseIssue);
raw_intake / intake_channel / raw_intake_attachment / intake_parse_attempt /
intake_resolution with all five invariant layers; candidate plus child tables
(candidate_email, candidate_phone, candidate_skill, candidate_employment,
candidate_education, candidate_link, candidate_document, candidate_consent,
candidate_tag, candidate_note); candidate_search_index with trigger maintenance;
manual upload and careers-portal channels; the intake triage UI and the parse-review panel.
1B — Requisition and application (weeks 7–15)
job / job_version / job_requirement with INSERT+SELECT-only grants, immutability
triggers and the deferrable weight-sum trigger; job_posting; job_application with
generated state, the reapplication partial unique index, attempt_no, stage and status
history via triggers, and transition() refusing terminal-negative transitions from a
non-human actor; assignment interval tables with EXCLUDE constraints and the partial
unique index on the current primary recruiter; approval_request + approval_decision in
their single-approver form, with publish_version() refusing a version that has no approval
decision from a user holding job.approve (REQ-JOB-07, 00 §2.4); requisition screens with
the weighted requirements editor and visible approval state.
The approval gate is in Phase 1 and not later because REQ-JOB-07 is a confirmed Phase 1
requirement, and because Phase 1 is the phase in which job_version publishing first exists.
Shipping publishing without its gate would mean a recruiter can unilaterally publish a
requisition version — which is precisely the control that OBD-12 and the job-board cost bands
(OBD-13) assume is already there. Only the multi-step chain (approval_route,
approval_route_step) is deferred; see §4.2 and 08 GAP-01.
1C — Scoring, authorization and mail (weeks 12–24)
ai_orchestration framework (AiRun ledger, prompt/model version pinning,
invoke(capability, context, actor) checked against the human actor); scoring
(scoring_config / scoring_config_version / scoring_config_criterion /
job_scoring_assignment / ats_result / ats_result_criterion with stored
weight_applied and contribution, input_fingerprint, explain(), batch rescore);
duplicate detection with the six signals and the canonical-pair constraint; the Outlook
Graph delta adapter plus the minimal outbound-mail slice — one outbound_message row per
send, Mail.Send through the same MailProvider port, the outbound:{public_id} +
sent_at IS NULL idempotency guard, NDR classification against the thread token, and one
seeded transactional template (T-16b); authorization enforced on every endpoint; the score explanation panel
and AI provenance badges; the API layer conventions, OpenAPI schema and generated TS
client; the five Playwright journeys.
React screens in Phase 1, in migration order: shell + login (Phase 0), then the untrusted-data screens — Intake triage (Inbox), Manual CV upload, Candidate list, Candidate profile — then Requisitions and a minimal application list with a transition drawer.
4.2 EXCLUDED — the Phase 1 cut line
This is the most important cut line in the document, because Phase 1 is where scope creep is fatal.
| Excluded from Phase 1 | Phase | Why it is safe to defer |
|---|---|---|
| Drag-and-drop pipeline board | 2 | A stage transition is demonstrable from a list plus a drawer. The board is UX polish over an already-working transition service |
| Duplicate merge and unmerge | 2 | Detection plus a flagged queue delivers the value; merge is the highest-risk logic in the schema and deserves a dedicated phase |
| Interviews, scorecards, calendar sync | 2 | Nothing in the intake→score thread needs them |
| Assessments, offers | 3 | — |
Multi-step approval chains (approval_route, approval_route_step, most-specific-match route selection, per-step SLA and delegation) |
2 | The single-approver case is in Phase 1 as T-17b — REQ-JOB-07 is confirmed at Phase 1 (00 §2.4) and is one approval_request row plus one approval_decision. What is deferred is the route table, the ordered steps and the grade/threshold branching from OBD-12. Deferring the whole engine, as the previous version of this line did, left Phase 1 publishing ungated |
notifications delivery pipeline — template authoring UI, retry with backoff, complaint handling, digests, per-user preferences, in-app notification centre |
2 | The minimal send slice is in Phase 1 as T-16b, because Phase 1 owns inbound email and an NDR with no outbound_message row to attach to cannot be classified at all — deferring the row defers a Phase 1 correctness property (04 §9.1 row 5; 08 §7 finding 10). What is safe to defer is everything around that one Mail.Send: the pipeline's costly parts are sender-domain configuration, template approval and bounce policy, none of which Phase 1 needs. Internal notifications remain in-app rows only in Phase 1 (OBD-21, revised from Phase 3 to Phase 2) |
| Analytics dashboards and the report library | 2 / 3 | Phase 1 has too little real data for a dashboard to be honest |
| The chatbot (any form) | 2 | It must call an authorization-checked service layer that does not exist until 1C completes |
fairness_evaluation |
3 | Hard gate: ranking is not released to recruiters as a ranking until the fairness evaluation passes. Phase 1 shows a score with components on a single application, not a ranked shortlist |
| Job-board inbound (4 boards), outbound publishing (8 platforms) | 3 / 4 | Outlook plus careers portal plus manual upload are the channels with real Phase 1 volume |
| Talent pool, saved segments | 3 | — |
| Settings UI, Help | 4 | Django admin covers the vocabularies |
| pgvector / semantic matching | 2+ | Postgres FTS plus trigram is sufficient at this volume |
worker-untrusted queue split, RLS |
2 | Phase 1 mitigations are timeouts, memory caps, a restricted OS user and no egress from the parse step — weaker, and explicitly logged as a risk |
| Auto-creation of candidates without human review | never in Phase 1 | Every create_candidate resolution in Phase 1 is decision_mode = 'human' |
4.2.1 The second cut line — what moves out if the calendar is fixed instead of the scope
The table above is the cut line for scope creep. This one is the cut line for a fixed calendar, and it exists because §2's reconciliation leaves Phase 1 at 24–30 weeks against an inherited target of 10–14. If the business fixes the calendar rather than the scope, these are the tasks that move — named, sized, and in this order, so the conversation is about a list rather than about optimism. The order is the existing de-scope order from §4.4 and R-08, extended past the point where it ran out.
| # | Task | Size cut | Moves to | What is actually lost |
|---|---|---|---|---|
| 1 | T-16 Outlook Graph + careers-portal adapters, and T-16b with it — the minimal outbound slice sends through the same port and cannot survive its removal | L + M (9–15) | 2 | Both automated channels. Manual upload carries the whole slice. Already the top of the de-scope order because it is the item most likely to be externally blocked anyway (R-02). Losing T-16b restores the hole 04 §9.1 row 5 identified: a recruiter answers every parse failure from Outlook by hand, outside the audit trail. That is tolerable only because cutting T-16 already means Phase 1 has no mailbox to answer from |
| 2 | T-22 Duplicate detection | L (6–10) | 2 | Duplicates are found by recruiters, not by the system, until Phase 2. The global unique index on candidate_email.address_normalised still prevents silent identity collisions, so nothing becomes incorrect — only manual |
| 3 | T-13b OCR fallback | M (3–5) | 2 | Scanned PDFs land in needs_review instead of parsing. Defensible: they were never going to parse well (R-03), and the hardening half of T-13b (timeout, memory cap, restricted user, no egress) is not cuttable and stays in Phase 1 |
| 4 | A-14 Candidate profile — timeline, tags, notes | L → M (−3 to −5) | 2 | Identity, applications and documents ship; the social layer waits |
| 5 | A-21 Service-level API test suite — narrow to the intake, candidate and application facades | L → M (−3 to −5) | 2 | Coverage of the facades that carry the invariants is kept; the rest waits |
| 6 | A-16 Requisition screens — drop the version diff view | L → M (−3 to −5) | 2 | Versions still mint correctly and old scores still pin; the reader compares two versions by opening both |
| 7 | A-24 Playwright journeys — 5 down to 2 (ingest→parse, promote→score) | M → S (−2 to −3) | 2 | The two journeys that assert the raw-intake-before-candidate invariant are kept |
| 8 | A-20 Error/loading/empty-state system — one shared pattern instead of a system | M → S (−2 to −3) | 2 | Screens still have the four states (DoD 15); they are less consistent |
| 9 | A-19 AI provenance badges | S (1–2) | 2 | The score panel still shows pinned versions as text (A-18); the badge component waits |
Cumulative cut: 32–53 dev-days, bringing Phase 1 to 118–198 dev-days — the same endpoint as before T-16b was added, because T-16b enters Phase 1 and leaves again with cut 1. That puts the floor at 118 — just inside the inherited 119 — and Talha's own stream at 75 dd ÷ 4.0 = 18.8 weeks. So the honest statement even after the full cut is: a 14-week Phase 1 is not reachable; a 19-week one is, in the optimistic half of the band. The gap between 14 and 19 is not recoverable by cutting further, because everything left is either an invariant, the authorization layer, or the scoring engine.
Three §4.5 acceptance criteria move with these cuts and must be restated rather than quietly dropped: criterion 1 (a CV arriving by email) becomes a Phase 2 criterion under cut 1, with the Phase 1 form being "a CV arriving by manual upload or the careers form"; criterion 14 (a parse failure resolvable in-product) moves to Phase 2 under the same cut, because it sends through the port cut 1 removes; criterion 12 narrows from five Playwright journeys to two under cut 7. Every other criterion, including criterion 13, survives the full cut set intact — which is the test of whether a cut list is honest.
Never cut, at any calendar pressure: the five intake invariant layers; candidate/application
separation; per-application ats_result with pinned versions; job and scoring-config versioning;
the T-17b approval gate; iam.can() on every endpoint; the A-26 forbidden-write suite; the
no-AI-terminal-negative guard. Each of these is either a non-negotiable constraint or is
unrecoverably expensive to retrofit — cutting them does not buy time, it converts schedule debt
into permanent data-model debt.
4.3 Dependencies
| Dependency | Blocks | Mitigation if late |
|---|---|---|
Entra ID app registration + Mail.Read and Mail.Send admin consent + recruiting mailbox |
The Outlook channel (T-16) and the minimal outbound slice (T-16b) | The slice is demonstrable end-to-end on manual upload and the careers form. Mail is the last channel wired, deliberately, so it cannot block the thread. Request both scopes in the same T-07 conversation in Phase 0 week 1 — asking for Mail.Send separately, later, means a second admin-consent cycle at the exact point Phase 1 needs it, and 04 §9.3 already rates that dependency as weeks not days. T-16b is testable against FakeMailProvider while consent is pending |
| A real CV corpus (30–50 anonymised or synthetic-but-realistic CVs including scanned PDFs) | Parser accuracy work, the malformed-CV test corpus | Talha assembles a synthetic corpus in week 1 of the phase; the business supplies real ones when available |
| A decision on the model provider (OQ-1) | ai_orchestration beyond the ledger, AI-assisted field extraction |
Scoring v1 is deterministic and rule-based over parsed fields plus job_requirement weights. It does not need a model. This is why scoring is not blocked by OQ-1 |
| Phase 0 complete | Everything | — |
A published job_version with weighted requirements summing to 1.0 |
Any score at all | Talha seeds one by migration in 1B so scoring work can start before the requisition UI is finished |
4.4 Risks specific to Phase 1
| Risk | Handling |
|---|---|
| Parsing accuracy falls far short of "no recruiter re-keying" | Design mitigation already chosen: low-confidence fields are left empty, never guessed, and the parse-review panel makes the review step explicit and fast. The business must accept a review step; OQ-6 (behaviour on parse failure) must be answered in this phase |
| Untrusted files parsed inside our own worker (RCE, zip bombs, resource exhaustion) | Per-document CPU and wall-clock timeout, memory cap, restricted OS user, no outbound network from the parse step, virus scan before parse. Full isolation is the Phase 2 worker-untrusted queue. This is the split trigger most likely to fire early |
The global unique index on candidate_email.address_normalised backs up the review queue |
Real violations exist: shared family addresses, agency mailboxes, info@ referral forms. Decide the non-identifying-address exclusion list inside Phase 1, before go-live |
| Authorization is brand new and there is no database-level backstop until RLS in Phase 2 | One centralised module, no repository access from views, and A-22's endpoint × role matrix test asserting every candidate-reading endpoint routes through iam.can() |
| Ahmed blocked waiting on Talha's schema and services | Ahmed's Phase 1 stream is deliberately sequenced so that at any moment he has one UI task against a shipped API and one test/validation task that needs no new API |
| Review throughput becomes the bottleneck | PR size cap of ~400 changed lines, two fixed review windows per day, Ahmed keeps two branches in flight |
| The inherited 14-week upper bound is exceeded | Already exceeded on paper, before the phase starts — the §10 task roll-up floors Phase 1 at 150 dev-days against 119 (§2). Treated as a certainty rather than a risk: the published range is 24–30 weeks, and the §4.2.1 cut list is the instrument if the calendar is fixed instead. The forward-looking risk is now "Phase 1 exceeds 30 weeks" — see R-08 |
| The published 24–30 week range is itself exceeded | Re-forecast at the end of sub-slice 1A against real parsing throughput, when T-13a is the first task with actual measured effort behind it. Apply §4.2.1 in order, top down, and stop at the first cut that closes the gap. Scoring, the invariants, the T-17b approval gate and authorization are never cut |
| Talha's stream is the binding constraint and has no second reviewer | Talha holds 93 of the 150 floor dev-days (62%) at the lower weekly budget (§2). No Phase 1 item is transferable — all of it is schema, constraints, parsing, authorization or integration — so the mitigation is not rebalancing but reducing his queue via §4.2.1 cuts 1–3, all of which are his. R-25 tracks this |
4.5 Acceptance criteria
- A real PDF CV arrives by email, appears as a
raw_intakerow within the polling interval, and is idempotent under redelivery (sameexternal_message_idor samepayload_sha256produces no second row). - A CV that cannot be parsed reaches a terminal state (
rejected_unusableorquarantined) with nocandidateand nojob_applicationrow, and is visible and retryable in the triage UI. Nothing is silently dropped. - Attempting to insert a
candidatewithout araw_intakerow 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. - Two applications from the same person to two different jobs produce one candidate
and two applications with two independent
ats_resultrows. - 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. - Editing a requirement mints a new
job_version; the previously computed score is unchanged and still displays its original pinned criteria and contributions. UPDATEagainstjob_version,job_requirement,scoring_config_versionandats_result(outside the granted column list) raises an exception. Asserted by tests.- The score panel shows, per criterion: requirement, matched evidence,
weight_applied,contribution, and the pinnedjob_version/scoring_config_version/algorithm_code_version/ parse attempt. - No code path can move an application to a terminal-negative status without a human
actor.
review_outcomecannot be set withoutreviewed_by_user_id. Asserted by tests. - Every endpoint returns 403 for a role without the permission, and the permission matrix test covers every endpoint × every role.
- A recruiter moves an application through two stages;
job_application_stage_historyshows the actor, reason and non-overlapping intervals. - Playwright journeys 1–5 pass in CI against a real Postgres.
- A recruiter cannot publish a
job_version.publish_version()raises unless anapproval_decisionrow exists against that version'sapproval_requestwithdecision = 'approved'and anapprover_user_idholding thejob.approvepermission 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 orpsqlpath. The requisition screen shows the version's approval state (draft→pending_approval→approved/rejected→published) and the approver, and the approve/reject action is invisible to a role without the permission (REQ-JOB-07,00§2.4; closes08GAP-01). - 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_messagerow before the provider call; replaying the job findsprovider_message_idset and is a no-op (no second email); and a synthetic NDR for that message is matched byX-Utopia-Threadand sets the bounce state on the same row. An NDR that matches nothing lands inneeds_reviewrather than being discarded. Asserted againstFakeMailProvider, so it runs in CI without a mailbox (T-16b;04§4.3, §9.1 row 5; closes08§7 finding 10).
4.6 Effort and confidence
Effort: 24–30 weeks (150–251 dev-days). This replaces the 10–14 weeks (85–119 dev-days)
inherited from _decisions.md. The 36 tasks in §10.2 sum to 150 dev-days at their band minima —
above the inherited maximum of 119 — and Talha's stream alone is 93 of those days at 4.0
dev-days/week, which sets the 23.3-week floor. §2 derives this; §4.2.1 is the cut list if the
calendar is fixed instead of the scope. Seven of the 150 floor days are tasks this revision
added, each closing a hole that had no implementing task in any phase: T-17b (M) and A-16b (S) —
the REQ-JOB-07 approval gate — and T-16b (M), the minimal outbound-mail slice that makes a Phase 1
parse failure resolvable in-product (04 §9.1 row 5; 08 §7 finding 10). All three are the kind
of item that is invisible in a phase plan until someone tries to run the phase.
Confidence: Medium. Medium applies to the published 24–30 week range, not to the inherited one, which is now known to be unachievable rather than uncertain. The reasoning, item by item: the schema is already decided in detail so there is little design discovery left, and the invariants are expressible in Postgres features Talha has chosen deliberately — that part is Medium-High. Three things pull the whole phase down to Medium. (1) Real CV parsing has irreducible discovery risk: you cannot estimate the long tail of scanned, two-column, table-based and non-English CVs until you have run a real corpus through it, and the estimate for T-13 could be wrong by a factor of two in either direction. (2) The Outlook dependency is outside the team's control and historically these consents take weeks, not days. (3) This is the first authorization layer this system has ever had, being built at the same time as the domain it protects, by the same person, with no second reviewer. Against that, the phase is structured so the demonstrable thread does not depend on the two riskiest items — manual upload substitutes for mail, and rule-based scoring substitutes for a model — which is what keeps the confidence at Medium rather than Low.
4.7 Demonstration milestone — D1
On staging, with real data, in this order: send a real CV to the recruiting mailbox from a
phone; watch it land in the triage queue; show the parse result with per-field confidence
and one field deliberately left empty because confidence was low; send a second,
deliberately broken file and show it terminate in rejected_unusable with no candidate
created; promote the good one to a candidate and create an application against a published
requisition version; attempt to publish a requisition version as the recruiter who drafted it and
show it refused, then log in as the hiring manager, approve it, and show it publish — with the
approval_decision row and its approver on screen; show the score with its per-criterion
contributions and pinned versions; edit a requirement, publish a new version, and show the old score unchanged
beside the new one; move the application two stages and show the history with actor and
reason; log in as an interviewer and show the same candidate is not visible; show the audit
trail for all of it.
5. Phase 2 — Recruiter workflow
Objective. Turn a working thread into a working job: a recruiter can run a requisition end to end without leaving the product, duplicates can be resolved reversibly, and the first honest numbers appear.
5.1 Included
Drag-and-drop pipeline board over the existing transition service, with allowed
transitions driven by pipeline.is_allowed(); interview scheduling with UTC instant plus
wall-clock intent plus IANA zone, EXCLUDE double-booking on participants, scorecards that
lock on submit; duplicate merge with reversible unmerge (candidate_merge,
candidate_merge_operation undo log, descending-seq replay, stack discipline, retention
interlock); the full notifications delivery pipeline — template authoring and versioning
UI, retry with backoff, complaint handling, digests, per-user preferences and the in-app
notification centre — built on the minimal send path Phase 1 already shipped as T-16b, so
the provider, the MailProvider port and outbound_message exist and only the pipeline around
them is new (08 §7 finding 10; internal notification email arrives here, OBD-21);
worklist with rule-driven tasks; analytics read-model views plus seven of REQ-ANL-01's eight
dashboard KPIs on the retained charts.js — Open Jobs, Closed Jobs, Total Candidates, Interviews
Today, Offers Accepted, Time to Hire, Time to Fill (Cost per Hire is excluded, see §5.2);
read-only assistant over whitelisted typed query intents with
on_behalf_of_user_id audit; worker-untrusted queue split; pg_partman (or a scheduled
SQL function) for audit partitions; pgvector enabled for Phase 2 hybrid retrieval.
5.2 EXCLUDED — the Phase 2 cut line
- No assistant write actions and no tool-calling beyond read intents.
- No offers, no assessments, no approval chains.
- No fairness evaluation, and therefore no ranked shortlist surfaced to recruiters — scores remain per-application, not a league table.
- No report library or saved reports; dashboard KPIs only.
- No Cost per Hire KPI tile. Seven of REQ-ANL-01's eight KPIs ship; the eighth does not,
because no entity in the design holds recruiting cost. Its only possible source,
app.job_posting_metric.spend_amount, is Phase 4, and the prototype's figure is the literal constantcostPerHire: 4280atjs/data.js:247. Reinstated in Phase 4 alongsidejob_posting_metric, scoped honestly as cost per hire from measurable channel spend — agency fees, referral bonuses and recruiter time are out of scope unless ahiring_costtable is confirmed. Ruled in08GAP-02; reversible by Finance via_open-items.mdOPEN-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
- Merge two candidates who share an email address; both applications survive, nothing is
deleted, the loser's
public_idstill resolves via 301, and the funnel report count is unchanged (no double counting). - 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.
- An out-of-order unmerge is refused with an error naming the blocking merge.
- Two interviews cannot be scheduled for the same interviewer in overlapping slots — the database refuses it, not the UI.
- A reschedule across a DST boundary preserves the organiser's wall-clock intent.
- 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.
- Dashboard KPIs match a hand-written SQL query over the same window.
5.6 Effort, confidence, demonstration
Effort: 7–10 weeks (60–85 dev-days). Confidence: Medium-low. Two reasons, both specific: merge/unmerge is genuinely hard concurrent state manipulation whose failure mode is silent and delayed, so its estimate has a long tail; and the assistant's scope is the easiest thing in the whole plan for stakeholders to inflate, because all 15 AI capabilities are already visible in the prototype UI as an interface preview.
Demonstration — D2: run one requisition front to back in a single session — board, interview scheduled across two timezones, scorecard submitted and locked, a duplicate found, merged, then unmerged in front of the audience, and the dashboard KPI moving as a consequence.
One thing to say out loud in D2, not to leave for someone to notice: the dashboard shows
seven KPIs, not eight. Cost per Hire is deliberately absent because no entity holds recruiting
cost until Phase 4 — the prototype's $4,280 was a hardcoded constant, and a tile computed from a
constant is worse than no tile. It returns in Phase 4 with job_posting_metric, covering
measurable channel spend. If Finance wants it at Phase 2, that is a small cost-entry table and a
recruiter screen, and it is their call to make (_open-items.md OPEN-02) — but it should be made
before the tile is promised, not after.
6. Phase 3 — Offers, assessments, reporting, and the fairness gate
Objective. Close the hiring loop, and pass the gate that lets ranking be used as ranking.
6.1 Included
assessment (templates, assignment, results); offer with immutable offer_version rows,
approval chain, money as amount+currency pairs with the minor-unit rounding trigger, and
issue() requiring explicit human confirmation; the report library and saved reports;
fairness_evaluation as an activation gate — no scoring_config_version becomes active
without a passing EvaluationRun reference; talent_pool as tags plus saved segments
with rematch; job-board inbound adapters (4 boards); the retention purge as
pseudonymisation with retention_hold, plus the subject-access export; custom
per-requisition pipeline configuration.
6.2 EXCLUDED — the Phase 3 cut line
- No outbound publishing to external platforms.
- No assistant write actions or full tool-use.
- No Settings or Help UI (Django admin remains the vocabulary surface).
- No special-category data (diversity, health, accommodation). Its exclusion is a decision, not an omission — it needs separate access control, aggregate-only reads and a distinct lawful basis.
- No self-hosted model inference.
6.3 Dependencies and the honest gating problem
fairness_evaluation depends on OQ-2: historic hiring outcome data, which is
unconfirmed. If it does not exist, the evaluation can only be run against face-plausibility
checks and synthetic distributions, which means the gate may block the ranking release
with no engineering fix available. That is not a schedule risk; it is a scope risk that
has to be escalated to the business in Phase 1, not discovered in Phase 3. Offers also
depend on OQ-4 (jurisdictions) for letter templates and retention periods.
6.4 Risks and acceptance criteria
| Risk | Handling |
|---|---|
| Fairness gate cannot be satisfied without outcome data (OQ-2) | Raise in Phase 1. Fallback: release scores as per-application decision support with ranking explicitly disabled in the UI, and say so to stakeholders |
| Retention pseudonymisation permanently blocks merge reversal | Exclude candidates in an unreversed merge from purge for a defined window, or require explicit acknowledgement that reversibility is being surrendered |
| Offer approval chains grow into a workflow engine | No generic engine. Offer transitions live in offer.service until three modules demonstrably need the same machinery |
| Six jurisdictions of offer letters and retention rules | Templates are config reference data per jurisdiction; retention is per-record, never per-region storage |
Acceptance: an offer cannot be issued by a background job; an amount in JPY with decimal
places is refused by the rounding trigger; a salary range with mismatched currencies is
refused; activating a scoring config without a passing evaluation is refused; a retention
purge pseudonymises a candidate while leaving ats_result scores and stage history intact;
a subject-access export returns everything the pii_classification registry says it should.
Effort: 7–10 weeks (60–85 dev-days). Confidence: Low — not because the engineering is hard but because two of the four workstreams (fairness, retention/jurisdiction) are gated by unanswered business and legal questions, and an estimate for work whose acceptance criteria are not yet decidable is not a real estimate.
Demonstration — D3: an offer drafted, approved through a two-step chain, issued by a human with confirmation, and the letter produced; a fairness evaluation run shown to a non-engineering audience with a pass/fail verdict; a retention purge run against a test candidate showing the pseudonymised record with intact history.
7. Phase 4 — Outbound, full assistant, and admin surfaces
Objective. Everything that only makes sense once the internal product works.
Included. integrations_outbound publishing job_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: 5–8 weeks (42–68 dev-days). Confidence: Low — dominated by third-party API variance and procurement timing, neither of which the team controls.
Demonstration — D4: publish a requisition to two external platforms and show the
applications arriving back through raw_intake; ask the assistant to perform a write
action and show the confirmation, the audit entry, and the refusal when the asking user
lacks the permission.
8. The P0 XSS remediation — scheduled work, in order
This is scheduled Phase 0 work with a named owner, a review gate and acceptance criteria,
because real CV data cannot be rendered safely until it is done (findings §E). Today
nothing is exploitable — the data is generated locally by a seeded PRNG
(js/data.js:8-10) — but the two Phase 1 intake sources are attacker-supplied by design,
and the storage decisions deliberately preserve unsanitised originals
(full_name_original, candidate_email.address_original, raw_intake.payload,
intake_parse_attempt.parsed), which guarantees attacker-controlled strings reach the
rendering layer. Sanitising on write is the wrong fix and would violate the
preserve-the-original rule; escaping on output is the fix.
The order is load-bearing — doing it in any other order breaks the prototype:
| Step | Work | Why this position |
|---|---|---|
| 1 | Add UI.esc() to js/ui.js and apply it at every interpolation of a data-derived value across the 34 innerHTML sites in 14 files. Start with the untrusted-data screens: js/candidates.js (e.g. ${c.name}, ${c.currentTitle}, ${c.location} at js/candidates.js:68), js/inbox.js, js/import.js |
Highest-risk sinks first, so partial completion still reduces exposure |
| 2 | Replace inline handlers such as onclick="Candidates.openProfile('${c.id}')" (js/candidates.js:121) with delegated listeners reading data-* attributes |
Must precede step 3. A CSP without unsafe-inline blocks inline handlers, so enabling the policy first would break every interactive control |
| 3 | Add the Content-Security-Policy header (and a meta fallback for the static devserver.py path) with no unsafe-inline for scripts. Verify no inline <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 6–12 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: 2–3 dev-days for steps 1–3, plus ~1.5 days for steps 4–5. 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:
- Authorization before the domain it protects.
identityandiam.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). - 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.
- 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.
- 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 apublish_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 ofidentity(2) because the gate is a permission check, and upstream of scoring (16) because the only legitimate way to obtain a publishedjob_versionto score against is through it. The one exception is the migration-seeded version in §4.3, which is created by migration rather than bypublish_version()and is flagged as such in itschange_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, Proposed → Accepted) — 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 4–6 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 screen — draft → pending_approval → approved / rejected → published, 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:
UPDATEonjob_version,job_requirement,scoring_config_version,audit_event,ats_resultoutside the granted columns; acandidateinsert with noraw_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_intakerow, by bothexternal_message_idandpayload_sha256. - History-completeness tests. Every state transition produces exactly one history row
with non-overlapping intervals, and a transition performed without the
SET LOCALactor bridge producesactor_unknown = truerather 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 acandidate_merge_operationfor 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 | failed → needs_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) — 7–12+ 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 3–7 | 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 10–14 | 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 1–3 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 17–21 | 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 24–28 | 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 W9–W12 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 1–3, 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
- Merged to
mainvia a PR reviewed by Talha under its named review gate (§10). No direct pushes tomain. - PR is ≤ ~400 changed lines, or was split. Oversized PRs are returned unreviewed.
- CI green on every job: ruff, mypy, import-linter contracts, pytest on real Postgres,
migration-drift gate, ESLint (
react/no-dangererror), stylelint token rule,tsc --noEmit, unescaped-interpolation grep gate. - Deployed to staging automatically on merge, and exercised there — not only locally.
- No new
TODOreferencing a missing invariant, permission or test.
Backend / schema tasks additionally
- Every invariant is expressed in the database (constraint, trigger, index or grant), not only in application code.
- A forbidden-write test exists and passes for each new constraint.
- Migration applies forward from empty and from a staging-shaped clone. No down-migration is written; recovery is forward-fix plus PITR.
- Cross-module access goes through
service.pyonly;import-lintercontract updated if a legitimate new edge was added. - Every state change produces an
audit_eventwith a correct actor, and history rows with non-overlapping intervals.
API tasks additionally
- OpenAPI schema regenerated, TS client regenerated, error envelope and pagination conventions followed, Zod schema and DRF serializer agree on every field and message.
- A row exists in the permission matrix test for every new endpoint.
Frontend tasks additionally
- No
dangerouslySetInnerHTML. All user- or candidate-derived text renders through JSX escaping. - CSS uses only existing
var(--…)tokens from the frozenstyles.css; class names match the design system. - Loading, empty, error and permission-denied states all exist and were demonstrated.
- Keyboard reachable, focus visible, contrast AA in both themes, 44px touch targets, usable at 320px — matching the prototype's verified baseline (findings §G).
- If it replaces a prototype screen, the prototype counterpart is deleted in the same PR.
AI-touching tasks additionally
- Every invocation writes an
AiRunrow before its output is usable; model, prompt and config versions are pinned on any stored result. - No path exists by which an AI actor produces a terminal-negative outcome; a test asserts the refusal.
- 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 | 0–1 |
| 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 | 1–2 |
| 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 24–30 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 1–3 (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 2–3 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 17–25, a 3.4–3.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 9–12 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 W7–W12 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 | 1–3 |
| 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 6–12 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 | 1–4 |
| 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 | 1–2 |
| 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 | 1–4 |
| 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 2–3 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 7–12", 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
innerHTMLinterpolation sites escaped, zero inline handlers, an active CSP withoutunsafe-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, adangerouslySetInnerHTML, a cross-module import bypassingservice.py, a model change with no migration, a raw colour instead of avar(--…)token, and an unclassified candidate-touching column. Six mechanical gates, all passing onmain, where today.github/does not exist. - The schema foundation — schemas
app/ref/audit/ai/stagingand 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.jsprimitives and a chart from the retainedjs/charts.js— the findings §G assets demonstrably intact after the move.
15.2 Lands in weeks 7–12, 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) andraw_intake(T-11) are Phase 1 and start mid-W12. A real CV file landing as an immutableraw_intakerow with a sha256 in blob storage — previously a month-one promise — is a Phase 1 deliverable. - A malformed file terminating in
rejected_unusablewith 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 W1–W3 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.mdstates 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_versionwith the old score unchanged. Needs T-17 and T-20b, both Phase 1. - Inbound email. Depends on an Entra ID app registration and
Mail.Readadmin 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
AiRunledger 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 2–4.
- 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 1–3 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.7–1.8x on the total —
56–75 weeks rather than 31–45, with Phase 0 at 9–12 weeks rather than 2–3. 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 0–1 | 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 20k–60k applications/year and 200–600 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 3–4 orders away and very likely never fires; if volume is far higher, re-derive rather than inherit |
| A-7 | Peak concurrency ~20–25 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 | 30–50 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;
2–4 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_*.sqlis the schema authority. Every Django migration isSeparateDatabaseAndState(database_operations=[RunSQL(<that file>)], state_operations=[…]), so Django owns ordering and the applied-state ledger and authors no DDL. Every model staysmanaged = True;managed = Falseis used on no table, because it would remove exactly the tables that carry invariants from the one gate watching them.makemigrations --checkcompares 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 inMeta.constraints/Meta.indexes(CheckConstraint,UniqueConstraint(condition=…),Index(Lower(…)),ExclusionConstraint) and mirroring those same declarations instate_operations. Objects Django cannot model at all — triggers, column-levelGRANT/REVOKE,RANGEpartitions and their attach/detach, generated columns,DEFERRABLE INITIALLY DEFERREDconstraint triggers, andprocrastinate's vendor-managed migrations — are named in an explicit, revieweddb/schema-ignore.toml, and are covered instead by a second, SQL-level gate: CI builds a database by running every migration, capturespg_dump --schema-only --no-ownerplus 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 migration001is written; it restates the mechanism already binding inadr/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 2–3 weeks, Phase 1 at
10–14, and a total of 31–45 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.5–631.5 dev-days — 56–75 weeks — with Phase 0 at 9–12 and Phase 1 at 24–30. 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 notifications — Notification,
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 2–3, 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, 4–6 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 2–3 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.