first commit

This commit is contained in:
2026-08-24 13:06:29 +05:30
commit 7d12ebef3d
86 changed files with 39996 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
-- ============================================================================
-- Krow — initial schema, reversed.
--
-- Drops exactly what 000001_initial_schema.up.sql created, in reverse
-- dependency order, and nothing else. Every DROP is qualified to `public` and
-- named explicitly — there is no CASCADE on a schema, no DROP SCHEMA, and no
-- DROP DATABASE anywhere in this file.
-- ============================================================================
SET search_path = public;
-- Tables, children before parents. Indexes and table-level constraints are
-- dropped implicitly with their table.
DROP TABLE IF EXISTS public.user_activity;
DROP TABLE IF EXISTS public.evidence;
DROP TABLE IF EXISTS public.shift_records;
DROP TABLE IF EXISTS public.assignments;
DROP TABLE IF EXISTS public.staff;
DROP TABLE IF EXISTS public.ai_interviews;
DROP TABLE IF EXISTS public.job_applications;
DROP TABLE IF EXISTS public.worker_profiles;
DROP TABLE IF EXISTS public.job_postings;
DROP TABLE IF EXISTS public.learning_paths;
DROP TABLE IF EXISTS public.courses;
DROP TABLE IF EXISTS public.badges;
DROP TABLE IF EXISTS public.certifications;
DROP TABLE IF EXISTS public.role_categories;
DROP TABLE IF EXISTS public.user_preferences;
DROP TABLE IF EXISTS public.users;
DROP TABLE IF EXISTS public.organizations;
-- Enums, after every table that referenced them is gone.
DROP TYPE IF EXISTS public.badge_verification;
DROP TYPE IF EXISTS public.badge_level;
DROP TYPE IF EXISTS public.evidence_verdict;
DROP TYPE IF EXISTS public.challenge_type;
DROP TYPE IF EXISTS public.skill_level;
DROP TYPE IF EXISTS public.course_status;
DROP TYPE IF EXISTS public.shift_status;
DROP TYPE IF EXISTS public.assignment_status;
DROP TYPE IF EXISTS public.profile_tier;
DROP TYPE IF EXISTS public.staff_status;
DROP TYPE IF EXISTS public.interview_verdict;
DROP TYPE IF EXISTS public.application_status;
DROP TYPE IF EXISTS public.english_level;
DROP TYPE IF EXISTS public.posting_priority;
DROP TYPE IF EXISTS public.posting_status;
-- The citext extension is deliberately NOT dropped.
--
-- CREATE EXTENSION IF NOT EXISTS is a no-op when the extension already exists,
-- so the up migration cannot tell whether it created citext or inherited it.
-- Dropping it on the way down would therefore risk removing an object this
-- migration never owned, and would break any other schema that has since
-- adopted the type. Leaving it is harmless: it holds no data, and re-applying
-- the up migration is unaffected.
--
-- To remove it by hand on a database where nothing else uses it:
-- DROP EXTENSION IF EXISTS citext;

View File

