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