-- DevRunbook v1.1 relational reference. Migration code may differ syntactically -- but must preserve the ownership, immutability and uniqueness contracts. CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text NOT NULL, display_name text NOT NULL, password_hash text NOT NULL, instance_role text NOT NULL CHECK (instance_role IN ('instance_owner','instance_admin','user')), status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled','pending_deletion')), password_changed_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz ); CREATE UNIQUE INDEX users_email_ci_uq ON users (lower(email)) WHERE deleted_at IS NULL; CREATE TABLE auth_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 UNIQUE, created_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), idle_expires_at timestamptz NOT NULL, absolute_expires_at timestamptz NOT NULL, revoked_at timestamptz, source_ip_hash text, user_agent_summary text ); CREATE INDEX auth_sessions_user_active_idx ON auth_sessions(user_id, absolute_expires_at) WHERE revoked_at IS NULL; CREATE TABLE invitations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text NOT NULL, token_hash text NOT NULL UNIQUE, instance_role text NOT NULL CHECK (instance_role IN ('instance_admin','user')), workspace_id uuid, workspace_role text CHECK (workspace_role IN ('owner','editor','viewer')), expires_at timestamptz NOT NULL, accepted_at timestamptz, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE password_reset_tokens ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, expires_at timestamptz NOT NULL, used_at timestamptz, created_by uuid REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE workspaces ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, type text NOT NULL DEFAULT 'personal' CHECK (type IN ('personal','team')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz ); ALTER TABLE invitations ADD CONSTRAINT invitations_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; CREATE TABLE workspace_memberships ( workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, role text NOT NULL CHECK (role IN ('owner','editor','viewer')), created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (workspace_id, user_id) ); CREATE INDEX workspace_memberships_user_idx ON workspace_memberships(user_id); CREATE TABLE instance_settings ( singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), setup_completed_at timestamptz, owner_user_id uuid REFERENCES users(id), config_json jsonb NOT NULL DEFAULT '{}'::jsonb, config_digest text, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE playbooks ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE, logical_id text NOT NULL, slug text NOT NULL, namespace text NOT NULL, source_type text NOT NULL CHECK (source_type IN ('built_in','private','imported','remote_registry')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(namespace, logical_id), UNIQUE(namespace, slug) ); CREATE INDEX playbooks_workspace_idx ON playbooks(workspace_id); CREATE TABLE playbook_versions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), playbook_id uuid NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE, semantic_version text NOT NULL, lifecycle text NOT NULL CHECK (lifecycle IN ('draft','reviewed','validated','battle-tested','deprecated')), package_api_version text NOT NULL, title text NOT NULL, summary text NOT NULL, category text NOT NULL, risk_tier text NOT NULL CHECK (risk_tier IN ('low','moderate','high','critical')), package_json jsonb NOT NULL, template_text text NOT NULL, content_digest text NOT NULL, draft_revision integer NOT NULL DEFAULT 1 CHECK (draft_revision > 0), draft_digest text NOT NULL DEFAULT repeat('0', 64) CHECK (draft_digest ~ '^[0-9a-f]{64}$'), draft_validation_json jsonb NOT NULL DEFAULT '{"valid":true,"issues":[]}'::jsonb CHECK (jsonb_typeof(draft_validation_json) = 'object'), search_document tsvector, published_at timestamptz, supersedes_version_id uuid REFERENCES playbook_versions(id), created_by uuid REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(playbook_id, semantic_version) ); CREATE INDEX playbook_versions_search_idx ON playbook_versions USING gin(search_document); CREATE INDEX playbook_versions_filters_idx ON playbook_versions(category, risk_tier, lifecycle, published_at DESC); CREATE FUNCTION normalize_playbook_version_draft_digest() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.draft_digest = repeat('0', 64) THEN NEW.draft_digest := NEW.content_digest; END IF; RETURN NEW; END; $$; CREATE TRIGGER playbook_versions_normalize_draft_digest BEFORE INSERT ON playbook_versions FOR EACH ROW EXECUTE FUNCTION normalize_playbook_version_draft_digest(); CREATE TABLE playbook_package_files ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), playbook_version_id uuid NOT NULL REFERENCES playbook_versions(id) ON DELETE CASCADE, path text NOT NULL CHECK (length(path) BETWEEN 1 AND 512 AND path = btrim(path) AND path !~ '(^/|\\|//|(^|/)\.\.?(/|$))'), role text NOT NULL CHECK (role IN ('manifest','template','partial','documentation','changelog','example','evaluation','resource','run-pack-resource')), content bytea NOT NULL, size_bytes bigint NOT NULL CHECK (size_bytes BETWEEN 0 AND 5242880 AND octet_length(content) = size_bytes), sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$' AND encode(digest(content, 'sha256'), 'hex') = sha256), created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(playbook_version_id, path) ); CREATE INDEX playbook_package_files_version_idx ON playbook_package_files(playbook_version_id); CREATE FUNCTION reject_published_playbook_file_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE target_version_id uuid; target_published_at timestamptz; BEGIN target_version_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.playbook_version_id ELSE NEW.playbook_version_id END; SELECT published_at INTO target_published_at FROM playbook_versions WHERE id = target_version_id FOR SHARE; IF target_published_at IS NOT NULL THEN RAISE EXCEPTION 'published playbook package files are immutable' USING ERRCODE = '55000'; END IF; IF TG_OP = 'UPDATE' AND OLD.playbook_version_id <> NEW.playbook_version_id THEN SELECT published_at INTO target_published_at FROM playbook_versions WHERE id = OLD.playbook_version_id FOR SHARE; IF target_published_at IS NOT NULL THEN RAISE EXCEPTION 'published playbook package files are immutable' USING ERRCODE = '55000'; END IF; END IF; RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; END; $$; CREATE TRIGGER playbook_package_files_immutable_when_published BEFORE INSERT OR UPDATE OR DELETE ON playbook_package_files FOR EACH ROW EXECUTE FUNCTION reject_published_playbook_file_mutation(); CREATE TABLE favorites ( workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, playbook_id uuid NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(workspace_id, user_id, playbook_id) ); CREATE TABLE collections ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, name text NOT NULL, description text NOT NULL DEFAULT '', created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT collections_owner_name_uq UNIQUE(workspace_id, created_by, name), CONSTRAINT collections_name_check CHECK ( char_length(name) BETWEEN 1 AND 80 AND name = btrim(name) AND name !~ '[[:cntrl:]]' ), CONSTRAINT collections_description_check CHECK ( char_length(description) <= 500 ) ); CREATE TABLE collection_items ( collection_id uuid NOT NULL REFERENCES collections(id) ON DELETE CASCADE, playbook_id uuid NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE, position integer NOT NULL DEFAULT 0, added_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(collection_id, playbook_id), CONSTRAINT collection_items_position_check CHECK (position >= 0) ); CREATE TABLE integrations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, type text NOT NULL CHECK (type IN ('gitea')), display_name text NOT NULL, base_url text NOT NULL, allow_private_http boolean NOT NULL DEFAULT false, request_timeout_ms integer NOT NULL DEFAULT 15000 CHECK (request_timeout_ms BETWEEN 1000 AND 60000), status text NOT NULL DEFAULT 'configured' CHECK (status IN ('configured','healthy','degraded','disabled')), capabilities_json jsonb NOT NULL DEFAULT '{}'::jsonb, server_version text, remote_identity_id text, remote_identity_login text, health_code text CHECK (health_code IS NULL OR health_code IN ('AUTH_INVALID','PERMISSION_MISSING','CAPABILITY_UNSUPPORTED','RATE_LIMITED','NETWORK_BLOCKED','TLS_ERROR','REMOTE_UNAVAILABLE','CONTENT_TOO_LARGE')), last_checked_at timestamptz, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(workspace_id, type, base_url), CHECK ((remote_identity_id IS NULL) = (remote_identity_login IS NULL)) ); CREATE INDEX integrations_workspace_status_idx ON integrations(workspace_id, status, updated_at DESC); CREATE TABLE integration_secrets ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), integration_id uuid NOT NULL REFERENCES integrations(id) ON DELETE CASCADE, secret_kind text NOT NULL CHECK (secret_kind = 'access_token'), envelope_version integer NOT NULL CHECK (envelope_version = 1), key_version text NOT NULL CHECK (length(btrim(key_version)) BETWEEN 1 AND 64), nonce bytea NOT NULL CHECK (octet_length(nonce) = 12), ciphertext bytea NOT NULL, auth_tag bytea NOT NULL CHECK (octet_length(auth_tag) = 16), last_four text CHECK (last_four IS NULL OR length(last_four) = 4), created_at timestamptz NOT NULL DEFAULT now(), rotated_at timestamptz, UNIQUE(integration_id, secret_kind) ); CREATE TABLE repositories ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, display_name text NOT NULL, source_type text NOT NULL CHECK (source_type IN ('manual','gitea')), external_owner text, external_name text, external_id text, integration_id uuid REFERENCES integrations(id) ON DELETE SET NULL, default_branch text, archived boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX repositories_external_uq ON repositories(workspace_id, integration_id, external_id) WHERE external_id IS NOT NULL; CREATE TABLE repository_snapshots ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, integration_id uuid REFERENCES integrations(id) ON DELETE SET NULL, state text NOT NULL CHECK (state IN ('collecting','complete','failed','cancelled')), captured_at timestamptz, capability_snapshot_json jsonb NOT NULL DEFAULT '{}'::jsonb, evidence_json jsonb NOT NULL DEFAULT '{}'::jsonb, evidence_digest text, sync_job_id uuid, created_at timestamptz NOT NULL DEFAULT now(), CHECK (state <> 'complete' OR (captured_at IS NOT NULL AND evidence_digest ~ '^[0-9a-f]{64}$')) ); CREATE INDEX repository_snapshots_repo_time_idx ON repository_snapshots(repository_id, captured_at DESC); CREATE UNIQUE INDEX repository_snapshots_sync_job_uq ON repository_snapshots(sync_job_id) WHERE sync_job_id IS NOT NULL; CREATE INDEX repository_snapshots_integration_state_idx ON repository_snapshots(integration_id, state, created_at DESC); CREATE TABLE repository_profile_revisions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, revision_number integer NOT NULL, profile_json jsonb NOT NULL, source_snapshot_id uuid REFERENCES repository_snapshots(id) ON DELETE SET NULL, content_digest text NOT NULL, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(repository_id, revision_number), UNIQUE(repository_id, content_digest) ); CREATE TABLE repository_findings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), snapshot_id uuid NOT NULL REFERENCES repository_snapshots(id) ON DELETE CASCADE, rule_id text NOT NULL, severity text NOT NULL CHECK (severity IN ('info','low','medium','high','critical')), title text NOT NULL, rationale text NOT NULL, evidence_pointer text NOT NULL, recommended_playbook_slug text, status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','dismissed','resolved')), resolution_note text, updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(snapshot_id, rule_id, evidence_pointer) ); CREATE TABLE composition_drafts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, playbook_version_id uuid NOT NULL REFERENCES playbook_versions(id), repository_profile_revision_id uuid REFERENCES repository_profile_revisions(id), input_json jsonb NOT NULL DEFAULT '{}'::jsonb, scope_override_json jsonb NOT NULL DEFAULT '{}'::jsonb, policy_override_json jsonb NOT NULL DEFAULT '{}'::jsonb, autonomy_level text NOT NULL CHECK (autonomy_level IN ('observe','diagnose','plan','implement','verify','repair')), work_mode text NOT NULL CHECK (work_mode IN ('inspect','plan','guided','execute','recovery')), output_format text NOT NULL DEFAULT 'prompt' CHECK (output_format IN ('prompt','markdown','run-pack')), last_render_digest text CHECK (last_render_digest IS NULL OR last_render_digest ~ '^[0-9a-f]{64}$'), revision integer NOT NULL DEFAULT 1 CHECK (revision > 0), created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX composition_drafts_workspace_updated_idx ON composition_drafts(workspace_id, updated_at DESC); CREATE TABLE generated_runs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, source_draft_id uuid REFERENCES composition_drafts(id) ON DELETE SET NULL, playbook_version_id uuid NOT NULL REFERENCES playbook_versions(id), playbook_snapshot_json jsonb NOT NULL, repository_profile_snapshot_json jsonb, normalized_input_json jsonb NOT NULL, policy_snapshot_json jsonb NOT NULL, provenance_json jsonb NOT NULL, lint_result_json jsonb NOT NULL, rendered_prompt text NOT NULL, render_digest text NOT NULL CHECK (render_digest ~ '^[0-9a-f]{64}$'), idempotency_key text NOT NULL CHECK (length(idempotency_key) BETWEEN 1 AND 255 AND btrim(idempotency_key) = idempotency_key), generated_by uuid NOT NULL REFERENCES users(id), generated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(workspace_id, idempotency_key) ); CREATE INDEX generated_runs_workspace_time_idx ON generated_runs(workspace_id, generated_at DESC); CREATE UNIQUE INDEX generated_runs_digest_actor_uq ON generated_runs(workspace_id, generated_by, render_digest, generated_at); CREATE TABLE generated_artifacts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid NOT NULL REFERENCES generated_runs(id) ON DELETE CASCADE, artifact_type text NOT NULL CHECK (artifact_type IN ('prompt_text','markdown','run_pack_zip','agents_suggestion','support_bundle')), storage_key text NOT NULL UNIQUE, filename text NOT NULL, media_type text NOT NULL, size_bytes bigint NOT NULL CHECK (size_bytes >= 0), sha256 text NOT NULL, expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE run_feedback ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid NOT NULL REFERENCES generated_runs(id) ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating text CHECK (rating IN ('helpful','mixed','unhelpful')), notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(run_id, user_id) ); CREATE TABLE evaluation_cases ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), playbook_version_id uuid NOT NULL REFERENCES playbook_versions(id) ON DELETE CASCADE, logical_case_id text NOT NULL, case_version text, fixture_version text NOT NULL, target_digest text CHECK (target_digest IS NULL OR target_digest ~ '^[0-9a-f]{64}$'), fixture_id text, fixture_digest text CHECK (fixture_digest IS NULL OR fixture_digest ~ '^[0-9a-f]{64}$'), environment_digest text CHECK (environment_digest IS NULL OR environment_digest ~ '^[0-9a-f]{64}$'), case_json jsonb NOT NULL, case_digest text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(playbook_version_id, logical_case_id, fixture_version) ); CREATE TABLE evaluation_results ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), evaluation_case_id uuid NOT NULL REFERENCES evaluation_cases(id) ON DELETE CASCADE, environment_json jsonb NOT NULL, target_digest text CHECK (target_digest IS NULL OR target_digest ~ '^[0-9a-f]{64}$'), fixture_digest text CHECK (fixture_digest IS NULL OR fixture_digest ~ '^[0-9a-f]{64}$'), environment_digest text CHECK (environment_digest IS NULL OR environment_digest ~ '^[0-9a-f]{64}$'), result_json jsonb, status text NOT NULL CHECK (status IN ('passed','failed','error','skipped','stale')), dimension_scores_json jsonb NOT NULL DEFAULT '{}'::jsonb, evidence_artifact_id uuid REFERENCES generated_artifacts(id) ON DELETE SET NULL, executed_by uuid REFERENCES users(id), executed_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE playbook_review_attestations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), playbook_version_id uuid NOT NULL REFERENCES playbook_versions(id) ON DELETE CASCADE, reviewed_by uuid NOT NULL REFERENCES users(id), attested_digest text NOT NULL CHECK (attested_digest ~ '^[0-9a-f]{64}$'), schema_and_semantic_validation_passed boolean NOT NULL, blocking_lint_finding_count integer NOT NULL CHECK (blocking_lint_finding_count >= 0), limitations_documented boolean NOT NULL, unresolved_safety_regression boolean NOT NULL, review_json jsonb NOT NULL DEFAULT '{}'::jsonb, reviewed_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX playbook_review_attestations_version_time_idx ON playbook_review_attestations(playbook_version_id, reviewed_at DESC); CREATE TABLE jobs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE, type text NOT NULL, state text NOT NULL CHECK (state IN ('queued','running','succeeded','failed','cancelled')), idempotency_key text, payload_json jsonb NOT NULL DEFAULT '{}'::jsonb, progress_json jsonb NOT NULL DEFAULT '{}'::jsonb, attempt_count integer NOT NULL DEFAULT 0, max_attempts integer NOT NULL DEFAULT 3, lease_owner text, lease_expires_at timestamptz, available_at timestamptz NOT NULL DEFAULT now(), started_at timestamptz, finished_at timestamptz, error_code text, error_detail_redacted text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(workspace_id, type, idempotency_key) ); CREATE INDEX jobs_claim_idx ON jobs(state, available_at, created_at) WHERE state = 'queued'; CREATE INDEX jobs_lease_idx ON jobs(state, lease_expires_at) WHERE state = 'running'; ALTER TABLE repository_snapshots ADD CONSTRAINT repository_snapshots_job_fk FOREIGN KEY (sync_job_id) REFERENCES jobs(id) ON DELETE SET NULL; CREATE TABLE audit_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), occurred_at timestamptz NOT NULL DEFAULT now(), actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, workspace_id uuid REFERENCES workspaces(id) ON DELETE SET NULL, action text NOT NULL, resource_type text NOT NULL, resource_id text, request_id text, outcome text NOT NULL CHECK (outcome IN ('success','denied','failed')), metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX audit_events_workspace_time_idx ON audit_events(workspace_id, occurred_at DESC); CREATE INDEX audit_events_action_time_idx ON audit_events(action, occurred_at DESC);