1567 lines
136 KiB
Markdown
1567 lines
136 KiB
Markdown
# 07 — Implementation Plan
|
||
|
||
## Status / Scope of this document
|
||
|
||
Binding delivery plan for the Utopia Brands internal HR Recruitment & ATS platform. It
|
||
converts the architecture and database decisions in `_decisions.md` into a phased,
|
||
owner-assigned, dependency-ordered schedule for **exactly two developers** — Talha Ahmed
|
||
(senior) and Ahmed Mujtaba (junior). It covers Phases 0–4, the task split, development
|
||
order, review gates, testing strategy, week-by-week milestones through Phase 0, Definition
|
||
of Done, and a risk register.
|
||
|
||
Scope boundaries, stated so nothing here is over-read:
|
||
|
||
- Requirements are taken from the assignment brief and `_decisions.md`. **There is no
|
||
meeting transcript in this repository** (findings §B) — nothing here derives from one.
|
||
- The repository is a static browser-only prototype: no backend, no database, no auth, no
|
||
tests, no build step, no Docker, no env files (findings §A, §B). Every backend line item
|
||
below is greenfield. The retained assets are `css/styles.css`, `js/ui.js` and
|
||
`js/charts.js` (findings §G).
|
||
- Two kinds of effort figure appear and they are not interchangeable. **Task sizes (§10) are
|
||
single-person effort in dev-days including review rework** — see the §1 size key. **Phase ranges
|
||
(§2) are calendar weeks for the two-person team**, derived from the task sizes and the per-person
|
||
weekly budgets. Where a range is wide, that is the honest estimate, not padding.
|
||
- **The §2 phase ranges are larger than the ones in `_decisions.md`, by roughly 1.7–1.8x on the total.**
|
||
That is not a scope change — it is this plan's own bottom-up task roll-up contradicting the
|
||
inherited calendar figure, resolved in favour of the roll-up. Both numbers are published side by
|
||
side in §2 and the divergence is recorded as §17 divergence 5. If you read only one section of
|
||
this document, read §2.
|
||
- The schedule is anchored to an **assumed kickoff of Monday 2026-08-03**. Anchor date is
|
||
an assumption; all dates shift as one block if kickoff moves.
|
||
- Terminology: this document uses the Part 1 module names (`requisition`, `application`,
|
||
`scoring`) for code boundaries and the Part 2 table names (`job`, `job_version`,
|
||
`job_application`, `ats_result`) for schema objects. That divergence exists inside
|
||
`_decisions.md` itself and is flagged in §17.
|
||
|
||
---
|
||
|
||
## 1. The delivery constraints that shape every number below
|
||
|
||
| Constraint | Consequence for the plan |
|
||
|---|---|
|
||
| Two developers, one of whom is junior | No parallel workstreams beyond two. Every plan line is either Talha's or Ahmed's; there is no "the team will" |
|
||
| One reviewer | Talha is the sole reviewer of Ahmed's work *and* has no reviewer for his own. Review is a scheduled capacity cost, not free |
|
||
| Greenfield backend | Phase 0 is not optional setup — it is the first time this system has a database, a migration, an auth check or a test |
|
||
| 66 named seats, ~20–25 peak concurrency (assumption, `_decisions.md` deployment section) | No scale work in Phases 0–4. No Kubernetes, Kafka, microservices, Elasticsearch, second database or separate AI service |
|
||
| Attacker-supplied input is a **Phase 1** input, not a later one | CV files and inbound mail are the two Phase 1 intake channels, so the XSS remediation (findings §E) and untrusted-file hardening are Phase 0/1 scheduled work, not backlog |
|
||
| Unresolved open questions gate Phase 3+ | Model hosting (OQ-1), historic outcome data (OQ-2), jurisdictions (OQ-4). Confidence is stated lower for those phases for that reason |
|
||
|
||
### Capacity model (assumption, used for every effort figure)
|
||
|
||
| Person | Nominal | Absorbed by review / architecture / ops / stakeholder time | Productive dev-days per week |
|
||
|---|---|---|---|
|
||
| Talha | 5 | ~1.0 (PR review, ADRs, IT liaison, deploys) | **4.0** |
|
||
| Ahmed | 5 | ~0.5 (ramp, rework after review) | **4.5** |
|
||
| **Combined** | 10 | — | **~8.5 dev-days / week** |
|
||
|
||
Size key used throughout: **XS** ≤ 0.5 d · **S** 1–2 d · **M** 3–5 d · **L** 6–10 d ·
|
||
**XL** > 10 d (an XL task must be split before it enters a sprint).
|
||
|
||
Three properties of that key matter, because §2 rolls the §10 task tables up against it:
|
||
|
||
1. **The sizes are single-person effort in dev-days, not elapsed calendar.** A task sized L is
|
||
6–10 days of one developer's productive time. At 4.0 dev-days/week for Talha it therefore
|
||
occupies 1.5–2.5 of *his* weeks. Nothing in the §10 tables is an elapsed-time figure; where a
|
||
task's real constraint is waiting rather than working (T-07, the Entra ID request) the size
|
||
covers only the work, and the waiting is handled as a dependency and a risk, never as size.
|
||
2. **The bands are conservative envelopes, not point estimates.** This has an asymmetric
|
||
consequence for roll-ups. The sum of the band *minima* is a **hard floor** — every task must
|
||
consume at least its minimum, so the phase cannot finish faster. The sum of the band *maxima*
|
||
is a **pessimistic envelope**, not a p90: independent tasks do not all land on their worst
|
||
case simultaneously. Read the published lower bounds in §2 as commitments and the upper bounds
|
||
as envelopes.
|
||
3. **Where a task-level estimate elsewhere in this document is tighter than its band, the tighter
|
||
number wins for planning and the band is retained for the roll-up.** There is exactly one such
|
||
case: §8 estimates A-01 → A-05 at ~4.5 dev-days in total from a step-by-step breakdown, while
|
||
their bands sum to 6.5–11.5. §8's figure is derived from the enumerated 34 sites and is the
|
||
better number; the §12 weekly derivation uses band midpoints for consistency, which makes that
|
||
stretch of the schedule the most likely to come in early.
|
||
|
||
Roll-up convention: **min** = XS 0.5 / S 1 / M 3 / L 6; **midpoint** (used in §12) = XS 0.5 /
|
||
S 1.5 / M 4 / L 8; **max** = XS 0.5 / S 2 / M 5 / L 10.
|
||
|
||
---
|
||
|
||
## 2. Phase overview
|
||
|
||
**Two independent numbers exist for every phase and they disagree.** The **inherited range** is
|
||
the calendar figure carried over from `_decisions.md`. The **bottom-up roll-up** is the sum of the
|
||
§10 task sizes against the §1 size key. Both are published below, because the correction is large
|
||
enough that hiding it would be the single most misleading thing this document could do.
|
||
|
||
Where the two came from, stated plainly: the inherited dev-day column in the previous version of
|
||
this table was not an estimate at all — it was the inherited calendar range multiplied by the 8.5
|
||
dev-days/week capacity figure. The §10 task sizes were assigned afterwards, per task, and were
|
||
never reconciled against it. In Phases 2–4 the two happen to agree within the bands. **In Phases 0
|
||
and 1 they do not, and Phase 1's bottom-up minimum exceeds its inherited maximum by 20%** — a
|
||
bottom-up floor above a published ceiling is not a wide estimate, it is a wrong one.
|
||
|
||
**The reconciliation resolves in favour of the roll-up.** The roll-up is derived from 96 enumerated
|
||
tasks with named owners, dependencies and review gates; the inherited range was derived before that
|
||
enumeration existed. The inherited column is retained rather than deleted so the size of the
|
||
correction stays visible.
|
||
|
||
| Phase | Objective in one line | Inherited (`_decisions.md`) | §10 tasks | Roll-up dev-days | Roll-up ÷ 8.5 | Busier stream's floor | **Published range** | Confidence |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| **0** | Make the prototype safe and stand up a real engineering platform | 2–3 wk (17–25 dd) | 20 — 4L 9M 6S 1XS | 57.5–97.5 | 6.8–11.5 wk | Talha 34 dd ÷ 4.0 = **8.5 wk** | **9–12 weeks** | Medium-high |
|
||
| **1** | One real CV, end to end, on real infrastructure, with an explainable score | 10–14 wk (85–119 dd) | 36 — 16L 17M 3S | 150–251 | 17.6–29.5 wk | Talha 93 dd ÷ 4.0 = **23.3 wk** | **24–30 weeks** | Medium |
|
||
| **2** | Turn the slice into a working recruiter workflow | 7–10 wk (60–85 dd) | 17 — 8L 8M 1S | 73–122 | 8.6–14.4 wk | Ahmed 40 dd ÷ 4.5 = **8.9 wk** | **9–14 weeks** | Medium-low |
|
||
| **3** | Offers, assessments, reporting, and the fairness gate before ranking goes live | 7–10 wk (60–85 dd) | 13 — 5L 7M 1S | 52–87 | 6.1–10.2 wk | Talha 27 dd ÷ 4.0 = **6.8 wk** | **7–10 weeks** | Low |
|
||
| **4** | Outbound publishing, full assistant, remaining AI capabilities, admin surfaces | 5–8 wk (42–68 dd) | 11 — 5L 4M 2S | 44–74 | 5.2–8.7 wk | Talha 27 dd ÷ 4.0 = **6.8 wk** | **7–9 weeks** | Low |
|
||
|
||
Total roll-up: **376.5–631.5 dev-days**. Published total: **56–75 weeks** — the sum of the
|
||
per-phase published ranges, roughly 13 to 17 months of two-person delivery for the full platform,
|
||
against the 31–45 weeks previously stated.
|
||
|
||
Note that the published lower bound (56 weeks) is **above** the roll-up ÷ 8.5 figure (44.3 weeks),
|
||
and the gap is not an error — it is the cost of the load imbalance. Dividing by combined capacity
|
||
assumes both developers are always busy on that phase's work; dividing each stream by its own
|
||
budget does not. The 12-week gap between 44 and 56 is the amount of Talha-bound sequential work
|
||
that Ahmed cannot absorb, and §12.2 shows it concretely as four weeks of his slack inside Phase 0
|
||
alone. Anyone quoting 44 weeks is quoting a number that requires a third developer to be true.
|
||
|
||
That correction is the honest number. Any commitment materially shorter than it is a commitment to
|
||
cut scope, and §4.2.1 now names the specific tasks that would be cut.
|
||
|
||
**How each published bound is derived, so it can be checked and challenged:**
|
||
|
||
| Bound | Basis | Why |
|
||
|---|---|---|
|
||
| Lower | The **busier developer's** band-minimum ÷ that person's own weekly budget (Talha 4.0, Ahmed 4.5) | A phase cannot finish faster than its busiest stream. The combined-capacity figure (roll-up ÷ 8.5) silently assumes perfect load balance and is optimistic wherever the streams are unequal — which is every phase except 2 |
|
||
| Upper | Roll-up band-maximum ÷ 8.5 combined | Deliberately the *combined* figure. It is an envelope, not a p90 (§1), and it too assumes perfect balance |
|
||
|
||
**The load imbalance is the finding behind the finding.** Talha holds 93 of Phase 1's 150
|
||
floor dev-days — 62% of the work at the lower of the two weekly budgets. Applying the same
|
||
stream-level arithmetic to the *upper* band gives Talha 57 dd (14 wk) in Phase 0 and 155 dd
|
||
(39 wk) in Phase 1, both above the published ceilings. The published ceilings are therefore only
|
||
reachable if load is actively rebalanced. Two levers exist and both are used in this plan rather
|
||
than left as advice:
|
||
|
||
1. **Rebalance.** `procrastinate` queue configuration moves from Talha to Ahmed as **A-10b**
|
||
(§10.1) — one senior-owned module rotated per phase, which is R-04's stated mitigation rather
|
||
than a new idea. Phase 1 has no comparable candidate: every Talha item there is schema,
|
||
constraints, parsing, authorization or an integration.
|
||
2. **Cut.** The §4.2 Phase 1 cut line is extended with a named, sized set of tasks that brings
|
||
Phase 1's floor back under the inherited 119 dev-days if the calendar is fixed rather than the
|
||
scope.
|
||
|
||
```mermaid
|
||
gantt
|
||
title Utopia Brands ATS phase schedule — earliest-finish chain plus slip bands
|
||
dateFormat YYYY-MM-DD
|
||
axisFormat %b %Y
|
||
excludes weekends
|
||
section Phase 0
|
||
Hardening and platform foundations :p0, 2026-08-03, 45d
|
||
Slip band :p0b, after p0, 15d
|
||
Phase 0 demo D0 earliest :milestone, m0, 2026-10-05, 0d
|
||
section Phase 1
|
||
Vertical slice intake to score :p1, after p0, 120d
|
||
Slip band :p1b, after p1, 30d
|
||
Phase 1 demo D1 earliest :milestone, m1, 2027-03-22, 0d
|
||
section Phase 2
|
||
Recruiter workflow :p2, after p1b, 45d
|
||
Slip band :p2b, after p2, 25d
|
||
section Phase 3
|
||
Offers assessments reporting fairness :p3, after p2b, 35d
|
||
Slip band :p3b, after p3, 15d
|
||
section Phase 4
|
||
Outbound and full assistant :p4, after p3b, 35d
|
||
Slip band :p4b, after p4, 10d
|
||
```
|
||
|
||
Read the chart as: the solid bar is the published lower bound (the busier stream's floor), the slip
|
||
band is the remainder up to the published upper bound. Bands are **cumulative** — a Phase 1 slip
|
||
moves everything after it, which is why Phase 2 is drawn from the end of the Phase 1 band rather
|
||
than from the end of the Phase 1 bar. The two milestones are **earliest** dates on the
|
||
no-slip chain; D0 lands in week 9, not week 3, and §12 derives that week by week.
|
||
|
||
---
|
||
|
||
## 3. Phase 0 — Safety and platform foundations
|
||
|
||
**Objective.** Two independent outcomes: (a) the existing prototype can no longer execute
|
||
attacker-supplied strings, so a stakeholder demo pointed at real data is survivable; and
|
||
(b) the team has a repository, a database, migrations, CI, a deploy path, an
|
||
authorization decision point and an audit trail — none of which exist today (findings §B,
|
||
§D).
|
||
|
||
### 3.1 Included
|
||
|
||
| # | Item | Why in Phase 0 |
|
||
|---|---|---|
|
||
| 1 | **XSS/CSP remediation of the prototype** — see §8 for the ordered plan | 34 unescaped `innerHTML` sites (findings §E); Phase 1 intake is attacker-supplied by design |
|
||
| 2 | Monorepo layout (`api/`, `web/`, `db/migrations/`), docker compose (Postgres 16, Redis, MinIO) | No Dockerfile, no compose file, no env file exists (findings §B) |
|
||
| 3 | CI pipeline: ruff, mypy, `import-linter` contracts, pytest against a real Postgres service container, ESLint `react/no-danger` as error, stylelint token rule, `tsc --noEmit`, migration-drift gate, unescaped-interpolation grep gate, evidence-citation gate (`tools/check_evidence_citations.py`) | `.github/` is absent (findings §B). Boundary rules must be machine-enforced from commit one |
|
||
| 4 | `db/migrations` bootstrap: schemas `app`/`ref`/`audit`/`ai`/`staging`, extensions `pg_trgm`, `unaccent`, `btree_gist`, `pgcrypto`; **ADR 0017 signed off** (`Proposed` → `Accepted`) | Resolves the ORM-vs-plain-SQL conflict in §17 before any table exists, with one ruling instead of six recommendations. Merge blocker on migration `001` |
|
||
| 5 | `identity`: `app_user`, `role`, `permission`, scoped `role_assignment`, sessions, and the single `iam.can(actor, action, resource)` decision point with DRF permission classes | Today the RBAC matrix gates nothing (`js/rbac.js:78`, `js/rbac.js:111-112`); retrofitting authz across 25 modules is far more expensive later |
|
||
| 6 | `audit`: partitioned `audit.audit_event`, generic change trigger, `SET LOCAL` actor bridge, hash chain, nightly verifier | History and audit are cheap now, unrecoverable later — you cannot backfill what was never captured |
|
||
| 7 | `config`: reference-data seed migrations plus Django admin registration for the 7 controlled vocabularies | Removes the entire Settings UI from Phase 1 scope |
|
||
| 8 | `pii_classification` table plus a CI completeness check | Three later jobs read it (purge, subject access, non-prod anonymisation) |
|
||
| 9 | React shell scaffold: Vite + TS + React, `git mv css/styles.css` **verbatim**, `<Chart/>` wrapper over the retained `js/charts.js`, the 10 `js/ui.js` primitives ported as typed components keeping class names | The design system is the asset worth keeping (findings §G) |
|
||
| 10 | Deployment: staging + production on the managed container platform, one image / two entrypoints, secret store, auto-deploy to staging on merge | A deploy path that exists in week 2 is worth more than a better one in week 20 |
|
||
| 11 | procrastinate queue configuration: named queues, retry policy, job-status endpoint | Transactional enqueue must exist before intake writes exist |
|
||
| 12 | **File the Entra ID app registration and Graph `Mail.Read` admin-consent request, plus the dedicated recruiting mailbox request, in week 1** | Outside the team's control; the single most likely Phase 1 blocker |
|
||
| 13 | ADRs for the binding decisions in `_decisions.md` | Bus-factor mitigation, and the junior's reference material |
|
||
|
||
### 3.2 EXCLUDED — the Phase 0 cut line
|
||
|
||
Nothing below is started in Phase 0, even if it looks quick:
|
||
|
||
- No candidate, application, requisition or intake tables. **Only** `identity`, `audit`,
|
||
`config` and the reference vocabularies get schema in Phase 0.
|
||
- No CV parsing, no mail polling, no scoring, no model provider client.
|
||
- No React screen wired to real data. The shell renders the design system and a login;
|
||
that is all.
|
||
- No migration of prototype screens beyond the shell — the prototype is frozen after the
|
||
patch, security fixes only.
|
||
- No SSO cutover to production Entra ID; local users plus a dev tenant are sufficient.
|
||
- No monitoring/alerting stack beyond platform logs and the audit verifier.
|
||
- No RLS, no partition automation (`pg_partman` is Phase 2), no pgvector.
|
||
|
||
### 3.3 Dependencies
|
||
|
||
- **External:** corporate IT for the Entra ID app registration, `Mail.Read` admin consent
|
||
and the recruiting mailbox (item 12). Cloud subscription and budget approval for the
|
||
container platform, managed Postgres, blob storage and secret store.
|
||
- **Internal:** none. Phase 0 is deliberately buildable with zero external unblocks —
|
||
the only external item is *filed* in Phase 0 and *needed* in Phase 1.
|
||
|
||
### 3.4 Risks specific to Phase 0
|
||
|
||
| Risk | Handling |
|
||
|---|---|
|
||
| Cloud subscription/budget approval slips | Phase 0 runs entirely on docker compose; only items 10 and 12 need the subscription. Escalate on day 3 if not approved |
|
||
| The CSP breaks the prototype because inline handlers remain | Ordering is enforced: escaping → delegated listeners → CSP. See §8 |
|
||
| CI pipeline becomes a week-long yak shave | Timeboxed to 5 dev-days. Ship a pipeline with 6 jobs that all pass, not 12 that are half-wired |
|
||
| Ahmed's first authorization/migration exposure lands with no scaffolding | Talha lands `identity` and the first migration before Ahmed's schema tasks (A-09, A-10) start, so there is a pattern to copy |
|
||
|
||
### 3.5 Acceptance criteria
|
||
|
||
1. `docker compose up` on a clean machine yields a running API, worker, Postgres, Redis
|
||
and object store, with migrations applied, in one command.
|
||
2. CI is green on `main` and **red** for: a new unescaped `${` inside an HTML template
|
||
literal, a `dangerouslySetInnerHTML`, a cross-module import that bypasses `service.py`,
|
||
a model change without a migration, and a CSS rule using a raw colour instead of a
|
||
`var(--…)` token.
|
||
3. `iam.can()` is the only authorization implementation; a test asserts that a
|
||
deny-by-default endpoint with no permission declared returns 403, not 200.
|
||
4. Every write to a classified table produces an `audit.audit_event` row with a correct
|
||
actor, and the nightly hash-chain verifier passes.
|
||
5. The prototype: all 34 `innerHTML` interpolation sites escaped, zero inline event
|
||
handlers remain, CSP active without `unsafe-inline` for scripts, and a stored XSS
|
||
payload in a candidate name renders as literal text in both themes.
|
||
6. A merge to `main` auto-deploys to staging without manual steps.
|
||
|
||
### 3.6 Effort and confidence
|
||
|
||
**Effort: 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:
|
||
|
||
```mermaid
|
||
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
|
||
|
||
1. A real PDF CV arrives by **email**, appears as a `raw_intake` row within the polling
|
||
interval, and is idempotent under redelivery (same `external_message_id` or same
|
||
`payload_sha256` produces no second row).
|
||
2. A CV that cannot be parsed reaches a terminal state (`rejected_unusable` or
|
||
`quarantined`) with **no** `candidate` and **no** `job_application` row, and is visible
|
||
and retryable in the triage UI. Nothing is silently dropped.
|
||
3. Attempting to insert a `candidate` without a `raw_intake` row fails at the database.
|
||
Attempting to commit a candidate with no email and no phone fails at COMMIT.
|
||
Attempting a malformed email address fails the CHECK. All three are asserted by tests.
|
||
4. Two applications from the same person to two different jobs produce **one** candidate
|
||
and **two** applications with **two independent** `ats_result` rows.
|
||
5. A second live application to the same job is refused by the partial unique index; a
|
||
reapplication after a terminal outcome plus cooling-off succeeds with `attempt_no = 2`.
|
||
6. Editing a requirement mints a new `job_version`; the previously computed score is
|
||
unchanged and still displays its original pinned criteria and contributions.
|
||
7. `UPDATE` against `job_version`, `job_requirement`, `scoring_config_version` and
|
||
`ats_result` (outside the granted column list) raises an exception. Asserted by tests.
|
||
8. The score panel shows, per criterion: requirement, matched evidence, `weight_applied`,
|
||
`contribution`, and the pinned `job_version` / `scoring_config_version` /
|
||
`algorithm_code_version` / parse attempt.
|
||
9. No code path can move an application to a terminal-negative status without a human
|
||
actor. `review_outcome` cannot be set without `reviewed_by_user_id`. Asserted by tests.
|
||
10. Every endpoint returns 403 for a role without the permission, and the permission matrix
|
||
test covers every endpoint × every role.
|
||
11. A recruiter moves an application through two stages; `job_application_stage_history`
|
||
shows the actor, reason and non-overlapping intervals.
|
||
12. Playwright journeys 1–5 pass in CI against a real Postgres.
|
||
13. **A recruiter cannot publish a `job_version`.** `publish_version()` raises unless an
|
||
`approval_decision` row exists against that version's `approval_request` with
|
||
`decision = 'approved'` and an `approver_user_id` holding the `job.approve` permission in
|
||
scope — and the requesting recruiter approving their own version is refused as
|
||
self-approval. Asserted as a **forbidden-write test in A-26**, not only through the UI, so
|
||
the guarantee survives a direct API or `psql` path. The requisition screen shows the version's
|
||
approval state (`draft` → `pending_approval` → `approved` / `rejected` → `published`) and the
|
||
approver, and the approve/reject action is invisible to a role without the permission (REQ-JOB-07,
|
||
`00` §2.4; closes `08` GAP-01).
|
||
14. **A parse failure is resolvable without leaving the product.** From the triage UI, a recruiter
|
||
asks the sender for an unprotected copy; the send writes one `outbound_message` row **before**
|
||
the provider call; replaying the job finds `provider_message_id` set and is a no-op (no second
|
||
email); and a synthetic NDR for that message is matched by `X-Utopia-Thread` and sets the
|
||
bounce state on the same row. An NDR that matches nothing lands in `needs_review` rather than
|
||
being discarded. Asserted against `FakeMailProvider`, so it runs in CI without a mailbox
|
||
(T-16b; `04` §4.3, §9.1 row 5; closes `08` §7 finding 10).
|
||
|
||
### 4.6 Effort and confidence
|
||
|
||
**Effort: 24–30 weeks (150–251 dev-days).** This replaces the 10–14 weeks (85–119 dev-days)
|
||
inherited from `_decisions.md`. The 36 tasks in §10.2 sum to 150 dev-days at their band *minima* —
|
||
above the inherited *maximum* of 119 — and Talha's stream alone is 93 of those days at 4.0
|
||
dev-days/week, which sets the 23.3-week floor. §2 derives this; §4.2.1 is the cut list if the
|
||
calendar is fixed instead of the scope. **Seven of the 150 floor days are tasks this revision
|
||
added, each closing a hole that had no implementing task in any phase:** T-17b (M) and A-16b (S) —
|
||
the REQ-JOB-07 approval gate — and T-16b (M), the minimal outbound-mail slice that makes a Phase 1
|
||
parse failure resolvable in-product (`04` §9.1 row 5; `08` §7 finding 10). All three are the kind
|
||
of item that is invisible in a phase plan until someone tries to run the phase.
|
||
|
||
**Confidence: Medium.** Medium applies to the *published* 24–30 week range, not to the inherited
|
||
one, which is now known to be unachievable rather than uncertain. The reasoning, item by item: the schema is already decided in
|
||
detail so there is little *design* discovery left, and the invariants are expressible in
|
||
Postgres features Talha has chosen deliberately — that part is Medium-High. Three things
|
||
pull the whole phase down to Medium. (1) **Real CV parsing has irreducible discovery risk**:
|
||
you cannot estimate the long tail of scanned, two-column, table-based and non-English CVs
|
||
until you have run a real corpus through it, and the estimate for T-13 could be wrong by a
|
||
factor of two in either direction. (2) **The Outlook dependency is outside the team's
|
||
control** and historically these consents take weeks, not days. (3) **This is the first
|
||
authorization layer this system has ever had**, being built at the same time as the domain
|
||
it protects, by the same person, with no second reviewer. Against that, the phase is
|
||
structured so the *demonstrable thread* does not depend on the two riskiest items — manual
|
||
upload substitutes for mail, and rule-based scoring substitutes for a model — which is what
|
||
keeps the confidence at Medium rather than Low.
|
||
|
||
### 4.7 Demonstration milestone — **D1**
|
||
|
||
On staging, with real data, in this order: send a real CV to the recruiting mailbox from a
|
||
phone; watch it land in the triage queue; show the parse result with per-field confidence
|
||
and one field deliberately left empty because confidence was low; send a second,
|
||
deliberately broken file and show it terminate in `rejected_unusable` with no candidate
|
||
created; promote the good one to a candidate and create an application against a published
|
||
requisition version; **attempt to publish a requisition version as the recruiter who drafted it and
|
||
show it refused, then log in as the hiring manager, approve it, and show it publish** — with the
|
||
`approval_decision` row and its approver on screen; show the score with its per-criterion
|
||
contributions and pinned versions; edit a requirement, publish a new version, and show the old score unchanged
|
||
beside the new one; move the application two stages and show the history with actor and
|
||
reason; log in as an interviewer and show the same candidate is not visible; show the audit
|
||
trail for all of it.
|
||
|
||
---
|
||
|
||
## 5. Phase 2 — Recruiter workflow
|
||
|
||
**Objective.** Turn a working thread into a working job: a recruiter can run a requisition
|
||
end to end without leaving the product, duplicates can be resolved reversibly, and the
|
||
first honest numbers appear.
|
||
|
||
### 5.1 Included
|
||
|
||
Drag-and-drop pipeline board over the existing transition service, with allowed
|
||
transitions driven by `pipeline.is_allowed()`; `interview` scheduling with UTC instant plus
|
||
wall-clock intent plus IANA zone, `EXCLUDE` double-booking on participants, scorecards that
|
||
lock on submit; **duplicate merge with reversible unmerge** (`candidate_merge`,
|
||
`candidate_merge_operation` undo log, descending-seq replay, stack discipline, retention
|
||
interlock); the **full** `notifications` delivery pipeline — template authoring and versioning
|
||
UI, retry with backoff, complaint handling, digests, per-user preferences and the in-app
|
||
notification centre — **built on the minimal send path Phase 1 already shipped as T-16b**, so
|
||
the provider, the `MailProvider` port and `outbound_message` exist and only the pipeline around
|
||
them is new (`08` §7 finding 10; internal notification email arrives here, OBD-21);
|
||
`worklist` with rule-driven tasks; analytics read-model views plus **seven** of REQ-ANL-01's eight
|
||
dashboard KPIs on the retained `charts.js` — Open Jobs, Closed Jobs, Total Candidates, Interviews
|
||
Today, Offers Accepted, Time to Hire, Time to Fill (**Cost per Hire is excluded, see §5.2**);
|
||
read-only assistant over whitelisted typed query intents with
|
||
`on_behalf_of_user_id` audit; `worker-untrusted` queue split; `pg_partman` (or a scheduled
|
||
SQL function) for audit partitions; pgvector enabled for Phase 2 hybrid retrieval.
|
||
|
||
### 5.2 EXCLUDED — the Phase 2 cut line
|
||
|
||
- No assistant **write** actions and no tool-calling beyond read intents.
|
||
- No offers, no assessments, no approval chains.
|
||
- No fairness evaluation, and therefore **no ranked shortlist surfaced to recruiters** —
|
||
scores remain per-application, not a league table.
|
||
- No report library or saved reports; dashboard KPIs only.
|
||
- **No Cost per Hire KPI tile.** Seven of REQ-ANL-01's eight KPIs ship; the eighth does not,
|
||
because no entity in the design holds recruiting cost. Its only possible source,
|
||
`app.job_posting_metric.spend_amount`, is Phase 4, and the prototype's figure is the literal
|
||
constant `costPerHire: 4280` at `js/data.js:247`. **Reinstated in Phase 4** alongside
|
||
`job_posting_metric`, scoped honestly as *cost per hire from measurable channel spend* — agency
|
||
fees, referral bonuses and recruiter time are out of scope unless a `hiring_cost` table is
|
||
confirmed. Ruled in `08` GAP-02; reversible by Finance via `_open-items.md` OPEN-02. **Say this to
|
||
stakeholders in the D2 walkthrough** rather than letting an absent tile read as an oversight —
|
||
shipping the tile against a constant would be the worse outcome.
|
||
- No job-board inbound or outbound.
|
||
- No custom per-requisition pipeline configuration (default stages only; custom is Phase 3).
|
||
- No RLS. Authorization remains application-layer only.
|
||
- No talent pool.
|
||
|
||
### 5.3 Dependencies
|
||
|
||
Phase 1 complete, specifically: `application.transition()`, duplicate detection with
|
||
`confirmed_distinct` memory, the authorization layer, and `ai_orchestration.invoke()`.
|
||
Calendar integration needs the same Entra tenant as the mail channel. The assistant needs
|
||
OQ-1 resolved.
|
||
|
||
### 5.4 Risks specific to Phase 2
|
||
|
||
| Risk | Handling |
|
||
|---|---|
|
||
| **Merge reversal is the highest-risk logic in the schema.** A table carrying `candidate_id` that merge re-parents but does not record becomes silently unreversible, discovered months later | Enumerate every `candidate_id`-carrying table in one place; a test asserts the merge routine records an operation for each; property test merge→unmerge→assert byte-identical child-row parentage |
|
||
| Stack discipline blocks a legitimate reversal with no in-product resolution | Accepted deliberately over silent cross-contamination. The error message must **name the blocking merge** so the recruiter knows what to reverse first |
|
||
| The pipeline board's optimistic updates diverge from server-side transition rules | The board never decides legality; it calls `is_allowed()` and rolls back visually on rejection. Asserted by a Playwright test that attempts an illegal drag |
|
||
| Timezone divergence between `starts_at` and `local_start_wall` | A single scheduling service function is the only writer, plus a nightly reconciliation job reporting mismatches |
|
||
| Assistant leaks data through a query intent that forgets the actor check | No service account exists; `invoke()` takes the human actor. A test asserts a low-privilege user's question cannot return a record the REST API would deny them |
|
||
|
||
### 5.5 Acceptance criteria
|
||
|
||
1. Merge two candidates who share an email address; both applications survive, nothing is
|
||
deleted, the loser's `public_id` still resolves via 301, and the funnel report count is
|
||
unchanged (no double counting).
|
||
2. Unmerge restores every re-parented row; rows created after the merge stay with the
|
||
survivor and were listed on the confirmation screen before the recruiter confirmed.
|
||
3. An out-of-order unmerge is refused with an error naming the blocking merge.
|
||
4. Two interviews cannot be scheduled for the same interviewer in overlapping slots — the
|
||
database refuses it, not the UI.
|
||
5. A reschedule across a DST boundary preserves the organiser's wall-clock intent.
|
||
6. The assistant answers a recruiter's question about their own requisition and refuses
|
||
the same question from an interviewer, with both attempts in the access audit log.
|
||
7. Dashboard KPIs match a hand-written SQL query over the same window.
|
||
|
||
### 5.6 Effort, confidence, demonstration
|
||
|
||
**Effort: 7–10 weeks (60–85 dev-days). Confidence: Medium-low.** Two reasons, both
|
||
specific: merge/unmerge is genuinely hard concurrent state manipulation whose failure mode
|
||
is silent and delayed, so its estimate has a long tail; and the assistant's scope is the
|
||
easiest thing in the whole plan for stakeholders to inflate, because all 15 AI capabilities
|
||
are already visible in the prototype UI as an interface preview.
|
||
|
||
**Demonstration — D2:** run one requisition front to back in a single session — board,
|
||
interview scheduled across two timezones, scorecard submitted and locked, a duplicate
|
||
found, merged, then unmerged in front of the audience, and the dashboard KPI moving as a
|
||
consequence.
|
||
|
||
**One thing to say out loud in D2, not to leave for someone to notice:** the dashboard shows
|
||
**seven** KPIs, not eight. Cost per Hire is deliberately absent because no entity holds recruiting
|
||
cost until Phase 4 — the prototype's `$4,280` was a hardcoded constant, and a tile computed from a
|
||
constant is worse than no tile. It returns in Phase 4 with `job_posting_metric`, covering
|
||
measurable channel spend. If Finance wants it at Phase 2, that is a small cost-entry table and a
|
||
recruiter screen, and it is their call to make (`_open-items.md` OPEN-02) — but it should be made
|
||
before the tile is promised, not after.
|
||
|
||
---
|
||
|
||
## 6. Phase 3 — Offers, assessments, reporting, and the fairness gate
|
||
|
||
**Objective.** Close the hiring loop, and pass the gate that lets ranking be used as
|
||
ranking.
|
||
|
||
### 6.1 Included
|
||
|
||
`assessment` (templates, assignment, results); `offer` with immutable `offer_version` rows,
|
||
approval chain, money as amount+currency pairs with the minor-unit rounding trigger, and
|
||
`issue()` requiring explicit human confirmation; the report library and saved reports;
|
||
**`fairness_evaluation` as an activation gate — no `scoring_config_version` becomes active
|
||
without a passing `EvaluationRun` reference**; `talent_pool` as tags plus saved segments
|
||
with rematch; job-board **inbound** adapters (4 boards); the retention purge as
|
||
pseudonymisation with `retention_hold`, plus the subject-access export; custom
|
||
per-requisition pipeline configuration.
|
||
|
||
### 6.2 EXCLUDED — the Phase 3 cut line
|
||
|
||
- No outbound publishing to external platforms.
|
||
- No assistant write actions or full tool-use.
|
||
- No Settings or Help UI (Django admin remains the vocabulary surface).
|
||
- No special-category data (diversity, health, accommodation). Its exclusion is a
|
||
decision, not an omission — it needs separate access control, aggregate-only reads and a
|
||
distinct lawful basis.
|
||
- No self-hosted model inference.
|
||
|
||
### 6.3 Dependencies and the honest gating problem
|
||
|
||
`fairness_evaluation` depends on **OQ-2: historic hiring outcome data**, which is
|
||
unconfirmed. If it does not exist, the evaluation can only be run against face-plausibility
|
||
checks and synthetic distributions, which means **the gate may block the ranking release
|
||
with no engineering fix available**. That is not a schedule risk; it is a scope risk that
|
||
has to be escalated to the business in Phase 1, not discovered in Phase 3. Offers also
|
||
depend on OQ-4 (jurisdictions) for letter templates and retention periods.
|
||
|
||
### 6.4 Risks and acceptance criteria
|
||
|
||
| Risk | Handling |
|
||
|---|---|
|
||
| Fairness gate cannot be satisfied without outcome data (OQ-2) | Raise in Phase 1. Fallback: release scores as per-application decision support with ranking explicitly disabled in the UI, and say so to stakeholders |
|
||
| Retention pseudonymisation permanently blocks merge reversal | Exclude candidates in an unreversed merge from purge for a defined window, or require explicit acknowledgement that reversibility is being surrendered |
|
||
| Offer approval chains grow into a workflow engine | No generic engine. Offer transitions live in `offer.service` until three modules demonstrably need the same machinery |
|
||
| Six jurisdictions of offer letters and retention rules | Templates are `config` reference data per jurisdiction; retention is per-record, never per-region storage |
|
||
|
||
Acceptance: an offer cannot be issued by a background job; an amount in JPY with decimal
|
||
places is refused by the rounding trigger; a salary range with mismatched currencies is
|
||
refused; activating a scoring config without a passing evaluation is refused; a retention
|
||
purge pseudonymises a candidate while leaving `ats_result` scores and stage history intact;
|
||
a subject-access export returns everything the `pii_classification` registry says it should.
|
||
|
||
**Effort: 7–10 weeks (60–85 dev-days). Confidence: Low** — not because the engineering is
|
||
hard but because two of the four workstreams (fairness, retention/jurisdiction) are gated
|
||
by unanswered business and legal questions, and an estimate for work whose acceptance
|
||
criteria are not yet decidable is not a real estimate.
|
||
|
||
**Demonstration — D3:** an offer drafted, approved through a two-step chain, issued by a
|
||
human with confirmation, and the letter produced; a fairness evaluation run shown to a
|
||
non-engineering audience with a pass/fail verdict; a retention purge run against a test
|
||
candidate showing the pseudonymised record with intact history.
|
||
|
||
---
|
||
|
||
## 7. Phase 4 — Outbound, full assistant, and admin surfaces
|
||
|
||
**Objective.** Everything that only makes sense once the internal product works.
|
||
|
||
**Included.** `integrations_outbound` publishing `job_version`s to the 8 external
|
||
platforms with state reconciliation and `HR-admin`-gated publish (it has cost
|
||
implications); the full tool-using assistant with write actions behind explicit
|
||
confirmation; the remaining P2 AI capabilities; the Settings UI split correctly between
|
||
`identity` (security, users, roles) and `config` (vocabularies, templates, branding);
|
||
Help as static docs with client-side search; the referral/agency/campus/walk-in inbound
|
||
forms; RLS for the AI query path if ad-hoc querying proves genuinely necessary; the final
|
||
accessibility regression sweep across all migrated screens; **retirement of the prototype**
|
||
— the last prototype screens are deleted as their React counterparts land.
|
||
|
||
**EXCLUDED — the Phase 4 cut line.** No self-hosted inference. No mobile app. No
|
||
candidate-facing portal beyond the token-gated status page. No multi-tenant or per-region
|
||
anything (excluded by constraint). No microservice extraction unless a **hard** split
|
||
trigger from `_decisions.md` has actually fired.
|
||
|
||
**Dependencies.** Commercial accounts and API credentials for 8 external platforms —
|
||
procurement, not engineering, and it can slip independently. Phase 3 complete.
|
||
|
||
**Risks.** Eight third-party publishing APIs is eight independent breakage surfaces
|
||
maintained by two people — mitigate by treating platforms as individually
|
||
deferrable/degradable, with `PublishAttempt` failures surfaced rather than retried
|
||
forever. Assistant write actions are the largest new authorization surface in the product;
|
||
every write goes through the same domain service as the UI, with confirmation, and never
|
||
through a new path.
|
||
|
||
**Acceptance.** Publishing a requisition version to a platform and unpublishing it
|
||
reconciles state; an assistant write action requires confirmation and produces an audit
|
||
event with `actor_kind = 'ai_agent'` and `on_behalf_of_user_id` set; AI Studio shows true
|
||
per-capability availability rather than a coming-soon grid; the accessibility sweep passes
|
||
WCAG 2.1 AA across every migrated route in both themes; no prototype screen remains
|
||
reachable.
|
||
|
||
**Effort: 5–8 weeks (42–68 dev-days). Confidence: Low** — dominated by third-party API
|
||
variance and procurement timing, neither of which the team controls.
|
||
|
||
**Demonstration — D4:** publish a requisition to two external platforms and show the
|
||
applications arriving back through `raw_intake`; ask the assistant to perform a write
|
||
action and show the confirmation, the audit entry, and the refusal when the asking user
|
||
lacks the permission.
|
||
|
||
---
|
||
|
||
## 8. The P0 XSS remediation — scheduled work, in order
|
||
|
||
This is scheduled Phase 0 work with a named owner, a review gate and acceptance criteria,
|
||
because **real CV data cannot be rendered safely until it is done** (findings §E). Today
|
||
nothing is exploitable — the data is generated locally by a seeded PRNG
|
||
(`js/data.js:8-10`) — but the two Phase 1 intake sources are attacker-supplied by design,
|
||
and the storage decisions deliberately preserve unsanitised originals
|
||
(`full_name_original`, `candidate_email.address_original`, `raw_intake.payload`,
|
||
`intake_parse_attempt.parsed`), which guarantees attacker-controlled strings reach the
|
||
rendering layer. Sanitising on write is the wrong fix and would violate the
|
||
preserve-the-original rule; escaping on output is the fix.
|
||
|
||
**The order is load-bearing** — doing it in any other order breaks the prototype:
|
||
|
||
| Step | Work | Why this position |
|
||
|---|---|---|
|
||
| 1 | Add `UI.esc()` to `js/ui.js` and apply it at every interpolation of a data-derived value across the 34 `innerHTML` sites in 14 files. Start with the untrusted-data screens: `js/candidates.js` (e.g. `${c.name}`, `${c.currentTitle}`, `${c.location}` at `js/candidates.js:68`), `js/inbox.js`, `js/import.js` | Highest-risk sinks first, so partial completion still reduces exposure |
|
||
| 2 | Replace inline handlers such as `onclick="Candidates.openProfile('${c.id}')"` (`js/candidates.js:121`) with delegated listeners reading `data-*` attributes | **Must precede step 3.** A CSP without `unsafe-inline` blocks inline handlers, so enabling the policy first would break every interactive control |
|
||
| 3 | Add the `Content-Security-Policy` header (and a meta fallback for the static `devserver.py` path) with no `unsafe-inline` for scripts. Verify no inline `<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.
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
S1["1. Repo + compose + CI + migration runner"] --> S2["2. identity + iam.can()"]
|
||
S1 --> S3["3. Prototype XSS patch + CSP + CI gate"]
|
||
S2 --> S4["4. audit + SET LOCAL actor bridge"]
|
||
S1 --> S5["5. React shell + frozen CSS + UI primitives"]
|
||
S4 --> S6["6. config + reference data + pii_classification"]
|
||
S6 --> S7["7. files + blob store + virus scan hook"]
|
||
S7 --> S8["8. document_parsing in the worker"]
|
||
S7 --> S9["9. raw_intake + attachments + parse_attempt"]
|
||
S9 --> S8
|
||
S9 --> S10["10. candidate + child tables + search index"]
|
||
S10 --> S11["11. intake_resolution + promote_to_candidate"]
|
||
S11 --> S12["12. Intake triage UI + manual upload + parse review"]
|
||
S6 --> S13["13. job + job_version + job_requirement"]
|
||
S13 --> S13b["13b. approval_request + approval_decision<br/>single approver; publish_version() gate"]
|
||
S2 --> S13b
|
||
S13 --> S14["14. job_application + history triggers + reapplication rule"]
|
||
S10 --> S14
|
||
S14 --> S15["15. assignment interval tables"]
|
||
S13 --> S16["16. scoring_config + ats_result + explain()"]
|
||
S13b --> S16
|
||
S14 --> S16
|
||
S8 --> S16
|
||
S2 --> S17["17. ai_orchestration ledger + invoke(actor)"]
|
||
S17 --> S16
|
||
S16 --> S18["18. Score explanation panel + provenance badges"]
|
||
S2 --> S19["19. Authorization on every endpoint + permission matrix tests"]
|
||
S10 --> S20["20. Duplicate detection"]
|
||
S9 --> S21["21. Outlook Graph adapter"]
|
||
S19 --> S22["Phase 2 onwards"]
|
||
S18 --> S22
|
||
S20 --> S22
|
||
S21 --> S22
|
||
```
|
||
|
||
Four ordering decisions worth defending:
|
||
|
||
1. **Authorization before the domain it protects.** `identity` and `iam.can()` land in
|
||
Phase 0, before a single candidate table exists. Retrofitting authorization onto 25
|
||
modules costs multiples of building it first, and the current codebase has literally
|
||
none (findings §D).
|
||
2. **Versioning and history in Phase 1, even though nothing visibly needs them yet.** Both
|
||
are cheap to design in and expensive to retrofit, because retrofitting means backfilling
|
||
history that was never captured. Every prototype gap in findings §F becomes permanent if
|
||
deferred.
|
||
3. **The Outlook channel last within Phase 1.** It is the item most likely to be blocked by
|
||
someone outside the team, so it must not sit upstream of anything demonstrable.
|
||
4. **The approval gate in the same step as publishing, never after it (13b).** `publish_version()`
|
||
and the approval that authorises it are built together, because a `publish_version()` that ships
|
||
ungated and is gated later is not a refactor — it is a period during which requisitions were
|
||
published without the control REQ-JOB-07 requires, and there is no way to retroactively
|
||
establish who approved what. It sits downstream of `identity` (2) because the gate is a
|
||
permission check, and upstream of scoring (16) because the only legitimate way to obtain a
|
||
published `job_version` to score against is through it. The one exception is the migration-seeded
|
||
version in §4.3, which is created by migration rather than by `publish_version()` and is
|
||
flagged as such in its `change_reason`.
|
||
|
||
---
|
||
|
||
## 10. Task split
|
||
|
||
Rules applied: Talha owns architecture, database and constraints, integrations, parsing,
|
||
matching, duplicate detection, routing, access control, chatbot backend, security reviews,
|
||
deployment and all code review. Ahmed's stream is deliberately varied across frontend,
|
||
backend APIs, validation, testing, AI UX, dashboards and workflow logic — never only CRUD,
|
||
never only data cleaning. **Every Ahmed task names a Talha review checkpoint.**
|
||
|
||
Review gate key (each is a named Talha checkpoint with its own checklist):
|
||
|
||
| Gate | Name | Checklist focus |
|
||
|---|---|---|
|
||
| **G-ARCH** | Boundary & architecture review | `service.py`-only imports, import-linter contracts pass, no cross-module table access |
|
||
| **G-SCHEMA** | Migration & constraint review | Every invariant expressed in the DB; forbidden-write test exists; grants correct; migration is forward-only and reviewed as SQL |
|
||
| **G-API** | API contract review | OpenAPI diff, error envelope, pagination, Zod/serializer parity, generated client regenerated |
|
||
| **G-SEC** | Security review | Escaping, CSP, untrusted-input handling, no secret in code, authz on every path |
|
||
| **G-AUTHZ** | Permission review | Deny-by-default, `iam.can()` is the only decision point, matrix test updated |
|
||
| **G-AI** | AI provenance review | AiRun linked, versions pinned, no auto-reject path, explanation renders real stored values |
|
||
| **G-UX** | Design-system & a11y review | Token-only CSS, class names match the frozen stylesheet, keyboard reachable, contrast AA, 44px targets, loading/error/empty states present |
|
||
| **G-TEST** | Test-quality review | Real Postgres, meaningful assertions, forbidden-write tests, no DB mocking |
|
||
| **G-REL** | Release readiness | Migration replay on a staging clone, rollback plan, audit verifier green |
|
||
|
||
### 10.1 Phase 0
|
||
|
||
| ID | Owner | Description | Depends on | Review gate | Size |
|
||
|---|---|---|---|---|---|
|
||
| T-01 | Talha | Monorepo layout (`api/`, `web/`, `db/migrations/`) + docker compose (Postgres 16, Redis, MinIO) | — | G-ARCH | M |
|
||
| T-02 | Talha | Django 5 + DRF skeleton: per-env settings, drf-spectacular, single error envelope, request-id middleware, `SET LOCAL` actor middleware | T-01 | G-ARCH | M |
|
||
| T-03 | Talha | CI pipeline: ruff, mypy, import-linter, pytest on a real Postgres container, ESLint `react/no-danger`, stylelint token rule, `tsc --noEmit`, and **both** migration-drift gates — `makemigrations --check` (models vs state) and the SQL-level `pg_dump --schema-only` + trigger/column-privilege catalogue diff against the committed expected dump (§17). Also wires in the documentation gates, including `tools/check_evidence_citations.py`, which already exists in the repository and passes — it needs no services, so it belongs in the fast lint job rather than the Postgres job | T-01 | G-ARCH | M |
|
||
| T-04 | Talha | Sign off **ADR 0017** (`adr/0017-plain-sql-migrations-as-schema-authority.md`, `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: `UPDATE` on `job_version`,
|
||
`job_requirement`, `scoring_config_version`, `audit_event`, `ats_result` outside the
|
||
granted columns; a `candidate` insert with no `raw_intake`; a commit with no contact
|
||
channel; a malformed email; a second live application to the same job; an
|
||
overlapping assignment interval; an overlapping interview slot for one participant.
|
||
- **Idempotency tests.** Redelivering the same mail message or webhook payload produces no
|
||
second `raw_intake` row, by both `external_message_id` and `payload_sha256`.
|
||
- **History-completeness tests.** Every state transition produces exactly one history row
|
||
with non-overlapping intervals, and a transition performed without the `SET LOCAL` actor
|
||
bridge produces `actor_unknown = true` rather than a wrong attribution.
|
||
- **Merge/unmerge property test (Phase 2).** For a generated pair of candidates with child
|
||
rows across every `candidate_id`-carrying table: merge, then unmerge, then assert every
|
||
child row is back under its original parent. Plus a test asserting the merge routine
|
||
records a `candidate_merge_operation` for **every** such table — the enumeration is
|
||
maintained in one place and the test fails when a new table is added without it.
|
||
- **Migration replay test.** Every migration applies cleanly from empty to head, and from
|
||
a staging-shaped clone, in CI.
|
||
- **Audit hash-chain test.** Tampering with a row is detected by the verifier.
|
||
|
||
### 11.3 Permission tests
|
||
|
||
Treated as a first-class layer, because Phase 1 has no database-level backstop (RLS is
|
||
Phase 2) and the codebase has no authorization at all today (findings §D).
|
||
|
||
| Test | Assertion |
|
||
|---|---|
|
||
| Endpoint × role matrix (A-22) | Every endpoint, for every one of the 7 roles (`05` §2.1), returns the expected allow/deny. A new endpoint without a matrix row fails CI |
|
||
| Single decision point | Static assertion that no view module queries a repository directly, and every candidate-reading endpoint routes through `iam.can()` |
|
||
| Deny by default | An endpoint declaring no permission returns 403, not 200 |
|
||
| Scope enforcement | An interviewer sees only candidates on their own assigned interviews; a recruiter sees only their own workload where scoped |
|
||
| Chatbot parity (Phase 2) | For a matrix of questions × roles, any answer containing data the same user's REST call would be denied is a test failure. No service account exists to be tested |
|
||
| Delegated access audit | Every chatbot answer writes an `audit_event` with `actor_kind = 'ai_agent'` and `on_behalf_of_user_id` set |
|
||
| Token surfaces | An expired, revoked or consumed `candidate_access_token` is refused; knowledge of a `public_id` alone grants nothing |
|
||
|
||
### 11.4 Pipeline tests against malformed CVs
|
||
|
||
The intake pipeline's contract is: **nothing is silently lost, and nothing malformed ever
|
||
produces a candidate.** That is tested against a versioned corpus in the repository, each
|
||
file with a declared expected outcome. A new parser version must re-run the whole corpus.
|
||
|
||
| Corpus case | Expected outcome |
|
||
|---|---|
|
||
| Clean text PDF | `parsed`, high confidence, promotable |
|
||
| Scanned image-only PDF | `parsed` via OCR with low confidence, or `needs_review` — never a guessed field |
|
||
| Two-column / table-heavy layout | `parsed` or `partial`; no field silently interleaved from the wrong column |
|
||
| Password-protected / encrypted PDF | `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**
|
||
|
||
1. Merged to `main` via a PR reviewed by Talha under its named review gate (§10). No direct
|
||
pushes to `main`.
|
||
2. PR is ≤ ~400 changed lines, or was split. Oversized PRs are returned unreviewed.
|
||
3. CI green on every job: ruff, mypy, import-linter contracts, pytest on real Postgres,
|
||
migration-drift gate, ESLint (`react/no-danger` error), stylelint token rule,
|
||
`tsc --noEmit`, unescaped-interpolation grep gate.
|
||
4. Deployed to staging automatically on merge, and exercised there — not only locally.
|
||
5. No new `TODO` referencing a missing invariant, permission or test.
|
||
|
||
**Backend / schema tasks additionally**
|
||
|
||
6. Every invariant is expressed in the database (constraint, trigger, index or grant), not
|
||
only in application code.
|
||
7. A forbidden-write test exists and passes for each new constraint.
|
||
8. Migration applies forward from empty **and** from a staging-shaped clone. No
|
||
down-migration is written; recovery is forward-fix plus PITR.
|
||
9. Cross-module access goes through `service.py` only; `import-linter` contract updated if
|
||
a legitimate new edge was added.
|
||
10. Every state change produces an `audit_event` with a correct actor, and history rows
|
||
with non-overlapping intervals.
|
||
|
||
**API tasks additionally**
|
||
|
||
11. OpenAPI schema regenerated, TS client regenerated, error envelope and pagination
|
||
conventions followed, Zod schema and DRF serializer agree on every field and message.
|
||
12. A row exists in the permission matrix test for every new endpoint.
|
||
|
||
**Frontend tasks additionally**
|
||
|
||
13. No `dangerouslySetInnerHTML`. All user- or candidate-derived text renders through JSX
|
||
escaping.
|
||
14. CSS uses only existing `var(--…)` tokens from the frozen `styles.css`; class names
|
||
match the design system.
|
||
15. Loading, empty, error and permission-denied states all exist and were demonstrated.
|
||
16. Keyboard reachable, focus visible, contrast AA in **both** themes, 44px touch targets,
|
||
usable at 320px — matching the prototype's verified baseline (findings §G).
|
||
17. If it replaces a prototype screen, the prototype counterpart is deleted **in the same
|
||
PR**.
|
||
|
||
**AI-touching tasks additionally**
|
||
|
||
18. Every invocation writes an `AiRun` row before its output is usable; model, prompt and
|
||
config versions are pinned on any stored result.
|
||
19. No path exists by which an AI actor produces a terminal-negative outcome; a test
|
||
asserts the refusal.
|
||
20. 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 `innerHTML` interpolation sites escaped,
|
||
zero inline handlers, an active CSP without `unsafe-inline`, and a hostile candidate name
|
||
rendering as literal text in both themes, with the poisoned-CV fixture corpus asserting it. This
|
||
is the highest-value item in month one and it lands earliest, deliberately: it is the one thing
|
||
that must be true before real CV data exists anywhere near this system (findings §E).
|
||
- **A working local environment from one command** — API, worker, Postgres 16, Redis and object
|
||
storage under `docker compose up`, with the first migration applied, where today the repository
|
||
has no compose file, no migration and no backend of any kind (findings §B).
|
||
- **A CI pipeline that is red for the right reasons** — a new unescaped `${` in a template literal,
|
||
a `dangerouslySetInnerHTML`, a cross-module import bypassing `service.py`, a model change with no
|
||
migration, a raw colour instead of a `var(--…)` token, and an unclassified candidate-touching
|
||
column. Six mechanical gates, all passing on `main`, where today `.github/` does not exist.
|
||
- **The schema foundation** — schemas `app`/`ref`/`audit`/`ai`/`staging` and the four extensions
|
||
applying from empty, with the migration-runner ADR settling the §17 divergence 1 conflict in
|
||
writing before the first table exists.
|
||
- **The React shell** rendering the preserved design system in both themes, with the ported
|
||
`js/ui.js` primitives and a chart from the retained `js/charts.js` — the findings §G assets
|
||
demonstrably intact after the move.
|
||
|
||
### 15.2 Lands in weeks 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) and `raw_intake` (T-11) are Phase 1 and start mid-W12.
|
||
A real CV file landing as an immutable `raw_intake` row with a sha256 in blob storage — previously
|
||
a month-one promise — is a Phase 1 deliverable.
|
||
- **A malformed file terminating in `rejected_unusable` with no candidate.** Same reason. This is
|
||
the single most important invariant in the product and it is demonstrated at D1, not D0.
|
||
- **The first thin end-to-end thread** (manual CV → intake → parse → human promotion → candidate →
|
||
application → one `ats_result`). Previously a "stretch, only if 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.md` states that one month yields "Phase 0 plus one vertical slice" — on
|
||
this document's own sizing it does not, and §17 divergence 5 records that rather than restating
|
||
the claim.
|
||
- **Requirement editing minting a new `job_version` with the old score unchanged.** Needs T-17 and
|
||
T-20b, both Phase 1.
|
||
- **Inbound email.** Depends on an Entra ID app registration and `Mail.Read` admin consent
|
||
from corporate IT, requested on day 1 of week 1 and outside the team's control.
|
||
- **Accurate parsing across real CV variety.** Month one does not prove the pipeline exists at all,
|
||
let alone field-level accuracy on scanned, two-column or non-English CVs. That takes the full
|
||
Phase 1 corpus cycle.
|
||
- **Any AI capability.** The `AiRun` ledger is Phase 1, not Phase 0, and scoring v1 is
|
||
deliberately rule-based. No model is called in month one.
|
||
- **The pipeline board, interviews, scorecards, offers, assessments, notifications,
|
||
dashboards, reports, talent pool, the chatbot, and job-board publishing.** Phases 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_*.sql` is the schema authority. Every Django migration is
|
||
> `SeparateDatabaseAndState(database_operations=[RunSQL(<that file>)], state_operations=[…])`,
|
||
> so Django owns ordering and the applied-state ledger and authors no DDL. **Every model stays
|
||
> `managed = True`; `managed = False` is used on no table**, because it would remove exactly the
|
||
> tables that carry invariants from the one gate watching them. `makemigrations --check` compares
|
||
> models against declared migration *state* — never against the live database — so it is kept as
|
||
> the **model-vs-state** gate, and it is kept quiet not by a flag but by declaring every object
|
||
> Django *can* model in `Meta.constraints` / `Meta.indexes` (`CheckConstraint`,
|
||
> `UniqueConstraint(condition=…)`, `Index(Lower(…))`, `ExclusionConstraint`) and mirroring those
|
||
> same declarations in `state_operations`. Objects Django cannot model at all — triggers,
|
||
> column-level `GRANT`/`REVOKE`, `RANGE` partitions and their attach/detach, generated columns,
|
||
> `DEFERRABLE INITIALLY DEFERRED` constraint triggers, and `procrastinate`'s vendor-managed
|
||
> migrations — are named in an explicit, reviewed `db/schema-ignore.toml`, and are covered instead
|
||
> by a **second, SQL-level gate**: CI builds a database by running every migration, captures
|
||
> `pg_dump --schema-only --no-owner` plus a catalogue query for triggers and column privileges,
|
||
> and diffs that against the committed expected dump; any difference fails the build, and updating
|
||
> the expected dump is a reviewed part of the migration PR. Two gates, two failure modes, neither
|
||
> one silently lying: the ORM gate catches a model that has drifted from state, the SQL gate
|
||
> catches a database object that no migration created — or that a migration created and nobody
|
||
> reviewed. Signed off as ADR 0017 **before migration `001` is written**; it restates the
|
||
> mechanism already binding in `adr/0002-primary-relational-database.md` §3.
|
||
|
||
*Plan consequences:* T-04 must land the `db/schema-ignore.toml` file and the second CI gate — the
|
||
`pg_dump`/catalogue diff script and the committed expected dump — **before** migration `001`, so
|
||
the gate exists from the first table rather than being retrofitted over 60 tables. Budget ~1
|
||
developer-day for the script and the ignore list on top of the ADR itself; it is a clean,
|
||
independently demonstrable Ahmed task with a Talha review, and it is the reason the earlier
|
||
"`managed = False` where the ORM's view would drift" recommendation was dropped: that flag would
|
||
have removed the invariant-bearing tables from both gates instead of one.
|
||
|
||
**2. pgvector phasing.** Part 1's deployment decision provisions the database with
|
||
"`pgvector` and `pg_trgm` enabled"; Part 2 lists pgvector as "Phase 2 only". Harmless but
|
||
worth stating: this plan enables `pg_trgm`, `unaccent`, `btree_gist` and `pgcrypto` in
|
||
Phase 0 and defers the `pgvector` **extension creation and any embedding table** to Phase 2,
|
||
so no Phase 1 code can quietly start depending on vectors. If the managed instance is
|
||
provisioned with pgvector available-but-not-created, both statements hold.
|
||
|
||
**3. Entity naming.** Part 1 names the module `requisition` with `Requisition` /
|
||
`RequisitionVersion` / `Application` / `ApplicationScore` / `ApplicationStageHistory`;
|
||
Part 2 names the tables `job` / `job_version` / `job_application` / `ats_result` /
|
||
`job_application_stage_history`. Same entities, two vocabularies. This plan uses Part 1 for
|
||
module and service names and Part 2 for table names, and a glossary mapping the two should
|
||
be added to the ADR set (T-08) before Phase 1 — otherwise the junior will reasonably assume
|
||
they are different things, and API field names will end up mixed.
|
||
|
||
**4. Duplicate-review phasing granularity.** Part 1's module table says "1 detect / 2 merge
|
||
UI", while Part 2 specifies the full merge, undo-log and reversal machinery without a phase.
|
||
This plan reads the intent as: detection plus the flagged queue in Phase 1 (T-22), and the
|
||
merge engine, undo log and reversal in Phase 2 (T-25a/b) — because reversal correctness is
|
||
the highest-risk logic in the schema and should not be compressed into the phase that is
|
||
already carrying parsing, authorization and the Graph integration.
|
||
|
||
**5. Phase effort ranges — this plan's own task sizing contradicts the inherited ranges, and the
|
||
plan follows the sizing.** This is the largest divergence in the set and the only one where this
|
||
document declines to inherit. `_decisions.md`'s phase table gives Phase 0 at 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.
|