Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0052_requisition_hold_clock.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0052_requisition_hold_clock.sql |
| Original SHA-256 | 5c4fa72af11c8912eab302ff16914a5f335b6326342202038a98aecf5842ed67 |
| Git blob SHA | 5a0bd1b633825d9c36ea34be4bbf5153a3b9df92 |
| Reading-copy SHA-256 | 5c4fa72af11c8912eab302ff16914a5f335b6326342202038a98aecf5842ed67 |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / db/migrations/0052_requisition_hold_clock.sql | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / db/migrations/0052_requisition_hold_clock.sql | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓251 lines · 15.5 KB
-- +goose Up
-- Corrects the hold clock on requisitions and adds the status event trail.
--
-- 0047 stored held_days as an int. That silently discards every hold shorter than a day. A job order
-- put on hold Friday evening and resumed Monday morning three separate times accrued 30 real hours of
-- hold and would have recorded zero, because each period truncates to 0 before it is added. The error is
-- one directional, it always under counts, so adjusted time to fill would always read longer than the
-- truth and no amount of data would reveal it.
--
-- The accumulator is now exact seconds as numeric. Seconds rather than an interval on purpose: summing
-- intervals that carry a day component and then subtracting them from a timestamptz is ambiguous across
-- a daylight saving boundary, because a calendar day is not always 86400 seconds. EXTRACT(EPOCH FROM
-- (a - b)) is an unambiguous count of elapsed seconds at microsecond resolution, and every arithmetic
-- step after that is exact numeric, never floating point.
--
-- held_since is the open bracket of the current hold. The pairing invariant below is what makes the
-- accumulator trustworthy: it is impossible to be on hold without an open bracket, and impossible to
-- carry an open bracket while not on hold. Without it a requisition closed while on hold would keep
-- accruing hold time forever against a dead row.
--
-- Safe to rewrite the column: requisitions is empty in every environment at the time of this migration.
ALTER TABLE requisitions DROP COLUMN held_days;
ALTER TABLE requisitions
ADD COLUMN held_seconds numeric(20,6) NOT NULL DEFAULT 0 CHECK (held_seconds >= 0),
ADD COLUMN held_since timestamptz;
-- The pairing invariant. Together with requisitions_closed_at_chk from 0047 this makes the three clocks
-- on a requisition, opened_at, closed_at and the hold accumulator, mutually consistent for every legal
-- status, enforced by the database rather than by the care of whoever writes the next service method.
ALTER TABLE requisitions ADD CONSTRAINT requisitions_held_since_chk CHECK (
(status = 'on_hold' AND held_since IS NOT NULL)
OR (status <> 'on_hold' AND held_since IS NULL));
-- A hold cannot have started before the order was opened, and cannot be settled against a close that
-- happened first. Both would make the adjusted age negative, which is the one arithmetic outcome that
-- is never a real measurement.
ALTER TABLE requisitions ADD CONSTRAINT requisitions_held_since_after_open_chk CHECK (
held_since IS NULL OR held_since >= opened_at);
-- ---------------------------------------------------------------------------
-- Closes an integrity hole in 0047. hiring_manager_id referenced client_contacts by (organization_id,
-- id), which proves the contact exists somewhere in the tenant but not that it belongs to THIS client.
-- Nothing stopped a job order at one client naming a hiring manager who works at another, and the first
-- symptom would have been a submittal emailed to the wrong company.
--
-- The fix is a composite foreign key that carries client_id, so the database checks the contact belongs
-- to the client on the order. It needs a unique key on the referenced side to point at.
ALTER TABLE client_contacts ADD CONSTRAINT uq_client_contacts_client_id UNIQUE (organization_id, client_id, id);
ALTER TABLE requisitions DROP CONSTRAINT requisitions_organization_id_hiring_manager_id_fkey;
-- A composite foreign key with any NULL column is not checked at all under MATCH SIMPLE, so a manager
-- attached to a clientless order is not verified by the database. The obvious remedy, a CHECK requiring a
-- client whenever a manager is named, was tried and removed, because it cannot coexist with the cascade:
--
-- Deleting a client fires two independent referential actions on this table, one nulling client_id and
-- one nulling hiring_manager_id by way of the cascade through client_contacts. Postgres applies each as
-- its own statement and evaluates row constraints after each, so whichever runs first leaves the row
-- momentarily inconsistent and the CHECK aborts the delete. Postgres does not support DEFERRABLE on a
-- CHECK, and MATCH FULL is not usable here because organization_id is never null, so it would reject
-- every legitimately clientless order. Verified against the live schema: the constraint reintroduced
-- the exact deletion failure that migration 0053 exists to remove.
--
-- The rule is therefore enforced in the service, which rejects a hiring manager without a client before
-- the insert is attempted. The database still guarantees the part that matters and that the application
-- cannot check without a race: when both are present, the manager works at that client.
-- The column list on SET NULL is load bearing, not decoration. A bare ON DELETE SET NULL on a composite
-- key nulls EVERY referencing column, so deleting one contact would blank organization_id, which is NOT
-- NULL and would abort, and would also silently detach the order from its client. Naming the column
-- confines the effect to the manager, which is the only part that stopped being true. Postgres 15 and
-- later support this form and this deployment is on 16.
ALTER TABLE requisitions ADD CONSTRAINT requisitions_hiring_manager_fkey
FOREIGN KEY (organization_id, client_id, hiring_manager_id)
REFERENCES client_contacts (organization_id, client_id, id)
ON DELETE SET NULL (hiring_manager_id);
-- Deleting a client leaves hiring_manager_id dangling, and this is what clears it.
--
-- Verified behaviour: deleting a client fires two referential actions on this table. The one nulling
-- client_id runs first, and once client_id is NULL the composite key contains a NULL, so under MATCH
-- SIMPLE the manager key is no longer enforced and the cascade that deletes the client's contacts never
-- nulls hiring_manager_id. The row is left pointing at a contact that no longer exists.
--
-- The effect is invisible in the interface, because the name is resolved by a join that now finds
-- nothing, which is precisely why it needs handling here rather than being left to be noticed. A trigger
-- is used because no declarative constraint can express it: the rule spans two foreign keys whose actions
-- Postgres applies as separate statements.
--
-- Restricted to UPDATE deliberately. On INSERT the same situation is a mistake by the caller, and the
-- service rejects it with a message naming the field rather than silently discarding what was typed.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION rm_clear_orphaned_hiring_manager() RETURNS trigger AS $$
BEGIN
IF NEW.client_id IS NULL AND NEW.hiring_manager_id IS NOT NULL THEN
NEW.hiring_manager_id := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER requisitions_clear_orphaned_manager
BEFORE UPDATE ON requisitions
FOR EACH ROW EXECUTE FUNCTION rm_clear_orphaned_hiring_manager();
-- ---------------------------------------------------------------------------
-- requisition_events. Append only, because every metric Phase 17 reports about how long an order sat in
-- a state is derived from this table, and a derived metric is only auditable if its inputs cannot be
-- rewritten after the fact. Recording it from the first status change means the history exists when the
-- reporting is built, rather than starting from whenever someone remembered to add it.
-- ---------------------------------------------------------------------------
CREATE TABLE requisition_events (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
requisition_id uuid NOT NULL,
from_status text,
to_status text NOT NULL,
-- Seconds spent in from_status, closed out at the moment of this event. Stored rather than derived
-- so a report does not have to self join the table to the previous row per requisition, and so the
-- number stays correct if a future migration ever changes what a status means.
seconds_in_from numeric(20,6) CHECK (seconds_in_from IS NULL OR seconds_in_from >= 0),
note text NOT NULL DEFAULT '',
actor_user_id uuid,
-- The actor's name at the time of the event, copied rather than joined.
--
-- actor_user_id is nulled when a user is deleted, which is required for offboarding and erasure. If
-- the name were only reachable through that key, deleting a recruiter would silently rewrite years of
-- history into "unknown" and destroy the audit value of the table this trigger exists to protect.
-- Denormalising the name keeps the history legible after the reference is gone, and it is the correct
-- value regardless: it records who acted THEN, not who happens to hold that user row now.
actor_name text NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, id),
FOREIGN KEY (organization_id, requisition_id) REFERENCES requisitions (organization_id, id) ON DELETE CASCADE,
-- The column list is required. A bare SET NULL on a composite key nulls organization_id too, which is
-- NOT NULL, so deleting any user would abort. Migration 0053 repairs twenty three constraints across
-- this schema that made exactly that mistake; this one is written correctly from the start.
FOREIGN KEY (organization_id, actor_user_id) REFERENCES users (organization_id, id)
ON DELETE SET NULL (actor_user_id),
-- The first event of a requisition's life has no predecessor. Every later one must name what it left,
-- so the chain cannot silently lose a link.
CONSTRAINT requisition_events_from_chk CHECK (
(from_status IS NULL AND seconds_in_from IS NULL)
OR (from_status IS NOT NULL AND seconds_in_from IS NOT NULL)),
CONSTRAINT requisition_events_to_chk CHECK (
to_status IN ('draft', 'open', 'on_hold', 'filled', 'closed', 'cancelled')),
CONSTRAINT requisition_events_from_vocab_chk CHECK (
from_status IS NULL OR from_status IN ('draft', 'open', 'on_hold', 'filled', 'closed', 'cancelled'))
);
CREATE INDEX idx_requisition_events_req ON requisition_events (organization_id, requisition_id, occurred_at);
ALTER TABLE requisition_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE requisition_events FORCE ROW LEVEL SECURITY;
CREATE POLICY requisition_events_isolation ON requisition_events
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
-- Only SELECT and INSERT reach the application role. The append only property has two independent
-- barriers, the missing grant and the trigger, so neither one being wrong on its own is enough to let
-- history be rewritten.
GRANT SELECT, INSERT ON requisition_events TO missionmatch_app;
-- ---------------------------------------------------------------------------
-- Widens the append only guard by exactly one case, and fixes the same latent conflict on
-- application_stage_events, which shares this function.
--
-- A foreign key declared ON DELETE SET NULL performs an UPDATE on the referencing row. The guard refuses
-- every UPDATE from the application role, so deleting a user who ever changed a requisition status or
-- moved a candidate through a stage would be refused by the trigger even after 0053 makes the constraint
-- itself valid. That is the same shape of defect as 0050: a rule written to protect history instead
-- making a legitimate lifecycle operation impossible.
--
-- The exemption is deliberately narrow. It permits only an update that clears actor_user_id from a value
-- to NULL while every other column is byte identical, which is precisely and only what the referential
-- action does. The jsonb comparison is what makes that precise rather than approximate: any other column
-- differing makes the two objects unequal and the update is refused. History still cannot be rewritten,
-- and the actor's name survives in actor_name regardless.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION rm_forbid_event_mutation() RETURNS trigger AS $$
BEGIN
IF current_user = 'missionmatch_migrator' THEN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.actor_user_id IS NOT NULL
AND NEW.actor_user_id IS NULL
AND (to_jsonb(NEW) - 'actor_user_id') = (to_jsonb(OLD) - 'actor_user_id') THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'append-only table: % not permitted for role %', TG_OP, current_user;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER requisition_events_append_only
BEFORE UPDATE OR DELETE ON requisition_events
FOR EACH ROW EXECUTE FUNCTION rm_forbid_event_mutation();
CREATE TRIGGER requisition_events_no_truncate
BEFORE TRUNCATE ON requisition_events
FOR EACH STATEMENT EXECUTE FUNCTION rm_forbid_event_mutation();
-- Listing a client's job orders and a recruiter's own desk are the two reads the dashboard makes on
-- every load, so both get an index that already carries the filter.
CREATE INDEX idx_requisitions_org_client ON requisitions (organization_id, client_id, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_requisitions_org_owner ON requisitions (organization_id, owner_user_id, status) WHERE deleted_at IS NULL;
-- +goose Down
-- Restores the 0050 form of the guard, without the referential exemption.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION rm_forbid_event_mutation() RETURNS trigger AS $$
BEGIN
IF current_user = 'missionmatch_migrator' THEN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION 'append-only table: % not permitted for role %', TG_OP, current_user;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
ALTER TABLE requisitions DROP CONSTRAINT IF EXISTS requisitions_hiring_manager_fkey;
ALTER TABLE client_contacts DROP CONSTRAINT IF EXISTS uq_client_contacts_client_id;
-- The column list form is kept on the way back down, deliberately, and this is the one place this down
-- does not restore the previous definition byte for byte. The original was a bare ON DELETE SET NULL on a
-- composite key, which nulls EVERY column in the key including organization_id, and organization_id is
-- NOT NULL on requisitions, so deleting a client contact aborts and the contact becomes undeletable.
-- Migration 0053 exists to repair exactly that defect across twenty three constraints. Reinstating it here
-- would hand 0053 back a broken constraint it had already fixed and silently break contact offboarding for
-- anyone who rolled this migration back. A down migration restores the previous SHAPE of the schema, not a
-- known defect that a later migration has already ruled out.
ALTER TABLE requisitions ADD CONSTRAINT requisitions_organization_id_hiring_manager_id_fkey
FOREIGN KEY (organization_id, hiring_manager_id)
REFERENCES client_contacts (organization_id, id) ON DELETE SET NULL (hiring_manager_id);
DROP TRIGGER IF EXISTS requisitions_clear_orphaned_manager ON requisitions;
DROP FUNCTION IF EXISTS rm_clear_orphaned_hiring_manager();
DROP INDEX IF EXISTS idx_requisitions_org_owner;
DROP INDEX IF EXISTS idx_requisitions_org_client;
DROP TABLE IF EXISTS requisition_events;
ALTER TABLE requisitions DROP CONSTRAINT IF EXISTS requisitions_held_since_after_open_chk;
ALTER TABLE requisitions DROP CONSTRAINT IF EXISTS requisitions_held_since_chk;
ALTER TABLE requisitions DROP COLUMN IF EXISTS held_since;
ALTER TABLE requisitions DROP COLUMN IF EXISTS held_seconds;
ALTER TABLE requisitions ADD COLUMN held_days int NOT NULL DEFAULT 0 CHECK (held_days >= 0);