@@ -0,0 +1,648 @@
-- ============================================================================
-- Krow — initial schema
--
-- Source of truth: the Krow Backend Blueprint §05, reconciled against the
-- frontend repository (krow-demo) on 2026-08-21. Every table here corresponds
-- to an entity the frontend actually reads or writes through
-- `src/api/base44Client.js` (ENTITY_NAMES, lines 16-30), except `organizations`
-- and `user_preferences` — see the notes below.
--
-- Deliberately NOT in this migration (Phase 2+):
-- sessions, definitions, definition_versions, conversations,
-- conversation_messages, conversation_feedback, file_assets,
-- documents, document_chunks, the `vector` extension.
--
-- Target schema: public. No system schema is read or written.
-- ============================================================================
-- Atomicity comes from golang-migrate: the postgres driver sends this file as a
-- single simple query, which Postgres executes inside one implicit transaction.
-- Any failure below rolls the whole migration back.
SET search_path = public;
-- citext gives case-insensitive email equality. Email is a real join key in
-- this domain, not a convenience: assignments, shift_records and evidence all
-- key the worker by email, and the frontend looks profiles up with
-- filter({ email }). The extension is created here but deliberately NOT
-- dropped by the down migration — see 000001_initial_schema.down.sql.
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
-- ── Enums ───────────────────────────────────────────────────────────────────
-- Every value below was enumerated from the frontend, not from the blueprint.
-- `src/components/ds/StatusBadge.jsx` STATUS_MAP is the authoritative vocabulary.
CREATE TYPE posting_status AS ENUM ('draft', 'active', 'paused', 'closed');
CREATE TYPE posting_priority AS ENUM ('urgent', 'high', 'normal');
CREATE TYPE english_level AS ENUM ('basic', 'conversational', 'fluent', 'native');
-- 'assigned' is written by useAssignWorkers but is absent from STAGE_ORDER and
-- from STATUS_MAP, so those candidates vanish from funnel counts. That is
-- blueprint decision D3, still open. The value is included because the code
-- writes it; the funnel bug is a separate fix.
CREATE TYPE application_status AS ENUM (
'applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected', 'assigned'
);
-- aiEngine.js:436 — `overall >= 78 ? 'hire' : overall >= 55 ? 'maybe' : 'no'`.
-- The blueprint said 'yes'; the frontend says 'hire'. The frontend wins.
CREATE TYPE interview_verdict AS ENUM ('hire', 'maybe', 'no');
-- RetentionMetrics.jsx and hiringRecords.js:93 both test for 'inactive'.
-- The blueprint said 'ended'; no such value exists in the frontend.
CREATE TYPE staff_status AS ENUM ('onboarding', 'active', 'inactive');
CREATE TYPE profile_tier AS ENUM ('Beginner', 'Cross-Trained', 'Skilled');
CREATE TYPE assignment_status AS ENUM ('active', 'completed', 'cancelled');
-- attendanceSeed.js writes exactly these four. The blueprint also listed
-- 'excused', which appears nowhere in the frontend, so it is omitted.
-- ALTER TYPE ... ADD VALUE can add it later without a table rewrite.
CREATE TYPE shift_status AS ENUM ('present', 'late', 'absent', 'no_show');
CREATE TYPE course_status AS ENUM ('active', 'inactive');
CREATE TYPE skill_level AS ENUM ('beginner', 'intermediate', 'advanced', 'expert');
CREATE TYPE challenge_type AS ENUM ('roleplay', 'video', 'photo_identify');
-- provingGround.js:6 declares enum ['verified','needs_work','failed'].
-- The blueprint listed only the first two.
CREATE TYPE evidence_verdict AS ENUM ('verified', 'needs_work', 'failed');
CREATE TYPE badge_level AS ENUM ('bronze', 'silver', 'gold', 'platinum');
CREATE TYPE badge_verification AS ENUM ('pending', 'verified', 'expired');
-- ── Tenancy ─────────────────────────────────────────────────────────────────
-- No frontend evidence. Carried from day one per blueprint decision D1: a
-- single-tenant deployment simply has one row, and retrofitting org_id across
-- 16 tables later is far more expensive than carrying it now.
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug citext NOT NULL UNIQUE,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT organizations_name_not_blank CHECK (length(btrim(name)) > 0)
);
-- ── Users ───────────────────────────────────────────────────────────────────
-- `User` is one of the 15 frontend entities; auth.me / updateMe / preferences
-- are live in base44Client.js. This is schema only — no authentication is
-- implemented in Phase 1. `password_hash` is nullable and stays NULL until
-- auth is built.
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
email citext NOT NULL,
full_name text NOT NULL DEFAULT '',
password_hash text,
role text NOT NULL DEFAULT 'admin',
account_type text NOT NULL DEFAULT 'employer',
status text NOT NULL DEFAULT 'active',
last_login_at timestamptz,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_org_email_key UNIQUE (org_id, email),
CONSTRAINT users_role_check CHECK (role IN ('admin', 'employer', 'talent')),
CONSTRAINT users_status_check CHECK (status IN ('active', 'suspended')),
CONSTRAINT users_email_not_blank CHECK (length(btrim(email::text)) > 0)
);
-- Lifted out of the User JSON blob so preferences are queryable and small,
-- while auth.preferences() can still return a single merged object.
CREATE TABLE user_preferences (
user_id uuid PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
owliver_default boolean NOT NULL DEFAULT true,
compact_density boolean NOT NULL DEFAULT false,
email_digest boolean NOT NULL DEFAULT true,
extra jsonb NOT NULL DEFAULT '{}'::jsonb,
updated_date timestamptz NOT NULL DEFAULT now()
);
-- ── Reference data ──────────────────────────────────────────────────────────
CREATE TABLE role_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
name text NOT NULL,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT role_categories_org_name_key UNIQUE (org_id, name)
);
CREATE TABLE certifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
name text NOT NULL,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT certifications_org_name_key UNIQUE (org_id, name)
);
-- Reference table only. The blueprint flags Badge as "inferred dead": the UI
-- reads every badge it displays from worker_profiles.earned_badges, and
-- useBadges is exported but never imported. The entity and its seed rows exist,
-- so the table is created; nothing reads it yet.
CREATE TABLE badges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
image_url text NOT NULL DEFAULT '',
level badge_level NOT NULL DEFAULT 'bronze',
requirements text NOT NULL DEFAULT '',
expiration_months int,
verification_status badge_verification NOT NULL DEFAULT 'pending',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT badges_org_name_key UNIQUE (org_id, name),
CONSTRAINT badges_expiration_positive CHECK (expiration_months IS NULL OR expiration_months > 0)
);
-- ── Training ────────────────────────────────────────────────────────────────
CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
-- NULL org_id = platform-wide course library, shared by every organization.
org_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
category text NOT NULL DEFAULT '',
difficulty text NOT NULL DEFAULT 'beginner',
xp int NOT NULL DEFAULT 0,
estimated_minutes int NOT NULL DEFAULT 0,
badge_reward text,
proof_skill text NOT NULL DEFAULT '',
skill_id text,
target_level skill_level,
required_level skill_level,
completion_criteria text[] NOT NULL DEFAULT '{}',
verification_criteria text[] NOT NULL DEFAULT '{}',
challenge jsonb NOT NULL DEFAULT '{}'::jsonb,
unlock_requirements jsonb NOT NULL DEFAULT '{}'::jsonb,
quiz jsonb NOT NULL DEFAULT '[]'::jsonb,
pass_score int NOT NULL DEFAULT 70,
status course_status NOT NULL DEFAULT 'active',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT courses_title_not_blank CHECK (length(btrim(title)) > 0),
CONSTRAINT courses_xp_nonneg CHECK (xp >= 0),
CONSTRAINT courses_minutes_nonneg CHECK (estimated_minutes >= 0),
CONSTRAINT courses_pass_score_pct CHECK (pass_score BETWEEN 0 AND 100)
);
CREATE INDEX courses_skill_level_idx ON courses (skill_id, target_level);
CREATE INDEX courses_org_status_idx ON courses (org_id, status);
CREATE TABLE learning_paths (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
name text NOT NULL,
target_role text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
difficulty text NOT NULL DEFAULT 'beginner',
-- [{ order, course_id, course_title }] — a json reference list, not an FK.
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT learning_paths_name_not_blank CHECK (length(btrim(name)) > 0),
CONSTRAINT learning_paths_steps_is_array CHECK (jsonb_typeof(steps) = 'array')
);
-- ── Hiring: postings ────────────────────────────────────────────────────────
CREATE TABLE job_postings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
-- Free text today: the client a role is staffed for. Blueprint decision D2
-- (promote to a `clients` table) is still open, so it stays text.
company text NOT NULL DEFAULT '',
title text NOT NULL,
-- Matched to role_categories.name BY NAME, exactly as the frontend does.
role_category text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
responsibilities text[] NOT NULL DEFAULT '{}',
qualifications text[] NOT NULL DEFAULT '{}',
nice_to_haves text[] NOT NULL DEFAULT '{}',
custom_requirements text NOT NULL DEFAULT '',
physical_requirements text NOT NULL DEFAULT '',
leadership_expectations text NOT NULL DEFAULT '',
attendance_expectations text NOT NULL DEFAULT '',
min_experience_years int NOT NULL DEFAULT 0,
english_required english_level NOT NULL DEFAULT 'basic',
certifications_required text[] NOT NULL DEFAULT '{}',
-- [{ skill_id, level, weight }]
skill_requirements jsonb NOT NULL DEFAULT '[]'::jsonb,
pay_range_min int NOT NULL DEFAULT 0,
pay_range_max int NOT NULL DEFAULT 0,
location text NOT NULL DEFAULT '',
status posting_status NOT NULL DEFAULT 'draft',
ai_generated boolean NOT NULL DEFAULT false,
headcount int NOT NULL DEFAULT 1,
start_date date,
duration_months numeric(4,1),
priority posting_priority NOT NULL DEFAULT 'normal',
vetting_criteria jsonb NOT NULL DEFAULT
'{"experience":25,"english":20,"reliability":20,"certifications":20,"availability":15}'::jsonb,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT job_postings_title_not_blank CHECK (length(btrim(title)) > 0),
CONSTRAINT job_postings_experience_range CHECK (min_experience_years BETWEEN 0 AND 40),
CONSTRAINT job_postings_headcount_min CHECK (headcount >= 1),
CONSTRAINT job_postings_pay_nonneg CHECK (pay_range_min >= 0 AND pay_range_max >= 0),
-- A max of 0 means "unspecified", so it is exempt from the ordering rule.
CONSTRAINT job_postings_pay_ordered CHECK (pay_range_max = 0 OR pay_range_max >= pay_range_min),
CONSTRAINT job_postings_duration_positive CHECK (duration_months IS NULL OR duration_months > 0),
CONSTRAINT job_postings_skill_reqs_array CHECK (jsonb_typeof(skill_requirements) = 'array'),
CONSTRAINT job_postings_vetting_object CHECK (jsonb_typeof(vetting_criteria) = 'object')
);
CREATE INDEX job_postings_org_created_idx ON job_postings (org_id, created_date DESC);
CREATE INDEX job_postings_org_active_idx ON job_postings (org_id, status) WHERE status = 'active';
CREATE INDEX job_postings_org_category_idx ON job_postings (org_id, role_category);
CREATE INDEX job_postings_skill_reqs_gin ON job_postings USING gin (skill_requirements jsonb_path_ops);
-- ── Workforce: profiles ─────────────────────────────────────────────────────
-- Declared before job_applications because that table references it.
CREATE TABLE worker_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
full_name text NOT NULL,
email citext NOT NULL,
phone text NOT NULL DEFAULT '',
address text NOT NULL DEFAULT '',
selfie_url text NOT NULL DEFAULT '',
languages text[] NOT NULL DEFAULT '{}',
availability text[] NOT NULL DEFAULT '{}',
transportation text NOT NULL DEFAULT '',
certifications text[] NOT NULL DEFAULT '{}',
-- [{ company, role, years }]
experience jsonb NOT NULL DEFAULT '[]'::jsonb,
experience_years int NOT NULL DEFAULT 0,
current_position text NOT NULL DEFAULT '',
desired_position text NOT NULL DEFAULT '',
career_goals text NOT NULL DEFAULT '',
skills text[] NOT NULL DEFAULT '{}',
industries text[] NOT NULL DEFAULT '{}',
personality text NOT NULL DEFAULT '',
strengths text[] NOT NULL DEFAULT '{}',
weaknesses text[] NOT NULL DEFAULT '{}',
communication_style text NOT NULL DEFAULT '',
salary_expectations text NOT NULL DEFAULT '',
leadership_potential int NOT NULL DEFAULT 0,
ai_interview_score int NOT NULL DEFAULT 0,
krow_score int NOT NULL DEFAULT 0,
reliability_score int NOT NULL DEFAULT 0,
profile_completion int NOT NULL DEFAULT 0,
xp int NOT NULL DEFAULT 0,
completed_courses jsonb NOT NULL DEFAULT '[]'::jsonb,
earned_badges jsonb NOT NULL DEFAULT '[]'::jsonb,
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
shifts_completed int NOT NULL DEFAULT 0,
attendance_score int NOT NULL DEFAULT 100,
performance_score int NOT NULL DEFAULT 0,
client_rating numeric(2,1) NOT NULL DEFAULT 0,
supervisor_rating numeric(2,1) NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'active',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
-- Email IS the lookup key: useWorkerProfile resolves a profile by
-- filter({ email }). Enforcing it here makes that assumption safe.
CONSTRAINT worker_profiles_org_email_key UNIQUE (org_id, email),
CONSTRAINT worker_profiles_name_not_blank CHECK (length(btrim(full_name)) > 0),
CONSTRAINT worker_profiles_email_not_blank CHECK (length(btrim(email::text)) > 0),
CONSTRAINT worker_profiles_scores_pct CHECK (
krow_score BETWEEN 0 AND 100 AND
reliability_score BETWEEN 0 AND 100 AND
profile_completion BETWEEN 0 AND 100 AND
attendance_score BETWEEN 0 AND 100 AND
performance_score BETWEEN 0 AND 100 AND
ai_interview_score BETWEEN 0 AND 100 AND
leadership_potential BETWEEN 0 AND 100
),
CONSTRAINT worker_profiles_ratings_range CHECK (
client_rating BETWEEN 0 AND 5 AND supervisor_rating BETWEEN 0 AND 5
),
CONSTRAINT worker_profiles_experience_nonneg CHECK (experience_years >= 0),
CONSTRAINT worker_profiles_xp_nonneg CHECK (xp >= 0),
CONSTRAINT worker_profiles_shifts_nonneg CHECK (shifts_completed >= 0),
CONSTRAINT worker_profiles_json_arrays CHECK (
jsonb_typeof(experience) = 'array' AND
jsonb_typeof(completed_courses) = 'array' AND
jsonb_typeof(earned_badges) = 'array' AND
jsonb_typeof(capabilities) = 'array'
)
);
CREATE INDEX worker_profiles_org_score_idx ON worker_profiles (org_id, krow_score DESC);
CREATE INDEX worker_profiles_courses_gin ON worker_profiles USING gin (completed_courses jsonb_path_ops);
-- ── Hiring: applications ────────────────────────────────────────────────────
CREATE TABLE job_applications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE,
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL,
-- Denormalised: the UI reads job_title straight off the application.
job_title text NOT NULL DEFAULT '',
applicant_name text NOT NULL,
email citext NOT NULL,
phone text NOT NULL DEFAULT '',
years_experience int NOT NULL DEFAULT 0,
english_level english_level NOT NULL DEFAULT 'basic',
certifications text[] NOT NULL DEFAULT '{}',
availability text[] NOT NULL DEFAULT '{}',
skills text[] NOT NULL DEFAULT '{}',
companies_worked text[] NOT NULL DEFAULT '{}',
client_rating numeric(2,1) NOT NULL DEFAULT 0,
professional_summary text NOT NULL DEFAULT '',
cover_letter text NOT NULL DEFAULT '',
selfie_url text NOT NULL DEFAULT '',
status application_status NOT NULL DEFAULT 'applied',
ai_score int NOT NULL DEFAULT 0,
ai_match_label text NOT NULL DEFAULT '',
ai_summary text NOT NULL DEFAULT '',
ai_strengths text[] NOT NULL DEFAULT '{}',
ai_gaps text[] NOT NULL DEFAULT '{}',
ai_recommendation text NOT NULL DEFAULT '',
score_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb,
screened_at timestamptz,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
-- useAssignWorkers already looks an application up by
-- (job_posting_id, lowercased email) before creating one, so it assumes this.
CONSTRAINT job_applications_posting_email_key UNIQUE (job_posting_id, email),
CONSTRAINT job_applications_name_not_blank CHECK (length(btrim(applicant_name)) > 0),
CONSTRAINT job_applications_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100),
CONSTRAINT job_applications_rating_range CHECK (client_rating BETWEEN 0 AND 5),
CONSTRAINT job_applications_experience_nonneg CHECK (years_experience >= 0),
CONSTRAINT job_applications_breakdown_object CHECK (jsonb_typeof(score_breakdown) = 'object'),
-- A screened application must carry the timestamp that says when.
CONSTRAINT job_applications_screened_consistent CHECK (
status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0
)
);
CREATE INDEX job_applications_org_score_idx ON job_applications (org_id, ai_score DESC);
CREATE INDEX job_applications_posting_status_idx ON job_applications (job_posting_id, status);
CREATE INDEX job_applications_org_status_idx ON job_applications (org_id, status, created_date DESC);
CREATE INDEX job_applications_org_email_idx ON job_applications (org_id, email);
CREATE INDEX job_applications_profile_idx ON job_applications (worker_profile_id)
WHERE worker_profile_id IS NOT NULL;
-- ── Hiring: AI interviews ───────────────────────────────────────────────────
CREATE TABLE ai_interviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
application_id uuid NOT NULL REFERENCES job_applications (id) ON DELETE CASCADE,
job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE,
job_title text NOT NULL DEFAULT '',
candidate_name text NOT NULL DEFAULT '',
messages jsonb NOT NULL DEFAULT '[]'::jsonb,
overall_interview_score int NOT NULL DEFAULT 0,
verdict interview_verdict NOT NULL DEFAULT 'maybe',
hire_recommendation text NOT NULL DEFAULT '',
integrity_score int NOT NULL DEFAULT 100,
ai_flags text[] NOT NULL DEFAULT '{}',
category_scores jsonb NOT NULL DEFAULT '{}'::jsonb,
strengths text[] NOT NULL DEFAULT '{}',
concerns text[] NOT NULL DEFAULT '{}',
best_fit_roles text[] NOT NULL DEFAULT '{}',
summary text NOT NULL DEFAULT '',
reasoning text NOT NULL DEFAULT '',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ai_interviews_score_pct CHECK (overall_interview_score BETWEEN 0 AND 100),
CONSTRAINT ai_interviews_integrity_pct CHECK (integrity_score BETWEEN 0 AND 100),
CONSTRAINT ai_interviews_messages_array CHECK (jsonb_typeof(messages) = 'array'),
CONSTRAINT ai_interviews_categories_object CHECK (jsonb_typeof(category_scores) = 'object')
);
CREATE INDEX ai_interviews_application_idx ON ai_interviews (application_id);
CREATE INDEX ai_interviews_org_created_idx ON ai_interviews (org_id, created_date DESC);
-- ── Hiring: staff (the hire record) ─────────────────────────────────────────
CREATE TABLE staff (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
application_id uuid REFERENCES job_applications (id) ON DELETE SET NULL,
job_posting_id uuid REFERENCES job_postings (id) ON DELETE SET NULL,
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL,
name text NOT NULL,
email citext NOT NULL,
phone text NOT NULL DEFAULT '',
role text NOT NULL DEFAULT '',
profile_tier profile_tier NOT NULL DEFAULT 'Beginner',
hire_date date NOT NULL,
ai_score int NOT NULL DEFAULT 0,
status staff_status NOT NULL DEFAULT 'onboarding',
client_rating numeric(2,1) NOT NULL DEFAULT 0,
endorsement_text text NOT NULL DEFAULT '',
endorsed_skills text[] NOT NULL DEFAULT '{}',
review_date date,
reviewer_name text NOT NULL DEFAULT '',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT staff_name_not_blank CHECK (length(btrim(name)) > 0),
CONSTRAINT staff_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100),
CONSTRAINT staff_rating_range CHECK (client_rating BETWEEN 0 AND 5),
CONSTRAINT staff_review_after_hire CHECK (review_date IS NULL OR review_date >= hire_date)
);
CREATE INDEX staff_org_created_idx ON staff (org_id, created_date DESC);
CREATE INDEX staff_org_email_idx ON staff (org_id, email);
CREATE INDEX staff_posting_idx ON staff (job_posting_id) WHERE job_posting_id IS NOT NULL;
-- ── Workforce: assignments ──────────────────────────────────────────────────
CREATE TABLE assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE,
application_id uuid REFERENCES job_applications (id) ON DELETE SET NULL,
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL,
-- The email columns stay and stay populated: lib/workforce.js and
-- lib/attendance.js join on them today.
worker_email citext NOT NULL,
worker_name text NOT NULL DEFAULT '',
starts_at timestamptz NOT NULL,
ends_at timestamptz,
status assignment_status NOT NULL DEFAULT 'active',
source text NOT NULL DEFAULT 'owliver',
match_score int,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT assignments_period_ordered CHECK (ends_at IS NULL OR ends_at > starts_at),
CONSTRAINT assignments_match_score_pct CHECK (match_score IS NULL OR match_score BETWEEN 0 AND 100)
);
CREATE INDEX assignments_posting_active_idx ON assignments (job_posting_id) WHERE status = 'active';
CREATE INDEX assignments_org_worker_idx ON assignments (org_id, worker_email, starts_at);
-- "Is this person free between X and Y" — lib/workforce.js availability checks.
CREATE INDEX assignments_period_gist ON assignments
USING gist (tstzrange(starts_at, COALESCE(ends_at, 'infinity'::timestamptz)));
-- ── Workforce: shift records ────────────────────────────────────────────────
CREATE TABLE shift_records (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
staff_id uuid REFERENCES staff (id) ON DELETE CASCADE,
assignment_id uuid REFERENCES assignments (id) ON DELETE SET NULL,
job_posting_id uuid REFERENCES job_postings (id) ON DELETE SET NULL,
worker_name text NOT NULL DEFAULT '',
worker_email citext NOT NULL,
role text NOT NULL DEFAULT '',
role_category text NOT NULL DEFAULT '',
shift_date date NOT NULL,
scheduled_start timestamptz NOT NULL,
scheduled_end timestamptz NOT NULL,
scheduled_hours numeric(5,2) NOT NULL,
actual_start timestamptz,
actual_end timestamptz,
actual_hours numeric(5,2) NOT NULL DEFAULT 0,
overtime_hours numeric(5,2) NOT NULL DEFAULT 0,
minutes_late int NOT NULL DEFAULT 0,
status shift_status NOT NULL DEFAULT 'present',
notes text NOT NULL DEFAULT '',
-- NOT defaulted: for a shift this is the instant the shift was worked, and
-- attendanceSeed.js documents that as load-bearing. Writers must supply it.
created_date timestamptz NOT NULL,
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT shift_records_schedule_ordered CHECK (scheduled_end > scheduled_start),
CONSTRAINT shift_records_actual_ordered CHECK (
actual_start IS NULL OR actual_end IS NULL OR actual_end >= actual_start
),
CONSTRAINT shift_records_hours_nonneg CHECK (
scheduled_hours >= 0 AND actual_hours >= 0 AND overtime_hours >= 0
),
CONSTRAINT shift_records_minutes_late_nonneg CHECK (minutes_late >= 0),
-- An absence has no hours on the clock and nobody was late for it.
CONSTRAINT shift_records_absence_has_no_hours CHECK (
status NOT IN ('absent', 'no_show') OR (actual_hours = 0 AND minutes_late = 0)
)
);
CREATE INDEX shift_records_org_created_idx ON shift_records (org_id, created_date DESC);
CREATE INDEX shift_records_org_staff_idx ON shift_records (org_id, staff_id, shift_date DESC);
CREATE INDEX shift_records_org_category_idx ON shift_records (org_id, role_category, shift_date);
CREATE INDEX shift_records_org_exception_idx ON shift_records (org_id, status)
WHERE status IN ('absent', 'no_show', 'late');
-- ── Proving Ground: evidence ────────────────────────────────────────────────
-- `media_asset_id` and the `file_assets` table are deliberately absent: object
-- storage is Phase 2. `media_url` is the column the frontend actually writes.
CREATE TABLE evidence (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
course_id uuid REFERENCES courses (id) ON DELETE SET NULL,
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE CASCADE,
course_title text NOT NULL DEFAULT '',
skill text NOT NULL DEFAULT '',
worker_email citext NOT NULL,
worker_name text NOT NULL DEFAULT '',
type challenge_type NOT NULL,
media_url text NOT NULL DEFAULT '',
transcript text NOT NULL DEFAULT '',
ai_verdict evidence_verdict NOT NULL DEFAULT 'needs_work',
ai_score int NOT NULL DEFAULT 0,
ai_rubric jsonb NOT NULL DEFAULT '{}'::jsonb,
ai_feedback text NOT NULL DEFAULT '',
supervisor_verified boolean NOT NULL DEFAULT false,
supervisor_name text NOT NULL DEFAULT '',
verified_date timestamptz,
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT evidence_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100),
CONSTRAINT evidence_rubric_object CHECK (jsonb_typeof(ai_rubric) = 'object'),
-- A verification must record who did it and when.
CONSTRAINT evidence_verification_complete CHECK (
supervisor_verified = false
OR (verified_date IS NOT NULL AND length(btrim(supervisor_name)) > 0)
)
);
CREATE INDEX evidence_org_worker_idx ON evidence (org_id, worker_email, created_date DESC);
CREATE INDEX evidence_course_idx ON evidence (course_id) WHERE course_id IS NOT NULL;
-- ── Activity log ────────────────────────────────────────────────────────────
-- Append-only. The reference columns are flat and unconstrained on purpose:
-- the frontend calls filter({ position_id }) directly against them, and an
-- activity row must survive the deletion of whatever it describes.
CREATE TABLE user_activity (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
legacy_id text UNIQUE,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
event_type text NOT NULL,
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
user_email citext NOT NULL DEFAULT 'anonymous',
user_name text NOT NULL DEFAULT '',
account_type text NOT NULL DEFAULT 'unknown',
details text NOT NULL DEFAULT '',
position_id uuid,
application_id uuid,
candidate_id uuid,
interview_id uuid,
worker_email citext,
metadata jsonb,
created_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_activity_event_type_not_blank CHECK (length(btrim(event_type)) > 0)
);
CREATE INDEX user_activity_org_created_idx ON user_activity (org_id, created_date DESC);
CREATE INDEX user_activity_org_event_idx ON user_activity (org_id, event_type, created_date DESC);
CREATE INDEX user_activity_org_user_idx ON user_activity (org_id, user_email, created_date DESC);
CREATE INDEX user_activity_position_idx ON user_activity (position_id) WHERE position_id IS NOT NULL;
CREATE INDEX user_activity_application_idx ON user_activity (application_id) WHERE application_id IS NOT NULL;

