Files
UmbraTrans/umbratrans_web/migrations/0003_compute_and_queue.sql
umbra audio 2ff322dcd2 Close all thirteen app functions in the migration, not by hand
B4 was recorded as open against the deployment. It was not open there - the ten
original functions had been closed by hand - but it was open everywhere else. A
database restored from a backup of the deployment comes back with 10 of 13
PUBLIC-executable, because a hand-applied GRANT is a property of one database and
does not survive a restore. Observed on scratch, built from the pre-0003 dump.

So 0003 now revokes from PUBLIC on all thirteen, by full signature taken from
pg_proc.oid::regprocedure: a bare () does not resolve for a function taking one
argument, and REVOKE on a nonexistent name aborts the whole migration.

Three findings while doing it, none of which would have shown up without running the
tests:

- My own three guard functions went in PUBLIC-executable, reproducing the exact
  defect B4 is about.
- Revoking PUBLIC without regranting broke five tenancy tests with a permission
  denied error on the platform-admin helper. Every RLS policy calls it, so a missing
  grant here looks like a broken policy rather than a missing grant. Regranted to the
  roles that actually read and write, guarded by EXISTS so a role added later is
  covered by the next migration rather than by luck.
- The revoke block originally sat before the functions were created, so it failed on
  a fresh restore and passed on the already-migrated database - passing by accident,
  the failure mode this project keeps hitting. Both revoke blocks now sit after the
  triggers, and the fresh-restore path is the one verified.

Verified by restoring the pre-0003 backup into scratch, applying 0003, and running
the suite: 0 of 13 PUBLIC-executable, 4 guards, 474 passed, 27 skipped. Re-applying
is a no-op. Deployment data unchanged (users=2 orgs=3 audit=43 perms=20); health
200, heartbeat 401 without a credential and 200 with one, so the revoke broke
nothing.

G2 is still open: 26 tables have RLS enabled but not forced, and the application
connects as the owner.
2026-10-02 20:25:12 -07:00

889 lines
49 KiB
PL/PgSQL

