Applications Link

A public, shareable apply form for every position — moving Utkrushta one step earlier in the hiring funnel: collect candidates before you assess them.

v1 design — ready for review 2026-07-07 app.utkrusht.ai/apply/<token> D1 · D2 · D3 resolved — §9

Locked: pool-only post-apply · one application per email per position · predefined fields + custom text questions · opaque token URL. Canonical text spec: Utkrushta/docs/applications-link-design.md · Live/commentable: html-docs.com/s/0967d77bc4.

01Summary & use cases

Today a recruiter creates a position only once they already have candidates to assess. The Applications Link gives every position a public apply form (Ashby/Lever/Tally-simple). A submission creates a users row + an applications row, lands in the position's candidate pool, bumps the dashboard Candidates count automatically, and emails the candidate a confirmation. The recruiter invites pooled applicants to the assessment exactly as they do today — nothing about the assessment flow changes.

Use caseHow the link is used
LinkedIn job postPaste the link as the job's apply URL — candidates land on the branded form.
Direct sendsRecruiter keeps the link handy for WhatsApp/email one-offs.
Careers page embedv1: copy-paste button snippet. v2: WordPress / Framer / Webflow native integrations (§12).
In-person eventsQR code download (print-ready PNG/SVG) for standees.
Free winThe dashboard "Candidates" number is already computed by counting applications rows (getApplicationMetricsForPositions()). Apply-link submissions appear in it with zero frontend work.

02How it flows through the system

One new table (position_apply_links), one new public API router, one new candidate page, and two recruiter surfaces. Everything downstream of the applications row is existing machinery.

RECRUITER CANDIDATE PLATFORM Review step: configure fields · questions · open/close POST /testmakers/positions payload grows apply_form{} create + edit, one path position_apply_links token minted server-side on insert, never rotated Share surfaces Share menu · QR · embed success screen row LinkedIn · careers page WhatsApp · QR standee app.utkrusht.ai/apply/<token> public page, candidate app Fills the form + resume consent · Turnstile Confirmation email "watch this inbox" POST /v2/apply/{token} dedup → users row (no cookie) applications row: PENDING, added_method + org source hash Candidates page pool "not-invited" rows + detail drawer dashboard count updates Invite to assessment existing test/task invite flow, unchanged

End-to-end flow. Green boxes are new; white boxes are existing surfaces gaining a small addition.

Three verified facts the design leans on 1) The recruiter candidates page query is org-scoped by applications.source_hashcode — apply-link rows must carry the org's source hash or recruiters never see them. 2) The dashboard count reads applications rows, but the legacy assessment testmaker still reads the positions.applications array — so the dual-write stays and must become atomic. 3) There is no public backend candidate endpoint today, and FastAPI has no rate limiter wired — abuse controls are v1 scope, not hardening.

03Data model

Revised 2026-07-07 (two rounds with Naman) Changes from the first draft: (1) the config table stays separate, renamed to position_apply_links — chosen for an extensible future (multiple links/position, per-link metrics, form variants) and a clearer name; (2) channeladded_method; (3) applicant_email droppedusers.email is the store, dedup is keyed there.
position_apply_links · NEW position_apply_link_id pk position_id fk unique · cascade token unique · base62 ≥12 is_open bool show_contact_email / contact_email org_privacy_policy_url form_config jsonb created_at / updated_at RLS: service-role only (anon-deny) UNIQUE relaxable → 1:N links later positions title · location · compensation status DRAFT/OPEN/CLOSED · applications[] organizations name · logo · website · linkedin_url source_codes ORGANIZATION hash (attribution, kept) applications · 4 new columns user_id fk · position_id fk · state 'PENDING' source_hashcode = org's ORGANIZATION hash + added_method text — 'apply_link' + form_responses jsonb + consented_at · notice_version dedup: users.email (CI) + existing source index no applicant_email · no new index array appends via atomic apply_submit RPC users — email is the dedup store name (only required) · email · phone · linkedin · resume — no login cookie minted 1:1 1:N

Entity changes. One new table (position_apply_links), four columns on applications, one apply_submit RPC. Everything else reused.

Separate table vs. columns on positions — the trade-off