View File

@@ -0,0 +1,17 @@
-- Reverses 000002, dropping each added column. Indexes and the check
-- constraint go with their columns.
SET search_path = public;
DROP INDEX IF EXISTS public.job_applications_interview_idx;
ALTER TABLE public.job_applications
DROP COLUMN IF EXISTS interview_id;
ALTER TABLE public.courses
DROP COLUMN IF EXISTS training_outline;
ALTER TABLE public.worker_profiles
DROP CONSTRAINT IF EXISTS worker_profiles_score_breakdown_object;
ALTER TABLE public.worker_profiles
DROP COLUMN IF EXISTS score_breakdown;

View File

@@ -0,0 +1,82 @@
-- ============================================================================
-- Frontend reconciliation gaps
--
-- Three columns the frontend reads or writes but migration 000001 had no place
-- for. All three were found by sweeping runtime write paths, not just seed data.
--
-- ── 1. job_applications.interview_id ──────────────────────────────────────
--
-- WRITE components/krow/AIInterviewModal.jsx:180
-- PATCH {status:'interview', interview_id, ai_score} — reachable from
-- admin/CandidateProfile.jsx, admin/Candidates.jsx, PositionDetail.jsx
-- READ components/krow/CandidateExpandedDetails.jsx:41,142
-- via CandidateCard.jsx ← admin/Candidates.jsx, PositionDetail.jsx
-- SEED 5 of 24 applications in src/api/seed.js carry it
--
-- Deliberately NOT a foreign key. `app_devon` references `int_devon`, for which
-- no AIInterview record exists in the seed; a REFERENCES constraint would
-- either fail the seed or force that value to NULL, and nulling it would be
-- silently transforming source data. The column is a soft reference, which is
-- also how the frontend treats it — every read is a truthiness check, never a
-- lookup.
-- ============================================================================
SET search_path = public;
ALTER TABLE public.job_applications
ADD COLUMN interview_id uuid;
COMMENT ON COLUMN public.job_applications.interview_id IS
'Soft reference to ai_interviews.id. Intentionally unconstrained: seed data '
'contains a dangling reference, and the frontend only ever tests it for '
'presence.';
CREATE INDEX job_applications_interview_idx
ON public.job_applications (interview_id)
WHERE interview_id IS NOT NULL;
-- ── 2. courses.training_outline ───────────────────────────────────────────
--
-- WRITE components/forge/AddSkillTraining.jsx:103 ← pages/University.jsx
-- (mounted at /admin/university)
-- READ components/forge/challengeMeta.js:139, ai-assistant/insights.js:177,
-- ai-assistant/capabilities/admin.js:1547, lib/skills/actions.js:369
-- SEED 0 of 40 courses carry it — the Forge authoring flow writes it, and
-- every shipped course predates that flow.
--
-- text[] rather than jsonb: the writer is
-- `form.outline.map((s) => s.trim()).filter(Boolean)`, a plain list of strings,
-- and every reader guards with Array.isArray then filters. This matches the
-- shape of completion_criteria and verification_criteria on the same table.
ALTER TABLE public.courses
ADD COLUMN training_outline text[] NOT NULL DEFAULT '{}';
COMMENT ON COLUMN public.courses.training_outline IS
'Ordered written training steps, authored through the Forge flow. Empty for '
'every course that predates it.';
-- ── 3. worker_profiles.score_breakdown ────────────────────────────────────
--
-- WRITE lib/krowScore.js:64 recalcProfilePatch() → spread into the profile
-- patch at lib/krowHooks.js:682, the live challenge-submission path
-- reached through CourseDetail.jsx.
-- READ No reader consumes it *from a profile*: every score_breakdown reader
-- in the frontend works on a job application. The column exists so the
-- write lands rather than being silently discarded.
--
-- jsonb, mirroring job_applications.score_breakdown, which holds the output of
-- the same scoring engine.
ALTER TABLE public.worker_profiles
ADD COLUMN score_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.worker_profiles
ADD CONSTRAINT worker_profiles_score_breakdown_object
CHECK (jsonb_typeof(score_breakdown) = 'object');
COMMENT ON COLUMN public.worker_profiles.score_breakdown IS
'Per-dimension KROW score detail from recalcProfilePatch(). Written by the '
'challenge flow; no frontend reader consumes it from a profile yet.';

