From 16c8a436a6ea46839ed56889acf201ab5ae81a54 Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Sat, 1 Aug 2026 17:53:14 +0300 Subject: [PATCH 1/4] docs: define the Phase 1A profile architecture --- README.md | 1 + docs/ARCHITECTURE.md | 40 ++++++--- docs/DATA_FLOW.md | 67 +++++--------- docs/DATA_MODEL.md | 88 ++++++++++++------- docs/DEVELOPMENT_ROADMAP.md | 38 ++++---- docs/MVP_SCOPE.md | 15 ++-- docs/OBSERVABILITY.md | 5 +- docs/OPEN_QUESTIONS.md | 2 +- docs/PRODUCT_REQUIREMENTS.md | 12 ++- docs/PROFILE_COMPLETENESS_SPEC.md | 43 +++++++++ docs/RLS_POLICY_MATRIX.md | 24 +++-- docs/SECURITY_AND_PRIVACY.md | 18 +++- docs/TESTING_STRATEGY.md | 20 ++++- docs/USER_WORKFLOWS.md | 31 +++---- ...manual-profile-before-resume-extraction.md | 38 ++++++++ ...012-canonical-skills-and-skill-evidence.md | 36 ++++++++ ...-013-deterministic-profile-completeness.md | 28 ++++++ ...med-profile-versus-imported-suggestions.md | 20 +++++ ...nimized-work-eligibility-representation.md | 30 +++++++ docs/adr/README.md | 5 ++ 20 files changed, 415 insertions(+), 146 deletions(-) create mode 100644 docs/PROFILE_COMPLETENESS_SPEC.md create mode 100644 docs/adr/ADR-011-manual-profile-before-resume-extraction.md create mode 100644 docs/adr/ADR-012-canonical-skills-and-skill-evidence.md create mode 100644 docs/adr/ADR-013-deterministic-profile-completeness.md create mode 100644 docs/adr/ADR-014-confirmed-profile-versus-imported-suggestions.md create mode 100644 docs/adr/ADR-015-minimized-work-eligibility-representation.md diff --git a/README.md b/README.md index 15bde2c..1d00b83 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ce3c70..286c5a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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"] @@ -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 @@ -83,6 +83,21 @@ 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. 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 @@ -92,9 +107,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. @@ -136,10 +151,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. @@ -317,8 +331,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 diff --git a/docs/DATA_FLOW.md b/docs/DATA_FLOW.md index 5fbd769..bab8297 100644 --- a/docs/DATA_FLOW.md +++ b/docs/DATA_FLOW.md @@ -78,56 +78,37 @@ 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=\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 + DB-->>SPA: profile sections and confirmations + 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. 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 diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 0e54591..2a65d01 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -11,7 +11,7 @@ bucket — including the anonymous-access answer — is enumerated exhaustively [RLS_POLICY_MATRIX.md](RLS_POLICY_MATRIX.md); this document should not be treated as the authoritative permission reference on its own. -## Revision note (this pass) +## Revision note (Phase 1A architecture pass) This revision corrects two gaps identified in review: @@ -26,6 +26,11 @@ This revision corrects two gaps identified in review: double-counting market statistics. A `source_listings` layer and a conservative deduplication design now sit between raw source data and the canonical `opportunities` table. See [ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md). +3. Phase 1 is split into **Phase 1A manual structured profile** and **Phase 1B resume intake and + extraction drafts** ([ADR-011](adr/ADR-011-manual-profile-before-resume-extraction.md)). The + profile model below is a Phase 1A proposal only: it does not create migrations or resume/ + suggestion structures. `profiles.profile_complete` has no authoritative role; completeness is + derived from [PROFILE_COMPLETENESS_SPEC.md](PROFILE_COMPLETENESS_SPEC.md). ## Entity-relationship overview @@ -35,9 +40,16 @@ erDiagram PROFILES ||--o{ EDUCATION_ENTRIES : has PROFILES ||--o{ WORK_EXPERIENCE : has PROFILES ||--o{ PROJECTS : has + PROFILES ||--o{ PROFILE_LINKS : has PROFILES ||--o{ USER_SKILLS : has + SKILLS_CATALOG ||--o{ SKILL_ALIASES : has + SKILLS_CATALOG ||--o{ USER_SKILLS : claimed_as + USER_SKILLS ||--o{ SKILL_EVIDENCE : supported_by + PROJECTS ||--o{ PROJECT_SKILLS : uses PROFILES ||--o{ USER_LANGUAGES : has PROFILES ||--|| PREFERENCES : has + PREFERENCES ||--o{ PREFERENCE_LOCATIONS : has + PROFILES ||--o{ WORK_ELIGIBILITY : has USERS ||--o{ RESUMES : uploads RESUMES ||--o{ RESUME_EXTRACTIONS : produces @@ -62,41 +74,55 @@ erDiagram OPPORTUNITIES ||--o{ MARKET_SKILL_MENTIONS : contributes_to ``` -## Profile domain (user-owned, RLS-protected) - -**Implementation status**: `profiles` and `education_entries` are implemented as of Phase 0 -(migration `supabase/migrations/20260726145047_profiles_and_education_entries.sql`), with a -deliberately minimal Phase 0 field set — the fuller field lists below (`work_eligibility`, -`expected_graduation`, the `source` provenance column, etc.) are the Phase 1 target, not yet -built. `work_experience`, `projects`, `skills_catalog`, `user_skills`, `user_languages`, and -`preferences` are not yet implemented — deferred to Phase 1, since they reuse the same two RLS -patterns Phase 0 already proves (owned-directly, owned-via-FK) and add no new pattern to justify -building them before the rest of the profile domain is real. - -**Ownership naming convention (binding, see [AGENTS.md](../AGENTS.md))**: every user-owned table's -owner column is literally named `user_id` — never aliased (e.g. `profile_id`), even on child -tables that reference `profiles.user_id`. `education_entries.user_id` is a direct FK to -`profiles.user_id`, not a same-named-but-different column. This is deliberate: aliasing the -column to `profile_id` while comparing it to `auth.uid()` in RLS policies obscures, at every call -site, that it is the actual row-ownership key — a future contributor reading `education_entries` -in isolation should be able to tell who owns a row without knowing it joins to `profiles`. - -| Table | Key fields | Notes | -|---|---|---| -| `profiles` | `user_id` (PK, FK to `auth.users(id)` on delete cascade), `headline`, `degree_program`, `degree_year` (checked 1–10), `profile_complete`, `created_at` | Phase 0 implemented. `work_eligibility`/`expected_graduation` deferred to Phase 1 | -| `education_entries` | `id` (PK), `user_id` (FK to `profiles.user_id` on delete cascade — **not** `profile_id`), `institution` (not null), `degree`, `field`, `start_date`, `end_date`, `created_at` | Phase 0 implemented. Date-order check constraint. `source` provenance column deferred to Phase 1 | -| `work_experience` | `id`, `user_id`, `organization`, `role`, `start_date`, `end_date`, `description`, `skills_used[]`, `source` | Not yet implemented — Phase 1 | -| `projects` | `id`, `user_id`, `title`, `description`, `technologies[]`, `link`, `engineering_areas[]`, `source` | Not yet implemented — Phase 1. Central to project-evidence scoring | -| `skills_catalog` | `id`, `name`, `category` | Not yet implemented — Phase 1. Shared reference table (not user-owned), seeded + grown from ingestion | -| `user_skills` | `id`, `user_id`, `skill_id`, `proficiency`, `evidence_project_id` (nullable FK), `source` | Not yet implemented — Phase 1. Links a skill claim to supporting evidence where possible, for explainability | -| `user_languages` | `id`, `user_id`, `language`, `proficiency_cefr` | Not yet implemented — Phase 1 | -| `preferences` | `user_id` (PK/FK), `preferred_locations[]`, `bremen_hamburg_priority` (bool), `remote_acceptable`, `target_engineering_areas[]`, `target_opportunity_types[]` | Not yet implemented — Phase 1 | +## Profile domain (Phase 1A proposal; user-owned unless marked shared) + +**Implementation status**: only the deliberately minimal Phase 0 `profiles` and +`education_entries` migration exists. Everything in this section is a Phase 1A logical target, +not implemented SQL. Phase 1B resume and suggestion records are expressly excluded. + +**Ownership rule (binding, see [AGENTS.md](../AGENTS.md))**: every user-owned table has direct +`user_id`, including association tables. User-owned child references use ownership-safe composite +foreign keys — e.g. `(user_id, project_id)` references a unique `(user_id, id)` on `projects` — or +an equivalent trigger/constraint. A UUID alone never authorizes attaching another user's row. +Each manual row may include `created_via = manual`, `created_at`, `updated_at`, and meaningful +`last_confirmed_at`; this is row context, not a Phase 1B provenance solution. + +| Entity | Purpose / ownership / key | Major foreign keys and constraints | Deletion / RLS / sensitivity / browser access / later consumer | +|---|---|---|---| +| `profiles` | One matching-ready identity per user; user-owned; `user_id` PK. | FK `auth.users(id)`; degree year bounded; expected graduation timing optional; no authoritative `profile_complete`. | Cascades from user; own-row RLS; High; browser CRUD; Phase 1A overview, later matching/scoring. | +| `education_entries` | Education history; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; institution required; date order; current entry is explicit/derived consistently. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, matching, evidence. | +| `work_experience` | Employment, research, or volunteering evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; role/organization required; date order; bounded description. | Cascade from profile; own-row RLS; High; browser CRUD; evidence and future matching. | +| `projects` | User project evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; title required; bounded description; optional safe URL. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, evidence, matching. | +| `profile_links` | Links to portfolio/GitHub/public work; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; URL scheme/length allowlist; labeled link. | Cascade from profile; own-row RLS; Medium; browser CRUD; evidence and optional enrichment. | +| `skills_catalog` | Small canonical skill names for matching; shared; `id` PK. | Normalized canonical name unique; bounded category; no user data. | Service maintains; authenticated read only; Low; browser SELECT only; matching and market aggregation. | +| `skill_aliases` | Controlled alternate names for a canonical skill; shared; `id` PK. | `skill_id` → catalog; normalized alias unique; aliases never point to a custom label. | Cascade from catalog; authenticated read only, service write; Low; browser SELECT only; normalization/matching. | +| `user_skills` | A user's skill claim, canonical or private custom; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; optional `skill_id` → catalog XOR bounded `custom_label`; unique normalized claim per user; scalar `self_assessment` enum is explicitly self-reported. | Cascade from profile; own-row RLS; High; browser CRUD; completeness and future matching. | +| `project_skills` | Relational project technologies/skills; user-owned association; composite PK `(user_id, project_id, skill_id)`. | `(user_id, project_id)` → projects; `skill_id` → catalog; no arrays. | Cascade from project/catalog as appropriate; own-row plus composite-parent checks; High; browser CRUD; evidence and matching. | +| `skill_evidence` | Many evidence items for a skill claim; user-owned; `id` PK, direct `user_id`. | `(user_id, user_skill_id)` → user skills; exactly one supported target among project, experience, education, link, bounded note; each target uses `(user_id, target_id)` FK. | Cascade from claim/target; own-row and parent-bypass tests; High; browser CRUD; evidence-first matching. | +| `user_languages` | Language claims; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; normalized language unique per user; scalar CEFR enum. | Cascade from profile; own-row RLS; High; browser CRUD; readiness and matching. | +| `preferences` | One user preference container; user-owned; `user_id` PK. | FK profile; scalar `remote_preference` enum; no location/target arrays. | Cascade from profile; own-row RLS; High; browser CRUD; matching/filtering. | +| `preference_locations` | Preferred locations relation; user-owned; `id` PK, direct `user_id`. | `(user_id)` → preferences; normalized location + country/region; unique per user. | Cascade from preferences; own-row RLS; Medium; browser CRUD; matching/filtering. | +| `engineering_areas` | Controlled engineering-area reference; shared; `id` PK. | Unique normalized name. | Service-maintained; authenticated read only; Low; browser SELECT only; targeting and matching. | +| `role_types` | Controlled role-type reference; shared; `id` PK. | Unique normalized name. | Service-maintained; authenticated read only; Low; browser SELECT only; targeting and matching. | +| `user_target_engineering_areas` | User's selected areas; user-owned association; composite PK `(user_id, engineering_area_id)`. | `(user_id)` → profile; area → catalog. | Cascade from profile/catalog; own-row RLS; Medium; browser CRUD; readiness and matching. | +| `user_target_role_types` | User's selected roles; user-owned association; composite PK `(user_id, role_type_id)`. | `(user_id)` → profile; role → catalog. | Cascade from profile/catalog; own-row RLS; Medium; browser CRUD; readiness and matching. | +| `target_companies` | Optional watchlist; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; normalized company name unique per user; bounded note only. | Cascade from profile; own-row RLS; Medium; browser CRUD; optional enrichment, future discovery. | +| `work_eligibility` | Minimal self-reported eligibility state; user-owned, highly sensitive; `user_id` PK. | FK profile; enum `eligible`/`eligible_with_conditions`/`requires_sponsorship`/`unknown`; optional non-negative weekly limit; `last_confirmed_at` required after review. | Cascade from profile; own-row RLS; Very High; browser CRUD; readiness and future eligibility checks. | + +Relational structures are required for skills, aliases, project technologies, evidence, targets, and +preferred locations because they support validation, aggregation, future matching, and explainable +links. Arrays would conceal ownership and parent checks. Scalar enums remain appropriate for +remote preference, skill self-assessment, and work-eligibility state. ## Resume domain +**Status: Phase 1B deferred.** These records are not part of the Phase 1A model and must not be +created until the confirmed manual profile is stable. Their eventual extraction output is a +reviewable suggestion, not a write path to confirmed profile rows. + | Table | Key fields | Notes | |---|---|---| -| `resumes` | `id`, `user_id`, `storage_path`, `uploaded_at`, `status` (`pending`\|`processing`\|`completed`\|`failed`), `claimed_at`, `claimed_by_run_id`, `is_active_version` | File itself lives in Supabase Storage, private bucket. `status`/`claimed_*` support the atomic-claim job pattern in [DATA_FLOW.md](DATA_FLOW.md#2-resume-upload-and-profile-extraction-flow) | +| `resumes` | `id`, `user_id`, `storage_path`, `uploaded_at`, `status` (`pending`\|`processing`\|`completed`\|`failed`), `claimed_at`, `claimed_by_run_id`, `is_active_version` | File itself lives in Supabase Storage, private bucket. Its Phase 1B behavior must extend the deferred flow in [DATA_FLOW.md](DATA_FLOW.md#2b-phase-1b-resume-extraction-deferred). | | `resume_extractions` | `id`, `resume_id`, `extracted_json`, `extracted_at`, `parser_version` | Raw extraction kept separate from confirmed profile fields, so re-parsing or parser upgrades never silently overwrite user corrections | ## Opportunity identity domain (shared/global, service-role write) diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md index 5eeb84e..cc153c3 100644 --- a/docs/DEVELOPMENT_ROADMAP.md +++ b/docs/DEVELOPMENT_ROADMAP.md @@ -25,22 +25,28 @@ process. save it (RLS-permitted), sign out — verified both by automated tests and by hand in a real browser against the real local stack. No opportunity data yet. -## Phase 1 — Profile - -- Resume upload → Storage → scheduled parsing job → draft profile → manual review/correction UI. -- Full profile CRUD: add `work_experience`, `projects`, `skills_catalog`, `user_skills`, - `user_languages`, `preferences` (all deferred from Phase 0 — see - [DATA_MODEL.md](DATA_MODEL.md)), plus the fuller `profiles`/`education_entries` field sets - (`work_eligibility`, `expected_graduation`, `source` provenance) not needed to prove Phase 0's - RLS pattern. -- The resume-parsing job implements the atomic-claim and lease-timeout pattern from - [DATA_FLOW.md](DATA_FLOW.md#2-resume-upload-and-profile-extraction-flow) from the start, and the - UI ships with explicit `pending` / `processing` / `completed` / `failed` states — this is not a - hardening step added later, since a fake "instant" UI would misrepresent how the system actually - behaves (see [ARCHITECTURE.md](ARCHITECTURE.md#7-github-actions-reliability-characteristics-read-before-relying-on-cadence)). - -**Exit condition**: Workflow 1 in [USER_WORKFLOWS.md](USER_WORKFLOWS.md) works end to end with the -real user's real resume, including a visible, honest processing-state transition. +## Phase 1A — Structured manual career profile + +- Full section-based CRUD for the logical profile domain in [DATA_MODEL.md](DATA_MODEL.md): + education, experience/research, projects/links, skills/evidence, languages, preferences, + targets, and minimized work eligibility. +- Deterministic completeness using [PROFILE_COMPLETENESS_SPEC.md](PROFILE_COMPLETENESS_SPEC.md), + exact grants/RLS, and the Phase 1A tests in [TESTING_STRATEGY.md](TESTING_STRATEGY.md). +- Multiple bookmarkable profile pages justify React Router; no dependency is installed in this + planning PR. TanStack Query remains deferred until its documented trigger occurs. + +**Exit condition**: Workflow 1 works through manual entry only, with independent section saves, +actionable completeness, and verified cross-user isolation. + +## Phase 1B — Resume intake and extraction drafts + +- Private resume storage, upload, scheduled parsing, and separately reviewable extraction + suggestions only after Phase 1A is confirmed stable. +- Explicit acceptance, correction, and rejection; no extraction silently overwrites confirmed + profile data. Revisit field/suggestion-level provenance at implementation time. + +**Exit condition**: a resume produces a truthful, reviewable draft without altering confirmed +records unless the user explicitly accepts a change. ## Phase 2 — First ingestion pipeline, unscored diff --git a/docs/MVP_SCOPE.md b/docs/MVP_SCOPE.md index 29a417c..db15130 100644 --- a/docs/MVP_SCOPE.md +++ b/docs/MVP_SCOPE.md @@ -8,13 +8,12 @@ for how this maps to phases. 1. **Authentication** — Supabase Auth, single account, schema designed for many. No public sign-up flow yet. -2. **User career profile** — structured record of education, target roles, location preferences, - work eligibility, languages. -3. **Resume upload and structured extraction** — PDF/DOCX upload, parsed into structured skills / - experience / education entries. Parsing is assisted, not blindly trusted (see requirement 4). -4. **Manual profile correction** — every extracted field is user-editable; extraction is a - starting point, not an authority. -5. **Skills, education, projects, languages, preferences, and target roles** as first-class, +2. **Phase 1A user career profile** — manually maintained structured education, target roles, + locations, minimized work eligibility, languages, skills, projects, and evidence. +3. **Phase 1B resume upload and structured extraction** — only after the manual model and editing + workflows are stable. Parsing creates reviewable suggestions; it never overwrites confirmed + data ([ADR-014](adr/ADR-014-confirmed-profile-versus-imported-suggestions.md)). +4. **Skills, education, projects, languages, preferences, and target roles** as first-class, individually editable profile sections. 6. **Opportunity ingestion from a small number of compliant sources** — enough to validate the pipeline end to end, not broad coverage. See [DATA_SOURCES_AND_COMPLIANCE.md](DATA_SOURCES_AND_COMPLIANCE.md). @@ -34,6 +33,8 @@ for how this maps to phases. ## Explicitly deferred +- Resume storage, upload, parsing, extraction drafts, and imported suggestions until Phase 1B. + - Full smart-calendar optimization (calendar sync, scheduling assistant). - Voice recognition / voice interfaces. - Autonomous applications (the system never submits an application on the user's behalf). diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index aa1b73e..fbb969c 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -60,9 +60,8 @@ Any table using the atomic-claim pattern (`resumes.status = 'processing'`, and a claimable work queue) should be monitorable for items whose `claimed_at` has exceeded the lease timeout without completing — in practice, a simple count query an operator (currently the user, acting as their own administrator) can check, surfacing a job that died mid-run without a -subsequent run having reclaimed it yet. See -[DATA_FLOW.md](DATA_FLOW.md#2-resume-upload-and-profile-extraction-flow) for the claim/lease -mechanism this monitors. +subsequent run having reclaimed it yet. See the deferred [resume domain](DATA_MODEL.md#resume-domain) +for the claim/lease mechanism Phase 1B must define. ## 6. Error logging content rules diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index 56d3dfe..bdfaadc 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -30,7 +30,7 @@ phase that needs it. used instead, with migrations as the sole source of truth. No second hosted "dev" project, no Supabase Branching. Exactly one hosted project is reserved for production only, created later. See [DEPLOYMENT_STRATEGY.md](DEPLOYMENT_STRATEGY.md). -- **Resume-parsing method**: the brief doesn't specify whether extraction should use a +- **Resume-parsing method (Phase 1B, not Phase 1A)**: the brief doesn't specify whether extraction should use a traditional NLP/rules pipeline (spaCy, regex-based section detection) or an LLM call. Given the project's deterministic-before-LLM preference, a rules-first approach with LLM assistance only where rules clearly fail is the likely direction, but this needs a dedicated design pass before diff --git a/docs/PRODUCT_REQUIREMENTS.md b/docs/PRODUCT_REQUIREMENTS.md index b67ba65..57e6c6f 100644 --- a/docs/PRODUCT_REQUIREMENTS.md +++ b/docs/PRODUCT_REQUIREMENTS.md @@ -63,8 +63,16 @@ See [OPPORTUNITY_TAXONOMY.md](OPPORTUNITY_TAXONOMY.md) for the full definition. and personal competitiveness. - **Development opportunities** (hackathons, competitions, research programmes, summer schools, scholarships, workshops, career fairs, insight events, open-source programmes, conferences) get - a single ranking built from a different set of dimensions (cost, distance, deadline, resume - value, etc.). +a single ranking built from a different set of dimensions (cost, distance, deadline, resume +value, etc.). + +## Phase 1 profile sequencing + +**Phase 1A** is a structured, manual, matching-ready career profile. It includes no resume +upload, extraction, background parsing, or imported suggestions. **Phase 1B** follows only after +the manual profile model and workflows are stable; it may create extraction suggestions but never +silently overwrite confirmed data. These are separate planning units, branches, pull requests, and +exit conditions ([ADR-011](adr/ADR-011-manual-profile-before-resume-extraction.md)). ## 5. Explainability requirement (hard constraint) diff --git a/docs/PROFILE_COMPLETENESS_SPEC.md b/docs/PROFILE_COMPLETENESS_SPEC.md new file mode 100644 index 0000000..39414e8 --- /dev/null +++ b/docs/PROFILE_COMPLETENESS_SPEC.md @@ -0,0 +1,43 @@ +# Profile Completeness Specification + +**Status**: proposed Phase 1A contract; not implemented. Version: `profile-completeness/v1`. + +This is language-neutral input/output behavior, not shared TypeScript/Python code. Implementations +must produce the named check identifier, outcome, and user-facing explanation below. + +## Inputs and outcomes + +Inputs are confirmed manual profile rows plus `last_confirmed_at` values. A check returns +`present`, `missing`, `unconfirmed`, or `optional`; Phase 1A has no imported suggestions, while +Phase 1B adds imported-suggestion state. `profiles.profile_complete` is not an input or output. + +| Category | Check | Input | Pass / user-facing explanation | +|---|---|---|---| +| Basic matching readiness | `current_education` | current `education_entries` | Present: “Current education is recorded.” Missing: “Add your current education.” | +| Basic matching readiness | `degree_year` | profile or current education degree year | Present: “Degree year is recorded.” Missing: “Add your degree year.” | +| Basic matching readiness | `graduation_timing` | expected graduation date/timing | Present: “Graduation timing is recorded.” Missing: “Add expected graduation timing.” | +| Basic matching readiness | `target_direction` | target engineering area or role type | Present: “Target direction is recorded.” Missing: “Choose a target role or engineering area.” | +| Basic matching readiness | `location_or_remote` | preferred location or remote preference | Present: “Location or remote preference is recorded.” Missing: “Set a location or remote preference.” | +| Basic matching readiness | `language` | `user_languages` | Present: “At least one language is recorded.” Missing: “Add a language.” | +| Basic matching readiness | `eligibility_reviewed` | `work_eligibility.last_confirmed_at` | Present: “Work eligibility was reviewed.” Unconfirmed: “Review your work-eligibility state.” | +| Evidence strength | `experience_or_project` | `work_experience` or `projects` | Present: “You have project or experience evidence.” Missing: “Add a project or experience.” | +| Evidence strength | `technical_skill` | technical `user_skills` | Present: “Technical skills are recorded.” Missing: “Add technical skills.” | +| Evidence strength | `skill_evidence` | evidence per technical skill | Present: “Claimed technical skills have evidence.” Missing: “Link evidence to each claimed technical skill.” | +| Review state | `required_sections_reviewed` | required section confirmations | Present: “Required sections were reviewed.” Unconfirmed: “Review the sections marked for confirmation.” | +| Review state | `imported_suggestions_resolved` | Phase 1B suggestion states | Phase 1A: Optional, “Resume suggestions are not in this phase.” Phase 1B: Present only when none remain unresolved. | +| Optional enrichment | `profile_links` | `profile_links` | Optional: “Add links to make evidence easier to review.” | +| Optional enrichment | `target_companies` | `target_companies` | Optional: “Add target companies to personalize discovery.” | +| Optional enrichment | `additional_languages_and_evidence` | extra languages, projects, evidence | Optional: “More detail can strengthen explanations.” | + +## Synthetic test vectors + +| ID | Synthetic input summary | Expected notable outcomes | +|---|---|---| +| `minimal-ready` | Current education, degree year, graduation timing, one target role, remote preference, one language, reviewed eligibility; one project; one evidenced technical skill; required sections reviewed | All basic and evidence checks `present`; optional checks remain `optional`. | +| `direction-missing` | Same as `minimal-ready`, without target roles or areas | `target_direction = missing`; explanation asks to choose one. | +| `claim-without-evidence` | Same as `minimal-ready`, but technical skill claim has no evidence | `skill_evidence = missing`; basic readiness stays `present`. | +| `needs-review` | Required fields exist but eligibility and projects lack confirmations | `eligibility_reviewed` and/or `required_sections_reviewed = unconfirmed`. | +| `phase-1b-pending-suggestion` | `minimal-ready` plus an unresolved extracted suggestion | In Phase 1B, `imported_suggestions_resolved = missing`; in Phase 1A this vector is not applicable. | + +An implementation may summarize these checks visually, but may not show a percentage as the only +result. Any later weighted percentage must publish each component and weight. diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md index 9bde8e5..725247c 100644 --- a/docs/RLS_POLICY_MATRIX.md +++ b/docs/RLS_POLICY_MATRIX.md @@ -57,10 +57,9 @@ both this file and its corresponding test. ## Profile domain (user-owned) -**Status**: `profiles` and `education_entries` rows below are implemented and pgTAP-tested as of -Phase 0 (see [DATA_MODEL.md](DATA_MODEL.md)); `work_experience`, `projects`, `user_skills`, -`user_languages`, `preferences`, and `skills_catalog` are not yet built (Phase 1) — their rows -here describe the intended, not-yet-implemented policy. +**Status**: `profiles` and `education_entries` are implemented and pgTAP-tested as of Phase 0. +All other rows in this profile-domain section are proposed Phase 1A policy, not schema. Phase 1B +resume/suggestion tables are intentionally not designed here. Every table's owner column is literally named `user_id` (see [AGENTS.md](../AGENTS.md)) — child tables reference the owning user via a direct `user_id` foreign key to `profiles.user_id`, never @@ -68,13 +67,24 @@ via a renamed column like `profile_id`. The implemented predicate form is `(select auth.uid()) = user_id` (wrapping `auth.uid()` in a `select` lets Postgres evaluate it once per statement rather than once per row — the currently-recommended, non-deprecated pattern). -| Table(s) | Owner / data type | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation test | Sensitivity | +| Table(s) | Category / owner | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation and ownership-bypass test | Sensitivity | |---|---|---|---|---|---|---|---|---|---|---| -| `profiles`, `education_entries`, `work_experience`, `projects`, `user_skills`, `user_languages`, `preferences` | One row/set per user; every table's owner column is `user_id` | Own rows only | Own rows only | Own rows only | Own rows only | Full (admin/export/account-deletion tooling) | `(select auth.uid()) = user_id` on every table (`profiles.user_id` is the PK; child tables' `user_id` is a direct FK to it) | No | User A cannot SELECT, INSERT (for A's `user_id`), UPDATE, or DELETE any row owned by user B, including by rewriting a child row's `user_id` to claim it; User A has full CRUD on their own rows | High (`profiles.work_eligibility`, once added in Phase 1, reveals immigration/visa status — treat as Very High for that column specifically) | -| `skills_catalog` | Shared reference data (skill names), not user-owned | All authenticated users | No (service-role only, grown by ingestion/normalization) | No | No | Full | None (public reference table, but still gated behind authentication for MVP) | No | Any authenticated user can read the full catalog; no authenticated user can write to it | Low | +| `profiles`, `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) | +| `preference_locations`, `user_target_engineering_areas`, `user_target_role_types` | User-owned relation; direct `user_id` | Own only | Own only | Own only where applicable | Own only | Full | Direct owner predicate plus composite parent `(user_id, parent_id)` validation | No | Owner CRUD; all cross-user denials; forged owner; cannot attach a valid user A row to B's preferences/profile | Medium | +| `project_skills` | User-owned relation; direct `user_id` | Own only | Own only | N/A or own only | Own only | Full | Direct owner predicate plus `(user_id, project_id)` composite FK | No | User A cannot attach B's project to A's skill or vice versa, even when UUID is known; owner CRUD | High | +| `skill_evidence` | User-owned evidence; direct `user_id` | Own only | Own only | Own only | Own only | Full | Direct owner predicate plus same-user composite references to claim and target | No | User A cannot create evidence on B's claim/project/experience/education/link; cannot rewrite owner or target; owner CRUD | High | +| `work_eligibility` | **Highly sensitive user-owned**; direct `user_id` | Own only | Own only | Own only | Own only | Full only for explicit maintenance/export/deletion | Direct owner predicate | No | Owner CRUD; cross-user and forged-owner denials; anon 403; API-path JWT test | Very High | +| `skills_catalog`, `skill_aliases`, `engineering_areas`, `role_types` | Shared authenticated-readable reference data | All authenticated users | No | No | No | Full; service maintains canonical values | No owner predicate; grant SELECT only to authenticated | No | Authenticated read succeeds; authenticated INSERT/UPDATE/DELETE fails at grant; service maintenance path succeeds | Low | + +Private `user_skills.custom_label` values are never copied into shared catalogs or market +aggregation automatically. Every future migration must include exact grants, RLS policies, and the +listed pgTAP plus real Auth/JWT/PostgREST checks; no frontend permission check substitutes for them. ## Resume domain (user-owned) +**Status: Phase 1B deferred.** This retained future-policy sketch is not authorization for a +Phase 1A migration; resume/suggestion design must be revisited with field-level provenance. + | Table | Owner / data type | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation test | Sensitivity | |---|---|---|---|---|---|---|---|---|---|---| | `resumes` | One row per uploaded resume, owned by the uploading user | Own rows only | Own rows only (creates the `pending` record at upload time) | **No** — `status`, `claimed_at`, `claimed_by_run_id` are job-owned fields; the browser never updates this row directly (a corrected resume is a new upload, not an edit) | Own rows only | Full (claim, process, mark `completed`/`failed`) | `user_id = auth.uid()` | No | User A cannot SELECT or DELETE user B's resume record; user A cannot UPDATE any resume row (including their own) via the browser role at all | Very High (references the actual resume file) | diff --git a/docs/SECURITY_AND_PRIVACY.md b/docs/SECURITY_AND_PRIVACY.md index 11d8d49..dfafa23 100644 --- a/docs/SECURITY_AND_PRIVACY.md +++ b/docs/SECURITY_AND_PRIVACY.md @@ -5,10 +5,10 @@ without application to this system. ## 1. What this system holds -Resume content, education/employment history, skills, application records, saved job -descriptions, and third-party public content (opportunity postings). The resume and profile data -in particular is sensitive personal data the user would not want exposed to anyone else, including -a future second user of the same system. +Education/employment history, skills, project evidence, preferences, minimized work eligibility, +application records, saved job descriptions, and third-party public content (opportunity postings). +Resume content is deferred to Phase 1B. Profile data is sensitive personal data the user would not +want exposed to anyone else, including a future second user of the same system. ## 2. Authentication @@ -74,6 +74,11 @@ multi-user-later" claim in the project brief; it is not just an aspiration. ## 5. Resume and application data privacy +**Phase boundary**: Phase 1A collects only manual profile data. It records no resume file, +extraction output, imported suggestion, or extraction-specific provenance column. Phase 1B must +keep parsed suggestions separate from confirmed records and revisit field/suggestion-level +provenance; a row-level manual marker does not solve mixed-origin data. + - Resumes stored in a private Supabase Storage bucket; access only via short-lived signed URLs generated server-side (by the ingestion job) or via RLS-scoped client requests — never a public bucket. @@ -168,3 +173,8 @@ Only fields that serve a stated product requirement (see (e.g., no reason to ever collect government ID numbers, financial information, or health data) is requested by this product, and no such field should ever be added without updating this document first. + +For work eligibility, the permitted Phase 1A representation is only a self-reported structured +state, optional weekly-hour limit, and last confirmation time. Passport numbers, permit scans, +government identifiers, immigration narratives, and legal advice are explicitly out of scope; see +[ADR-015](adr/ADR-015-minimized-work-eligibility-representation.md). diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md index 98b4705..40c068d 100644 --- a/docs/TESTING_STRATEGY.md +++ b/docs/TESTING_STRATEGY.md @@ -68,7 +68,25 @@ mocked: unintended shifts. - **Eligibility-rule tests are exhaustive by design** — because these are binary gates with outsized consequences (an eligible candidate wrongly marked ineligible is filtered out of view), - each rule gets explicit positive and negative test cases. +each rule gets explicit positive and negative test cases. + +## Phase 1A profile implementation tests (future) + +- **Migration structure tests**: exact tables, direct `user_id` ownership, required foreign keys, + ownership-safe composite parent references, constraints, timestamps, and no authoritative + `profile_complete` column/behavior. +- **Exact permission tests**: revoke-then-grant sets, exact RLS-policy names/predicates, no anon + grants, owner CRUD, cross-user isolation, forged-owner insert, and ownership-rewrite denial. +- **Composite-parent bypass tests**: user A must not attach user B's project, experience, + education, profile link, preference container, or skill claim by knowing its UUID. +- **Shared catalog tests**: authenticated read succeeds; INSERT/UPDATE/DELETE of canonical + skills, aliases, areas, and roles fails; private custom labels never enter shared aggregation. +- **Real API tests**: Auth → JWT → PostgREST tests cover the same positive and negative cases, + rather than relying only on pgTAP. +- **Frontend tests**: validate forms, independent section save/delete behavior, state handling, + accessibility-focused behavior, and the language-neutral vectors in + [PROFILE_COMPLETENESS_SPEC.md](PROFILE_COMPLETENESS_SPEC.md). Fixtures must be synthetic and + public-safe; Phase 0 regressions remain required. ## 4. Adapter and scheduled-job testing diff --git a/docs/USER_WORKFLOWS.md b/docs/USER_WORKFLOWS.md index 9f02172..5797363 100644 --- a/docs/USER_WORKFLOWS.md +++ b/docs/USER_WORKFLOWS.md @@ -3,24 +3,19 @@ Status: **proposal**. These are concrete usage scenarios the MVP must support; they exist to keep architecture and data-model decisions grounded in real usage rather than abstract requirements. -## Workflow 1 — Onboarding and profile setup - -1. User signs in (Supabase Auth, single account for MVP). -2. User uploads a resume (PDF/DOCX) to private storage; the resume record is immediately visible - in the UI with status `pending`. -3. A scheduled parsing job picks up pending resumes on a short interval (e.g., every 10-15 - minutes) and extracts candidate structured data: education entries, skills, projects, prior - experience, languages. This is **eventually consistent, not instant** — GitHub Actions cron can - start late or occasionally skip a run (see [DATA_FLOW.md](DATA_FLOW.md#0-a-standing-caveat-github-actions-is-a-scheduler-not-a-reliable-interactive-system)), - so the UI shows the resume moving through `pending` → `processing` → `completed` (or `failed` - with a reason) rather than implying an instant result. A synchronous processing option (Edge - Function or worker) is deferred until waiting is a demonstrated real problem for the user, not - built speculatively now — see [ARCHITECTURE.md](ARCHITECTURE.md). -4. User reviews the extracted profile screen by screen and corrects anything wrong or missing: - adds project links, adjusts skill proficiency, sets location preferences (Bremen/Hamburg - priority, Germany-wide, remote-acceptable), sets work-eligibility facts (student visa status, - hours/week permitted), and sets target roles/areas from the engineering-area list. -5. Profile is marked complete enough for scoring (a minimum-field checklist, not perfection). +## Workflow 1 — Phase 1A manual profile setup + +1. User signs in and sees a profile overview with named completeness checks and concrete next + actions, never just a mysterious percentage. +2. User edits education, experience/research, projects/links, skills/evidence, languages, + preferences, targets, and minimized work eligibility as independent sections. +3. Each section saves independently; it supports empty, loading, success, validation-error, + server-error, and deletion-confirmation states where applicable. +4. The overview reports what is present, missing, unconfirmed, or optional. It asks for deliberate + review of required sections instead of inferring it from field presence alone. + +Resume upload, parsing, and imported drafts are Phase 1B workflows. They are not part of Phase 1A; +when built, they follow ADR-014's explicit accept/correct/reject model. ## Workflow 2 — Discovering and understanding the market diff --git a/docs/adr/ADR-011-manual-profile-before-resume-extraction.md b/docs/adr/ADR-011-manual-profile-before-resume-extraction.md new file mode 100644 index 0000000..a614507 --- /dev/null +++ b/docs/adr/ADR-011-manual-profile-before-resume-extraction.md @@ -0,0 +1,38 @@ +# ADR-011: Manual Profile Before Resume Extraction + +**Status**: Proposed + +## Context + +A combined profile-and-parser phase would make the first useful profile dependent on file upload, +asynchronous processing, extraction quality, and a review workflow. A resume-first design also +risks making a parsed document look authoritative before the destination model and manual editing +experience have been proven. + +## Decision + +Split the work into separate planning units, branches, pull requests, and exit conditions: + +- **Phase 1A — structured manual career profile**: build and validate the matching-ready profile + through independent manual sections. It includes no resume upload, extraction, background + parsing, or imported suggestions. +- **Phase 1B — resume intake and extraction drafts**: after Phase 1A is stable, resume parsing + produces separately reviewable suggestions against the confirmed Phase 1A model. + +The manual profile is the destination and source of truth. Extraction is an assistive input later, +never the model-defining feature. + +## Alternatives considered + +- **Combined profile/parser phase**: rejected because it couples basic profile validation to an + asynchronous, higher-risk integration. +- **Resume-first design**: rejected because it obscures whether the model is useful without a + particular document and makes correction workflows foundational too early. +- **Manual structured profile first**: chosen because it validates the intended matching inputs, + section editing, completeness feedback, and authorization model in the smallest useful slice. + +## Consequences + +Phase 1A does not create resume tables, storage, parser jobs, extraction columns, or suggestion +tables. Phase 1B must revisit provenance at field/suggestion level: row-level `created_via` alone +cannot represent a row with both manually confirmed and resume-assisted information. diff --git a/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md b/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md new file mode 100644 index 0000000..7231f6c --- /dev/null +++ b/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md @@ -0,0 +1,36 @@ +# ADR-012: Canonical Skills and Skill Evidence + +**Status**: Proposed + +## Context + +Matching and market aggregation need comparable skill names, while a profile must still let a user +express a niche or personal label. A proficiency claim alone is weak evidence for a match. + +## Decision + +Use a small shared canonical `skills_catalog`, controlled `skill_aliases`, and private custom +labels on `user_skills`. A skill claim can have many `skill_evidence` items. Evidence may point to +a user-owned project, work experience, education entry, profile link, or bounded manual note. + +`self_assessment` remains explicitly self-reported. Future matching should favor linked evidence +over claimed proficiency; it must explain which evidence it used. Private custom labels never +automatically enter shared market aggregation. This is deliberately not a complete global +technical ontology. + +## Alternatives considered + +- **Unrestricted skill strings**: rejected; aliases and spelling variants make validation and + market aggregation unreliable. +- **One project-evidence foreign key on a skill**: rejected; it cannot represent multiple or + non-project evidence. +- **Multiple nullable evidence foreign keys**: rejected; it makes every new evidence type a schema + change and permits ambiguous combinations. +- **Canonical catalog plus generic evidence associations**: chosen; it preserves controlled + matching while supporting multiple evidence types without over-modeling the catalog. + +## Consequences + +The generic association needs ownership-safe validation: an evidence row may only reference a +parent row with the same `user_id`. A bounded note is capped and treated as personal content, not +as a source for shared aggregation. diff --git a/docs/adr/ADR-013-deterministic-profile-completeness.md b/docs/adr/ADR-013-deterministic-profile-completeness.md new file mode 100644 index 0000000..01332d9 --- /dev/null +++ b/docs/adr/ADR-013-deterministic-profile-completeness.md @@ -0,0 +1,28 @@ +# ADR-013: Deterministic Profile Completeness + +**Status**: Proposed + +## Context + +A stored `profiles.profile_complete` flag becomes stale and hides why a profile is not ready. An +opaque percentage has the same explainability problem as an unexplained matching score. + +## Decision + +Remove the authoritative role of `profiles.profile_complete`. Derive completeness from the +versioned, language-neutral checks in [PROFILE_COMPLETENESS_SPEC.md](../PROFILE_COMPLETENESS_SPEC.md). +The UI shows the named checks and whether each is present, missing, unconfirmed, or optional in +four categories: basic matching readiness, evidence strength, review state, and optional +enrichment. + +## Alternatives considered + +- **Stored boolean**: rejected; it is duplicative state and does not explain remediation. +- **Opaque percentage**: rejected; users cannot tell its components or weights. +- **Deterministic named checks**: chosen; it is testable, portable between clients, and actionable. + +## Consequences + +Phase 1A records deliberate section review where meaningful, but has no imported suggestions. +Phase 1B adds the unresolved-suggestion check. A future percentage is allowed only when every +component and weight is displayed and documented; it is never the sole representation. diff --git a/docs/adr/ADR-014-confirmed-profile-versus-imported-suggestions.md b/docs/adr/ADR-014-confirmed-profile-versus-imported-suggestions.md new file mode 100644 index 0000000..2795df6 --- /dev/null +++ b/docs/adr/ADR-014-confirmed-profile-versus-imported-suggestions.md @@ -0,0 +1,20 @@ +# ADR-014: Confirmed Profile Versus Imported Suggestions + +**Status**: Proposed (preparation for Phase 1B) + +## Context + +Resume parsing is probabilistic. Treating extraction as an update to a confirmed profile could +silently discard user corrections and make it impossible to distinguish a claim from a draft. + +## Decision + +Confirmed profile records are authoritative. Phase 1B extraction creates separately reviewable +suggestions; it never silently overwrites confirmed records. Acceptance, correction, and rejection +are explicit user actions. Phase 1A creates neither resume nor suggestion tables. + +## Consequences + +Phase 1B must design suggestion-level or field-level provenance and lifecycle state. Row-level +manual provenance in Phase 1A is useful audit context but does not solve mixed manual/imported +provenance on its own. diff --git a/docs/adr/ADR-015-minimized-work-eligibility-representation.md b/docs/adr/ADR-015-minimized-work-eligibility-representation.md new file mode 100644 index 0000000..4ef7416 --- /dev/null +++ b/docs/adr/ADR-015-minimized-work-eligibility-representation.md @@ -0,0 +1,30 @@ +# ADR-015: Minimized Work-Eligibility Representation + +**Status**: Proposed + +## Context + +Matching may need a small, user-confirmed work-eligibility signal, but collecting immigration +documents or narratives creates disproportionate privacy and legal risk. + +## Decision + +Store only a structured `eligibility_state` (`eligible`, `eligible_with_conditions`, +`requires_sponsorship`, or `unknown`), an optional weekly working-hour limit, and +`last_confirmed_at`. The value is self-reported and used for explainable matching, not legal +advice. + +## Alternatives considered + +- **Free-form immigration narrative**: rejected; it collects unnecessary sensitive detail and is + difficult to apply consistently. +- **Detailed permit-document storage**: rejected; it would store government identifiers and scans + with no Phase 1A need. +- **Minimal structured eligibility state**: chosen; it supports future eligibility checks while + minimizing collection. + +## Consequences + +Never store passport numbers, residence-permit scans, government identifiers, unnecessary +immigration narrative, or legal conclusions presented as professional legal advice. The table is +highly sensitive user-owned data with direct `user_id` ownership and strict RLS. diff --git a/docs/adr/README.md b/docs/adr/README.md index 83808b1..dc06b9f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,11 @@ that explicitly supersedes it. Do not silently drift away from what's recorded h | [ADR-008](ADR-008-private-first-multi-user-later.md) | Private single-user MVP on a multi-tenant-ready schema | **Accepted** | | [ADR-009](ADR-009-zero-cost-mvp-deployment.md) | Zero-recurring-cost MVP deployment | Proposed (nothing deployed yet) | | [ADR-010](ADR-010-opportunity-identity-and-cross-source-deduplication.md) | Source-listing/canonical-opportunity split with conservative, review-gated cross-source deduplication | Proposed | +| [ADR-011](ADR-011-manual-profile-before-resume-extraction.md) | Manual structured profile before resume extraction | Proposed | +| [ADR-012](ADR-012-canonical-skills-and-skill-evidence.md) | Canonical skills with flexible, ownership-safe evidence | Proposed | +| [ADR-013](ADR-013-deterministic-profile-completeness.md) | Deterministic, named profile-completeness checks | Proposed | +| [ADR-014](ADR-014-confirmed-profile-versus-imported-suggestions.md) | Confirmed profile records remain authoritative over imported suggestions | Proposed | +| [ADR-015](ADR-015-minimized-work-eligibility-representation.md) | Minimal structured work-eligibility representation | Proposed | "Proposed" means the decision is this planning phase's recommendation, presented for the project owner's review — not yet exercised in a real implementation. Once implemented and validated, From e0e8a12376e36aeacd45dad0c62a5f1e7147e8c2 Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Sat, 1 Aug 2026 17:53:32 +0300 Subject: [PATCH 2/4] chore: align CI and local Node configuration --- .github/workflows/ci.yml | 16 +++++++++------- app/.nvmrc => .nvmrc | 0 README.md | 3 ++- app/package.json | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) rename app/.nvmrc => .nvmrc (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4a1666..64acb48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. @@ -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 @@ -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 diff --git a/app/.nvmrc b/.nvmrc similarity index 100% rename from app/.nvmrc rename to .nvmrc diff --git a/README.md b/README.md index 1d00b83..e0d029e 100644 --- a/README.md +++ b/README.md @@ -57,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. diff --git a/app/package.json b/app/package.json index 06796e1..9942fc1 100644 --- a/app/package.json +++ b/app/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "engines": { - "node": ">=22 <23" + "node": "22.23.1" }, "scripts": { "dev": "vite", From 50d8809214897815720dcdcd96de563111051b74 Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Sat, 1 Aug 2026 18:28:11 +0300 Subject: [PATCH 3/4] docs: resolve Phase 1A model ambiguities Clarify primary education as the source of truth, add section reviews with deletion-safe freshness, and define typed skill evidence. Restrict direct profile deletion and refine completeness semantics and staged implementation scope. --- docs/ARCHITECTURE.md | 5 +- docs/DATA_FLOW.md | 8 +- docs/DATA_MODEL.md | 45 ++++++--- docs/DEVELOPMENT_ROADMAP.md | 15 +++ docs/OPEN_QUESTIONS.md | 4 + docs/PROFILE_COMPLETENESS_SPEC.md | 95 +++++++++++++------ docs/RLS_POLICY_MATRIX.md | 7 +- docs/SCORING_AND_MATCHING_MODEL.md | 2 +- docs/SECURITY_AND_PRIVACY.md | 9 +- docs/TESTING_STRATEGY.md | 16 +++- docs/USER_WORKFLOWS.md | 9 +- ...012-canonical-skills-and-skill-evidence.md | 24 +++-- ...-013-deterministic-profile-completeness.md | 10 +- 13 files changed, 173 insertions(+), 76 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 286c5a1..6109850 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,8 +88,9 @@ indication. 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. Sections must support empty, loading, success, validation-error, -server-error, and deletion-confirmation states. +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 diff --git a/docs/DATA_FLOW.md b/docs/DATA_FLOW.md index bab8297..ca6c035 100644 --- a/docs/DATA_FLOW.md +++ b/docs/DATA_FLOW.md @@ -87,8 +87,8 @@ sequenceDiagram participant DB as Postgres (RLS) User->>SPA: open profile overview - SPA->>DB: read own confirmed manual rows - DB-->>SPA: profile sections and confirmations + 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 @@ -98,7 +98,9 @@ sequenceDiagram ``` All Phase 1A profile rows are manual (`created_via = manual` where recorded). A section is saved -independently; completing a profile wizard is never required. The browser writes only through the +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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 2a65d01..55e3a45 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -38,6 +38,8 @@ This revision corrects two gaps identified in review: erDiagram USERS ||--o| PROFILES : has PROFILES ||--o{ EDUCATION_ENTRIES : has + PROFILES ||--o{ PROFILE_SECTION_REVIEWS : records + PROFILES ||--o{ PROFILE_SECTION_REVISIONS : tracks PROFILES ||--o{ WORK_EXPERIENCE : has PROFILES ||--o{ PROJECTS : has PROFILES ||--o{ PROFILE_LINKS : has @@ -47,9 +49,9 @@ erDiagram USER_SKILLS ||--o{ SKILL_EVIDENCE : supported_by PROJECTS ||--o{ PROJECT_SKILLS : uses PROFILES ||--o{ USER_LANGUAGES : has - PROFILES ||--|| PREFERENCES : has + PROFILES ||--o| PREFERENCES : has PREFERENCES ||--o{ PREFERENCE_LOCATIONS : has - PROFILES ||--o{ WORK_ELIGIBILITY : has + PROFILES ||--o| WORK_ELIGIBILITY : has USERS ||--o{ RESUMES : uploads RESUMES ||--o{ RESUME_EXTRACTIONS : produces @@ -81,39 +83,54 @@ erDiagram not implemented SQL. Phase 1B resume and suggestion records are expressly excluded. **Ownership rule (binding, see [AGENTS.md](../AGENTS.md))**: every user-owned table has direct -`user_id`, including association tables. User-owned child references use ownership-safe composite -foreign keys — e.g. `(user_id, project_id)` references a unique `(user_id, id)` on `projects` — or -an equivalent trigger/constraint. A UUID alone never authorizes attaching another user's row. -Each manual row may include `created_via = manual`, `created_at`, `updated_at`, and meaningful -`last_confirmed_at`; this is row context, not a Phase 1B provenance solution. +`user_id`, including association tables. The root `profiles.user_id` references `auth.users(id)`; +child user-owned rows generally reference `profiles(user_id)` through that same direct `user_id`. +User-owned child references use ownership-safe composite foreign keys — e.g. `(user_id, +project_id)` references a unique `(user_id, id)` on `projects` — or an equivalent constraint. A +UUID alone never authorizes attaching another user's row. Each manual row may include +`created_via = manual`, `created_at`, `updated_at`, and meaningful `last_confirmed_at`; this is +row context, not a Phase 1B provenance solution. | Entity | Purpose / ownership / key | Major foreign keys and constraints | Deletion / RLS / sensitivity / browser access / later consumer | |---|---|---|---| -| `profiles` | One matching-ready identity per user; user-owned; `user_id` PK. | FK `auth.users(id)`; degree year bounded; expected graduation timing optional; no authoritative `profile_complete`. | Cascades from user; own-row RLS; High; browser CRUD; Phase 1A overview, later matching/scoring. | -| `education_entries` | Education history; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; institution required; date order; current entry is explicit/derived consistently. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, matching, evidence. | +| `profiles` | One profile identity per user; user-owned; `user_id` PK. | FK `auth.users(id)`; holds only profile-level facts such as preferred name and headline; no authoritative degree program, degree year, or graduation timing; no `profile_complete`. | Cascades from Auth-user deletion; own-row RLS; High; browser SELECT/INSERT/UPDATE only — never direct DELETE; Phase 1A overview, later matching/scoring. | +| `education_entries` | Education history and authoritative education facts; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; institution, degree, field, `degree_year`, expected graduation month/year, start date, and `education_status`; date/order checks. `is_primary` is optional, but partial unique `(user_id) WHERE is_primary` permits at most one primary entry. A primary normally must be current; explicit status constraint enforces that relationship. Multiple active entries remain allowed. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, matching, evidence. A later forward migration copies Phase 0 `profiles.degree_program`/`degree_year` into the selected or created primary entry before removing their authoritative role. | +| `profile_section_reviews` | Deliberate review of a UI section, including intentionally empty sections; user-owned; composite PK `(user_id, section_key)`. | `(user_id)` → profiles; controlled keys: `basic_profile`, `education`, `experience`, `projects`, `skills`, `languages`, `preferences`, `work_eligibility`; `reviewed_at`, `reviewed_content_revision`, timestamps. | Cascade from profile; own-row RLS; High; browser CRUD; deterministic review state/completeness. | +| `profile_section_revisions` | Minimal deletion-safe content freshness state; user-owned metadata; composite PK `(user_id, section_key)`. | Same controlled keys; non-negative monotonic `content_revision`, `updated_at`. Created at revision zero for each section; database-maintained mutation path increments it transactionally on relevant row create/update/delete. | Cascade from profile; own-row RLS; Medium; browser SELECT only; supports review freshness without a reset-flag trigger. | | `work_experience` | Employment, research, or volunteering evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; role/organization required; date order; bounded description. | Cascade from profile; own-row RLS; High; browser CRUD; evidence and future matching. | | `projects` | User project evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; title required; bounded description; optional safe URL. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, evidence, matching. | | `profile_links` | Links to portfolio/GitHub/public work; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; URL scheme/length allowlist; labeled link. | Cascade from profile; own-row RLS; Medium; browser CRUD; evidence and optional enrichment. | -| `skills_catalog` | Small canonical skill names for matching; shared; `id` PK. | Normalized canonical name unique; bounded category; no user data. | Service maintains; authenticated read only; Low; browser SELECT only; matching and market aggregation. | +| `skills_catalog` | Small canonical skill names for matching; shared; `id` PK. | Normalized canonical name unique; bounded category and controlled `is_technical` classification; no user data. | Service maintains; authenticated read only; Low; browser SELECT only; matching and market aggregation. | | `skill_aliases` | Controlled alternate names for a canonical skill; shared; `id` PK. | `skill_id` → catalog; normalized alias unique; aliases never point to a custom label. | Cascade from catalog; authenticated read only, service write; Low; browser SELECT only; normalization/matching. | -| `user_skills` | A user's skill claim, canonical or private custom; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; optional `skill_id` → catalog XOR bounded `custom_label`; unique normalized claim per user; scalar `self_assessment` enum is explicitly self-reported. | Cascade from profile; own-row RLS; High; browser CRUD; completeness and future matching. | +| `user_skills` | A user's skill claim, canonical or private custom; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; optional `skill_id` → catalog XOR bounded `custom_label`; unique normalized claim per user; scalar `self_assessment` enum is explicitly self-reported. A claim is technical when its canonical catalog entry is controlled as technical, or a private label is explicitly self-classified technical; private labels remain private. | Cascade from profile; own-row RLS; High; browser CRUD; completeness and future matching. | | `project_skills` | Relational project technologies/skills; user-owned association; composite PK `(user_id, project_id, skill_id)`. | `(user_id, project_id)` → projects; `skill_id` → catalog; no arrays. | Cascade from project/catalog as appropriate; own-row plus composite-parent checks; High; browser CRUD; evidence and matching. | -| `skill_evidence` | Many evidence items for a skill claim; user-owned; `id` PK, direct `user_id`. | `(user_id, user_skill_id)` → user skills; exactly one supported target among project, experience, education, link, bounded note; each target uses `(user_id, target_id)` FK. | Cascade from claim/target; own-row and parent-bypass tests; High; browser CRUD; evidence-first matching. | +| `skill_evidence` | Dedicated many-row association: one typed evidence item for a skill claim; user-owned; `id` PK, direct `user_id`. | `(user_id, user_skill_id)` → user skills. Nullable `project_id`, `work_experience_id`, `education_entry_id`, `profile_link_id`, and bounded `manual_note`; a check constraint requires exactly one source. Each relational target has a same-user composite FK. Adding a new evidence category is an intentional schema change for real FK integrity; note content never enters shared aggregation. | Cascade from claim/target; own-row and parent-bypass tests; High; browser CRUD; evidence-first matching. | | `user_languages` | Language claims; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; normalized language unique per user; scalar CEFR enum. | Cascade from profile; own-row RLS; High; browser CRUD; readiness and matching. | -| `preferences` | One user preference container; user-owned; `user_id` PK. | FK profile; scalar `remote_preference` enum; no location/target arrays. | Cascade from profile; own-row RLS; High; browser CRUD; matching/filtering. | +| `preferences` | Optional singleton preference container; user-owned; `user_id` PK. | FK profile; scalar `remote_preference` enum; no location/target arrays. | Zero-or-one until a migration deliberately auto-creates it; cascade from profile; own-row RLS; High; browser CRUD; matching/filtering. | | `preference_locations` | Preferred locations relation; user-owned; `id` PK, direct `user_id`. | `(user_id)` → preferences; normalized location + country/region; unique per user. | Cascade from preferences; own-row RLS; Medium; browser CRUD; matching/filtering. | | `engineering_areas` | Controlled engineering-area reference; shared; `id` PK. | Unique normalized name. | Service-maintained; authenticated read only; Low; browser SELECT only; targeting and matching. | | `role_types` | Controlled role-type reference; shared; `id` PK. | Unique normalized name. | Service-maintained; authenticated read only; Low; browser SELECT only; targeting and matching. | | `user_target_engineering_areas` | User's selected areas; user-owned association; composite PK `(user_id, engineering_area_id)`. | `(user_id)` → profile; area → catalog. | Cascade from profile/catalog; own-row RLS; Medium; browser CRUD; readiness and matching. | | `user_target_role_types` | User's selected roles; user-owned association; composite PK `(user_id, role_type_id)`. | `(user_id)` → profile; role → catalog. | Cascade from profile/catalog; own-row RLS; Medium; browser CRUD; readiness and matching. | | `target_companies` | Optional watchlist; user-owned; `id` PK, direct `user_id`. | `(user_id)` → profile; normalized company name unique per user; bounded note only. | Cascade from profile; own-row RLS; Medium; browser CRUD; optional enrichment, future discovery. | -| `work_eligibility` | Minimal self-reported eligibility state; user-owned, highly sensitive; `user_id` PK. | FK profile; enum `eligible`/`eligible_with_conditions`/`requires_sponsorship`/`unknown`; optional non-negative weekly limit; `last_confirmed_at` required after review. | Cascade from profile; own-row RLS; Very High; browser CRUD; readiness and future eligibility checks. | +| `work_eligibility` | Optional singleton minimal self-reported eligibility state; user-owned, highly sensitive; `user_id` PK. | FK profile; enum `eligible`/`eligible_with_conditions`/`requires_sponsorship`/`unknown`; optional non-negative weekly limit; `last_confirmed_at` required after review. | Zero-or-one per profile; cascade from profile; own-row RLS; Very High; browser CRUD; readiness and future eligibility checks. | Relational structures are required for skills, aliases, project technologies, evidence, targets, and preferred locations because they support validation, aggregation, future matching, and explainable links. Arrays would conceal ownership and parent checks. Scalar enums remain appropriate for remote preference, skill self-assessment, and work-eligibility state. +### Review freshness + +A section is deliberately reviewed only when its `profile_section_reviews` row exists. Its review +is current when `reviewed_content_revision` equals the current revision for that section and the +review time is at least as recent as the latest relevant surviving row's `updated_at`. This lets an +empty collection be reviewed. Timestamps alone are not sufficient after deletion: deleting the +newest row could otherwise make an old review appear current. The small per-section revision row +solves that case: every relevant create, update, or delete increments its revision transactionally, +and a review records the revision it observed. This is a narrowly scoped integrity mechanism, not +a fragile trigger that flips a duplicate completeness flag. + ## Resume domain **Status: Phase 1B deferred.** These records are not part of the Phase 1A model and must not be diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md index cc153c3..217d1a9 100644 --- a/docs/DEVELOPMENT_ROADMAP.md +++ b/docs/DEVELOPMENT_ROADMAP.md @@ -25,6 +25,17 @@ process. save it (RLS-permitted), sign out — verified both by automated tests and by hand in a real browser against the real local stack. No opportunity data yet. +## Next implementation PR — Profile core and primary education vertical slice + +This remains deliberately small. It will implement only the forward migration of existing Phase 0 +education values, refined `profiles` and `education_entries`, `profile_section_reviews` for +`basic_profile` and `education`, exact grants/RLS/pgTAP/API tests, React Router foundation, profile +overview, education CRUD, and completeness checks limited to those implemented inputs. It must not +claim full completeness while other Phase 1A domains do not exist. + +The forward migration must preserve `profiles.degree_program` and `profiles.degree_year` by copying +them into the selected or created primary education row before they stop being authoritative. + ## Phase 1A — Structured manual career profile - Full section-based CRUD for the logical profile domain in [DATA_MODEL.md](DATA_MODEL.md): @@ -38,6 +49,10 @@ browser against the real local stack. No opportunity data yet. **Exit condition**: Workflow 1 works through manual entry only, with independent section saves, actionable completeness, and verified cross-user isolation. +Later Phase 1A PRs add experience/projects/links; then skills/evidence/languages; then +preferences/targets/work eligibility; and finally the complete v2 evaluator. At every stage, +unimplemented checks are shown as product-unavailable rather than user omissions. + ## Phase 1B — Resume intake and extraction drafts - Private resume storage, upload, scheduled parsing, and separately reviewable extraction diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index bdfaadc..138987c 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -36,6 +36,10 @@ phase that needs it. where rules clearly fail is the likely direction, but this needs a dedicated design pass before Phase 1 of [DEVELOPMENT_ROADMAP.md](DEVELOPMENT_ROADMAP.md), including a cost check (LLM resume parsing would be the first real LLM cost, ahead of the Phase 7 explanation feature). +- **Primary-education migration selection**: the next implementation PR must define the + deterministic fallback when Phase 0 values exist but no education entry is an obvious primary + candidate (for example, create a clearly marked incomplete primary row for user review rather + than silently selecting among multiple entries). ## Compliance diff --git a/docs/PROFILE_COMPLETENESS_SPEC.md b/docs/PROFILE_COMPLETENESS_SPEC.md index 39414e8..2048b75 100644 --- a/docs/PROFILE_COMPLETENESS_SPEC.md +++ b/docs/PROFILE_COMPLETENESS_SPEC.md @@ -1,43 +1,76 @@ # Profile Completeness Specification -**Status**: proposed Phase 1A contract; not implemented. Version: `profile-completeness/v1`. +**Status**: proposed Phase 1A contract; not implemented. Version: `profile-completeness/v2`. -This is language-neutral input/output behavior, not shared TypeScript/Python code. Implementations -must produce the named check identifier, outcome, and user-facing explanation below. +This is language-neutral input/output behavior, not shared TypeScript/Python code. An +implementation returns every named check's identifier, category, outcome, deterministic +user-facing explanation, and any stated counts. `profiles.profile_complete` is not an input or +output. -## Inputs and outcomes +## Inputs, outcomes, and review freshness -Inputs are confirmed manual profile rows plus `last_confirmed_at` values. A check returns -`present`, `missing`, `unconfirmed`, or `optional`; Phase 1A has no imported suggestions, while -Phase 1B adds imported-suggestion state. `profiles.profile_complete` is not an input or output. +Inputs are confirmed manual profile rows, controlled catalog metadata, and section-review data. +The outcomes are `present`, `missing`, `unconfirmed`, and `optional`. `present` means the check's +non-blocking or blocking input exists; `optional` means absent content does not block readiness. +`missing` means required content is absent; `unconfirmed` means required content exists but has +not been deliberately and currently reviewed. -| Category | Check | Input | Pass / user-facing explanation | +Initial review keys are `basic_profile`, `education`, `experience`, `projects`, `skills`, +`languages`, `preferences`, and `work_eligibility`. A review is current only when a +`profile_section_reviews` row exists, its recorded content revision equals the current +`profile_section_revisions` value, and `reviewed_at` is at least the latest relevant surviving +row's `updated_at`. A revision increments for relevant create, update, **and delete**, so deleting +the newest row cannot make an older review look current. An empty section can therefore be +reviewed deliberately at revision zero (or its current post-deletion revision). + +Basic matching readiness requires current reviews of `basic_profile`, `education`, `skills`, +`languages`, `preferences`, and `work_eligibility`. Experience and projects may be intentionally +empty and reviewed; they do not block basic readiness, but affect evidence strength. + +| Category | Check | Input and outcome | User-facing explanation | |---|---|---|---| -| Basic matching readiness | `current_education` | current `education_entries` | Present: “Current education is recorded.” Missing: “Add your current education.” | -| Basic matching readiness | `degree_year` | profile or current education degree year | Present: “Degree year is recorded.” Missing: “Add your degree year.” | -| Basic matching readiness | `graduation_timing` | expected graduation date/timing | Present: “Graduation timing is recorded.” Missing: “Add expected graduation timing.” | -| Basic matching readiness | `target_direction` | target engineering area or role type | Present: “Target direction is recorded.” Missing: “Choose a target role or engineering area.” | -| Basic matching readiness | `location_or_remote` | preferred location or remote preference | Present: “Location or remote preference is recorded.” Missing: “Set a location or remote preference.” | -| Basic matching readiness | `language` | `user_languages` | Present: “At least one language is recorded.” Missing: “Add a language.” | -| Basic matching readiness | `eligibility_reviewed` | `work_eligibility.last_confirmed_at` | Present: “Work eligibility was reviewed.” Unconfirmed: “Review your work-eligibility state.” | -| Evidence strength | `experience_or_project` | `work_experience` or `projects` | Present: “You have project or experience evidence.” Missing: “Add a project or experience.” | -| Evidence strength | `technical_skill` | technical `user_skills` | Present: “Technical skills are recorded.” Missing: “Add technical skills.” | -| Evidence strength | `skill_evidence` | evidence per technical skill | Present: “Claimed technical skills have evidence.” Missing: “Link evidence to each claimed technical skill.” | -| Review state | `required_sections_reviewed` | required section confirmations | Present: “Required sections were reviewed.” Unconfirmed: “Review the sections marked for confirmation.” | -| Review state | `imported_suggestions_resolved` | Phase 1B suggestion states | Phase 1A: Optional, “Resume suggestions are not in this phase.” Phase 1B: Present only when none remain unresolved. | -| Optional enrichment | `profile_links` | `profile_links` | Optional: “Add links to make evidence easier to review.” | -| Optional enrichment | `target_companies` | `target_companies` | Optional: “Add target companies to personalize discovery.” | -| Optional enrichment | `additional_languages_and_evidence` | extra languages, projects, evidence | Optional: “More detail can strengthen explanations.” | +| Basic matching readiness | `current_education` | The **primary** `education_entries` row only, with current `education_status`. `present` when it exists; `missing` otherwise. | Present: “Primary current education is recorded.” Missing: “Choose a primary current education entry.” | +| Basic matching readiness | `degree_year` | The primary education's `degree_year` only. `present` when recorded; `missing` otherwise. | “Add the current degree year to your primary education.” | +| Basic matching readiness | `graduation_timing` | Expected graduation month and year on the primary education only. `present` only when both exist; `missing` otherwise. | “Add expected graduation month and year to your primary education.” | +| Basic matching readiness | `target_direction` | One target engineering area or role type. | Present: “Target direction is recorded.” Missing: “Choose a target role or engineering area.” | +| Basic matching readiness | `location_or_remote` | One preferred location or a recorded remote preference. | Present: “Location or remote preference is recorded.” Missing: “Set a location or remote preference.” | +| Basic matching readiness | `language` | At least one `user_languages` row. | Present: “At least one language is recorded.” Missing: “Add a language.” | +| Basic matching readiness | `eligibility_reviewed` | `work_eligibility` exists, `last_confirmed_at` is at least its `updated_at`, and its section review is current. | Present: “Work eligibility was reviewed.” Missing: “Add your work-eligibility state.” Unconfirmed: “Review your work-eligibility state.” | +| Evidence strength | `experience_or_project` | At least one `work_experience` or `projects` row. | Present: “You have project or experience evidence.” Missing: “Add a project or experience.” | +| Evidence strength | `technical_skill` | At least one technical `user_skills` claim. Canonical claims use the catalog's controlled technical classification; private custom labels count only when explicitly self-classified technical, and never enter shared aggregation. | Present: “Technical skills are recorded.” Missing: “Add a technical skill.” | +| Evidence strength | `skill_evidence` | Return `technical_skill_count` and `technical_skills_lacking_evidence`. `present` only when count is at least one and none lack evidence; `missing` when count is zero or any lack evidence. | Zero: “Add a technical skill before linking evidence.” Partial: “Link evidence to each claimed technical skill.” Present: “All technical skills have evidence.” | +| Review state | `required_sections_reviewed` | Current reviews for all six basic-readiness sections named above. | Present: “Required sections were reviewed.” Unconfirmed: “Review the sections marked for confirmation.” | +| Review state | `experience_and_projects_reviewed` | Current review of both sections; either may be intentionally empty. This does not change basic readiness. | Present: “Experience and projects were reviewed.” Unconfirmed: “Review your experience and projects sections, even if empty.” | +| Review state | `imported_suggestions_resolved` | Phase 1B suggestion state only. | Phase 1A: Optional, “Resume suggestions are not in this phase.” Phase 1B: present only when none remain unresolved. | +| Optional enrichment | `profile_links` | One or more `profile_links` rows. | Present: “Profile links are available as supporting context.” Optional: “Add a link to make evidence easier to review.” | +| Optional enrichment | `target_companies` | One or more `target_companies` rows. | Present: “Target companies are recorded.” Optional: “Add target companies to personalize discovery.” | +| Optional enrichment | `additional_languages` | More than one language row. | Present: “Additional languages are recorded.” Optional: “Add another language if it broadens your opportunities.” | +| Optional enrichment | `additional_evidence` | More than the minimum one evidence item, or evidence across more than one source type. | Present: “Additional evidence strengthens explanations.” Optional: “Add more project, experience, education, link, or note evidence.” | + +Basic matching readiness and evidence strength remain separate: a profile can be basic-ready while +evidence strength is incomplete. No percentage is the sole result. A later percentage is allowed +only if every component and weight is published. ## Synthetic test vectors +All inputs below are public-safe and synthetic. “Current review” means the review revision equals +the section revision; “stale” means it does not. + | ID | Synthetic input summary | Expected notable outcomes | |---|---|---| -| `minimal-ready` | Current education, degree year, graduation timing, one target role, remote preference, one language, reviewed eligibility; one project; one evidenced technical skill; required sections reviewed | All basic and evidence checks `present`; optional checks remain `optional`. | -| `direction-missing` | Same as `minimal-ready`, without target roles or areas | `target_direction = missing`; explanation asks to choose one. | -| `claim-without-evidence` | Same as `minimal-ready`, but technical skill claim has no evidence | `skill_evidence = missing`; basic readiness stays `present`. | -| `needs-review` | Required fields exist but eligibility and projects lack confirmations | `eligibility_reviewed` and/or `required_sections_reviewed = unconfirmed`. | -| `phase-1b-pending-suggestion` | `minimal-ready` plus an unresolved extracted suggestion | In Phase 1B, `imported_suggestions_resolved = missing`; in Phase 1A this vector is not applicable. | - -An implementation may summarize these checks visually, but may not show a percentage as the only -result. Any later weighted percentage must publish each component and weight. +| `primary-among-multiple-active` | Two active education entries; exactly one is primary and has year/graduation timing; all required reviews current. | Education, degree year, and graduation timing are `present` from the primary only. | +| `no-primary-education` | Active education entries exist but none is primary. | `current_education`, `degree_year`, and `graduation_timing` are `missing`. | +| `empty-experience-reviewed` | No experience rows; experience review current. Required basics complete. | Experience review is `present`; basic readiness is unaffected; `experience_or_project` depends on projects. | +| `content-modified-after-review` | Skills content revision incremented after its review. | `required_sections_reviewed` is `unconfirmed`; skills review requires renewal. | +| `content-deleted-after-review` | Latest project deleted after review, incrementing projects revision. | Projects review is `unconfirmed`, not falsely current. | +| `link-absent` | No profile links. | `profile_links = optional`. | +| `link-present` | One valid profile link. | `profile_links = present`. | +| `technical-skills-absent` | No technical claims. | `technical_skill = missing`; `skill_evidence = missing` with count 0. | +| `technical-skill-without-evidence` | One technical claim and no evidence rows. | `technical_skill = present`; `skill_evidence = missing`, lacking count 1. | +| `partial-technical-evidence` | Three technical claims; evidence for two. | `skill_evidence = missing`, count 3 and lacking count 1. | +| `all-technical-skills-evidenced` | Two technical claims, each with one valid typed evidence row. | `technical_skill` and `skill_evidence` are `present`; lacking count 0. | +| `eligibility-stale` | Work-eligibility row exists, but its confirmation or section review predates a change. | `eligibility_reviewed = unconfirmed`. | + +The next implementation vertical slice evaluates only checks whose inputs exist in that slice; all +other checks are reported as **not yet implemented by the product**, not as user omissions. The +full v2 evaluation begins only after its remaining Phase 1A tables exist. diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md index 725247c..4b9e17e 100644 --- a/docs/RLS_POLICY_MATRIX.md +++ b/docs/RLS_POLICY_MATRIX.md @@ -69,10 +69,13 @@ once per statement rather than once per row — the currently-recommended, non-d | Table(s) | Category / owner | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation and ownership-bypass test | Sensitivity | |---|---|---|---|---|---|---|---|---|---|---| -| `profiles`, `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) | +| `profiles` | User-owned root row; direct `user_id` references Auth user | Own only | Own only | Own only | **No direct browser DELETE** | Delete only in explicit trusted profile/account-deletion workflow, coordinating Auth user, owned records, Storage, export, and confirmation | `(select auth.uid()) = user_id` for SELECT/INSERT/UPDATE; no browser DELETE grant/policy | No | Owner SELECT/INSERT/UPDATE; DELETE fails even for owner; cross-user and forged-owner denial; service deletion is exercised only through the trusted workflow | High | +| `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) | +| `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | Own only | Own only | Own only | Full | Direct owner predicate; controlled section key and current revision must be recorded | No | Owner CRUD; forged-owner and cross-user denial; cannot write a review for an unknown key or stale/future revision | High | +| `profile_section_revisions` | User-owned, database-maintained freshness metadata | Own only | No direct browser write | No direct browser write | No direct browser write | Trusted migration/mutation mechanism only | Direct owner SELECT; no browser mutation grant | No | User can read only own revision; browser cannot forge/increment/rewrite revision; relevant insert/update/delete increments it atomically | Medium | | `preference_locations`, `user_target_engineering_areas`, `user_target_role_types` | User-owned relation; direct `user_id` | Own only | Own only | Own only where applicable | Own only | Full | Direct owner predicate plus composite parent `(user_id, parent_id)` validation | No | Owner CRUD; all cross-user denials; forged owner; cannot attach a valid user A row to B's preferences/profile | Medium | | `project_skills` | User-owned relation; direct `user_id` | Own only | Own only | N/A or own only | Own only | Full | Direct owner predicate plus `(user_id, project_id)` composite FK | No | User A cannot attach B's project to A's skill or vice versa, even when UUID is known; owner CRUD | High | -| `skill_evidence` | User-owned evidence; direct `user_id` | Own only | Own only | Own only | Own only | Full | Direct owner predicate plus same-user composite references to claim and target | No | User A cannot create evidence on B's claim/project/experience/education/link; cannot rewrite owner or target; owner CRUD | High | +| `skill_evidence` | User-owned typed evidence; direct `user_id` | Own only | Own only | Own only | Own only | Full | Direct owner predicate, same-user composite references, and exactly-one-source check (`project`/`experience`/`education`/`link`/bounded note) | No | User A cannot create evidence on B's claim/project/experience/education/link; zero or multiple sources and oversized notes fail; cannot rewrite owner/target; owner CRUD | High | | `work_eligibility` | **Highly sensitive user-owned**; direct `user_id` | Own only | Own only | Own only | Own only | Full only for explicit maintenance/export/deletion | Direct owner predicate | No | Owner CRUD; cross-user and forged-owner denials; anon 403; API-path JWT test | Very High | | `skills_catalog`, `skill_aliases`, `engineering_areas`, `role_types` | Shared authenticated-readable reference data | All authenticated users | No | No | No | Full; service maintains canonical values | No owner predicate; grant SELECT only to authenticated | No | Authenticated read succeeds; authenticated INSERT/UPDATE/DELETE fails at grant; service maintenance path succeeds | Low | diff --git a/docs/SCORING_AND_MATCHING_MODEL.md b/docs/SCORING_AND_MATCHING_MODEL.md index e064f76..417f279 100644 --- a/docs/SCORING_AND_MATCHING_MODEL.md +++ b/docs/SCORING_AND_MATCHING_MODEL.md @@ -57,7 +57,7 @@ Computed per user, per opportunity version. Sub-scores, each independently expla | `eligibility_status` | Hard rules: work authorization, degree-level requirement, language requirement if mandatory | Which specific rule passed/failed and why | | `degree_relevance_score` | Same basis as Ranking 1's, reused | — | | `skill_alignment_score` | Overlap between posting's required/preferred skills and `user_skills`, weighted by proficiency | List of matched skills with proficiency, and list of required skills absent from the profile | -| `project_evidence_score` | Whether claimed skills are backed by a linked project (`user_skills.evidence_project_id`), not just an assertion | Which projects support which claimed skills | +| `project_evidence_score` | Whether claimed skills are backed by linked `skill_evidence` rows (including project evidence), not just an assertion | Which evidence items support which claimed skills | | `experience_alignment_score` | Years/type of experience in `work_experience` vs. posting's stated experience expectation | Matched or missing experience type | | `language_alignment_score` | `user_languages` vs. posting's language requirement | — | | `location_alignment_score` | `preferences.preferred_locations`/`bremen_hamburg_priority` vs. posting location, and remote acceptability | — | diff --git a/docs/SECURITY_AND_PRIVACY.md b/docs/SECURITY_AND_PRIVACY.md index dfafa23..d084f1a 100644 --- a/docs/SECURITY_AND_PRIVACY.md +++ b/docs/SECURITY_AND_PRIVACY.md @@ -88,10 +88,11 @@ provenance; a row-level manual marker does not solve mixed-origin data. and application history in a portable form (proposal: a JSON export endpoint/script) — deferred implementation, but the schema must not make this structurally hard (it doesn't: everything is relational and user-scoped). -- Account deletion: deleting a user must cascade-delete or anonymize all `user_id`-scoped rows - (profile, resumes, saved opportunities, applications, tasks) and remove stored resume files from - Storage. This should be a documented, tested operation before any second real user is onboarded, - not just a `DELETE` a developer runs manually. +- Profile/account deletion: the browser must not directly delete `profiles`, because that would + cascade the career profile while leaving the Auth user alive. A later explicit trusted workflow + must coordinate export/confirmation requirements, Auth-user deletion, user-owned database rows, + and Storage objects. It must be documented and tested before any second real user is onboarded, + not a raw browser or developer `DELETE`. ## 6. Untrusted external content diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md index 40c068d..6892956 100644 --- a/docs/TESTING_STRATEGY.md +++ b/docs/TESTING_STRATEGY.md @@ -73,18 +73,24 @@ each rule gets explicit positive and negative test cases. ## Phase 1A profile implementation tests (future) - **Migration structure tests**: exact tables, direct `user_id` ownership, required foreign keys, - ownership-safe composite parent references, constraints, timestamps, and no authoritative - `profile_complete` column/behavior. + ownership-safe composite parent references, primary-education partial unique constraint, + typed-skill-evidence exactly-one-source check, bounded note, section-review/revision constraints, + timestamps, and no authoritative `profile_complete` column/behavior. - **Exact permission tests**: revoke-then-grant sets, exact RLS-policy names/predicates, no anon - grants, owner CRUD, cross-user isolation, forged-owner insert, and ownership-rewrite denial. + grants, owner CRUD, cross-user isolation, forged-owner insert, ownership-rewrite denial, and + owner `profiles` DELETE denial. - **Composite-parent bypass tests**: user A must not attach user B's project, experience, - education, profile link, preference container, or skill claim by knowing its UUID. + education, profile link, preference container, or skill claim by knowing its UUID. Typed evidence + must reject zero/multiple sources and targets belonging to another user. +- **Section-review freshness tests**: empty sections may be reviewed; a relevant create/update or + delete increments the section revision and makes the review stale; users cannot forge a review + for another owner, an unknown key, or a future revision. - **Shared catalog tests**: authenticated read succeeds; INSERT/UPDATE/DELETE of canonical skills, aliases, areas, and roles fails; private custom labels never enter shared aggregation. - **Real API tests**: Auth → JWT → PostgREST tests cover the same positive and negative cases, rather than relying only on pgTAP. - **Frontend tests**: validate forms, independent section save/delete behavior, state handling, - accessibility-focused behavior, and the language-neutral vectors in + accessibility-focused behavior, primary-education selection, and the language-neutral vectors in [PROFILE_COMPLETENESS_SPEC.md](PROFILE_COMPLETENESS_SPEC.md). Fixtures must be synthetic and public-safe; Phase 0 regressions remain required. diff --git a/docs/USER_WORKFLOWS.md b/docs/USER_WORKFLOWS.md index 5797363..04c705f 100644 --- a/docs/USER_WORKFLOWS.md +++ b/docs/USER_WORKFLOWS.md @@ -11,8 +11,13 @@ architecture and data-model decisions grounded in real usage rather than abstrac preferences, targets, and minimized work eligibility as independent sections. 3. Each section saves independently; it supports empty, loading, success, validation-error, server-error, and deletion-confirmation states where applicable. -4. The overview reports what is present, missing, unconfirmed, or optional. It asks for deliberate - review of required sections instead of inferring it from field presence alone. +4. The overview reports what is present, missing, unconfirmed, or optional. The user can mark a + section reviewed even when intentionally empty. Later section content changes or deletions make + that review stale through the section revision, so the UI asks for a renewed review rather than + inferring confirmation from field presence alone. +5. Education may have multiple active entries. The user explicitly chooses at most one primary + current education entry for matching; its degree year and expected graduation timing feed + completeness and later matching. Resume upload, parsing, and imported drafts are Phase 1B workflows. They are not part of Phase 1A; when built, they follow ADR-014's explicit accept/correct/reject model. diff --git a/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md b/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md index 7231f6c..03bd608 100644 --- a/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md +++ b/docs/adr/ADR-012-canonical-skills-and-skill-evidence.md @@ -10,8 +10,10 @@ express a niche or personal label. A proficiency claim alone is weak evidence fo ## Decision Use a small shared canonical `skills_catalog`, controlled `skill_aliases`, and private custom -labels on `user_skills`. A skill claim can have many `skill_evidence` items. Evidence may point to -a user-owned project, work experience, education entry, profile link, or bounded manual note. +labels on `user_skills`. A skill claim can have many `skill_evidence` rows. Each row represents +exactly one typed source: a user-owned project, work experience, education entry, profile link, or +a bounded manual note. The finite Phase 1A source columns have real same-user composite foreign +keys; a check constraint permits exactly one source per row. `self_assessment` remains explicitly self-reported. Future matching should favor linked evidence over claimed proficiency; it must explain which evidence it used. Private custom labels never @@ -24,13 +26,17 @@ technical ontology. market aggregation unreliable. - **One project-evidence foreign key on a skill**: rejected; it cannot represent multiple or non-project evidence. -- **Multiple nullable evidence foreign keys**: rejected; it makes every new evidence type a schema - change and permits ambiguous combinations. -- **Canonical catalog plus generic evidence associations**: chosen; it preserves controlled - matching while supporting multiple evidence types without over-modeling the catalog. +- **Fixed evidence foreign keys on `user_skills`**: rejected; putting one or many nullable + project/experience/etc. slots on a single skill claim fixes its evidence capacity and makes + multiple evidence awkward or ambiguous. +- **Dedicated typed `skill_evidence` association rows**: chosen. A skill has any number of rows, + each row has exactly one typed source. A new evidence category requires a schema change; that is + an accepted trade-off for real foreign-key integrity and clear querying at this scale. +- **Unconstrained `entity_type` + `entity_id` polymorphism**: rejected; it cannot enforce a real + foreign key to the claimed target. ## Consequences -The generic association needs ownership-safe validation: an evidence row may only reference a -parent row with the same `user_id`. A bounded note is capped and treated as personal content, not -as a source for shared aggregation. +Every evidence row may only reference a parent with the same `user_id`; its exactly-one-source +check and bounded-note limit are migration/test requirements. Private note content is personal and +never a source for shared aggregation. diff --git a/docs/adr/ADR-013-deterministic-profile-completeness.md b/docs/adr/ADR-013-deterministic-profile-completeness.md index 01332d9..4623fcf 100644 --- a/docs/adr/ADR-013-deterministic-profile-completeness.md +++ b/docs/adr/ADR-013-deterministic-profile-completeness.md @@ -13,7 +13,9 @@ Remove the authoritative role of `profiles.profile_complete`. Derive completenes versioned, language-neutral checks in [PROFILE_COMPLETENESS_SPEC.md](../PROFILE_COMPLETENESS_SPEC.md). The UI shows the named checks and whether each is present, missing, unconfirmed, or optional in four categories: basic matching readiness, evidence strength, review state, and optional -enrichment. +enrichment. `profile_section_reviews` records deliberate review, including an intentionally empty +section. Its `reviewed_content_revision` is compared with a small per-section revision counter so +create, update, and delete operations make a review stale without relying on a reset flag. ## Alternatives considered @@ -24,5 +26,7 @@ enrichment. ## Consequences Phase 1A records deliberate section review where meaningful, but has no imported suggestions. -Phase 1B adds the unresolved-suggestion check. A future percentage is allowed only when every -component and weight is displayed and documented; it is never the sole representation. +Phase 1B adds the unresolved-suggestion check. The initial vertical slice only evaluates checks +whose tables it implements; deferred inputs are product-unimplemented, not user-missing. A future +percentage is allowed only when every component and weight is displayed and documented; it is +never the sole representation. From aed3fd5cd0ea8af37f493d0ff16d0bf9f19eb9d9 Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Sat, 1 Aug 2026 20:53:47 +0300 Subject: [PATCH 4/4] docs: align Phase 1A ownership wording Clarify root-versus-child ownership references and trusted service-role profile access. Keep root-profile deletion inside the explicit coordinated account-deletion boundary. --- docs/DATA_MODEL.md | 11 +++++++---- docs/RLS_POLICY_MATRIX.md | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 55e3a45..c489727 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -1,10 +1,13 @@ # Data Model Status: **proposal**. This is a logical model to guide implementation, not final SQL migrations — -column types and constraints will be refined when migrations are actually written. All -user-owned tables include a `user_id` referencing `auth.users(id)` and an RLS policy restricting -access to `user_id = auth.uid()`; this is what makes the schema multi-tenant-ready from day one -per [ADR-008](adr/ADR-008-private-first-multi-user-later.md). Shared/global tables (opportunities, +column types and constraints will be refined when migrations are actually written. Every +user-owned table carries a direct `user_id` ownership column. `profiles.user_id` directly +references `auth.users(id)`; child user-owned tables generally reference `profiles(user_id)` +through their direct `user_id`. RLS compares that direct ownership column with +`(select auth.uid())`; association tables additionally use ownership-safe composite foreign keys +where needed. This is what makes the schema multi-tenant-ready from day one per +[ADR-008](adr/ADR-008-private-first-multi-user-later.md). Shared/global tables (opportunities, source listings, sources, market intelligence) are readable by any authenticated user and writable only by the service role used by ingestion jobs. The exact permission for every table and storage bucket — including the anonymous-access answer — is enumerated exhaustively in diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md index 4b9e17e..0a5ab94 100644 --- a/docs/RLS_POLICY_MATRIX.md +++ b/docs/RLS_POLICY_MATRIX.md @@ -69,7 +69,7 @@ once per statement rather than once per row — the currently-recommended, non-d | Table(s) | Category / owner | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation and ownership-bypass test | Sensitivity | |---|---|---|---|---|---|---|---|---|---|---| -| `profiles` | User-owned root row; direct `user_id` references Auth user | Own only | Own only | Own only | **No direct browser DELETE** | Delete only in explicit trusted profile/account-deletion workflow, coordinating Auth user, owned records, Storage, export, and confirmation | `(select auth.uid()) = user_id` for SELECT/INSERT/UPDATE; no browser DELETE grant/policy | No | Owner SELECT/INSERT/UPDATE; DELETE fails even for owner; cross-user and forged-owner denial; service deletion is exercised only through the trusted workflow | High | +| `profiles` | User-owned root row; direct `user_id` references Auth user | Own only | Own only | Own only | **No direct browser DELETE** | Trusted service/administrative code may have broader maintenance, export, repair, and coordinated-account-deletion access. Root-profile deletion is permitted only in an explicit trusted workflow that coordinates the Auth user, database records, Storage objects, confirmation, and any export requirement — never as a normal browser or routine service-role path. | `(select auth.uid()) = user_id` for SELECT/INSERT/UPDATE; no browser DELETE grant/policy | No | Owner SELECT/INSERT/UPDATE; DELETE fails even for owner; cross-user and forged-owner denial; service deletion is exercised only through the trusted workflow | High | | `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) | | `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | Own only | Own only | Own only | Full | Direct owner predicate; controlled section key and current revision must be recorded | No | Owner CRUD; forged-owner and cross-user denial; cannot write a review for an unknown key or stale/future revision | High | | `profile_section_revisions` | User-owned, database-maintained freshness metadata | Own only | No direct browser write | No direct browser write | No direct browser write | Trusted migration/mutation mechanism only | Direct owner SELECT; no browser mutation grant | No | User can read only own revision; browser cannot forge/increment/rewrite revision; relevant insert/update/delete increments it atomically | Medium |