Separate position_apply_links ✅ (chosen)Columns on positions
Extensibility✅ 1:N links, per-link metrics/variants without touching positions✗ each addition is a positions migration
Public GET+1 JOIN (org JOIN happens anyway)no extra JOIN
Token isolation✅ naturally isolated from position serializers⚠️ serializer must omit the token
positions width✅ stays lean⚠️ +6 cols on a ~40-col table
CostFK/cascade + one RLS-deviation blocknone

Decision: separate table, for extensibility. Columns would be simpler for a strict 1:1 v1 (the alt_ids precedent), but a dedicated table keeps the door open cheaply for things that would each otherwise need a positions migration: multiple links per position (campaign/source-specific, each with its own token — just drop the UNIQUE), per-link metrics (views→submits), form A/B variants, vanity slugs as an alias table, embed configs. Worth the one JOIN and the FK/RLS ceremony. (positions.public_link already exists — check its use; it may relate.)

form_config (jsonb on position_apply_links)

{
  "fields": { "linkedin": true, "resume": true, "ctc": true,
              "availability": true, "location": true },
  "custom_questions": [
    { "id": "srv-generated-uuid", "label": "Why this role?", "required": false }
  ]
}

Name, email, phone are always on and not represented in config. government_id / cover_video keys are reserved but not built in v1 (decisions D1/D2, §9). Custom-question ids are server-generated and immutable.

form_responses (jsonb on applications)

{
  "ctc":          { "current_lpa": 12.5, "expected_lpa": 18 },
  "availability": { "bucket": "serving_notice", "last_working_day": "2026-08-14" },
  "location":     "Pune",
  "custom_answers": [ { "id": "…", "label": "Why this role?", "answer": "…" } ],
  "files":        { "resume_key": "apply/{position_id}/{app_id}/resume.pdf" }
}

Labels are snapshotted at submit time — a question edited or removed later still renders correctly on historical applications, no tombstones. File refs under a distinct files key. Server validates against form_config, drops unknown keys, caps ~16 KB.

The two load-bearing decisions

provenance — kept
source_hashcode stays the system of record; apply-link candidates are attributed to the ORGANIZATION. Mint (or reuse) one ORGANIZATION source_codes row per link; every application inserts with it (user row gets the existing ORGANIZATION→UTKRUSHT rewrite). Without it, the row is invisible on the recruiter's org-scoped candidates page. added_method='apply_link' is only the UI/reporting discriminator.
duplicate guard — email on users
Dedup keyed on users.email, checked globally. CI lookup (list_by_email_ci) across ALL user rows — a returning candidate reuses their user_id even on a new position. Then a source-agnostic check "any application for this user on this position?" 409s repeats. Race safety without a denormalized column: Turnstile tokens are single-use (kills accidental double-submit) + the existing (user_id, position_id, source_hashcode) index as DB backstop + an optional apply_submit RPC with a pg_advisory_xact_lock on (email, position) for bulletproof concurrency.

04API design & submit sequence

New public FastAPI router fastapi_service/routers/v2/apply.py — FastAPI because the application-creation core (v2_add_application: canonicalization, ambiguity handling, backfill, dedup, insert_application) already lives there. The apply POST is a thin public wrapper around that core, not a parallel path.