View File

@@ -0,0 +1,12 @@
-- Restores the constraint dropped by 000003.
--
-- NOTE: this will FAIL on any database holding the seeded demo dataset, because
-- that data is what the constraint rejects. That is the point of dropping it.
-- Roll back only on a database whose applications satisfy the predicate.
SET search_path = public;
ALTER TABLE public.job_applications
ADD CONSTRAINT job_applications_screened_consistent CHECK (
status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0
);

View File

@@ -0,0 +1,28 @@
-- ============================================================================
-- Drop job_applications_screened_consistent
--
-- A defect in migration 000001. The constraint reads:
--
-- status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0
--
-- which asserts that a screened application records *when* it was screened.
-- The frontend makes no such promise: `screened_at` is never read and never
-- written anywhere in the repository, and none of the 24 seeded applications
-- carries it. The column and the constraint both came out of the backend
-- blueprint rather than out of repository evidence.
--
-- The effect was that 9 of 24 seeded applications were rejected — exactly the
-- 9 AI-scored ones behind the "9 scored, averaging 76" regression anchor.
--
-- 000001 is already applied and is immutable, so the fix lands here. All 52
-- other CHECK constraints from 000001 were verified against all 245 seeded
-- records and hold.
--
-- The `screened_at` column is kept: it is nullable and unused, and removing it
-- is a separate cleanup rather than part of this fix.
-- ============================================================================
SET search_path = public;
ALTER TABLE public.job_applications
DROP CONSTRAINT IF EXISTS job_applications_screened_consistent;

