HR-ATS-Portal/docs/architecture/09-end-to-end-flow.md

14 KiB
Raw Blame History

End-to-End Flow

Status / Scope. A consolidated view of how a candidate moves through the platform, from any intake source to a terminal outcome. The other documents in this package each diagram their own concern; this one exists because none of them shows the whole spine in a single picture.

Nothing here introduces new design. Every element traces to 02-system-architecture.md (modules), 03-database-design.md (entities), 04-integrations-and-processing.md (pipeline steps) and 05-security-rbac-ai-governance.md (human-decision gates). Where this document and those disagree, they win — raise it as a defect.

Rendered copies. Each diagram below is also exported as a standalone SVG in diagrams/, for dropping into slides or sharing with people who will not open a markdown file. They are generated from the mermaid blocks in this document — if you change a diagram here, re-export rather than editing the SVG.

Diagram SVG
1. Master flow diagrams/01-master-flow.svg
2. Intake resolution diagrams/02-intake-resolution.svg
3. Identity and duplicates diagrams/03-identity-and-duplicates.svg
4. Application lifecycle diagrams/04-application-lifecycle.svg
5. Phase cut diagrams/05-phase-cut.svg

Two conventions used throughout:

  • Bold-bordered nodes are human decision points. Everything else is automatic. The "AI is advisory" requirement (assignment §3.12, §5.7) is visible as a structural property of these diagrams: no automatic path reaches a terminal-negative outcome.
  • Phase tags (P1P4) mark when a step becomes available. Phase 1's cut line is 07-implementation-plan.md §4.2.

1. The master flow

The complete journey. Six intake sources converge on one raw-intake layer; nothing becomes a candidate until it has been resolved.

flowchart LR
    S1["Careers mailbox · P1"]
    S2["Careers website · P2"]
    S3["Manual upload · P1"]
    S4["Referral · P2"]
    S5["LinkedIn source · P1"]
    S6["Agency, campus,<br/>walk-in · P2"]

    S1 --> RAW
    S2 --> RAW
    S3 --> RAW
    S4 --> RAW
    S5 --> RAW
    S6 --> RAW

    RAW["<b>raw_intake</b><br/>provider msg id + mailbox<br/>= idempotency key"]
    RAW --> ATT["<b>intake_attachment</b><br/>content-addressed<br/>SHA-256"]
    ATT --> PARSE["<b>intake_parse_attempt</b><br/>every attempt kept,<br/>never overwritten"]

    PARSE --> RESOLVE{{"Resolvable?<br/>diagram 2"}}
    RESOLVE -->|"no"| QUEUE["Unassigned<br/>Applications Queue"]
    QUEUE --> HR1["HR resolves in<br/>Recruitment Inbox"]
    HR1 --> RESOLVE

    RESOLVE -->|"yes"| IDENT["Identity resolution<br/>diagram 3"]
    IDENT --> CAND["<b>candidate</b><br/>one master profile"]
    CAND --> APP["<b>job_application</b><br/>pins job_version"]

    APP --> SCORE["ATS scoring<br/>pins job_version +<br/>scoring_config + model"]
    SCORE --> ROUTE["Recruiter routing"]
    ROUTE --> REVIEW["Recruiter review<br/>score is advisory"]
    REVIEW --> PIPE["Pipeline<br/>every change to history"]

    PIPE --> HIRED["Hired"]
    PIPE --> REJ["Rejected<br/>with reason"]
    PIPE --> WD["Withdrawn"]
    PIPE --> POOL["Talent pool · P3"]

    classDef human stroke-width:3px,stroke:#004d43
    classDef ai stroke-dasharray:5 3
    classDef terminal fill:#eafff4,stroke:#004d43
    classDef src fill:#f4ffdf
    class HR1,REVIEW human
    class SCORE,PARSE ai
    class HIRED,REJ,WD,POOL terminal
    class S1,S2,S3,S4,S5,S6 src

The three terminal-negative outcomes (Rejected, Withdrawn, and any rejection routed to Talent pool) are reachable only from Recruiter review and the pipeline stages a human drives. No edge runs from ATS scoring to a terminal state.

Reading the diagram. Dashed nodes are the only two places a model runs. Both feed a bold-bordered human node before anything terminal happens. application.transition() refuses any terminal-negative move whose actor_kind is not 'user' (RULING-01), so the "AI never auto-rejects" guarantee is enforced at the service and database layer, not by the shape of this picture.


2. Intake resolution — the hard cases

Assignment §3.3 lists eight awkward realities of a careers mailbox. This is how each one resolves without creating an incorrect candidate or application record. Every path either produces a resolved pair or parks the item for a human; none silently drops or silently invents.