EndpointBehavior
GET /v2/apply/{token}Returns {state, position, organization, form_config, contact_email?}. Server computes a single state: open|closed from the lifecycle matrix (§10). 404 for unknown token AND for DRAFT positions (identical shape — unpublished roles don't leak). Compensation omitted server-side when is_salary_public=false; contact email only when enabled. Rate-limited.
POST /v2/apply/{token}Turnstile verify → re-check state (410 if closed mid-form) → canonicalize email/phone → CI lookup across all users → source-agnostic applied-check → create user if needed (no login cookie) → apply_submit RPC (added_method='apply_link', state='PENDING', org source hash, consent) → atomic array append → email only on first success → 201. Duplicate → uniform success, no email.
POST /v2/apply/{token}/filesMultipart resume. Server derives key + pins bucket + forces private ACL — the existing upload endpoint's client-controlled bucket/key/ACL shape must never go anonymous. Content-Length cap before reading, stream to disk, magic-byte sniff, PDF/DOC/DOCX ≤10 MB. Temp prefix with ~7-day S3 lifecycle; submit verifies + copies refs to the permanent private prefix. Never touches the TUS post-finish hook (it sets public-read).
position create/edit POST + GET /v2/positions/{id}/apply-link (testmaker JWT)The POST payload grows an apply_form{} object; the handler upserts a position_apply_links row keyed on position_id (mints token on insert). One path for create AND edit; the frontend never invents tokens. The GET is get-or-create, so legacy positions gain a link lazily the first time the share modal opens. Duplicate-position route copies config into a fresh row + regenerates the token.
/apply/<token> page FastAPI /v2/apply Turnstile Postgres S3 / SMTP GET {token} → branding, fields, state upload resume on file-select PUT private temp prefix (7-day lifecycle) · size cap · magic-byte sniff POST submit + turnstile_token server-side verify re-check state matrix → 410? CI email lookup (all user rows) + applied-check create users row if new — no login cookie apply_submit RPC — user + application (PENDING · apply_link · org hash · consent) advisory lock + existing source index guard races atomic array append (same RPC transaction) copy files to permanent prefix · send confirmation (only on first success) 201 → success screen

Submit sequence. Resume uploads ride their own early request (progress + retry, never wedging submit). The users.email check + single-use Turnstile token — not a denormalized column — carry the duplicate guard.

Abuse controls — right-sized (no Redis) Cloudflare Turnstile (server-verified before any write/email) is the primary defense and does ~80% of the work; its single-use tokens also give free double-submit protection. Rate limiting is Postgres-backed, not Redis — we don't run Redis and don't need to at ~1.4 req/s; a small counter caps per-IP and per-token. Email discipline: send only on the first successful insert (duplicates get the uniform screen and no email — closing the mail-pump loop), + per-recipient cap + a global Gmail circuit breaker. Plus upload caps (§4.3) and a honeypot. Full explanation in §04.5 of the spec.

05Recruiter UX — the review-step section

Where the link is managed (create and edit — position-edit reuses the same review UI). It slots as a sibling top-level section between Skills and the assessment sections, using the page's exact idiom: flat sections, text-h3 heading + the local Toggle, config panels on bg-surface-light with 16px radius, and the Preliminary-Questions add/remove row pattern. The config rides the existing position POST payload as apply_form{}; the token is minted server-side when the row is created — the frontend never invents tokens.

Skills & weightage
Python Advanced FastAPI Advanced
Application form
Manage

A public link where candidates apply to this position. Applications land in your candidate pool — invite them to the assessment whenever you're ready.

app.utkrusht.ai/apply/Xk7mQ2rT9wPz Copy
Name · Email · PhoneAlways on
Resume (PDF)
LinkedIn profile
Current location
Notice period / availability
CTC (current + expected, LPA)
Custom question 1
Why do you want to work on developer-assessment tooling?
Required
Remove Question
+ Add more
Accepting applications
Pause anytime — reversible. (Closing the whole position permanently disables the link.)
Show a contact email on the form
Off by default. When on, candidates see it at the bottom of the form.
priya@acmecorp.com (prefilled — editable)

Review-step section, rendered with the page's real tokens: text-h3 headings, the local 44×24 Toggle, bg-surface-light panels, "+ Add more" / "Remove Question" row idiom from Preliminary Questions.

Lifecycle notes Config saved with the position payload works for DRAFTs too — the link simply 404s until the position is OPEN. Legacy positions get a link lazily via the get-or-create endpoint the first time the share modal opens. Duplicating a position copies the config but regenerates the token (and duplicates are created OPEN — the copied link is live immediately).

06Recruiter UX — share surfaces, 4 options

Context: the card's right rail today is [Add candidates] [Share assessment ▾] [⋮] — and "Share assessment" is already a dropdown (Test / Task / How they are different?), with a quirk: task-only orgs bypass the menu entirely and jump straight into the task dialog.

Senior Backend Engineer

Full-time • 5-10 years • Pune, India

PythonFastAPIPostgreSQL+4 skills
36Candidates
12Assessments
4Recommended
🕐 Updated 2 days ago

Baseline — today's card. The Candidates metric (left tile) is the number apply-link submissions will grow automatically.

Option A — third item in the existing share dropdown Recommended

Application form NEW
Test invite
Task invite
How they are different?
Recommendation
+ One share home, funnel-ordered: Application form → Test → Task. Discoverable exactly where recruiters already share.
+ Zero new chrome on an already-tight right rail.
+ Menu rename ("Share") resolves the semantic mismatch of apply links under "Share assessment".
Requires removing the task-only-org bypass — the menu must always open now (it has ≥2 items for every org). A small, deliberate behavior change.
Apply item must skip checkShareLink billing gating and the org-profile gate — easy to miss in implementation.

Option B — Share button opens a two-card chooser modal Good end-state, later

Share
What do you want to share?
Apply link
Public application form — collect candidates into your pool.
Assessment
Invite candidates to a test or task.
Trade-offs
+ Explicit fork teaches the funnel distinction; room to explain each path.
+ Cleanest end-state if sharing keeps growing (reports, embeds…).
Adds a click before every existing share action — a regression for the dominant flow.
Chooser modal → assessment card → Test/Task dialog = three stacked layers.
Refactors the most gate-encrusted component in the app (billing, org-profile, task-only bypass, ?modal= deep links). Highest-risk option.

Option C — second standalone button Not recommended

Trade-offs
+ Both share actions one click away; maximal launch visibility.
Four controls in the right rail — crowded at ≤1280px, and two share-shaped buttons compete for the same click.
Label surgery on an established primary button ("Share assessment" → "Assessment").
Doesn't scale — the next shareable thing forces a fifth control.

Option D — top item in the ⋮ menu Cheap echo only

Apply link
Edit
Duplicate
Download
Close position
Trade-offs
+ Zero layout risk, trivial to ship, works even for closed-adjacent states.
The kebab is management territory (Edit / Duplicate / Download / Close) — burying a growth-loop action there undercuts the feature's point.
Invisible to recruiters who never open the overflow menu.
Recommendation Ship Option A: rename the trigger to "Share", menu = Application form / Test invite / Task invite / How they are different?, remove the task-only bypass, and keep billing gates on the test/task items only. Optionally add D as a cheap echo. Also add a "Share application link" row to the position success screen — the highest-intent moment to seed adoption. Revisit B if sharing grows more destinations.

07Recruiter UX — the apply-link share modal

Built on StandardModal, mirroring the ShareAssessmentDialog header pattern (icon + title + headerAction). New dependency: qrcode.react (SVG-rendered, crisp at any zoom).

Share application form
Senior Backend Engineer — collect applications into your candidate pool
app.utkrusht.ai/apply/Xk7mQ2rT9wPz
print-ready · standees
Embed on your careers page
<a href="https://app.utkrusht.ai/apply/Xk7mQ2rT9wPz"
   style="background:#1A7B48;color:#fff;padding:10px 20px;
   border-radius:8px;text-decoration:none">Apply now</a>
Works in WordPress, Framer, Webflow custom-HTML blocks. Native integrations — §12.

Apply-link modal. QR: on-screen QRCodeSVG; downloads PNG 1024×1024 (≈8.7 cm at 300 dpi — desk standees) + SVG for poster-size print vendors; error-correction M; filename apply-<position-slug>.png. When the link is paused/closed, the body shows a banner + "Manage in position edit".

Ships with v1: "View Application" (confirmed with Naman) The candidates page already lists zero-session applications as "not-invited" rows with bulk "Invite to test/task" — pool-then-invite works today. v1 adds a "View Application" row action → a modal showing everything the candidate submitted (name/email/phone/location, LinkedIn, CTC current+expected, notice period, custom-question answers, resume via presigned link) + an "Applied via link" indicator (from added_method). Kept a plain modal, not an over-built drawer. Forward-compat: when the candidates/positions merge lands (every card becomes a candidate card; "View Details" → "Candidate Details"), these answers move there — so it's built as a thin reader over form_responses that folds in later.

08Candidate UX — the apply form

app.utkrusht.ai/apply/<token>, in the candidate app (which already leaves /apply/* public). Server-component fetch, so first paint carries branding + fields with no spinner. Rendered below with the candidate app's real tokens: Poppins body, primary green #186844, shadcn Input (40px, 6px radius, #E5E5E5 border), the react-dropzone resume uploader, the green Header with the Playfair logo. Look-and-feel target: Ashby/Lever/Tally — single column, one field per row, section-grouped.

Powered by Utkrusht
Acme Corp

Senior Backend EngineerOPEN

🌐 acmecorp.comin/ acme-corp
Location
Pune, India (Hybrid)
Employment type
Full-time
Compensation
25–35 LPA
Experience
5–10 years
About you
As it appears on your resume
you@example.com
We'll send your application confirmation here.
🇮🇳 +9198765 43210
City
Your profile
Upload / drag your resume here (PDF)
Up to 10 MB · we'll prefill the form from it — you can review everything
linkedin.com/in/yourname
Compensation & availability
Select — Immediate / ≤15 days / 1 month / 2 months / 3 months / Serving notice
12.5
LPA
Fixed + variable, lakhs per annum
18
LPA
A question from Acme Corp
Your answer…

The apply form — candidate-app tokens (Poppins, #186844 primary, shadcn Input/dropzone). Contact email row appears only when the recruiter enabled it. Government ID and cover video are absent by design in v1 (§9, D1/D2).

Applications for Senior Backend Engineer at Acme Corp are closed
This role isn't accepting new applications right now.
Application received
Acme Corp has your application for Senior Backend Engineer. If your profile is a match, you'll get an email from Utkrusht inviting you to a skill assessment — watch ravi@gmail.com (and spam).
A confirmation email is on its way.

Closed state (branded, never a dead 410 — these URLs live on in WhatsApp forwards) and the honest pool-only success screen (echoes the submitted email as a typo self-check; no assessment is implied to start now).

Field spec (UX-review-validated)

FieldInputNotes
Full namesingle text — never first/lastautocomplete="name"; Indian names break first/last splits
Emailemaillowercase+trim server-side; microcopy pre-legitimizes the confirmation email
Phonereact-phone-number-input, +91 defaultinputmode="tel"; normalize "+91 / 0 / spaces" variants server-side, don't reject
Resumedropzone, PDF/DOC/DOCX ≤10 MBuploads on select (own request, progress + retry chip — never wedges submit); parse-and-prefill empty fields with "filled from your resume — please check"; degrade silently if slow
LinkedIntextaccept bare linkedin.com/in/x or handle, normalize to URL
Locationcity textautocomplete="address-level2"
Notice periodpicker: Immediate / ≤15d / 1m / 2m / 3m / Serving noticeNaukri's buckets — every Indian recruiter filters in these; "Serving notice" reveals a "Last working day" date field
CTCnumeric + "LPA" suffix, inputmode="decimal"current optional, expected required-if-enabled; stored as numbers so recruiters can sort/filter the pool
Custom questionsshort-answer textarea(s)answers snapshot {id, label, answer}
Consentcheckbox, unticked, blockingnames org as controller + Utkrusht as processor; 18+ folded in; consented_at + notice_version stored

Cross-cutting v1 requirements: explicit "(optional)" in labels · real <label for> + aria-describedby errors · validate on blur (mode:"onTouched"), scroll-to-first-error on submit · 16px input font (iOS zoom) · failed POST keeps all form state with inline retry · tested at 360px width on throttled 3G.

Confirmation email

Sender name "Acme Corp via Utkrusht" — this email primes deliverability for the later assessment invite; it IS the product handoff. Content: position, org, the same "watch this inbox" next-steps copy, privacy-notice link (doubles as DPDP notice delivery), "reply to withdraw/delete", and "if this wasn't you, contact us". The address is unverified public input: never echo form responses, CTC, phone, or attachments. Sent strictly after a successful deduped insert — never on error branches. v1 = code template via render_email_v2(logo_url=org.logo); org-editable EmailTemplateType.application_received later. Flag: Google/Gmail must be added to the sub-processor list (legal #184).

09Risks, compliance & decisions

Five adversarial reviews ran against this design (security/abuse, data-integrity, candidate UX, DPDP/GDPR, recruiter workflow). Everything they insisted on is folded into §§3-4 above. The three product decisions they surfaced were resolved with Naman on 2026-07-07 — all three recommendations accepted:

D1 — Government ID: cut from v1 ✓ decided Both the UX and compliance reviews say cut it from v1. An ID at apply-time fails DPDP purpose-limitation (identity proof isn't necessary to consider an application), you can't stop candidates uploading Aadhaar once any ID is accepted, and it contradicts the drafted Identity-Continuity posture (ID at offer stage via Didit, separately consented). No serious ATS collects ID on the apply form. The config key stays reserved; ID collection happens at offer stage via the Identity Continuity / Didit flow.
D2 — Cover video: deferred until it rides TUS ✓ decided The single-shot upload path provably fails on slow uplinks (CANDIDATE-EXPERIENCE-1B: 28 candidates blocked at the ~60s proxy timeout) — and an applicant has zero investment; they'll simply leave. When it returns (fast-follow on TUS): record-in-browser, attaches asynchronously with retry (form submits independently), never markable required, own consent checkbox, and excluded from any future biometric/embedding pipeline (added_method='apply_link' is the exclusion selector).
D3 — Duplicates: uniform success screen, no second email ✓ decided Duplicates stay blocked at the DB (the invariant holds), but the screen shows the same success state either way, and a duplicate sends no email — the candidate already got their confirmation on the first apply, and re-sending would reopen a mail-pump vector (§04.5). This closes the email-enumeration oracle (anyone — including a current employer — could otherwise type an email and learn that person applied) and supersedes the earlier "block with message" screen choice.

Compliance requirements shipping with v1 (from the DPDP/GDPR review)

Accepted-later register

Reputation-isolated mail domain (a poisoned apply flow must not blacklist assessment invites — flagged loudly, accepted for v1) · token rotation · double-opt-in email verification (kills misdirected email at the root; revisit on abuse signals) · access logging on form_responses reads · multilingual consent text (DPDP language right) · automated retention purge.

10Lifecycle & edge cases

position.statusis_openGET / POST result
DRAFTany404 — indistinguishable from an unknown token (unpublished roles don't leak)
OPENtruelive form
OPENfalsebranded closed page
CLOSEDanybranded closed page (positions can't be edited once CLOSED — closing the position permanently kills the link; is_open is the reversible dial)
deleted404 (FK cascade removed the link)

11Implementation plan — modules

#ModuleRepoDepends
M1Schema: position_apply_links table (RLS anon-deny) + 4 applications columns + apply_submit/array RPC + models/DAOsUtkrushta
M2Public apply API: GET/POST/files, Turnstile, Postgres rate limit (first FastAPI limiter wiring), email hookUtkrushtaM1
M3Recruiter config API: apply_form{} in the position payload upsert, get-or-create endpoint, duplicate-route carryUtkrushtaM1
M4Review-step section (create + edit)recruiter-utkrushtM3
M5Share surfaces: Share-menu rework (Option A) + apply-link modal (copy/QR/embed, qrcode.react) + success-screen rowrecruiter-utkrushtM3
M6Candidate apply page: /apply/[token], form, anon resume upload, states, consent, parse-prefill sliceutkrushta-assessmentM2
M7Confirmation email template + send hookUtkrushtawith M2
M8"View Application" row action + modal (thin reader over form_responses) + "Applied via link" tag on the candidates pagerecruiter-utkrushtM1
M9Legal/doc updates: candidate notice, DPA, ToS clause, sub-processors, retention rowlegallaunch-blocking, parallel
M10QA: race/dup tests, abuse drill (flood, oversized files, closed mid-form), 360px + 3G passallM4-M8
M1 schema M2 public API (+M7 email) M3 recruiter config API M6 candidate page M4 review UI · M5 share · M8 drawer M10 QA ship M9 legal runs parallel throughout (launch-blocking)

Sequencing. M1 first; backend pair in parallel; then three frontend modules in parallel; QA gate; legal in parallel throughout.

12Later / v2

ItemShape
WordPress / Framer / Webflow integrationsv1 ships the copy-paste button snippet. v2: build one hosted embed.js widget (styled Apply button / inline form), then wrap it three times — WP plugin (shortcode [utkrusht_apply token=…]), Framer code component, Webflow app/embed block. Iframe variant needs frame-ancestors CSP decisions, and the consent block must load inside the embed with no tracking cookies.
Vanity slugs/apply/{org}/{position} layered over tokens (tokens stay canonical — shorter QR payloads scan better).
Org-editable confirmation emailNew EmailTemplateType.application_received + per-org seed; merge fields constrained so orgs can't echo collected data into an unverified inbox.
Auto-invite togglePer-position "send assessment invite on application" — the pool-only default stays.
Application analyticsViews → starts → submits funnel; per-field drop-off shown next to the recruiter's config toggles (nudges away from greedy forms).
Government IDOffer-stage only, via the Identity Continuity / Didit flow. Never back on the apply form.
Cover videoReturns when it rides TUS resumable upload, with its own consent.
Hardening ladderDouble-opt-in email · localStorage draft persistence · multilingual consent · automated retention purge · reputation-isolated mail domain.

Applications Link design · 2026-07-07 · canonical text spec at Utkrushta/docs/applications-link-design.md · grounded in code by 3 exploration + 6 extraction agents, stress-tested by 5 adversarial reviews (security, data-integrity, candidate UX, DPDP/GDPR, recruiter workflow).