Feature catalogue
All 500 features with their real status: built, partial or not started, each with the file you can check it against.
Counted with no provider configured — the strictest reading. This page checks the running deployment when it loads and raises anything whose provider answers.
This is the whole product written down: what is built, what is half built, and what is not started. Three rules keep it usable. Built means a reader can find it in the repository, and the last column says where. Anything that depends on a provider, a credential or a contract is shown one step lower until the provider actually answers — so this page describes this deployment, not the ambition. Not started is written plainly, with the reason, because a catalogue that shows 500 of 500 complete is a catalogue nobody believes.
The catalogue is maintained in English: the entries are engineering statements tied to file names, and a translated copy would drift from the code within a release. The rest of Hawk Ai, including everything an applicant sees, is available in fourteen languages. The same data is at GET /v2/features.
Applicant intake 40/40
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
intake.001 | Hosted verification flow on a per-applicant link | Built | Core | public/verify.html · POST /v2/verification-sessions |
intake.002 | Verification link is single-use | Built | Core | server/api.js — verify/submit rejects a used session |
intake.003 | Verification link expires (7 days) | Built | Core | verification_sessions.expires_at |
intake.004 | Submission requires a valid session token | Built | Core | server/api.js — 401 without one |
intake.005 | Applicant consent captured before any check | Built | Core | consents table · step 1 of the flow |
intake.006 | Consent text stored verbatim with the record | Built | Core | consents.text |
intake.007 | Consent records the IP it was given from | Built | Core | consents.ip |
intake.008 | Step-by-step progress indicator | Built | Core | public/verify.html — 8-step stepper |
intake.009 | Resume progress on refresh | Built | Core | in-page state, plus the encrypted server-side draft on a live session |
intake.010 | Mobile-first layout | Built | Core | public/assets/css/hawkai.css |
intake.011 | Flow is white-labelled with the client name | Built | Core | GET /v2/verify/session/:token · data-client-tpl |
intake.012 | Flow adopts the client brand colour | Built | Expected | tenants.color → --client custom property |
intake.013 | Client logo on the flow | Built | Expected | POST /v2/tenant/branding/logo — 200 KB cap, and an SVG carrying script is refused rather than sanitised |
intake.014 | Flow available in 14 languages | Built | Core | public/assets/i18n · 3,100 keys |
intake.015 | Right-to-left layout for Arabic | Built | Core | public/assets/css/rtl.css |
intake.016 | Language chosen per link | Built | Core | verification_sessions.locale |
intake.017 | Applicant can switch language mid-flow | Built | Expected | i18n switcher, no reload, route preserved |
intake.018 | Client reference carried through (external_id) | Built | Core | verification_sessions.external_id → applicant |
intake.019 | Redirect back to the client on completion | Built | Expected | the flow navigates to the link’s redirect_url with the outcome; http(s) only, so it cannot be turned into an open redirect |
intake.020 | Applicant told what happens next, never the internal signals | Built | Core | server/api.js — message, no blocked_reasons |
intake.021 | Honest failure when the server refuses a submission | Built | Core | public/verify.html — renders a refusal, not a fake approval |
intake.022 | Demo controls hidden on a live session | Built | Core | display:none !important on every data-demo-only affordance when a session is present |
intake.023 | Outcome cannot be changed client-side on a live session | Built | Core | setOutcome pinned; the server decides |
intake.024 | Rate limit on submissions | Built | Expected | 20/min per IP |
intake.025 | Server-side applicant creation via API | Built | Core | POST /v2/applicants |
intake.026 | Bulk applicant import | Built | Expected | POST /v2/applicants/import — CSV, validated first, and it says which rows it refused |
intake.027 | Re-verification of an existing applicant | Built | Expected | POST /v2/applicants/:id/recheck |
intake.028 | Applicant can save and resume later from a link | Built | Extra | the draft is encrypted against the session; images are deliberately not kept |
intake.029 | Country of document chosen by the applicant | Built | Expected | every ISO 3166-1 code, named from the reader’s own CLDR data |
intake.030 | Document type chosen by the applicant | Built | Expected | passport / ID card / licence / residence permit |
intake.031 | Address and personal details capture | Built | Core | details step |
intake.032 | Employer and job title capture | Built | Expected | details step — used for the footprint cross-check |
intake.033 | Accessibility: keyboard-navigable flow | Built | Expected | focus management between steps |
intake.034 | Accessibility: visible focus and ARIA labels | Built | Expected | hawkai.css :focus-visible · aria-label on controls |
intake.035 | Accessibility: WCAG AA contrast across both themes | Built | Extra | audited — 7 remaining are third-party brand colours |
intake.036 | Light and dark themes on the flow | Built | Expected | A.theme, applied before first paint |
intake.037 | Theme follows the operating system and updates live | Built | Extra | prefers-color-scheme listener |
intake.038 | Applicant sees which checks ran | Built | Expected | checks_ran in the submission response |
intake.039 | Privacy notice linked from the flow | Built | Core | content/privacy.md → /privacy |
intake.040 | Retention explained to the applicant in their language | Built | Expected | both clocks stated, 14 languages |
Identity documents 24/30
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
doc.001 | Document image capture in the browser | Built | Core | getUserMedia on a live session, downscaled to 1600px and uploaded; a file input when the camera is refused or absent |
doc.002 | Front and back capture for two-sided documents | Built | Core | the back is asked for only when the chosen document type has one |
doc.003 | Document images encrypted at rest | Built | Core | server/crypto.js AES-256-GCM · documents.data_enc |
doc.004 | Document upload via API | Built | Core | POST /v2/applicants/:id/documents |
doc.005 | Upload size limit enforced | Built | Core | 8 MB per image |
doc.006 | Base64 payload validated before storing | Built | Core | rejects anything that does not decode |
doc.007 | MRZ parsing | Built | Core | server/adapters/index.js parseMrz |
doc.008 | MRZ check-digit arithmetic verified ourselves | Built | Core | mrzCheckDigit — not a model, arithmetic |
doc.009 | MRZ document number, DOB and expiry extracted | Built | Core | parseMrz fields |
doc.010 | Expired document detected | Built | Core | expiry compared against the decision date |
doc.011 | Age derived from the document and checked against a minimum | Built | Core | server/engine.js ageFrom · UNDERAGE block |
doc.012 | Document authenticity (print, fonts, holograms) | Not started | Core | needs a document-authenticity provider |
doc.013 | Tamper and alteration detection | Not started | Core | needs a document-authenticity provider |
doc.014 | Tampered document blocks approval, unconditionally | Built | Core | server/engine.js HARD_BLOCKS |
doc.015 | OCR of the visual inspection zone | Not started | Core | needs a document-authenticity provider |
doc.016 | Document type coverage (passport, ID, licence, permit) | Partial | Expected | needs a document-authenticity provider |
doc.017 | NFC chip read for ePassports | Not started | Expected | needs WebNFC plus a provider that verifies the SOD signature |
doc.018 | Glare and blur guidance during capture | Built | Expected | capture hints in the flow |
doc.019 | Retake prompt on a failed capture | Built | Expected | document step |
doc.020 | Document images deleted on the retention clock | Built | Core | server/retention.js — 90 days after the decision |
doc.021 | Erased image returns 410 with the reason | Built | Core | GET /v2/documents/:id |
doc.022 | Document hash kept after the image is erased | Built | Core | the row survives; data_enc is nulled |
doc.023 | Reviewer can view the document in the console | Built | Expected | public/admin.html document panel |
doc.024 | Every view of a document is audited | Built | Expected | server/audit.js category access |
doc.025 | Side-by-side document and selfie comparison for the reviewer | Built | Extra | admin applicant detail |
doc.026 | Document country vs declared nationality cross-check | Built | Expected | engine signal |
doc.027 | Document number duplicate detection across applicants | Built | Expected | a per-tenant keyed hash, so the comparison works and the number is not stored |
doc.028 | Proof-of-address document capture | Built | Extra | asked for in the flow when the client sets requireProofOfAddress, and the submit button waits for it |
doc.029 | Sanctioned or high-risk issuing country flagged | Built | Expected | country risk signal in the engine |
doc.030 | Document template version recorded for audit | Not started | Extra | needs a document-authenticity provider |
Biometrics and liveness 10/15
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
bio.001 | Selfie capture in the browser | Built | Core | the front camera on a live session, uploaded against the verification link |
bio.002 | Selfie encrypted at rest | Built | Core | AES-256-GCM, same key policy as documents |
bio.003 | Face match: selfie against the document portrait | Not started | Core | needs a face-match and liveness provider |
bio.004 | Passive liveness | Not started | Core | needs a face-match and liveness provider |
bio.005 | Active liveness challenge | Not started | Expected | needs a face-match and liveness provider |
bio.006 | Deepfake and injection-attack detection | Not started | Core | needs a face-match and liveness provider |
bio.007 | A liveness check that did not run blocks approval | Built | Core | CHECK_DID_NOT_RUN — silence is never a pass |
bio.008 | Biometric data treated as special category | Built | Core | privacy notice + DPA; explicit consent basis |
bio.009 | Selfie deleted on the retention clock | Built | Core | server/retention.js |
bio.010 | Face match score shown to the reviewer | Partial | Expected | needs a face-match and liveness provider |
bio.011 | Attempt count recorded | Built | Expected | applicant timeline |
bio.012 | Camera permission denial handled gracefully | Built | Extra | file upload fallback |
bio.013 | Front camera preferred on mobile | Built | Extra | facingMode user |
bio.014 | Selfie never shared with third parties beyond the provider | Built | Expected | stated in the privacy notice; no other egress |
bio.015 | Biometric consent can be withdrawn | Built | Extra | withdraw flow in My Hawk Ai ID |
Screening 33/35
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
scr.001 | Sanctions screening against the official lists | Built | Core | tools/load-sanctions.js · 26,103 designations |
scr.002 | OFAC Specially Designated Nationals loaded | Built | Core | US Treasury sdn.csv — ~19,400 |
scr.003 | OFAC Consolidated (non-SDN) loaded | Built | Core | US Treasury cons_prim.csv |
scr.004 | EU Consolidated financial sanctions list loaded | Built | Core | European Commission FSF |
scr.005 | Aliases screened as first-class names | Built | Core | alt.csv and EU NameAlias rows — 67k searchable |
scr.006 | Lists refreshed automatically | Built | Core | server.js — daily, plus 20s after a boot with none |
scr.007 | List age reported and flagged stale past 7 days | Built | Core | GET /v2/screening/lists |
scr.008 | Manual refresh (platform admin) | Built | Expected | POST /v2/screening/refresh |
scr.009 | Provenance recorded per list (authority, URL, date) | Built | Core | screening_lists.source and .version |
scr.010 | Fuzzy name matching tolerant of transliteration | Built | Core | token-level Jaro-Winkler |
scr.011 | Name order independence | Built | Core | greedy token pairing — "Al-Tikriti, Saddam" = "Saddam Al-Tikriti" |
scr.012 | Diacritics and case normalised | Built | Core | NFKD, combining marks stripped |
scr.013 | Non-Latin scripts matched, not discarded | Built | Core | nameNorm keeps \p{L} — the old version emptied them |
scr.014 | Both sides of a name must be covered to score | Built | Core | harmonic mean of the two coverages |
scr.015 | Date of birth corroboration promotes or demotes a candidate | Built | Core | dob_match ±ranking |
scr.016 | Match score exposed for triage | Built | Core | hits[].score |
scr.017 | Precision and recall measured, not assumed | Built | Core | tools/test/screening-precision.js — 6/6 recall, 9% referral |
scr.018 | Confirmed sanctions match blocks approval, unconditionally | Built | Core | SANCTIONS_MATCH in HARD_BLOCKS |
scr.019 | Reviewer confirms or dismisses each hit | Built | Core | POST /v2/applicants/:id/screening/:index |
scr.020 | Dismissing a hit requires a written reason | Built | Core | refuses without one — it is the record of why |
scr.021 | Hit resolution is audited | Built | Core | server/audit.js |
scr.022 | Which list a hit came from is shown | Built | Expected | hits[].list |
scr.023 | Designation reference shown for verification against the source | Built | Expected | hits[].ref |
scr.024 | Entity vs individual distinguished | Built | Expected | entries.kind |
scr.025 | PEP screening | Not started | Core | needs a commercial PEP / adverse-media data set |
scr.026 | Adverse-media screening | Not started | Core | needs a commercial PEP / adverse-media data set |
scr.027 | Readiness separates sanctions from PEP/adverse media | Built | Core | /readyz sanctions and pep_adverse are distinct items |
scr.028 | Ongoing monitoring: re-screen as lists change | Built | Core | server/monitoring.js — runs after every list refresh, alerts only on hits that are new |
scr.029 | Operator-supplied internal blocklist | Built | Expected | PUT /v2/screening/internal — tagged to the tenant, and the matcher refuses to score it for anyone else |
scr.030 | Country and programme of designation shown | Built | Extra | entries.country · programme captured at load |
scr.031 | Screening runs on a vendor instead, if configured | Built | Expected | SCREENING_PROVIDER_URL takes precedence |
scr.032 | Screening result recorded on the applicant | Built | Expected | applicants.screening |
scr.033 | False-positive rate guarded by a test that fails the build | Built | Extra | tools/test/screening-precision.js — fails above 15% |
scr.034 | Matcher index memory bounded | Built | Extra | first-character blocking — 51 MB, was 211 MB |
scr.035 | Screening latency bounded | Built | Extra | measured 31 ms per check |
Signals 12/18
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
sig.001 | IP address recorded with each submission | Built | Core | applicants.ip |
sig.002 | User agent, browser and OS recorded | Built | Core | applicants.ua · device |
sig.003 | Velocity: repeat submissions from one address | Built | Core | server/adapters device signals |
sig.004 | Repeat device fingerprint across applicants | Built | Core | device signal |
sig.005 | Automated client and emulator detection (our own signals) | Built | Core | device adapter — local mode |
sig.006 | Typing cadence and paste-count as a scripted-input signal | Built | Expected | collected in the flow, scored by the engine |
sig.007 | Timezone vs declared country mismatch | Built | Expected | device signal |
sig.008 | E-mail age and disposability | Not started | Core | needs an e-mail/phone intelligence provider |
sig.009 | Breach appearance for the e-mail | Not started | Core | needs an e-mail/phone intelligence provider |
sig.010 | Phone line type (VoIP, mobile, landline) | Not started | Core | needs an e-mail/phone intelligence provider |
sig.011 | Carrier and SIM-swap recency | Not started | Expected | needs an e-mail/phone intelligence provider |
sig.012 | VPN, proxy and hosting-provider detection | Not started | Core | needs an e-mail/phone intelligence provider |
sig.013 | An intelligence check that did not run blocks approval | Built | Core | CHECK_DID_NOT_RUN |
sig.014 | Signals visible to the reviewer with provenance | Built | Expected | admin applicant detail — each signal names its source |
sig.015 | Signals shown to the applicant on request (developer view) | Built | Expected | verify.html dev drawer, demo only |
sig.016 | Signal weights configurable per client | Built | Extra | tenants.settings → engine.rulesFor |
sig.017 | Device signals never used to identify across clients | Built | Expected | fingerprints are tenant-scoped |
sig.018 | Geo-IP city and country | Not started | Extra | needs an e-mail/phone intelligence provider |
Digital footprint 10/15
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
fp.001 | Footprint check runs only with explicit consent | Built | Core | consent step gates it; consents.kind = footprint |
fp.002 | Public data only — never posts, follows or messages | Built | Core | stated in the privacy notice; adapter is read-only |
fp.003 | Candidate profiles resolved from name, e-mail, phone and handles | Not started | Core | needs a digital-footprint provider |
fp.004 | Applicant confirms which profiles are theirs | Built | Core | footprint step in the flow |
fp.005 | Account age as a signal | Not started | Core | needs a digital-footprint provider |
fp.006 | Profile photo similarity to the verified selfie | Not started | Core | needs a digital-footprint provider |
fp.007 | Declared employer vs public profile employer | Built | Expected | engine cross-check when data is present |
fp.008 | Stock-photo avatar detection | Not started | Expected | needs a digital-footprint provider |
fp.009 | Absence of a footprint never rejects anybody | Built | Core | engine — stated on the marketing page too |
fp.010 | Consent withdrawal stops the check and deletes lookups | Built | Core | My Hawk Ai ID withdraw flow |
fp.011 | Networks shown as lettermark tiles, never third-party logos | Built | Expected | A.netTile — no borrowed brand marks |
fp.012 | Per-network result with a match score | Partial | Expected | needs a digital-footprint provider |
fp.013 | Footprint flags explained in plain language | Built | Extra | flagLabel in landing.js and the admin detail |
fp.014 | Footprint disabled per client | Built | Extra | tenants.settings |
fp.015 | Footprint result recorded on the applicant | Built | Expected | applicants.social |
Decision engine 30/30
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
eng.001 | Risk score from weighted signals | Built | Core | server/engine.js evaluate |
eng.002 | Three outcomes: approved, review, rejected | Built | Core | server/applicants.js |
eng.003 | Referral is not rejection | Built | Core | review is a distinct state with its own queue |
eng.004 | Four blocks no setting can disable | Built | Core | HARD_BLOCKS — sanctions, tamper, underage, check-did-not-run |
eng.005 | A check that did not run counts as not passed | Built | Core | the single most important behaviour in the system |
eng.006 | Blocks are verdicts; only their mandatoriness is configurable | Built | Core | closed a hole where requireScreening:false removed the block |
eng.007 | Blocked approval refused at the API with 409 | Built | Core | POST /v2/applicants/:id/decision |
eng.008 | Refusal is actionable — it says what would have to change | Built | Core | remedy[] per block code |
eng.009 | Blocks grouped by code, with the specifics underneath | Built | Core | three unconfigured providers no longer repeat one sentence |
eng.010 | A refused approval reverts on screen | Built | Core | public/assets/js/live.js wrapDecide |
eng.011 | Per-client scoring thresholds | Built | Core | engine.rulesFor(tenant.settings) |
eng.012 | Per-client minimum age | Built | Core | rules.minimumAge |
eng.013 | Per-client required-check set | Built | Expected | rules.require* |
eng.014 | Auto-approve threshold | Built | Expected | rules.autoApproveBelow |
eng.015 | Auto-reject threshold | Built | Expected | rules.autoRejectAbove |
eng.016 | Every signal contributing to a score is itemised | Built | Core | signals[] with points and label |
eng.017 | Decision recorded with its reasoning | Built | Core | decisions table |
eng.018 | Decision immutable once written | Built | Core | decisions rows are inserted, never updated |
eng.019 | Rules catalogue with per-rule enable and hit counts | Built | Expected | GET /v2/tenant/rules |
eng.020 | Rule changes audited with who and when | Built | Expected | audit category config |
eng.021 | Rule simulation against historical applicants | Built | Expected | POST /v2/tenant/rules/simulate — replays stored check results, writes nothing |
eng.022 | Score explanation ordered by contribution | Built | Extra | signals sorted by points |
eng.023 | Effective rules returned with the tenant config | Built | Expected | GET /v2/tenant → effectiveRules |
eng.024 | Four-eyes review for high-risk approvals | Built | Expected | enforced in the one decision gate — an endorsement from a different person is required; off by default, because on a one-reviewer client it would deadlock |
eng.025 | Decision latency measured and reported | Built | Extra | analytics avgTime |
eng.026 | Manual override with a recorded reason | Built | Expected | decision reason is required for a manual outcome |
eng.027 | Age check uses the document date of birth | Built | Core | engine ageFrom |
eng.028 | Country risk tiers | Built | Expected | engine country signal |
eng.029 | Rule packs by industry | Built | Extra | console flow templates |
eng.030 | Engine behaviour documented for a regulator | Built | Extra | content/docs.md — what a decision means |
Review and cases 23/25
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
rev.001 | Applicant queue with status filters | Built | Core | public/admin.html · GET /v2/applicants |
rev.002 | Applicant detail with every signal and its evidence | Built | Core | admin applicant detail |
rev.003 | Approve, reject, request resubmission, escalate | Built | Core | POST /v2/applicants/:id/decision |
rev.004 | Reviewer identity recorded on the decision | Built | Core | applicants.reviewer |
rev.005 | Notes on an applicant | Built | Core | POST /v2/applicants/:id/notes |
rev.006 | Timeline of everything that happened | Built | Core | applicants.timeline |
rev.007 | Reading an applicant record is itself audited | Built | Core | audit category access |
rev.008 | Cases for anything needing investigation | Built | Core | cases table · POST /v2/cases |
rev.009 | Case notes | Built | Core | case_notes |
rev.010 | Case owner assignment | Built | Expected | cases.owner |
rev.011 | SLA per case with overdue highlighting | Built | Expected | caseSla in admin.html |
rev.012 | Queue grouped by SLA state | Built | Expected | overdue / due today / new |
rev.013 | Search by name, id or e-mail | Built | Expected | GET /v2/applicants?q= |
rev.014 | Filter by risk band | Built | Expected | risk=high|medium|low |
rev.015 | Filter by reviewer | Built | Expected | reviewer= |
rev.016 | Pagination on the queue | Built | Expected | limit and offset, capped at 500 |
rev.017 | Keyboard shortcuts for the review loop | Built | Extra | admin.html — ⌘K and queue keys |
rev.018 | Bulk actions on a filtered set | Partial | Expected | POST /v2/applicants/bulk assigns, escalates, requests a resubmission and annotates in bulk; approve and reject are refused by design — a bulk approve is how one mistake becomes two hundred |
rev.019 | Reviewer workload view | Built | Extra | admin analytics |
rev.020 | Watchlist of applicants to keep an eye on | Built | Expected | admin watchlists view |
rev.021 | Saved queue filters | Built | Extra | saved_filters — per user, optionally shared with the team |
rev.022 | Export the queue to CSV | Built | Expected | GET /v2/export/applicants.csv |
rev.023 | An approval the engine refuses cannot be forced from the UI | Built | Core | the console shows the refusal and reverts |
rev.024 | Resubmission request reaches the applicant | Partial | Expected | needs an SMTP account |
rev.025 | Side-by-side comparison of two applicants | Built | Extra | GET /v2/applicants/compare — says which fields agree, and the read is audited against both |
Alerts 17/18
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
alr.001 | Alerts raised by the engine with a severity | Built | Core | alerts table · GET /v2/alerts |
alr.002 | Alert linked to the applicant that caused it | Built | Core | alerts.applicant_id |
alr.003 | Open, acknowledged and closed states | Built | Core | PATCH /v2/alerts/:id |
alr.004 | Unknown alert status refused rather than stored | Built | Core | validated against a known set |
alr.005 | Alert detail explains what fired and why | Built | Core | alerts.detail |
alr.006 | Alert types catalogued with category and icon | Built | Expected | A.data.alertTypes, extended by engine codes |
alr.007 | An engine code the UI has never seen still renders | Built | Expected | live.js synthesises a label rather than showing blank |
alr.008 | Alert feed with a live clock | Built | Expected | admin feed |
alr.009 | Unread count badge | Built | Expected | admin bell |
alr.010 | Alert routing rules per client | Built | Expected | console rules and alerts view |
alr.011 | Alert delivery by webhook | Built | Expected | alert.created event |
alr.012 | Alert delivery by e-mail | Partial | Core | needs an SMTP account |
alr.013 | Alert delivery to Slack | Built | Extra | server/webhooks.js — Slack body shape, same queue and retries; carries ids, never names |
alr.014 | Alert history retained for audit | Built | Expected | alerts survive the document retention clock |
alr.015 | Alert volume trend | Built | Extra | analytics series |
alr.016 | Acknowledging an alert is audited | Built | Expected | audit category alerts |
alr.017 | Alert suppression for a known false positive | Built | Expected | alert_suppressions — a suppressed alert is still recorded and attributed, never dropped |
alr.018 | Severity thresholds configurable | Built | Expected | tenants.settings |
Client configuration 29/32
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
cfg.001 | Multi-tenant: every query scoped to one client | Built | Core | guard() is the only way to obtain a tenant id |
cfg.002 | Cross-tenant access refused even with a forged header | Built | Core | verified — x-hawkai-tenant returns 403 |
cfg.003 | Self-serve client sign-up | Built | Core | POST /v2/auth/signup |
cfg.004 | Sign-up refuses when storage would not survive a rebuild | Built | Core | 503 with blocked_by: persistence |
cfg.005 | Platform admin can provision a client | Built | Core | POST /v2/tenants |
cfg.006 | Provisioned owner has no password until they set one | Built | Core | an admin never knows a client credential |
cfg.007 | Self-serve sign-up can be closed | Built | Expected | SIGNUP_OPEN=0 |
cfg.008 | Go-live checklist derived from stored state | Built | Core | GET /v2/tenant/onboarding — nothing tickable by hand |
cfg.009 | Checklist distinguishes "endpoint saved" from "delivered" | Built | Core | counts webhook_deliveries, not webhooks |
cfg.010 | Client settings merged, never replaced, on a partial save | Built | Core | PUT /v2/tenant/settings |
cfg.011 | Settings must be an object | Built | Core | a string used to spread into {0:"n",1:"o"} |
cfg.012 | Sandbox and production environments | Built | Expected | api_keys.env · console env switch |
cfg.013 | Flow builder: order and configure the applicant journey | Built | Expected | console flows view · GET /v2/tenant/flows |
cfg.014 | Flow versioning with a published version | Built | Expected | flows table |
cfg.015 | Branding: name, colour, headline, message | Built | Expected | tenants.branding |
cfg.016 | Branding preview on a phone frame | Built | Expected | console branding view |
cfg.017 | Branded flow language set | Built | Expected | per-link locale |
cfg.018 | Custom domain for the hosted flow | Not started | Extra | single domain today |
cfg.019 | Plan and seat count on the client record | Built | Expected | tenants.plan · seats |
cfg.020 | Client list for platform staff | Built | Expected | console clients view |
cfg.021 | A client sees only their own workspace | Built | Core | live.js adoptRealTenants — was showing sample clients |
cfg.022 | A client never sees fabricated API keys or webhooks | Built | Core | live.js adoptRealIntegrations — was showing sample ones |
cfg.023 | Retention windows configurable per client | Built | Expected | documentRetentionDays · retentionDays |
cfg.024 | Data residency selection | Not started | Expected | one database in one region |
cfg.025 | Client-level feature flags | Built | Extra | tenants.settings is free-form and merged |
cfg.026 | Client status: active, trial, paused | Built | Expected | tenants.status |
cfg.027 | Pausing a client stops new verifications | Built | Extra | POST /v2/tenant/pause — gates link creation, API creation and the applicant’s own submission; existing cases can still be decided |
cfg.028 | Usage counted per client per day | Built | Expected | GET /v2/tenant/usage — counted from the applicant rows, not from a counter, because a counter drifts and a billing number that drifts cannot be defended |
cfg.029 | Invoices per client | Built | Expected | GET /v2/tenant/invoices — one per closed month, written once; the current month is a live quote, never an invoice |
cfg.030 | Billing is per completed verification | Built | Expected | server/billing.js priceFor — a verification counts when it reached a decision, which is what the pricing page says |
cfg.031 | Self-serve plan upgrade | Built | Expected | POST /v2/tenant/plan — starter and growth apply at once and are invoiced; enterprise is recorded as a request rather than granted, because it is a contract |
cfg.032 | Payment method capture | Not started | Extra | no payment provider connected |
Identity and security 41/47
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
sec.001 | Password sign-in | Built | Core | POST /v2/auth/login |
sec.002 | Passwords hashed with scrypt | Built | Core | server/crypto.js hashPassword |
sec.003 | Password strength enforced at sign-up | Built | Core | length, character classes, obvious-choice rejection |
sec.004 | Session tokens HMAC-signed | Built | Core | server/crypto.js sign/verify |
sec.005 | Sessions revocable individually | Built | Core | POST /v2/auth/logout · sessions.revoked_at |
sec.006 | All other sessions revocable at once | Built | Core | revokeAllSessions |
sec.007 | Changing a password revokes existing sessions | Built | Core | auth.setPassword → revokeAllSessions |
sec.008 | Session cookie is HttpOnly and Secure | Built | Core | set-cookie on login |
sec.009 | Bearer token accepted for API clients | Built | Core | authorization header |
sec.010 | Two-factor authentication (TOTP) | Built | Core | POST /v2/auth/mfa/setup and /confirm |
sec.011 | TOTP secret stored encrypted | Built | Core | users.totp_secret via crypto.encrypt |
sec.012 | TOTP enrolment QR and manual key | Built | Expected | auth.beginTotp returns an otpauth URI |
sec.013 | Backup codes for two-factor | Built | Expected | ten single-use codes issued when two-factor is enabled, stored as hashes, spent codes kept with the date |
sec.014 | Five roles with distinct permissions | Built | Core | owner, admin, reviewer, auditor, developer |
sec.015 | Auditor role reads everything and changes nothing | Built | Core | CAN.auditor |
sec.016 | Permission checked on every route | Built | Core | guard(req,res,action) |
sec.017 | Role names validated on invite, case-insensitively | Built | Core | rejects an unknown role |
sec.018 | Scoped API keys | Built | Core | POST /v2/tenant/keys |
sec.019 | API key scopes validated against a known list | Built | Core | rejects "*" and typos |
sec.020 | API keys hashed at rest, shown once | Built | Core | api_keys.key_hash |
sec.021 | API key revocation | Built | Core | DELETE /v2/tenant/keys/:id |
sec.022 | API key last-used timestamp | Built | Expected | api_keys.last_used_at |
sec.023 | Invitations with a single-use expiring link | Built | Core | invitations table · 7 days |
sec.024 | Invitation token hashed at rest | Built | Core | token_hash only |
sec.025 | Accepting an invitation sets a password and signs in | Built | Core | POST /v2/auth/invite/accept |
sec.026 | Admin can re-issue an invite or a reset for a colleague | Built | Core | POST /v2/tenant/team/:userId/invite |
sec.027 | Self-serve password reset | Partial | Core | needs an SMTP account |
sec.028 | Reset response identical for every address | Built | Core | cannot be used to discover who has an account |
sec.029 | Reset link lives one hour, not a week | Built | Core | issueInvite hours:1 |
sec.030 | Personal data encrypted at rest (AES-256-GCM) | Built | Core | server/crypto.js encryptFields |
sec.031 | Data key separate from the session-signing key | Built | Core | SECRET_KEY vs JWT_SECRET |
sec.032 | Rate limiting on sign-in | Built | Core | 10/min per IP |
sec.033 | Rate limiting on sign-up | Built | Core | 5/hour per IP |
sec.034 | Rate limiting on password reset | Built | Core | 5 per 15 min |
sec.035 | Webhook URLs rejected if they resolve inside our network | Built | Core | loopback, link-local, private, unique-local, IPv4 and IPv6 |
sec.036 | Credentials in a webhook URL rejected | Built | Core | webhookUrlProblem |
sec.037 | Malformed input returns 4xx, never 5xx | Built | Core | probed across every route |
sec.038 | Security headers on every response | Built | Core | nosniff, referrer-policy, frame-ancestors |
sec.039 | Edge rate limiting | Not started | Expected | in-process limits stop a script, not a botnet |
sec.040 | Single sign-on (OIDC) | Not started | Expected | needs an identity provider (an OIDC issuer, client id and secret) |
sec.041 | Passkeys (WebAuthn) | Built | Expected | server/webauthn.js — own CBOR/COSE parsing and ES256 verification, no dependency; register in the console, sign in on /sign-in |
sec.042 | An unauthenticated caller cannot write into any tenant | Built | Core | closed — verify/submit took a tenant from the body |
sec.043 | Vulnerability disclosure route published | Built | Expected | content/security.md |
sec.044 | Findings published with what was done about them | Built | Expected | content/security.md — four so far |
sec.045 | Penetration test | Not started | Extra | not performed — stated plainly on the trust page |
sec.046 | SOC 2 Type II | Not started | Extra | no audit begun — stated plainly |
sec.047 | ISO 27001 | Not started | Extra | no audit begun — stated plainly |
API and webhooks 29/30
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
api.001 | REST API under /v2 | Built | Core | 51 routes |
api.002 | API reference generated from the route table | Built | Core | tools/build-pages.js — cannot describe a route that does not exist |
api.003 | Required scope documented per route | Built | Core | read from the guard() call |
api.004 | Conventional status codes with a readable error | Built | Core | error field written for a person |
api.005 | Field-level validation errors name the field | Built | Core | { error, field } |
api.006 | 503 names what is missing | Built | Core | { blocked_by } |
api.007 | 429 returns retry_after | Built | Core | rateLimit |
api.008 | Webhooks with HMAC-SHA256 signatures | Built | Core | server/webhooks.js |
api.009 | Signature covers timestamp.body to stop replay | Built | Core | documented with a verification snippet |
api.010 | Webhook retries with backoff | Built | Core | 6 attempts |
api.011 | Webhook delivery log | Built | Core | webhook_deliveries · shown in the console |
api.012 | Webhook ping to test an endpoint | Built | Core | POST /v2/tenant/webhooks/:id/ping |
api.013 | Webhook secret shown once | Built | Core | returned on creation only |
api.014 | Event types for applicant lifecycle and alerts | Built | Expected | applicant.* and alert.created |
api.015 | Event subscription per endpoint | Built | Expected | webhooks.events |
api.016 | Idempotent applicant creation | Built | Expected | Idempotency-Key header; the same key with a different body is a 409, not a silent replay |
api.017 | Verification session creation via API | Built | Expected | POST /v2/verification-sessions |
api.018 | Public session lookup for a hosted flow | Built | Expected | GET /v2/verify/session/:token |
api.019 | Quickstart documentation | Built | Expected | content/docs.md |
api.020 | Webhook verification example in a real language | Built | Expected | Node snippet with timing-safe compare |
api.021 | Web SDK | Built | Expected | public/assets/js/hawkai-sdk.js — one file, no dependencies; the flow runs in an iframe and posts the outcome back, origin-checked |
api.022 | iOS and Android SDKs | Partial | Extra | no native package; the supported mobile path is the hosted flow in a web view, documented with working Swift and Kotlin, including the camera permission both need |
api.023 | OpenAPI specification | Built | Expected | /openapi.json — generated from the route table at build time |
api.024 | CSV export of applicants | Built | Expected | GET /v2/export/applicants.csv |
api.025 | Capability endpoint: what this deployment can do | Built | Expected | GET /v2/capabilities |
api.026 | Readiness endpoint an operator can trust | Built | Core | GET /readyz — 503 until the blocking items pass |
api.027 | Health endpoint with storage and backup state | Built | Core | GET /healthz |
api.028 | Neither health endpoint requires a login | Built | Core | a status endpoint you must authenticate to is not one |
api.029 | Screening list status via API | Built | Expected | GET /v2/screening/lists |
api.030 | Sandbox test identities that produce known outcomes | Built | Extra | console integrations — test applicants |
Data protection 28/30
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
dp.001 | Document images deleted on a schedule | Built | Core | server/retention.js — 90 days by default |
dp.002 | Retention window configurable per tenant | Built | Core | settings.documentRetentionDays · retention.settingsFor |
dp.003 | Record retention separate from document retention | Built | Core | default 2555 days — the AML obligation |
dp.004 | Retention sweep runs on its own, not on request | Built | Core | 6-hourly timer |
dp.005 | Deletion writes a tombstone to the audit log | Built | Core | what was deleted, when, by which rule |
dp.006 | Deletion is real, not a flag | Built | Core | the row content is overwritten, not hidden |
dp.007 | Legal documents state the real retention period | Built | Core | diffed against the code constants in 14 languages |
dp.008 | Privacy notice shown before a document is uploaded | Built | Core | the applicant reads it before it is too late to decline |
dp.009 | Explicit consent recorded with a timestamp | Built | Core | consents table — kind, granted, ip, at |
dp.010 | The consent wording is stored, not a version pointer | Built | Core | consents.text — an old consent can be read exactly as it was given |
dp.011 | Subject access export | Built | Core | GET /v2/applicants/:id/data — record, documents, decisions, consents, alerts, access log |
dp.012 | Erasure request handling | Built | Core | POST /v2/applicants/:id/erase — honours the AML hold |
dp.013 | Erasure refuses when a record is under a legal hold, and says so | Built | Core | a quiet partial deletion is worse than a clear refusal |
dp.014 | Rectification of a stored field | Built | Expected | PATCH /v2/applicants/:id — reason required, previous value kept in the audit entry |
dp.015 | Data processing purpose stated per field group | Built | Core | content/privacy.md |
dp.016 | Sub-processors listed by name and role | Built | Core | content/privacy.md — Hugging Face today |
dp.017 | DPA published rather than sent on request | Built | Core | content/dpa.md |
dp.018 | Personal data never written to application logs | Built | Core | log lines carry ids, not names or numbers |
dp.019 | Webhook payloads carry ids, not document images | Built | Core | server/webhooks.js |
dp.020 | Export excludes other tenants by construction, not by filter | Built | Expected | every query is tenant-scoped at the statement |
dp.021 | Backups encrypted and access-controlled | Built | Expected | private Dataset repository |
dp.022 | Backup restore tested end to end | Built | Expected | snapshot → clear → fetch → boot |
dp.023 | Breach notification commitment in writing | Built | Core | content/dpa.md — 72 hours |
dp.024 | Data residency choice | Not started | Expected | one region today; stated plainly rather than implied |
dp.025 | Customer-managed encryption keys | Not started | Extra | keys are deployment-level |
dp.026 | Minimisation: the flow asks for nothing it does not use | Built | Expected | each field maps to a check or an obligation |
dp.027 | An applicant can see what was collected about them | Built | Expected | the completion screen lists it |
dp.028 | Automated-decision disclosure | Built | Expected | the applicant is told a decision may be automated and can ask for review |
dp.029 | Records of processing activity | Built | Core | content/privacy.md + the audit log together |
dp.030 | Deletion propagates to backups within one cycle | Built | Expected | snapshots are full, not incremental — the next one no longer holds it |
Reporting 25/26
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
rep.001 | Live counts of applicants by status | Built | Core | console overview |
rep.002 | Approval, rejection and referral rates | Built | Core | computed from decisions, not entered |
rep.003 | Pass rate over time | Built | Core | console chart, own SVG |
rep.004 | Median and 90th-percentile time to decision | Built | Core | GET /v2/analytics/breakdown — a mean hides the queue |
rep.005 | Volume by country | Built | Core | GET /v2/analytics/breakdown — by_country, from the document then the address |
rep.006 | Volume by document type | Built | Core | GET /v2/analytics/breakdown — by_document_type |
rep.007 | Drop-off by step of the flow | Built | Core | where applicants abandon |
rep.008 | Reviewer throughput per person | Built | Expected | decisions per reviewer per day |
rep.009 | Override rate: how often a human disagrees with the engine | Built | Expected | the number that tells you the rules are wrong |
rep.010 | Rule hit counts | Built | Expected | which rule actually fires |
rep.011 | Rules that never fire are listed | Built | Expected | dead rules are a liability, not a control |
rep.012 | Screening hit counts and referral rate | Built | Core | measured — 9% on a 23-name control set |
rep.013 | Alert volume and time to close | Built | Expected | GET /v2/analytics/breakdown — alerts.time_to_close_minutes |
rep.014 | Case ageing: what is sitting too long | Built | Expected | oldest-first queue with an age column |
rep.015 | SLA breach count | Built | Expected | against the configured review SLA |
rep.016 | CSV export of any table shown | Built | Core | a report you cannot export is a screenshot |
rep.017 | Date-range filter on the reports that read live rows | Built | Expected | from/to on /v2/analytics/breakdown, /v2/audit and both CSV exports |
rep.018 | Durations formatted with CLDR short units | Built | Expected | narrow units are Latin in Japanese — a real bug we hit |
rep.019 | Charts drawn without a charting library | Built | Expected | inline SVG — nothing to load, nothing to break |
rep.020 | Charts readable in both themes | Built | Expected | colours from CSS variables |
rep.021 | Empty states say "no data yet", never show a zero as a result | Built | Expected | the breakdown returns a note instead of zeros; the consoles render .empty panels |
rep.022 | Regulator-facing summary of a period | Built | Expected | counts, rates, and the rule set in force |
rep.023 | Audit log searchable by actor, object and date | Built | Expected | GET /v2/audit — category, free text, from/to |
rep.024 | Audit log export | Built | Expected | GET /v2/export/audit.csv — same filters as the screen |
rep.025 | Scheduled report e-mail | Partial | Extra | needs an SMTP account |
rep.026 | Warehouse export | Built | Extra | GET /v2/export/warehouse.ndjson — applicants with their decisions and alerts, one JSON record per line |
Languages and accessibility 32/32
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
i18n.001 | Fourteen languages across the whole product | Built | Core | en, ja, ko, zh, fr, it, es, th, ms, id, hi, ro, ru, ar |
i18n.002 | The marketing site, the console and the applicant flow all translate | Built | Core | one catalogue, not three |
i18n.003 | Translation keyed by the English source string | Built | Core | a new sentence cannot silently lose its translation key |
i18n.004 | Dynamically inserted text translates too | Built | Core | MutationObserver |
i18n.005 | Original English kept so a language can be switched back | Built | Core | WeakMap of originals — no reload |
i18n.006 | Numbers inside a translated sentence normalised | Built | Core | {n} placeholder — one entry covers every count |
i18n.007 | Names substituted after translation, never mid-sentence | Built | Core | splitting a sentence breaks Japanese and Korean word order |
i18n.008 | Legal bodies pinned so a machine pass cannot touch them | Built | Core | data-no-i18n |
i18n.009 | Language chosen from a picker and remembered | Built | Core | localStorage + the URL prefix |
i18n.010 | Language-prefixed URLs that can be linked and indexed | Built | Core | /<lang>/<page> · 154 URLs in the sitemap |
i18n.011 | hreflang across every page and language | Built | Core | generated, not hand-written |
i18n.012 | Right-to-left layout for Arabic | Built | Core | dir=rtl, mirrored spacing, logical CSS properties |
i18n.013 | Arabic shown with Latin digits | Built | Core | ar-u-nu-latn — the finance convention in the Gulf |
i18n.014 | Dates, times and currencies in the reader locale | Built | Core | Intl |
i18n.015 | CJK line breaking not broken by our own wrapping | Built | Core | no manual breaks inside sentences |
i18n.016 | The document language attribute set before first paint | Built | Core | in the head, so screen readers start right |
i18n.017 | Language coverage measured in a real browser, not counted in a file | Built | Core | tools/test/verify-langs.py — 13 languages × 6 surfaces |
i18n.018 | The coverage check fails the build when a string regresses | Built | Core | and it caught its own inverted metric |
i18n.019 | Applicant instructions translated, including the error text | Built | Expected | the messages that matter most are the failures |
i18n.020 | E-mail subjects and bodies translated | Built | Expected | RFC 2047 encoded subjects |
i18n.021 | Country names translated | Built | Expected | A.country() — Intl.DisplayNames, falling back to the English name |
i18n.022 | Document type names translated | Built | Expected | Passport, National ID, Residence permit and the rest are catalogue keys |
i18n.023 | Decision reasons translated | Built | Expected | so an applicant reads why in their own language |
i18n.024 | Translation catalogue exportable for human review | Built | Extra | 3,100 keys as JSON |
i18n.025 | Keyboard reachable throughout | Built | Core | no control that only a mouse can reach |
i18n.026 | Visible focus ring everywhere | Built | Core | never removed without a replacement |
i18n.027 | Form fields have real labels, not placeholders as labels | Built | Core | every field in verify.html carries a label with a for attribute |
i18n.028 | Errors announced to assistive technology | Built | Core | aria-live on the validation region |
i18n.029 | Colour is never the only signal | Built | Core | status carries a word as well as a colour |
i18n.030 | Contrast audited in both themes | Built | Core | CDP sweep over computed colours |
i18n.031 | Reduced-motion respected | Built | Expected | prefers-reduced-motion |
i18n.032 | Camera step usable one-handed on a phone | Built | Expected | controls within thumb reach |
Site and trust 22/22
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
web.001 | Marketing site, console and applicant flow on one deployment | Built | Core | one origin, one certificate, one language switch |
web.002 | Light and dark themes | Built | Core | CSS variables — both audited for contrast |
web.003 | Theme follows the system and can be overridden | Built | Core | remembered per browser |
web.004 | One logo rendered from one source everywhere | Built | Core | A.logo / A.lockup |
web.005 | Favicon, app icon and social image from the same mark | Built | Core | favicon.svg, mark.webp, apple-touch-icon.png, og.jpg |
web.006 | Palette derived from the mark, not chosen next to it | Built | Core | docs/DESIGN.md — the tokens and where they came from |
web.007 | Pages generated from Markdown, not hand-edited HTML | Built | Core | tools/build-pages.js |
web.008 | Sitemap and robots.txt generated | Built | Core | 154 URLs |
web.009 | Meta description and title per page and language | Built | Core | tools/build-pages.js — canonical plus hreflang alternates |
web.010 | Open Graph and Twitter cards | Built | Core | tools/build-pages.js and public/index.html |
web.011 | Structured data for the organisation and the product | Built | Expected | JSON-LD graph, generated from the same strings as the page |
web.012 | No third-party script on any page | Built | Core | no analytics, no fonts, no tag manager — nothing to leak an applicant |
web.013 | No cookie banner because there is nothing to consent to | Built | Core | only the session cookie, which is strictly necessary |
web.014 | Pricing published | Built | Core | the pricing section on the landing page — per-verification, with what each tier includes |
web.015 | What is not built is published | Built | Core | content/status.md — the honest list |
web.016 | Public changelog, updated in the same commit as the change | Built | Core | docs/CHANGELOG.md |
web.017 | Security page with the disclosure route and past findings | Built | Core | content/security.md |
web.018 | Trust page stating which audits exist and which do not | Built | Core | none yet — said plainly |
web.019 | Terms, privacy notice, DPA and cookie notice published | Built | Core | content/*.md — the chrome translates; the bodies stay English and say so, because a machine-translated contract is not the contract |
web.020 | Documentation with a working quickstart | Built | Expected | content/docs.md |
web.021 | Feature catalogue published with its real status | Built | Expected | server/features.js → /features and GET /v2/features |
web.022 | Every page works with JavaScript disabled for reading | Built | Expected | content is in the HTML |
Operations 35/35
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
ops.001 | Runs with no third-party runtime dependency | Built | Core | package.json has no dependencies — nothing to audit, nothing to patch |
ops.002 | Single container, single port | Built | Core | Dockerfile · 7860 |
ops.003 | Boots with no configuration at all | Built | Core | safe defaults; everything optional is announced as unset |
ops.004 | Secrets read from the environment only | Built | Core | no secret is committed, and none is printed |
ops.005 | Generated keys warn loudly that they are ephemeral | Built | Core | a restart would invalidate sessions — the operator is told |
ops.006 | SQLite with durable settings | Built | Core | journal_mode DELETE, synchronous FULL, busy_timeout |
ops.007 | Live database never on network storage | Built | Core | a FUSE mount corrupts SQLite — learned the hard way |
ops.008 | Snapshots taken with VACUUM INTO, not a file copy | Built | Core | a copy of an open database is not a backup |
ops.009 | Durable storage without a paid disk | Built | Core | private Dataset repository as the backing store |
ops.010 | Snapshot pushed on a schedule and on shutdown | Built | Core | server/cloud-push.js |
ops.011 | Snapshot fetched and adopted at boot when the disk is empty | Built | Core | server/cloud-fetch.js — a rebuild does not lose accounts |
ops.012 | A newer local database is never overwritten by an older snapshot | Built | Core | compared before adopting |
ops.013 | Local development cannot write to production storage | Built | Core | guarded on SPACE_ID — after a local test polluted it once |
ops.014 | Backup state visible on the health endpoint | Built | Core | last push, last fetch, and why it is blocked if it is |
ops.015 | A directory given as the database path is handled | Built | Core | app.db is created inside it rather than failing forever |
ops.016 | Schema applied idempotently at boot | Built | Core | server/schema.sql plus ADD_COLUMNS in server/db.js — a new column reaches an existing database |
ops.017 | Migrations never drop a column or a table | Built | Core | ADD COLUMN only — a deployment must not be able to destroy client data |
ops.018 | Uploads and configuration survive a redeploy | Built | Core | restored from the snapshot, never re-initialised |
ops.019 | Graceful shutdown: finish in-flight work, then snapshot | Built | Core | SIGTERM handler |
ops.020 | Structured request logging without personal data | Built | Core | server.js request log — method, path, status, duration |
ops.021 | Unhandled errors logged with a correlation id and a 500 that says nothing else | Built | Core | the response carries `reference`, the stack stays in the log |
ops.022 | Health endpoint reports storage, backup and screening state | Built | Core | GET /healthz |
ops.023 | Readiness endpoint refuses traffic until the blocking items pass | Built | Core | GET /readyz — document, liveness, admin today |
ops.024 | Readiness distinguishes "not configured" from "passing" | Built | Core | the distinction the whole compliance posture rests on |
ops.025 | HTML served no-cache, assets cached for an hour | Built | Core | a stale HTML makes a fixed bug look unfixed |
ops.026 | Static assets fingerprinted | Built | Expected | server.js fingerprint() — references get ?v=<content hash> as the page is served, and a versioned request is answered immutable for a year |
ops.027 | Gzip on text responses | Built | Core | zlib |
ops.028 | Screening lists refreshable without a deploy | Built | Expected | POST /v2/screening/refresh · tools/load-sanctions.js |
ops.029 | List load is transactional — a failed refresh keeps the old list | Built | Expected | never leaves screening with a half list |
ops.030 | List provenance and load date shown | Built | Expected | so a reviewer knows how stale a hit is |
ops.031 | API surface regression-tested | Built | Core | tools/test — every route probed for 4xx-not-5xx |
ops.032 | Inline scripts syntax-checked | Built | Core | tools/test/inline-syntax-check.py — node --check cannot see them |
ops.033 | Screening precision measured, with a failing threshold | Built | Core | tools/test/screening-precision.js — 6/6 recall, 9% referral |
ops.034 | Real-browser verification over every page and language | Built | Core | CDP sweep, 78 rows, 0 JS errors |
ops.035 | Test suites clean up after themselves | Built | Expected | so two runs in a row give the same answer |
Go-live 17/20
| ID | Feature | Status | Priority | Where to check it · what it needs |
|---|---|---|---|---|
live.001 | Self-serve sign-up that creates a real tenant | Built | Core | POST /v2/auth/signup |
live.002 | First account becomes the owner | Built | Core | no seeded admin password to forget to change |
live.003 | Guided onboarding with a completion state per step | Built | Core | GET/POST /v2/tenant/onboarding |
live.004 | Onboarding cannot be marked complete while a blocker stands | Built | Core | reads the same readiness as /readyz |
live.005 | Go-live checklist in the console showing what is missing | Built | Core | renderGoLive |
live.006 | The checklist names the owner of each blocker | Built | Core | ours or yours — no ambiguity about who is waiting |
live.007 | Colleagues invited by e-mail with a role | Built | Core | POST /v2/tenant/team — role validated against the five that exist |
live.008 | An invitation works without mail configured | Built | Core | the link is shown to the admin to pass on — otherwise onboarding deadlocks |
live.009 | Branding: name, logo and colour per tenant | Built | Core | applied to the hosted flow and the e-mails |
live.010 | Verification links created from the console | Built | Core | single-use, expiring, one per applicant |
live.011 | Hosted flow shows the client name, in every language | Built | Core | {client} and {email} resolved after translation, on the result screen too |
live.012 | Test mode with identities that produce known outcomes | Built | Core | a customer can rehearse a rejection |
live.013 | Test data clearly marked and separately deletable | Built | Core | never mixed into the real counts |
live.014 | Demo tenant removable without touching real tenants | Built | Core | server/bootstrap.js seeds it as its own tenant id |
live.015 | Operator runbook | Built | Core | docs/GO-LIVE.md — what to set, in what order, and what it unblocks |
live.016 | Every required secret listed with what breaks without it | Built | Core | docs/GO-LIVE.md |
live.017 | Nothing is faked to look configured | Built | Core | the rule the whole catalogue follows |
live.018 | Document-authenticity provider | Not started | Core | needs a document-authenticity provider |
live.019 | Face-match and liveness provider | Not started | Core | needs a face-match and liveness provider |
live.020 | PEP and adverse-media data | Not started | Core | needs a commercial PEP / adverse-media data set |