flowchart TB
    IN["Message arrives<br/>raw_intake row written first"] --> DUP{{"Provider msg id<br/>already seen?"}}
    DUP -->|"yes"| STOP["Ignore — idempotent.<br/>No second row."]
    DUP -->|"no"| SCAN["Validate + malware scan<br/>each attachment"]

    SCAN --> BAD{{"Attachment usable?"}}
    BAD -->|"corrupt / password-protected /<br/>unsupported type"| ERRQ["state = needs_review<br/>file retained, error shown"]
    BAD -->|"yes"| COUNT{{"How many CVs<br/>in this message?"}}

    COUNT -->|"zero"| NOCV["No CV.<br/>Could be a query, a reply,<br/>or spam."]
    COUNT -->|"one"| ONE["One intake item"]
    COUNT -->|"many"| MANY["One intake item PER CV.<br/>Siblings share the source message."]

    NOCV --> ERRQ
    ONE --> JOB
    MANY --> JOB

    JOB{{"Job identifiable?<br/>subject line, form field,<br/>reply-to thread"}}
    JOB -->|"yes"| KNOWN["Target job known"]
    JOB -->|"no — general application"| GEN["No job.<br/>Valid outcome, not an error."]

    GEN --> ERRQ
    KNOWN --> IDENT["Proceed to identity resolution<br/>diagram 3"]

    ERRQ --> HUMAN["HR in Recruitment Inbox:<br/>link a job, create or connect a candidate,<br/>reply, or dismiss"]
    HUMAN -->|"resolved"| IDENT
    HUMAN -->|"not an application"| DISMISS["Dismissed<br/>audit record retained"]

    classDef human stroke-width:3px,stroke:#004d43
    classDef park fill:#fff2d9
    class HUMAN human
    class ERRQ,NOCV,GEN,DISMISS park
Assignment §3.3 case Path above Why no bad record is created
One CV ONE → identity resolution Normal path
Multiple CVs MANY One intake item per CV; siblings keep the shared source_message_id, so the thread stays intact
No CV NOCVERRQ Never reaches candidate creation. A human decides whether it is an application at all
CV without a job title JOBGENERRQ A general application is a legitimate outcome, parked for a human to route — not a parse failure
General application same as above Resolvable later against any open job without re-parsing
Repeat applicant identity resolution, diagram 3 Matches to the existing candidate; a new application, not a new profile
Applying for multiple jobs one job_application per job The candidate row is untouched — this is the §5.2 separation working
Unsupported / corrupted BADERRQ File retained, error surfaced. Never discarded

3. Identity resolution and duplicate handling

The point at which raw intake becomes a person. Uncertain matches are never merged automatically (assignment §3.6).

flowchart TB
    START["Parsed CV fields available"] --> SIG["Compute match signals:<br/>normalised email, normalised phone,<br/>name, file hash, CV text similarity,<br/>LinkedIn URL, employment, education"]

    SIG --> CLASS{{"Classification"}}

    CLASS -->|"confirmed<br/>exact email or file hash"| LINK["Link to existing candidate"]
    CLASS -->|"probable"| PAIR["candidate_duplicate_pair<br/>queued for review"]
    CLASS -->|"possible"| PAIR
    CLASS -->|"not duplicate"| NEW["Create new candidate"]

    PAIR --> REV["HR duplicate review<br/>hr_admin only"]
    REV -->|"confirm"| MERGE["Merge"]
    REV -->|"reject"| DISTINCT["Recorded as confirmed_distinct<br/>never re-raised"]

    DISTINCT --> NEW
    LINK --> APPNEW["Create job_application"]
    NEW --> APPNEW

    MERGE --> OPS["candidate_merge_operation rows<br/>one per moved artefact,<br/>each with previous_value"]
    OPS --> SURV["Survivor holds all applications,<br/>documents, communications,<br/>scores, notes"]
    SURV --> APPNEW

    SURV -.->|"reversal"| UNDO["Replay operations by<br/>reversal_rank, not by seq"]
    UNDO --> SPLIT["Both candidates restored<br/>nothing lost"]

    classDef human stroke-width:3px,stroke:#004d43
    class REV human

Why reversal_rank and not descending seq. Undoing in reverse-application order clears superseded_by_application_id before re-parenting the application, which momentarily leaves two live applications on the same candidate for the same job — exactly what the uq_application_live partial unique index forbids. A partial unique index cannot be DEFERRABLE, so that violation aborts the whole reversal transaction, and every merge that collided on a job would become permanently unreversible. A fixed per-op_kind rank re-parents first and clears the supersession last. Full treatment: 03-database-design.md §19.5.


4. Application lifecycle