View File

@@ -0,0 +1,16 @@
-- Reverses 000004.
--
-- Drops exactly what the up migration created and nothing else: no CASCADE on
-- a schema, no DROP SCHEMA, no DROP DATABASE, and no touch to users beyond
-- removing the index 000004 added. users_org_email_key predates this migration
-- and is left alone.
--
-- Dropping `sessions` logs everyone out. That is the correct meaning of
-- rolling back the authentication foundation, and it destroys no application
-- data: every row in this table is a credential, not a record.
SET search_path = public;
DROP TABLE IF EXISTS public.sessions;
DROP INDEX IF EXISTS public.users_email_global_key;

View File

@@ -0,0 +1,119 @@
-- ============================================================================
-- Krow — authentication foundation
--
-- Phase 3B. This migration adds the two schema facts authentication needs and
-- nothing else:
--
-- 1. users.email is globally unique, because login identifies a user by
-- email alone.
-- 2. a `sessions` table, because sessions are server-side and opaque.
--
-- Deliberately NOT here, per the Phase 3B decisions:
-- roles, permissions, organization_members, credentials, refresh_tokens.
-- `users.role` is already the authorization field and `users.password_hash`
-- already exists; neither needs a table of its own.
--
-- No login, logout, middleware or enforcement ships with this migration. It is
-- schema only.
--
-- Target schema: public. No system schema is read or written.
-- ============================================================================
-- Atomicity comes from golang-migrate: the postgres driver sends this file as a
-- single simple query, which Postgres executes inside one implicit transaction.
-- Any failure below rolls the whole migration back.
SET search_path = public;
-- ── users.email — global uniqueness ─────────────────────────────────────────
--
-- 000001 constrains (org_id, email). That is the right key for a tenant-scoped
-- directory, and the wrong key for a login form: `POST /auth/login` will be
-- given an email and a password and nothing else, so an email that resolved to
-- two users in two organizations would have no single answer.
--
-- The column is `citext`, so this index is case-insensitive for free —
-- "Demo@Krow.app" and "demo@krow.app" collide, which is what a login form
-- needs. A plain btree over a citext column uses the type's own comparison; no
-- lower() expression is required, and using one here would in fact build a
-- *different*, case-sensitive index.
--
-- users_org_email_key is left in place. It is now implied by this index and
-- therefore redundant, but dropping it is a change to the existing table that
-- authentication does not need, and a redundant unique constraint costs one
-- index write per user row — of which there is currently one.
--
-- Verified before writing this migration: no two rows in the target database
-- share an email, so the index builds without a conflict.
CREATE UNIQUE INDEX users_email_global_key ON public.users (email);
COMMENT ON INDEX public.users_email_global_key IS
'Login identity. Email must resolve to exactly one user across every '
'organization, because the login form supplies no organization.';
-- ── sessions ────────────────────────────────────────────────────────────────
--
-- A session is a row, not a token payload. The browser holds an opaque random
-- string in an HttpOnly cookie; this table holds only SHA-256 of that string,
-- so a dump of this table cannot be replayed as a login.
--
-- token_hash is `text` holding lowercase hex rather than bytea: it is 64 ASCII
-- bytes either way after TOAST considerations, it is greppable in psql during
-- development, and it matches how password_hash is already stored. The CHECK
-- pins the format, so a caller cannot accidentally store a raw token here —
-- a raw token is base64url of 32 bytes and fails the pattern.
--
-- Two expiries, because Phase 3B decision 3 allows sliding expiry and also
-- requires that a session cannot live forever:
--
-- expires_at moves forward as the session is used (the sliding
-- window: 12 hours normally, 30 days with Remember Me).
-- absolute_expires_at is fixed at creation and never moves. Once it passes,
-- the session is dead no matter how recently it was
-- used, and the user authenticates again.
--
-- Without the second column the first can be slid indefinitely, which is
-- exactly the "session that lives forever" the decision rules out.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token_hash text NOT NULL,
expires_at timestamptz NOT NULL,
absolute_expires_at timestamptz NOT NULL,
created_date timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
-- UNIQUE is implemented by PostgreSQL as a btree index on token_hash, which
-- is also the index every session lookup uses: authentication hashes the
-- cookie and probes this one column. It is both the uniqueness guarantee and
-- the lookup path; a second index on the same column would be dead weight.
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash),
CONSTRAINT sessions_token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT sessions_absolute_after_created CHECK (absolute_expires_at > created_date),
CONSTRAINT sessions_within_absolute CHECK (expires_at <= absolute_expires_at)
);
-- ON DELETE CASCADE, not SET NULL and not RESTRICT: a deleted user must not
-- leave a live session behind that still authenticates as them.
-- Revoking every session for one user, and the FK's own cascade check.
CREATE INDEX sessions_user_idx ON sessions (user_id);
-- The periodic sweep of dead rows. Ordered by the column it filters on.
CREATE INDEX sessions_expires_idx ON sessions (expires_at);
COMMENT ON TABLE sessions IS
'Server-side sessions. The raw token exists only in the client HttpOnly '
'cookie; this table stores SHA-256 of it and never the token itself.';
COMMENT ON COLUMN sessions.token_hash IS
'Lowercase hex SHA-256 of the session token. Never the token.';
COMMENT ON COLUMN sessions.expires_at IS
'Sliding expiry. Moved forward on use; never past absolute_expires_at.';
COMMENT ON COLUMN sessions.absolute_expires_at IS
'Hard ceiling, fixed at creation. A session cannot outlive it.';

