Files
DevRunbook-Public/database/reference-schema.sql
T
DevRunbook release export cfd2804e27
Managed validation / full (push) Successful in 3m18s
Publish DevRunbook source
2026-09-03 04:09:17 +02:00

456 lines
21 KiB
PL/PgSQL

-- 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);