Stages are configuration, not code (pipeline_stage + pipeline_transition_rule). The states below are the seeded default from assignment §3.10 — a different job category can define a different set without a schema change.

stateDiagram-v2
    [*] --> Received: application created
    Received --> Processing: CV text extracted
    Processing --> Screening: AI score produced (advisory)
    Screening --> RecruiterReview: queued to assigned recruiter

    RecruiterReview --> Shortlisted: recruiter decides
    RecruiterReview --> Rejected: recruiter decides
    RecruiterReview --> OnHold: recruiter decides
    RecruiterReview --> TalentPool: recruiter decides

    Shortlisted --> Assessment
    Assessment --> InitialInterview
    InitialInterview --> FinalInterview
    FinalInterview --> OfferApproval
    OfferApproval --> OfferSent: approvers sign off
    OfferSent --> Hired: candidate accepts
    OfferSent --> Rejected: candidate declines

    Assessment --> Rejected
    InitialInterview --> Rejected
    FinalInterview --> Rejected
    OnHold --> RecruiterReview: resumed
    Rejected --> TalentPool: recruiter opts in

    Hired --> [*]
    TalentPool --> [*]
    Withdrawn --> [*]
    RecruiterReview --> Withdrawn: candidate withdraws

    note right of Screening
        The only automatic transition
        into a scored state. It cannot
        reach Rejected: actor_kind
        must be 'user' for any
        terminal-negative move.
    end note

Every transition writes an application_stage_history row with actor, actor_kind, reason and timestamp. The current_stage_id column on job_application is a denormalised convenience; the history table is the source of truth (assignment §5.5).


5. Where the phases cut

The same spine, coloured by phase, so the Phase 1 cut line is legible at a glance.

flowchart TB
    P0["<b>Phase 0 — scope and architecture</b><br/>this package · repository review · ADRs<br/>schema design · Phase 1 backlog · risk register<br/>P0 XSS and CSP hardening"]

    P1["<b>Phase 1 — core ATS and email intake</b><br/>auth, core roles, departments · jobs and job versions<br/>manual upload + careers mailbox · raw intake + Recruitment Inbox<br/>object storage · text extraction + basic parsing<br/>candidate and application · basic ATS score<br/>recruiter assignment · basic pipeline · audit history"]

    P2["<b>Phase 2 — connected recruitment</b><br/>requisitions + approval · director access<br/>careers website + job publishing · communication templates<br/>duplicate review and merge · advanced routing<br/>department and region reporting · stronger access control"]

    P3["<b>Phase 3 — complete workflow</b><br/>interview scheduling + feedback + scorecards<br/>assessments · offers and approval<br/>talent pool · recruiter analytics · data migration"]

    P4["<b>Phase 4 — AI assistant and advanced search</b><br/>read-only chatbot · semantic search (pgvector)<br/>candidate comparison · JD assistance<br/>communication drafting · HRMS readiness"]

    P0 --> P1 --> P2 --> P3 --> P4

    CUT["<b>Phase 1 cut line</b><br/>everything above this point is the<br/>demonstrable end-to-end spine"]
    P1 -.-> CUT

    classDef p0 fill:#ffffff,stroke:#54726c
    classDef p1 fill:#eafff4,stroke:#004d43,stroke-width:3px
    classDef p2 fill:#f4ffdf,stroke:#6f8f14
    classDef p3 fill:#ecedff,stroke:#5b60e8
    classDef p4 fill:#e4f4f9,stroke:#0d6580
    classDef cut fill:#fff2d9,stroke:#8a5a00,stroke-dasharray:5 3
    class P0 p0
    class P1 p1
    class P2 p2
    class P3 p3
    class P4 p4
    class CUT cut

Phase 1 proves the spine end to end: a CV arrives by email or manual upload, lands in the inbox, becomes a candidate and an application, is parsed and scored, is routed to a recruiter, and moves through the pipeline — with every step audited. Everything else builds outward from that.

What Phase 1 deliberately excludes, and why, is 07-implementation-plan.md §4.2. The honest schedule for it is 2430 weeks, not the 14 originally carried over — see §2 of that document for the reconciliation.


6. Traceability

Diagram Requirements shown Primary source document
1. Master flow §3.2 sources, §3.5 separation, §3.7 routing, §3.10 pipeline, §3.12 advisory AI 02 §3, 04 §4
2. Intake resolution §3.3 all eight cases, §3.4 inbox, §5.1 raw intake first 04 §2, 03 §18
3. Identity + duplicates §3.6 duplicate detection, merge, reversal 03 §13, §19
4. Lifecycle §3.10 configurable pipeline, §5.5 current + history, §5.7 no auto-reject 03 §15, §16
5. Phase cut §24 phased delivery 07 §2, §4