View File

@@ -0,0 +1,22 @@
-- Reverses 000005.
--
-- Drops exactly the two tables it created and nothing else. No CASCADE on a
-- schema, no DROP SCHEMA, no DROP DATABASE, and no touch to any table that
-- predates this migration. Indexes and constraints go with their tables.
--
-- No enum types were created by 000005 — the two status vocabularies are text
-- with CHECK constraints — so there is nothing left behind to clean up.
--
-- Rolling this back destroys every authored agent and skill definition. That is
-- the correct meaning of reversing the migration that introduced them, and it
-- reaches nothing else: shipped definitions live in Git, and the account
-- preferences these tables replaced are untouched by both directions of 000005.
--
-- Dropped in reverse creation order. The two tables do not reference each
-- other, so the order is convention rather than necessity.
SET search_path = public;
DROP TABLE IF EXISTS public.skill_definitions;
DROP TABLE IF EXISTS public.agent_definitions;

View File

@@ -0,0 +1,293 @@
-- ============================================================================
-- Krow — authored agent and skill definitions
--
-- Phase 4C. Two tables, and deliberately only two.
--
-- WHAT THIS IS FOR
--
-- An agent and a skill are each a Markdown file with YAML frontmatter. Three
-- tiers of them exist, and only two live here:
--
-- shipped src/agents/**/*.md, src/skills/**/*.md — product source,
-- versioned in Git, bundled at build time. NO ROWS HERE. They
-- are code: putting them in a table would trade `git log`,
-- code review and atomic deploy for nothing, and would make
-- every shipped-definition change a data migration.
-- organization authored in the app, shared across one tenant.
-- personal authored in the app, private to one user.
--
-- Before this migration the last two lived in `user_preferences.extra`, a
-- jsonb blob with no owner, no tenancy, no size bound, no server-side
-- validation and no query surface — and returned in full by GET /api/v1/me on
-- every page load. This migration is that move.
--
-- WHY TWO TABLES AND NOT ONE
--
-- Agents and skills do not share a lifecycle, and the difference is not
-- incidental:
--
-- agents status draft | published | archived, plus an integer version that
-- only goes up. They are published artefacts.
-- skills status active | inactive, and NO version at all — the frontend has
-- no notion of a skill version and none is invented here.
--
-- One table would need a union CHECK permitting `version 5, status inactive`,
-- and a version column that is forever 1 for half the rows. Two tables cost a
-- little repetition and buy a schema where every row is meaningful.
--
-- WHAT IS DELIBERATELY ABSENT
--
-- definition_versions nothing retains prior Markdown; no rollback
-- feature exists to serve.
-- definition_permissions the `permissions:` frontmatter block stays inside
-- the Markdown, parsed and unenforced, until its
-- semantics are defined (Phase 4H).
-- agent_skills `skills:` names ids in a namespace that includes
-- agent_subagents SHIPPED definitions, which have no rows here. A
-- join table would need foreign keys to rows that do
-- not exist. Resolution stays in the registry.
-- agent_knowledge embedded in frontmatter; no corpus exists.
-- conversations deferred.
--
-- `disabledSkills` and `removedSkills` also stay where they are, in
-- user_preferences.extra. They are per-account arrays of skill *ids* — mostly
-- shipped ids — so they are suppression preferences over a namespace, not
-- definitions, and they are already in the right place.
--
-- Target schema: public. No system schema is read or written.
-- ============================================================================
-- Atomicity comes from golang-migrate: the postgres driver sends this file as a
-- single simple query, which Postgres executes inside one implicit transaction.
-- Any failure below rolls the whole migration back.
SET search_path = public;
-- ── agent_definitions ───────────────────────────────────────────────────────
CREATE TABLE agent_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- The author-facing id — `id:` in the frontmatter, the address that skills,
-- subagents and the shadow-by-id merge all refer to. NOT globally unique:
-- the whole point of shadowing is that a personal definition may carry the
-- same id as an organization one, which may carry the same id as a shipped
-- one. See the two partial unique indexes below for what IS unique.
definition_id text NOT NULL,
-- Tenancy. NOT NULL on a personal definition too: a user belongs to exactly
-- one organization, so a personal definition is always inside a tenant, and
-- carrying org_id means the organization predicate applies to every read
-- whether or not the ownership predicate does. Defence in depth for one
-- column.
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
visibility text NOT NULL,
-- Ownership, in two columns because the two tiers have genuinely different
-- deletion semantics and one column cannot carry both:
--
-- owner_user_id set ONLY for a personal definition. CASCADE: a personal
-- definition dies with its owner, because there is nobody
-- else it could belong to.
-- created_by always the author. SET NULL: an organization-shared
-- definition must survive its author leaving the company.
-- Nullable for exactly that reason, and because that is
-- already this schema's pattern — job_postings.created_by.
owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
-- The definition, verbatim. THIS IS THE AUTHORITATIVE ARTEFACT: a definition
-- must survive a round trip to a .md file on disk unchanged, so the Markdown
-- is the record and the columns below are derived from it.
markdown text NOT NULL,
-- ── Projections ──────────────────────────────────────────────────────────
-- Everything below is parsed OUT of `markdown` by the server, never accepted
-- from a request body, and rebuildable by re-parsing every row. They exist so
-- that "this organization's published agents" is a query rather than a parse
-- of every blob, and so version conflicts can be detected with a predicate
-- rather than a read-modify-write in the browser.
status text NOT NULL DEFAULT 'draft',
-- Monotonic. A first publish keeps its version; republishing moves it on, so
-- "what is live" is always a specific number.
version integer NOT NULL DEFAULT 1,
name text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
-- text[] rather than jsonb, matching courses.training_outline: this is a
-- plain list of strings and every reader treats it as one.
pages text[] NOT NULL DEFAULT '{}',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
-- The format the frontend validator already enforces, restated here so the
-- database refuses what the application would have refused. Lower-case
-- letters, digits and dashes, not starting with a dash.
CONSTRAINT agent_definitions_definition_id_format
CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'),
CONSTRAINT agent_definitions_visibility_check
CHECK (visibility IN ('personal', 'organization')),
-- The ownership invariant, stated once and in both directions: a personal
-- definition HAS an owner, an organization definition has NONE. Written as an
-- equality of two booleans rather than two OR'd implications, because that is
-- the whole rule in one line and cannot be half-satisfied.
CONSTRAINT agent_definitions_visibility_owner
CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)),
-- text + CHECK rather than a PostgreSQL enum, following users.role and
-- users.status. A status vocabulary that may grow is easier to widen with an
-- ALTER of a constraint than with ALTER TYPE ... ADD VALUE, and it keeps the
-- down migration to a table drop with no type left behind.
CONSTRAINT agent_definitions_status_check
CHECK (status IN ('draft', 'published', 'archived')),
CONSTRAINT agent_definitions_version_check
CHECK (version >= 1),
-- A bound on the blob, which is the other half of moving definitions out of
-- user_preferences.extra. The largest definition shipped with the product is
-- 3,156 bytes and the median is 1,354, so 65,536 is roughly twenty times the
-- biggest thing anyone has actually written — unreachable by legitimate
-- authoring, and low enough that no single row can be used to bloat a
-- response. An empty definition cannot parse, so zero length is refused too.
--
-- `length()` counts CHARACTERS, which is the semantic this schema already
-- uses (organizations_name_not_blank, users_email_not_blank). A worst-case
-- 4-byte-per-character document would therefore be up to 256 KiB on disk;
-- that is accepted deliberately in exchange for one consistent rule.
CONSTRAINT agent_definitions_markdown_size
CHECK (length(markdown) BETWEEN 1 AND 65536)
);
-- Uniqueness, per tier. Partial rather than whole-table because the two tiers
-- are keyed on different columns: a personal definition is unique to its owner,
-- an organization definition to its tenant. Partial also keeps each index to
-- only the rows it governs.
--
-- (A plain UNIQUE (owner_user_id, definition_id) would technically also work,
-- because NULLs are distinct by default and organization rows all have a NULL
-- owner — but it would be relying on a subtlety to express a rule, which is
-- how the rule gets misread later.)
CREATE UNIQUE INDEX agent_definitions_personal_key
ON agent_definitions (owner_user_id, definition_id)
WHERE visibility = 'personal';
CREATE UNIQUE INDEX agent_definitions_org_key
ON agent_definitions (org_id, definition_id)
WHERE visibility = 'organization';
-- Listing one organization's definitions, split by tier. Also covers the
-- org_id foreign key, which PostgreSQL does not index on its own.
CREATE INDEX agent_definitions_org_visibility_idx
ON agent_definitions (org_id, visibility);
-- Listing one user's own definitions, and the owner_user_id foreign key's
-- cascade check.
CREATE INDEX agent_definitions_owner_idx
ON agent_definitions (owner_user_id);
-- The runtime's own query: the agents that are actually live in a tenant. An
-- unpublished agent contributes nothing at runtime, so the index carries only
-- published rows — the same shape as job_postings_org_active_idx.
CREATE INDEX agent_definitions_published_idx
ON agent_definitions (org_id, visibility)
WHERE status = 'published';
-- There is deliberately NO index on created_by. It is attribution only: no
-- listing is keyed by it, and its ON DELETE SET NULL scan happens when a user
-- is deleted, which is rare and against a small table. An index would cost a
-- write on every definition change to serve nothing.
COMMENT ON TABLE agent_definitions IS
'Agent definitions authored in the application. Shipped agents live in Git '
'under src/agents/ and have no rows here.';
COMMENT ON COLUMN agent_definitions.definition_id IS
'Author-facing id from the frontmatter. Unique per owner or per organization, '
'never globally: shadow-by-id is the point.';
COMMENT ON COLUMN agent_definitions.markdown IS
'The definition verbatim, and the authoritative record. Every other column '
'except the identity and ownership ones is parsed out of this.';
COMMENT ON COLUMN agent_definitions.owner_user_id IS
'Set only when visibility = personal. Organization definitions have none.';
COMMENT ON COLUMN agent_definitions.created_by IS
'The author, for attribution. Nullable so a shared definition survives its '
'author being deleted.';
-- ── skill_definitions ───────────────────────────────────────────────────────
--
-- The same shape, minus `version`. Skills have no version and no publish step
-- in the product: a skill is active or inactive, and that is the whole of its
-- lifecycle. Adding a version column "for symmetry" would be inventing a
-- concept the frontend does not have and cannot set.
CREATE TABLE skill_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
definition_id text NOT NULL,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
visibility text NOT NULL,
owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
markdown text NOT NULL,
-- Projections, as above.
status text NOT NULL DEFAULT 'active',
name text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
pages text[] NOT NULL DEFAULT '{}',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT skill_definitions_definition_id_format
CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'),
CONSTRAINT skill_definitions_visibility_check
CHECK (visibility IN ('personal', 'organization')),
CONSTRAINT skill_definitions_visibility_owner
CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)),
CONSTRAINT skill_definitions_status_check
CHECK (status IN ('active', 'inactive')),
CONSTRAINT skill_definitions_markdown_size
CHECK (length(markdown) BETWEEN 1 AND 65536)
);
CREATE UNIQUE INDEX skill_definitions_personal_key
ON skill_definitions (owner_user_id, definition_id)
WHERE visibility = 'personal';
CREATE UNIQUE INDEX skill_definitions_org_key
ON skill_definitions (org_id, definition_id)
WHERE visibility = 'organization';
CREATE INDEX skill_definitions_org_visibility_idx
ON skill_definitions (org_id, visibility);
CREATE INDEX skill_definitions_owner_idx
ON skill_definitions (owner_user_id);
-- The runtime loads active skills; an inactive one is registered and switched
-- off. Partial for the same reason as the agent index above.
CREATE INDEX skill_definitions_active_idx
ON skill_definitions (org_id, visibility)
WHERE status = 'active';
COMMENT ON TABLE skill_definitions IS
'Skill definitions authored in the application. Shipped skills live in Git '
'under src/skills/ and have no rows here. Skills have no version: their '
'lifecycle is active or inactive.';
COMMENT ON COLUMN skill_definitions.markdown IS
'The definition verbatim, and the authoritative record.';