Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
name: CI

# Phase 0 foundation checks. Runs on every pull request targeting main, and on
# pushes to main and the Phase 0 branch. Add additional branch patterns here
# as later phases introduce their own long-lived branches.
# Pull requests validate feature branches before merge; the merged result is
# validated again on protected main. This deliberately avoids duplicate runs
# for both the push and pull-request event on every feature branch.
on:
pull_request:
branches: [main]
branches:
- main
push:
branches: [main, phase-0/foundation]
branches:
- main

# Repository-default GITHUB_TOKEN permissions are not needed for anything in
# this workflow beyond reading the checked-out code.
Expand All @@ -26,7 +28,7 @@ jobs:

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: app/.nvmrc
node-version-file: .nvmrc
cache: npm
cache-dependency-path: app/package-lock.json

Expand All @@ -45,7 +47,7 @@ jobs:

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: app/.nvmrc
node-version-file: .nvmrc

# Pinned to the exact version verified locally during Phase 0
# development (docs/DEPLOYMENT_STRATEGY.md records this) -- never
Expand Down
File renamed without changes.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ matter of enabling sign-ups, not rearchitecting the schema or the pipeline. See
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System components, where they run, how they talk |
| [docs/DATA_FLOW.md](docs/DATA_FLOW.md) | How data moves from external source to scored, saved opportunity |
| [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | Database schema and entity relationships |
| [docs/PROFILE_COMPLETENESS_SPEC.md](docs/PROFILE_COMPLETENESS_SPEC.md) | Language-neutral, explainable Phase 1A completeness contract |
| [docs/SCORING_AND_MATCHING_MODEL.md](docs/SCORING_AND_MATCHING_MODEL.md) | How relevance and competitiveness scores are computed and explained |
| [docs/DATA_SOURCES_AND_COMPLIANCE.md](docs/DATA_SOURCES_AND_COMPLIANCE.md) | Which sources are used, legally and operationally |
| [docs/SECURITY_AND_PRIVACY.md](docs/SECURITY_AND_PRIVACY.md) | Auth, data isolation, secrets, resume privacy, threat handling |
Expand All @@ -56,7 +57,8 @@ matter of enabling sign-ups, not rearchitecting the schema or the pipeline. See

Prerequisites:

- Node.js (version pinned in [app/.nvmrc](app/.nvmrc))
- Node.js `22.23.1` (the root [.nvmrc](.nvmrc) is authoritative). Run `nvm use` from the
repository root before `npm ci`; the frontend package declares the same engine.
- A Docker-compatible container runtime. This project uses **Colima** (Docker CLI + Colima, via
Homebrew) rather than Docker Desktop — see [docs/DEPLOYMENT_STRATEGY.md](docs/DEPLOYMENT_STRATEGY.md)
for why, including a real network-exposure issue found and fixed during setup.
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22 <23"
"node": "22.23.1"
},
"scripts": {
"dev": "vite",
Expand Down
41 changes: 28 additions & 13 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ flowchart TB
subgraph Supabase["Supabase (managed)"]
Auth["Auth\n(email/magic-link)"]
PG["Postgres\n(app data + RLS)"]
Storage["Storage\n(resumes only;\nno raw external content by default)"]
Storage["Storage\n(resume files deferred to Phase 1B;\nno raw external content by default)"]
Edge["Edge Functions (Deno)\n[reserved, not used at MVP]"]
end

subgraph GHA["GitHub Actions (scheduled)"]
Ingest["Python ingestion job\n(source adapters)"]
Score["Python scoring job\n(deterministic pipeline)"]
Parse["Python resume-parsing job"]
Parse["Python resume-parsing job\n(deferred to Phase 1B)"]
end

subgraph Ext["External sources"]
Expand All @@ -58,8 +58,8 @@ flowchart TB
Ingest --> Ext
Ingest -->|service-role key, GitHub secret| PG
Score -->|reads opportunities + profile, writes scores| PG
Parse -->|reads uploaded resume| Storage
Parse -->|writes structured profile draft| PG
Parse -.->|future: reads uploaded resume| Storage
Parse -.->|future: writes reviewable suggestions| PG

GHA -.->|cron trigger| Ingest
GHA -.->|cron trigger| Score
Expand All @@ -83,6 +83,22 @@ depth (escaping on render) is still applied.
possible and a clear offline/error state; it never silently shows stale data as current without
indication.

### Phase 1A profile UI and client-state direction

Phase 1A introduces a profile overview plus bookmarkable Education, Experience and Research,
Projects and Links, Skills and Evidence, Languages, Preferences, and Work Eligibility pages.
Each section saves independently; the overview turns deterministic completeness checks into
concrete next actions. A section can be deliberately reviewed when empty; a per-section content
revision makes that review stale after a relevant create, update, or delete. Sections must support
empty, loading, success, validation-error, server-error, and deletion-confirmation states.

**React Router is now justified** because these are real bookmarkable pages with browser-history
behavior, not conditional panels. This is a recorded decision, not an installed dependency in this
planning PR. **TanStack Query is not added yet**: simple typed repositories and explicit reloads
remain enough until shared cached queries across routes, repetitive mutation invalidation,
background refresh, optimistic updates, or more complex server-state coordination provides a
concrete need.

### Data and auth layer — Supabase (managed Postgres + Auth + Storage)

Supabase is not treated as a black box; it is specifically "managed Postgres with batteries." The
Expand All @@ -92,9 +108,9 @@ architecture depends on three of its parts:
Row Level Security policies as the authorization boundary for every user-owned table.
- **Auth** for identity (email/password or magic link at MVP). Issues the JWT the frontend uses
for direct, RLS-scoped Postgres access.
- **Storage** for resume files, in private buckets with signed URLs. Raw external content
(unsanitized source pages/responses) is **not** stored here, or anywhere, by default — see
"Raw external-content retention policy" below.
- **Storage** for future Phase 1B resume files, in private buckets with signed URLs. Phase 1A does
not use Storage for profile data. Raw external content (unsanitized source pages/responses) is
**not** stored here, or anywhere, by default — see "Raw external-content retention policy" below.

Runs entirely on Supabase's managed infrastructure (their cloud, on top of AWS). The project holds
no server to patch or scale for this layer within the free tier's limits.
Expand Down Expand Up @@ -136,10 +152,9 @@ Three related but distinct jobs, all Python, all triggered by GitHub Actions cro
the last known version, and writes new/updated source listings, opportunities, and version
history to Postgres using a service-role key held in GitHub Actions secrets (never exposed to
the browser).
2. **Resume parsing**: picks up newly uploaded resumes from Storage, extracts structured
candidate data (skills, education, experience) into a draft profile the user then reviews in
the SPA. Runs on a short interval (e.g., every 10–15 minutes) rather than instantly — see
the trade-off discussion in [ADR-002](adr/ADR-002-frontend-and-backend-boundaries.md).
2. **Resume parsing**: a future Phase 1B capability. It will extract separately reviewable
suggestions, never overwrite confirmed profile records, and must revisit field/suggestion-level
provenance ([ADR-014](adr/ADR-014-confirmed-profile-versus-imported-suggestions.md)).
3. **Scoring**: recomputes market-relevance and personal-competitiveness scores whenever
opportunities or the profile change meaningfully, writing explainable, versioned score records.

Expand Down Expand Up @@ -317,8 +332,8 @@ UI status indicator must be built:
scheduled run and a manually-triggered one firing close together) cannot both pick up and
process the same pending item. The pattern is a conditional `UPDATE ... WHERE status = 'pending'
RETURNING *` — the database's own atomicity is what prevents the race, not application-level
locking. See [DATA_FLOW.md](DATA_FLOW.md#2-resume-upload-and-profile-extraction-flow) for a
worked example.
locking. This is a Phase 1B requirement; its eventual resume-processing design must document a
worked sequence before implementation.
- **A job that dies after claiming an item needs a lease/timeout recovery path.** If a runner is
killed mid-processing, the claimed item must not stay stuck in `processing` forever — a
subsequent run's claim query must also match items whose `processing` status has an expired
Expand Down
69 changes: 26 additions & 43 deletions docs/DATA_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,56 +78,39 @@ deterministic evidence (an exact shared identifier or application URL). Anything
[ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md) for the full rule
set, failure modes, and why an uncertain merge is treated as worse than a temporary duplicate.

## 2. Resume upload and profile extraction flow
## 2. Phase 1A manual profile flow

```mermaid
sequenceDiagram
participant User
participant SPA as React SPA
participant Storage as Supabase Storage
participant DB as Postgres
participant Parse as Resume parsing job (Python, scheduled, best-effort timing)

User->>SPA: upload resume file (PDF/DOCX)
SPA->>Storage: upload to private bucket (user-scoped path)
SPA->>DB: create resume record (status = pending)
Note over SPA: UI shows "Pending" immediately;\nthis is eventually consistent, not instant
Note over Parse: runs on a short interval (e.g. every 10-15 min),\nbut a run can start late or be skipped —\nsee ARCHITECTURE.md §7
Parse->>DB: ATOMIC CLAIM: UPDATE resumes SET status='processing',\nclaimed_at=now(), claimed_by_run_id=<this run>\nWHERE status='pending' RETURNING *
Note over DB: the WHERE status='pending' clause is what makes the\nclaim atomic — if two overlapping runs race,\nonly one UPDATE matches and returns a row
Parse->>Storage: fetch file (service-role, signed access)
Parse->>Parse: extract text, structure into\nskills/education/experience/languages draft
alt success
Parse->>DB: write draft profile fields, resume status = completed
else parser error
Parse->>DB: resume status = failed, error recorded
else job dies mid-processing (crash, runner killed)
Note over DB: resume stays status='processing' with a stale claimed_at
Note over Parse: a later run's claim query also matches rows where\nstatus='processing' AND claimed_at < now() - lease_timeout,\ntreating them as abandoned and reclaiming them
end
User->>SPA: open profile review screen
SPA->>DB: read resume status + draft profile fields
Note over SPA: UI renders one of pending / processing / completed / failed —\nnever implies a result exists before status = completed
User->>SPA: accept/correct each field
SPA->>DB: write confirmed profile fields (user-authored, RLS-scoped)
participant DB as Postgres (RLS)

User->>SPA: open profile overview
SPA->>DB: read own confirmed manual rows + section review/revision rows
DB-->>SPA: profile sections and current/stale review state
SPA->>SPA: evaluate profile-completeness/v1 checks
SPA-->>User: show present, missing, unconfirmed, optional actions
User->>SPA: edit one section
SPA->>DB: insert/update/delete own manual row
DB-->>SPA: success or RLS/validation error
SPA->>SPA: reload section and reevaluate named checks
```

**Why not synchronous**: making this instant requires a server that responds to the upload
request itself (Edge Function or API), which the MVP defers — see
[ADR-002](adr/ADR-002-frontend-and-backend-boundaries.md). The UI must clearly show which of
`pending` / `processing` / `completed` / `failed` a resume is in, and must not fake immediacy.
**A synchronous Edge Function or containerized worker is the correct next step once waiting
becomes a demonstrated user problem** — not introduced now (see
[ARCHITECTURE.md](ARCHITECTURE.md#7-github-actions-reliability-characteristics-read-before-relying-on-cadence)).

**Trust boundary**: resume content is the user's own private data, not external/untrusted content
— but the *file itself* is untrusted input (must be validated as an actual PDF/DOCX, size-limited,
and parsed with a library resistant to malformed-file exploits) before parsing.

**Lease/timeout recovery**: any job that atomically claims a unit of work (this flow, and the
ingestion flow's per-listing processing) must define a lease timeout after which a `processing` row
with a stale `claimed_at` is treated as abandoned and eligible for reclaiming by a subsequent run.
Without this, a single crashed run could leave work permanently stuck in `processing`.
All Phase 1A profile rows are manual (`created_via = manual` where recorded). A section is saved
independently; completing a profile wizard is never required. A deliberate review records the
section's current revision, including when the collection is empty; relevant creates, updates, and
deletes increment that revision so a stale review is visible. The browser writes only through the
user's JWT and RLS, while ownership-safe composite constraints prevent a known foreign UUID from
being attached as project, experience, education, link, or skill evidence.

## 2B. Phase 1B resume extraction (deferred)

Resume storage, parsing, lease recovery, and suggestion review are intentionally outside this
Phase 1A implementation. When planned, extraction must create separate suggestions and require
explicit accept, correct, or reject actions; it cannot write over confirmed rows. It must revisit
field/suggestion-level provenance because `created_via = manual` at row level does not represent
mixed-origin information.

## 3. Saving an opportunity and generating tasks

Expand Down
Loading