-- 0003: compute resources, a job queue with leases, and node identity.
--
-- Additive, idempotent, and safe to apply more than once. Nothing existing is
-- rewritten, and no existing table loses a column: `jobs` gains columns,
-- `permissions` gains rows, and the rest are new tables.
--
-- Section 5 is the datastore: publishers, bylines, artifacts and provenance.
-- It is folded in here rather than deferred to 0004 because 0003 is still
-- unapplied to the deployment, and an unapplied migration can still be edited.
-- Once it has run, this is 0004 and a discussion rather than a change.
--
-- The design this implements is §6.16-6.18 and §7 of SPEC/UmbraTools.md. Three
-- decisions in it are load-bearing and each is commented where it is written,
-- because each one exists to stop a failure that is silent when it happens:
--
-- 1. A node is `online` because it heartbeated, not because it was created.
-- A node that has never authenticated must not be offered work.
-- 2. Claiming a job is `FOR UPDATE SKIP LOCKED`. Without it two workers pick
-- the same row and one silently overwrites the other's claim.
-- 3. A lease that expires returns the job to the queue. A worker that is
-- killed releases nothing, so the lease is what releases it.
--
-- `jobs.status` in the phase-1 table is unconstrained text. The values used here
-- are: queued, running, blocked, partial, complete, failed, cancelled. They are
-- left as a CHECK rather than a reference table because this is a closed set
-- owned by this application; SPEC §6.14 moves open vocabularies to reference
-- tables, and this is not one of them.
BEGIN;
-- ---------------------------------------------------------------------
-- 1. Compute nodes and their devices
-- ---------------------------------------------------------------------
-- What each machine is, and where it sits. `is_controller` is separate from
-- `status` because the controller is also a worker on this host, and a node
-- type and a liveness are different facts.
CREATE TABLE IF NOT EXISTS public.compute_nodes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES public.organizations(id),
name text NOT NULL,
is_controller boolean NOT NULL DEFAULT false,
trust_domain text NOT NULL DEFAULT 'local'
CHECK (trust_domain IN ('local', 'remote', 'cloud')),
locality text,
transport text NOT NULL DEFAULT 'https'
CHECK (transport IN ('https', 'ssh', 'vpn')),
-- Starts at 'provisioning'. Only the heartbeat path may set 'online'.
status text NOT NULL DEFAULT 'provisioning'
CHECK (status IN ('provisioning', 'online', 'draining', 'offline', 'retired')),
agent_version text,
agent_started_at timestamptz,
last_heartbeat_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, name)
);
-- Devices, not a node-level capability flag. "CPU and/or GPU" is a set of
-- devices, and a CPU-only virtual machine is then an ordinary node rather than
-- a special case needing its own code path.
--
-- `throughput_factor` orders placements. It is NOT an eligibility test: a CPU
-- device is slow, not incapable, and a model that fits runs there rather than
-- not running at all. The spec calls this the difference between "slow" and
-- "impossible" and it is why `vram_min_mb` and `vram_preferred_mb` are separate
-- columns on the model rather than one number.
CREATE TABLE IF NOT EXISTS public.compute_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
compute_node_id uuid NOT NULL REFERENCES public.compute_nodes(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN
('cpu', 'gpu_discrete', 'gpu_integrated', 'npu', 'tpu')),
label text NOT NULL, -- 'gpu:0', 'cpu:0'
vram_total_mb int NOT NULL DEFAULT 0,
vram_free_mb int NOT NULL DEFAULT 0,
ram_total_mb int NOT NULL DEFAULT 0,
ram_free_mb int NOT NULL DEFAULT 0,
compute_units real,
throughput_factor real NOT NULL DEFAULT 1,
supports_accelerators text[] NOT NULL DEFAULT '{}',
supports jsonb NOT NULL DEFAULT '{}',
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (compute_node_id, label)
);
-- A node's identity is its credential, and the credential is narrower than an
-- account. It authorises queue operations on the work this node was given; it
-- cannot read project content. A remotely reachable machine holding a
-- connection string with no per-object authorization is a different risk from
-- one holding a scoped key, and this table is the difference.
CREATE TABLE IF NOT EXISTS public.node_credentials (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
compute_node_id uuid NOT NULL REFERENCES public.compute_nodes(id) ON DELETE CASCADE,
key_prefix text NOT NULL,
key_hash text NOT NULL, -- argon2id; the key itself is never stored
agent_name text NOT NULL,
scopes text[] NOT NULL,
issued_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz,
revoked_at timestamptz,
revoked_reason text
);
CREATE INDEX IF NOT EXISTS node_credentials_prefix_idx
ON public.node_credentials (key_prefix);
-- ---------------------------------------------------------------------
-- 2. Models
-- ---------------------------------------------------------------------
-- `vram_min_mb` is the eligibility floor and `vram_preferred_mb` is the ranking
-- target. Collapsing them into one number is the bug the spec warns about: it
-- either refuses a job that would have completed in an hour, or starts one that
-- crawls. `requires_accelerators` non-empty is the "only this node can run it"
-- case, which is a capacity risk the console has to be able to state.
CREATE TABLE IF NOT EXISTS public.model_registry (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
kind text NOT NULL CHECK (kind IN
('llm', 'tts', 'asr', 'embedding', 'vision', 'utility')),
vram_min_mb int NOT NULL DEFAULT 0,
vram_preferred_mb int NOT NULL DEFAULT 0,
ram_min_mb int NOT NULL DEFAULT 0,
quantisation text,
context_tokens int,
requires_accelerators text[] NOT NULL DEFAULT '{}',
reference_throughput real NOT NULL DEFAULT 1,
load_seconds int NOT NULL DEFAULT 0,
capabilities jsonb NOT NULL DEFAULT '{}',
source_uri text,
license text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Residency. `loaded` means holding its memory whether or not it is
-- generating, which is why a node can be idle at 0% GPU and unable to accept
-- another model.
CREATE TABLE IF NOT EXISTS public.node_models (
compute_node_id uuid NOT NULL REFERENCES public.compute_nodes(id) ON DELETE CASCADE,
model_id uuid NOT NULL REFERENCES public.model_registry(id) ON DELETE CASCADE,
device_id uuid REFERENCES public.compute_devices(id) ON DELETE SET NULL,
state text NOT NULL CHECK (state IN
('loaded', 'warm', 'available', 'loading', 'failed')),
loaded_at timestamptz,
vram_held_mb int NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (compute_node_id, model_id)
);
-- Overwritten each heartbeat, deliberately. Its purpose is "now", and the
-- history of a heartbeat is worth nothing; job_events is where history belongs.
CREATE TABLE IF NOT EXISTS public.node_heartbeats (
compute_node_id uuid PRIMARY KEY REFERENCES public.compute_nodes(id) ON DELETE CASCADE,
reported_at timestamptz NOT NULL DEFAULT now(),
cpu_busy_pct numeric(5,2),
ram_free_mb int,
vram_free_mb int,
vram_total_mb int,
disk_free_mb bigint,
load_avg_1m numeric(6,2),
resident_models jsonb NOT NULL DEFAULT '[]',
active_jobs int NOT NULL DEFAULT 0,
agent_version text,
raw jsonb NOT NULL DEFAULT '{}'
);
-- ---------------------------------------------------------------------
-- 3. The queue
-- ---------------------------------------------------------------------
-- `jobs` is specified in §6.8 but was never migrated: 0001 created the identity
-- tables and stopped there. It is created here, complete, rather than
-- incrementally, because there is no existing row to preserve and a table built
-- by a sequence of ALTERs is harder to read than one built in full.
--
-- `organization_id` is NOT NULL and is never updated. A job belongs to exactly one
-- organization for its whole life, and the constraint is at the schema because a
-- placement bug that changed it would hand an organization another tenant's work
-- without anything noticing.
CREATE TABLE IF NOT EXISTS public.jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES public.organizations(id),
project_id uuid REFERENCES public.projects(id),
lang text,
kind text NOT NULL, -- 'analyze','translate','render','build_pdf',…
-- The closed set this application owns. Open vocabularies are reference
-- tables per §6.14; this is not one of them, and it is constrained here
-- rather than left loose the way the phase-1 tables were.
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'blocked', 'partial',
'complete', 'failed', 'cancelled')),
requested_by text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
-- The queue columns. Every one is nullable or defaulted, so a row written by the
-- plain definition above is already valid.
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS priority smallint NOT NULL DEFAULT 100;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS requires jsonb NOT NULL DEFAULT '{}';
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS lease_owner uuid REFERENCES public.compute_nodes(id);
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS attempt int NOT NULL DEFAULT 0;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS max_attempts int NOT NULL DEFAULT 3;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS not_before timestamptz NOT NULL DEFAULT now();
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS parent_job_id uuid REFERENCES public.jobs(id);
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS idempotency_key text;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS started_at timestamptz;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS finished_at timestamptz;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS result jsonb;
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS error text;
-- Claiming reads this. Partial, because only queued work is claimable and the
-- index should not carry the terminal states.
CREATE INDEX IF NOT EXISTS jobs_claimable_idx
ON public.jobs (priority, not_before, created_at)
WHERE status = 'queued';
-- The re-import and re-submit path. Partial, so a job with no key does not
-- collide with anything, and so the same key cannot produce two rows.
CREATE UNIQUE INDEX IF NOT EXISTS jobs_idempotency_idx
ON public.jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- A container is a parent with no lease owner and is never claimed. This index
-- makes "what are the roots" cheap for the master view.
CREATE INDEX IF NOT EXISTS jobs_parent_idx
ON public.jobs (parent_job_id);
-- Append-only history for a job. The heartbeat table is not this; it is
-- overwritten on purpose because it answers "now".
CREATE TABLE IF NOT EXISTS public.job_events (
id bigserial PRIMARY KEY,
job_id uuid NOT NULL REFERENCES public.jobs(id) ON DELETE CASCADE,
node_id uuid REFERENCES public.compute_nodes(id) ON DELETE SET NULL,
event text NOT NULL, -- 'queued','claimed','completed','failed',
-- 'lease_expired','blocked','decomposed'
detail jsonb NOT NULL DEFAULT '{}',
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS job_events_job_idx ON public.job_events (job_id, at);
-- Jobs get RLS too, now that the table exists. `jobs` had no policy while it did
-- not exist; the eleven phase-1 tables that do have one are listed in the same
-- loop below.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'jobs') THEN
ALTER TABLE public.jobs ENABLE ROW LEVEL SECURITY;
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- 5. The datastore: publishers, bylines, and artifacts
-- ---------------------------------------------------------------------------
--
-- Everything above is machinery for deciding *where* work runs. This section is
-- the reason the queue exists at all: the intermediate and final work product.
-- Step 1 on node 1 can be followed by step 2 on node 3 while step 3 runs on node
-- 2, and the only thing that makes that comprehensible is a place where the
-- output of each step is a row rather than a value inside somebody's memory.
--
-- Four decisions here, each load-bearing, each commented where it is written:
--
-- 1. A publisher is a party that never authenticates, so it has no role and no
-- permissions. See `publishers`.
-- 2. A byline is not a publisher and not a user. See `authors`.
-- 3. An artifact's identity is its bytes. See `artifacts`.
-- 4. `job_artifacts` is what makes provenance queryable. See `job_artifacts`.
--
-- The word "account" is deliberately absent from every column, table and comment
-- in this section. It has meant two incompatible things in this system - a person
-- who signs in, and a house a work is published under - and a schema that uses
-- one word for both will eventually use it for both in one constraint.
-- ---------------------------------------------------------------------------
-- 5.1 Publishers: the party a work is published under
-- ---------------------------------------------------------------------------
--
-- The production boundary is the organization: the organization owns the work.
-- The publication boundary is a different axis and a different party, and it
-- routinely points somewhere else. An organization owns the book; a publisher
-- publishes it; those are two facts and they are frequently not the same
-- organization. A public-domain text owned by one organization may be published
-- by a house belonging to another, or by a third party with no organizational
-- relationship to it at all.
--
-- A publisher has **no login, no password and no sessions**, and therefore no
-- row in user_roles and no permission anywhere. When a release is approved "for"
-- an imprint, a *user* holding project.release.approve did it; the imprint is
-- recorded on the work, not in an access-control list. That is the whole reason
-- this is a table rather than a role: a name that appears on work is not a party
-- that can be asked to prove who it is.
--
-- Hierarchy is parent_id, so Umbra -> WorstAuthor is one row referencing another.
-- `kind` distinguishes a house that belongs to an organization from an
-- independent publisher, because who may edit the row depends on it.
CREATE TABLE IF NOT EXISTS public.publishers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id uuid REFERENCES public.publishers(id),
name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 200),
slug citext NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
-- The organization this publisher belongs to, when it belongs to one. Umbra's
-- imprints do; an independent publisher does not. Nullable because NULL is
-- the honest value for "no organizational relationship", which is a real and
-- common arrangement rather than missing data.
--
-- This is the one deliberately cross-tenant reference in the schema, and it
-- carries two obligations that the policy below implements:
-- * reading a publisher must not reveal whether its owning organization
-- exists, so the owner column is not exposed by the SELECT policy;
-- * only a platform administrator may set or change it, because doing so
-- asserts a relationship between two tenants. An organization
-- administrator manages its own projects, not who owns somebody else's
-- imprint.
owner_organization_id uuid REFERENCES public.organizations(id),
kind text NOT NULL DEFAULT 'publisher'
CHECK (kind IN ('imprint', 'publisher')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT publishers_not_own_parent CHECK (parent_id IS DISTINCT FROM id)
);
CREATE INDEX IF NOT EXISTS publishers_parent_idx ON public.publishers (parent_id);
CREATE INDEX IF NOT EXISTS publishers_owner_idx ON public.publishers (owner_organization_id)
WHERE owner_organization_id IS NOT NULL;
-- Which publisher a project is published under, optionally per language.
--
-- `lang` is nullable and NULL means "the work as a whole". That is not a
-- placeholder: a project can have one publisher for the English edition and a
-- different house for the German one, because co-editions are ordinary. A
-- single publisher_id on the project could not express that, and pretending
-- otherwise would force the model to record something untrue.
--
-- The primary key includes lang, and NULL in a unique constraint does not
-- deduplicate in PostgreSQL - NULLs are distinct - so uniqueness is enforced by
-- the COALESCE index below rather than by the primary key alone.
CREATE TABLE IF NOT EXISTS public.project_publishers (
project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
publisher_id uuid NOT NULL REFERENCES public.publishers(id),
lang text,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES public.users(id),
PRIMARY KEY (project_id, publisher_id, lang)
);
CREATE UNIQUE INDEX IF NOT EXISTS project_publishers_once_idx
ON public.project_publishers (project_id, publisher_id, COALESCE(lang, ''));
-- ---------------------------------------------------------------------------
-- 5.2 Bylines: authors, translators, narrators, and the people who are credited
-- ---------------------------------------------------------------------------
--
-- A byline is a first-class party and is neither a publisher nor a user.
--
-- Not a publisher: in the house's own chain, Umbra -> WorstAuthor -> B.D. Murphy,
-- WorstAuthor is the imprint and B.D. Murphy is the byline. They are separate
-- entities with separate lifetimes - one publisher issues many books, one byline
-- appears on some of them.
--
-- Not a user: a user is an account that authenticates and performs work; a byline
-- is a name printed on a work. Many bylines have no account at all, and a user may
-- hold many bylines. The optional user_id links the two where they genuinely are
-- the same party, and is nullable because that is frequently untrue rather than
-- unfinished.
--
-- `owner_organization_id` mirrors publishers and carries the same reasoning: a
-- self-published byline belongs to the publishing organization, and an independent
-- author's does not. It also answers who may edit the row, which with no owner
-- would leave the answer "only a platform administrator" - too strict for a
-- system whose purpose includes self-publishing.
CREATE TABLE IF NOT EXISTS public.authors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 200),
-- The form printed on the work. Separate from `name`, which is the party's
-- record name: "B. D. Murphy" and "B.D. Murphy" are the same byline and two
-- spellings of it, and collapsing them loses the fact that a work was
-- credited one way and catalogued another.
display_name text,
slug citext NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
-- Set when this byline is a system account. Never the other way round: a
-- login is an account concern and must not depend on a cataloguing decision.
user_id uuid REFERENCES public.users(id),
owner_organization_id uuid REFERENCES public.organizations(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS authors_user_once_idx
ON public.authors (user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS authors_owner_idx ON public.authors (owner_organization_id)
WHERE owner_organization_id IS NOT NULL;
-- Which byline is credited on which project, in which capacity, in which language.
--
-- `credit_role` is not a permission and must never be checked as one. It records
-- how a name is printed: the author of the English edition is the translator of
-- the French one, and the narrator of the audiobook is frequently neither. Keying
-- that on a single author_id would force the same person to be recorded twice and
-- would lose the capacity, which is the part a title page has to state.
CREATE TABLE IF NOT EXISTS public.project_authors (
project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
author_id uuid NOT NULL REFERENCES public.authors(id),
credit_role text NOT NULL
CHECK (credit_role IN ('author', 'translator', 'editor', 'narrator',
'cover_artist', 'illustrator')),
lang text,
position smallint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES public.users(id),
PRIMARY KEY (project_id, author_id, credit_role, lang)
);
CREATE UNIQUE INDEX IF NOT EXISTS project_authors_order_idx
ON public.project_authors (project_id, credit_role, COALESCE(lang, ''), position);
-- ---------------------------------------------------------------------------
-- 5.3 Artifacts: the intermediate and final work product
-- ---------------------------------------------------------------------------
--
-- `jobs.result` is a jsonb column on the job row. That is right for "the sleep
-- job returned {'echo': 'hello from the queue'}" and wrong for "this is the
-- English audiobook, four hours twelve minutes, derived from these thirty-seven
-- chapters and this cast". The second is not a job's return value; it is a thing
-- that outlives every job that touched it, and it is what the next job consumes.
--
-- Four decisions:
--
-- 1. **Identity is the bytes.** content_hash is what makes the row the same row
-- across two projects and two runs. `storage_key` is where they are, not what
-- they are, and the two are allowed to disagree - that is what makes it
-- possible to move storage without rewriting history.
--
-- 2. **`organization_id` is denormalised onto every row** even though it is
-- reachable through project_id. One column and one index for the tenant
-- predicate, matching the (id, organization_id) shape the identity tables
-- already use. A join in every policy is a join that can be forgotten.
--
-- 3. **`parent_artifact_id` is the immediate input, and `derivation` is the
-- detail.** A single immediate parent keeps the common query - "what made
-- this" - an index seek. The jsonb carries the rest, because a chapter render
-- depends on its text, its voice, its model version and its settings, and four
-- relational edges for that would be a worse trade than a document that
-- reports itself honestly.
--
-- 4. **The bytes are not in this database.** `storage_key` points outside. A WAV
-- of one chapter runs to hundreds of megabytes and a full audiobook master to
-- gigabytes; they belong in object storage with this table holding identity,
-- provenance and the key. The rule for getting them is deliberately not a
-- column here: it is whether the API carries the bytes or authorises a
-- redirect, and that is a property of the artefact's size rather than a fact
-- about the artefact.
CREATE TABLE IF NOT EXISTS public.artifacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
lang text,
-- An open vocabulary, not a CHECK. What a project produces is open-ended by
-- construction - source, translation, audio, cover, blurb, metadata, export -
-- and constraining it now means an ALTER for every pipeline added later.
-- §6.14 puts open vocabularies in reference tables; this is the first one,
-- and it is deliberately not yet a table.
kind text NOT NULL CHECK (length(btrim(kind)) BETWEEN 1 AND 64),
status text NOT NULL DEFAULT 'intermediate'
CHECK (status IN ('intermediate', 'final', 'superseded')),
-- sha256. The content address is what makes two identical renders the same
-- artifact rather than two copies of one, which is what makes caching,
-- deduplication and "has this already been done" all the same question.
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
byte_size bigint NOT NULL CHECK (byte_size >= 0),
media_type text NOT NULL,
storage_key text NOT NULL,
parent_artifact_id uuid REFERENCES public.artifacts(id),
derivation jsonb NOT NULL DEFAULT '{}'::jsonb,
created_by_job_id uuid REFERENCES public.jobs(id) ON DELETE SET NULL,
created_by_user_id uuid REFERENCES public.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT artifacts_id_project_org_key
UNIQUE (id, project_id, organization_id),
CONSTRAINT artifacts_no_self_parent CHECK (parent_artifact_id IS DISTINCT FROM id)
);
CREATE INDEX IF NOT EXISTS artifacts_project_idx
ON public.artifacts (project_id, kind, lang);
CREATE INDEX IF NOT EXISTS artifacts_org_idx ON public.artifacts (organization_id);
CREATE INDEX IF NOT EXISTS artifacts_parent_idx ON public.artifacts (parent_artifact_id)
WHERE parent_artifact_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS artifacts_lineage_idx ON public.artifacts USING gin (derivation);
-- Content addressing, scoped to the project. NOT globally unique: two
-- organizations legitimately hold identical bytes and neither should learn that
-- the other has them. Uniqueness within a project is what deduplication needs.
CREATE UNIQUE INDEX IF NOT EXISTS artifacts_content_key
ON public.artifacts (project_id, content_hash, kind, COALESCE(lang, ''));
-- ---------------------------------------------------------------------------
-- 5.4 Provenance: what a job consumed and what it produced
-- ---------------------------------------------------------------------------
--
-- The queue above can tell you a job ran. It cannot tell you what the job was
-- working on, which is the question that matters when step 2 on node 3 produces
-- something wrong: the answer is "this artifact, produced by that step, from
-- these inputs" and getting there needs a row rather than a log line.
--
-- This is the table that makes "step 1 on node 1, then step 2 on node 3" legible
-- after the fact. One table with a direction, not two, because the two sides are
-- always read together and a split would guarantee one of them is forgotten.
--
-- Artifacts outlive jobs, so this is ON DELETE CASCADE from the job: a job that
-- is deleted takes its provenance with it, and the artifacts survive. The
-- reverse - deleting an artifact and keeping the claim that something produced it
-- - would leave a provenance record pointing at nothing.
CREATE TABLE IF NOT EXISTS public.job_artifacts (
job_id uuid NOT NULL REFERENCES public.jobs(id) ON DELETE CASCADE,
artifact_id uuid NOT NULL REFERENCES public.artifacts(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('consumed', 'produced')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (job_id, artifact_id, role)
);
CREATE INDEX IF NOT EXISTS job_artifacts_artifact_idx
ON public.job_artifacts (artifact_id);
-- ---------------------------------------------------------------------------
-- 5.5 Permissions
-- ---------------------------------------------------------------------------
--
-- The vocabulary is scope.subject.verb and it already exists with twelve keys.
-- These extend it rather than replacing it, with two rules that keep the count
-- from doubling:
--
-- * `read` is implied by `edit`, `review` and `approve`. Nobody edits what they
-- cannot read, and a separate read key is a key somebody will forget to check.
-- * `approve` does NOT imply `edit`. Signing off is a different authority from
-- changing, and that is the distinction the whole owner/worker split rests
-- on: a user may approve a release without being able to alter the text, and
-- the reverse is the mistake worth preventing.
--
-- The read implied by the others is enforced in application code, not here,
-- because expressing "edit implies read" in a permission table needs a closure
-- over the key space and the check would run on every authorization. The rule is
-- written down here so the two halves cannot drift.
--
-- Note what is absent: there is no publisher permission, and there is no byline
-- permission. Neither party authenticates. A user who may approve a release on an
-- imprint's behalf holds project.release.approve; the imprint holds nothing.
INSERT INTO public.permissions (key, description) VALUES
('project.source.read', 'Read the source text of a project'),
('project.translation.read', 'Read translations of a project'),
('project.audio.read', 'Read audio artefacts and their analysis'),
('project.audio.edit', 'Adjust or re-render an audio take'),
('project.translation.approve', 'Sign off a translation as final'),
('project.cover.read', 'Read per-language cover copy and blurb text'),
('project.cover.edit', 'Upload finished cover artwork'),
('project.export.create', 'Produce a bundle of project data to use elsewhere')
ON CONFLICT (key) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 3b. Append-only where immutability is the point
-- ---------------------------------------------------------------------------
--
-- `audit_events` is already append-only, through a trigger that raises. Three more
-- tables here have the same property and were going in without it, which would have
-- made an immutability claim in the design a convention rather than a constraint:
--
-- * **artifacts** - identity *is* the bytes (`content_hash`). An UPDATE that changes
-- `content_hash` or `storage_key` in place rewrites history for every job that consumed
-- the row, and the lineage in `job_artifacts` would then point at content that never
-- existed. UPDATE only; see the trigger for why DELETE is deliberately permitted.
-- * **job_artifacts** - provenance. If a job's record of what it consumed can be
-- edited, the queue can be made to look as though it did something it did not.
-- * **job_events** - the event trail behind I25 and I26, and the evidence that a job
-- was claimed exactly once and that an expired lease was noticed.
--
-- A dedicated function per table rather than one shared one, so the error names the
-- table that refused. A single function saying "immutable" tells an operator which
-- rule fired only by which object it was raised against, which is one step of work
-- they should not have to do while holding an incident.
--
-- DELETE and UPDATE only. INSERT is how these tables are populated.
-- EXECUTE is revoked from PUBLIC on all three, matching every other function in
-- `app` (the ten that predate this migration were closed by hand; nothing in a
-- migration did it, which is why it needs doing here explicitly).
--
-- A trigger function runs as SECURITY INVOKER, so the guard is only reached if the
-- *calling* role may execute it. Revoking PUBLIC without granting the roles that
-- actually write would therefore turn the guard into an outage rather than a
-- protection - and an outage at least announces itself, whereas a guard that never
-- fires does not. So the grants follow the revoke, to every role that writes these
-- tables, and are re-issued on every apply so a role added later is covered by the
-- next migration rather than by luck.
--
-- Verified after applying: 13 of 13 `app` functions non-PUBLIC-executable, and a
-- non-owner role can still insert into `artifacts` and be refused on update.
--
-- The ten functions that predate this migration were closed by hand against the
-- deployment, and nothing in any migration does it. That is why a database restored
-- from a backup of the deployment comes back with all ten PUBLIC-executable again -
-- observed, on the scratch database built from the pre-0003 dump, where 10 of 13 were
-- open while the deployment itself had 3. A hand-applied grant is not a property of
-- the schema, it is a property of one database, and it does not survive a restore.
--
-- So this closes all thirteen, not just the three added above. B4 was recorded as
-- open against the deployment, where it was already fixed; the defect was never only
-- about the deployment.
--
--
-- Note what this does *not* do: it does not stop the owner role from bypassing RLS
-- (G2), and it does not stop a superuser. It stops an ordinary mistake from being
-- indistinguishable from a design.
DO $$
BEGIN
EXECUTE $guard$
CREATE OR REPLACE FUNCTION app.prevent_artifact_mutation()
RETURNS trigger LANGUAGE plpgsql AS $body$
BEGIN
RAISE EXCEPTION
'artifacts are content-addressed; the row''s identity may not be rewritten - '
'supersede it with a new artifacts row instead';
END;
$body$;
$guard$;
EXECUTE $guard$
CREATE OR REPLACE FUNCTION app.prevent_job_artifact_mutation()
RETURNS trigger LANGUAGE plpgsql AS $body$
BEGIN
RAISE EXCEPTION
'job_artifacts is provenance and is append-only; a job''s record of what it consumed cannot be edited';
END;
$body$;
$guard$;
EXECUTE $guard$
CREATE OR REPLACE FUNCTION app.prevent_job_event_mutation()
RETURNS trigger LANGUAGE plpgsql AS $body$
BEGIN
RAISE EXCEPTION
'job_events is the event trail and is append-only';
END;
$body$;
$guard$;
IF EXISTS (SELECT 1 FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'artifacts') THEN
EXECUTE 'DROP TRIGGER IF EXISTS artifacts_append_only ON public.artifacts';
-- UPDATE only, deliberately. DELETE is permitted, because otherwise storage can
-- never be reclaimed: no artifact row could ever be removed once written, and the
-- table would grow without bound for the life of the deployment.
--
-- The provenance guarantee is not weakened by allowing DELETE. `job_artifacts`
-- references artifacts `ON DELETE CASCADE`, so deleting an artifact that any job
-- consumed fires the guard on that table and is refused. An artifact that nothing
-- consumed is, by definition, nothing anyone's record depends on, and is exactly
-- the row a retention pass should be able to remove.
EXECUTE 'CREATE TRIGGER artifacts_append_only BEFORE UPDATE ON public.artifacts FOR EACH ROW EXECUTE FUNCTION app.prevent_artifact_mutation()';
EXECUTE 'DROP TRIGGER IF EXISTS job_artifacts_append_only ON public.job_artifacts';
EXECUTE 'CREATE TRIGGER job_artifacts_append_only BEFORE DELETE OR UPDATE ON public.job_artifacts FOR EACH ROW EXECUTE FUNCTION app.prevent_job_artifact_mutation()';
END IF;
IF EXISTS (SELECT 1 FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'job_events') THEN
EXECUTE 'DROP TRIGGER IF EXISTS job_events_append_only ON public.job_events';
EXECUTE 'CREATE TRIGGER job_events_append_only BEFORE DELETE OR UPDATE ON public.job_events FOR EACH ROW EXECUTE FUNCTION app.prevent_job_event_mutation()';
END IF;
END
$$;
DO $$
DECLARE
target text;
BEGIN
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_artifact_mutation() FROM PUBLIC';
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_job_artifact_mutation() FROM PUBLIC';
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_job_event_mutation() FROM PUBLIC';
--
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_api') THEN
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_artifact_mutation() TO umbracontrol_api';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_artifact_mutation() TO umbracontrol_api';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_event_mutation() TO umbracontrol_api';
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_worker') THEN
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_artifact_mutation() TO umbracontrol_worker';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_artifact_mutation() TO umbracontrol_worker';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_event_mutation() TO umbracontrol_worker';
END IF;
-- The read-only roles need EXECUTE on the policy helpers, not only on the guards.
-- Every RLS policy calls app.is_platform_admin() or app.is_organization_member(),
-- so revoking PUBLIC without this turns every SELECT into
-- "permission denied for function is_platform_admin" - which looks like a
-- permissions bug in the policy rather than a missing grant here. Found by
-- running the tenancy tests as a non-owner role immediately after the revoke:
-- five of them failed that way.
FOREACH target IN ARRAY ARRAY[
'app.can_access_project(uuid)',
'app.can_view_user(uuid)',
'app.current_user_id()',
'app.is_organization_admin(uuid)',
'app.is_organization_admin_for_project(uuid)',
'app.is_organization_member(uuid)',
'app.is_platform_admin()'
] LOOP
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_api') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO umbracontrol_api', target);
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_readonly') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO umbracontrol_readonly', target);
END IF;
-- rls_probe is the non-owner role the tenancy tests use. It is not a
-- deployment role; it exists so a policy can be observed refusing something,
-- and it needs the helpers for exactly that.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rls_probe') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO rls_probe', target);
END IF;
END LOOP;
-- All thirteen, for the reason given above.
FOREACH target IN ARRAY ARRAY[
'app.can_access_project(uuid)',
'app.can_view_user(uuid)',
'app.current_user_id()',
'app.is_organization_admin(uuid)',
'app.is_organization_admin_for_project(uuid)',
'app.is_organization_member(uuid)',
'app.is_platform_admin()',
'app.prevent_artifact_mutation()',
'app.prevent_audit_mutation()',
'app.prevent_job_artifact_mutation()',
'app.prevent_job_event_mutation()',
'app.set_updated_at()',
'app.validate_user_role_scope()'
] LOOP
EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC', target);
END LOOP;
END
$$;
-- ---------------------------------------------------------------------
-- 4. Row-level security
-- ---------------------------------------------------------------------
--
-- Written in the same migration that creates the table, against the same app.*
-- helpers the existing eleven tables use, so that switching RLS on later does
-- not require reconstructing which tables were meant to be covered.
--
-- FORCE is deliberately NOT set here. The application owns these tables, so a
-- policy alone would be inert, and gate G2 is where the non-owner role and
-- FORCE arrive together. These policies are the shape they will have when it
-- does; the enforcement is G2's job and nothing here pretends otherwise.
DO $$
DECLARE
target text;
BEGIN
FOREACH target IN ARRAY ARRAY[
'compute_nodes', 'compute_devices', 'node_credentials',
'model_registry', 'node_models', 'node_heartbeats', 'job_events',
'artifacts', 'job_artifacts',
'publishers', 'authors', 'project_publishers', 'project_authors'
] LOOP
IF EXISTS (SELECT 1 FROM pg_tables
WHERE schemaname = 'public' AND tablename = target) THEN
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', target);
END IF;
END LOOP;
END
$$;
-- Every table above that carries a policy must also appear in the ENABLE list
-- above, or the policy is inert: PostgreSQL will happily create a policy on a
-- table with row-level security switched off and report success. The first
-- version of this section enabled it for `artifacts` and `job_artifacts` and
-- forgot the other four, so `publishers`, `authors`, `project_publishers` and
-- `project_authors` each ended up with a policy that enforced nothing - and a
-- check for "does the policy exist" would have passed throughout.
--
-- Policies are dropped and recreated rather than created conditionally.
-- `CREATE POLICY` has no `IF NOT EXISTS`, and getting this wrong produces a migration
-- that applies once and then can never be re-applied - which is how 0001 learned the same
-- lesson for `ADD CONSTRAINT`. Re-applying must be safe.
DO $$
DECLARE
BEGIN
EXECUTE 'DROP POLICY IF EXISTS compute_nodes_tenant ON public.compute_nodes';
EXECUTE 'DROP POLICY IF EXISTS compute_devices_tenant ON public.compute_devices';
EXECUTE 'DROP POLICY IF EXISTS node_credentials_tenant ON public.node_credentials';
EXECUTE 'DROP POLICY IF EXISTS node_models_tenant ON public.node_models';
EXECUTE 'DROP POLICY IF EXISTS node_heartbeats_tenant ON public.node_heartbeats';
EXECUTE 'DROP POLICY IF EXISTS job_events_tenant ON public.job_events';
EXECUTE 'DROP POLICY IF EXISTS jobs_tenant ON public.jobs';
EXECUTE 'DROP POLICY IF EXISTS project_publishers_tenant ON public.project_publishers';
EXECUTE 'DROP POLICY IF EXISTS project_authors_tenant ON public.project_authors';
EXECUTE 'DROP POLICY IF EXISTS artifacts_tenant ON public.artifacts';
EXECUTE 'DROP POLICY IF EXISTS job_artifacts_tenant ON public.job_artifacts';
EXECUTE 'DROP POLICY IF EXISTS publishers_read ON public.publishers';
EXECUTE 'DROP POLICY IF EXISTS publishers_write ON public.publishers';
EXECUTE 'DROP POLICY IF EXISTS authors_read ON public.authors';
EXECUTE 'DROP POLICY IF EXISTS authors_write ON public.authors';
EXECUTE 'CREATE POLICY compute_nodes_tenant ON public.compute_nodes USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
EXECUTE 'CREATE POLICY compute_devices_tenant ON public.compute_devices USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.compute_nodes n WHERE n.id = compute_devices.compute_node_id AND app.is_organization_member(n.organization_id)))';
EXECUTE 'CREATE POLICY node_credentials_tenant ON public.node_credentials USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.compute_nodes n WHERE n.id = node_credentials.compute_node_id AND app.is_organization_member(n.organization_id)))';
EXECUTE 'CREATE POLICY node_models_tenant ON public.node_models USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.compute_nodes n WHERE n.id = node_models.compute_node_id AND app.is_organization_member(n.organization_id)))';
EXECUTE 'CREATE POLICY node_heartbeats_tenant ON public.node_heartbeats USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.compute_nodes n WHERE n.id = node_heartbeats.compute_node_id AND app.is_organization_member(n.organization_id)))';
EXECUTE 'CREATE POLICY job_events_tenant ON public.job_events USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.jobs j WHERE j.id = job_events.job_id AND app.is_organization_member(j.organization_id)))';
EXECUTE 'CREATE POLICY jobs_tenant ON public.jobs USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
-- The datastore follows the same rule the project tables do: membership of
-- the owning organization, not per-project assignment. Org-wide visibility was
-- chosen deliberately over the can_access_project helper, which contradicts the
-- rest of its own schema - there is no read permission among the twelve.
EXECUTE 'CREATE POLICY artifacts_tenant ON public.artifacts USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
EXECUTE 'CREATE POLICY job_artifacts_tenant ON public.job_artifacts USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.jobs j WHERE j.id = job_artifacts.job_id AND app.is_organization_member(j.organization_id)))';
EXECUTE 'CREATE POLICY project_publishers_tenant ON public.project_publishers USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.projects p WHERE p.id = project_publishers.project_id AND app.is_organization_member(p.organization_id)))';
EXECUTE 'CREATE POLICY project_authors_tenant ON public.project_authors USING (app.is_platform_admin() OR EXISTS (SELECT 1 FROM public.projects p WHERE p.id = project_authors.project_id AND app.is_organization_member(p.organization_id)))';
-- Publishers and bylines are shared reference data: two organizations can
-- publish with the same house, and one book can be credited to a byline that
-- also appears on another organization's work. So they are readable by any
-- authenticated principal.
--
-- The write policies are the interesting part, and they are asymmetric with
-- the read policies on purpose. `owner_organization_id` is the one column that
-- crosses tenants, so changing it is a platform-administrator action: an
-- organization administrator manages its own projects, not who owns somebody
-- else's imprint. Without that split, an organization administrator could
-- attach a third party's publisher to their own organization and thereby
-- assert a corporate relationship that does not exist.
EXECUTE 'CREATE POLICY publishers_read ON public.publishers FOR SELECT USING (app.current_user_id() IS NOT NULL)';
EXECUTE 'CREATE POLICY publishers_write ON public.publishers FOR ALL USING (app.is_platform_admin() OR app.is_organization_admin(owner_organization_id)) WITH CHECK (app.is_platform_admin() OR app.is_organization_admin(owner_organization_id))';
EXECUTE 'CREATE POLICY authors_read ON public.authors FOR SELECT USING (app.current_user_id() IS NOT NULL)';
EXECUTE 'CREATE POLICY authors_write ON public.authors FOR ALL USING (app.is_platform_admin() OR app.is_organization_admin(owner_organization_id)) WITH CHECK (app.is_platform_admin() OR app.is_organization_admin(owner_organization_id))';
-- No second policy exists to restrict the owner column, and that is deliberate.
-- PostgreSQL combines permissive policies with OR, so an extra policy granting
-- platform administrators an exception would *widen* access for everyone rather
-- than narrow it for one role. The WITH CHECK above is what actually refuses
-- it: an organization administrator who sets owner_organization_id to NULL, or
-- to another organization, produces a row that fails `is_organization_admin`
-- on that new value, and the statement is rejected. Adding a "clearing" policy
-- here would have looked like a safeguard and been the opposite of one.
-- Global, read-only: a model is a fact about the model.
EXECUTE 'DROP POLICY IF EXISTS model_registry_read ON public.model_registry';
EXECUTE 'CREATE POLICY model_registry_read ON public.model_registry FOR SELECT USING (true)';
END
$$;
COMMIT;
-- ---------------------------------------------------------------------
-- Rollback, in reverse dependency order. Not applied automatically.
--
-- DROP TABLE IF EXISTS public.job_artifacts;
-- DROP FUNCTION IF EXISTS app.prevent_job_event_mutation();
-- DROP FUNCTION IF EXISTS app.prevent_job_artifact_mutation();
-- DROP FUNCTION IF EXISTS app.prevent_artifact_mutation();
-- DROP TABLE IF EXISTS public.artifacts;
-- DROP TABLE IF EXISTS public.project_authors;
-- DROP TABLE IF EXISTS public.project_publishers;
-- DROP TABLE IF EXISTS public.authors;
-- DROP TABLE IF EXISTS public.publishers;
-- DELETE FROM public.permissions WHERE key IN (
-- 'project.source.read','project.translation.read','project.audio.read',
-- 'project.audio.edit','project.translation.approve','project.cover.read',
-- 'project.cover.edit','project.export.create');
-- DROP TRIGGER IF EXISTS job_events_append_only ON public.job_events;
-- DROP TRIGGER IF EXISTS job_artifacts_append_only ON public.job_artifacts;
-- DROP TRIGGER IF EXISTS artifacts_append_only ON public.artifacts;
-- DROP TABLE IF EXISTS public.job_events;
-- DROP INDEX IF EXISTS public.jobs_idempotency_idx;
-- DROP INDEX IF EXISTS public.jobs_claimable_idx;
-- DROP INDEX IF EXISTS public.jobs_parent_idx;
-- DROP TABLE IF EXISTS public.node_heartbeats;
-- DROP TABLE IF EXISTS public.node_models;
-- DROP TABLE IF EXISTS public.model_registry;
-- DROP TABLE IF EXISTS public.node_credentials;
-- DROP TABLE IF EXISTS public.compute_devices;
-- DROP TABLE IF EXISTS public.compute_nodes;
-- ALTER TABLE public.jobs DROP CONSTRAINT IF EXISTS jobs_organization_immutable;
-- ALTER TABLE public.jobs
-- DROP COLUMN IF EXISTS error, DROP COLUMN IF EXISTS result,
-- DROP COLUMN IF EXISTS finished_at, DROP COLUMN IF EXISTS started_at,
-- DROP COLUMN IF EXISTS idempotency_key, DROP COLUMN IF EXISTS parent_job_id,
-- DROP COLUMN IF EXISTS not_before, DROP COLUMN IF EXISTS max_attempts,
-- DROP COLUMN IF EXISTS attempt, DROP COLUMN IF EXISTS lease_expires_at,
-- DROP COLUMN IF EXISTS lease_owner, DROP COLUMN IF EXISTS requires,
-- DROP COLUMN IF EXISTS priority;