-- +goose Up -- W0 AEDT governance and auditability. Run as the BYPASSRLS migrator role. -- -- The single highest impact change is the compliance read grant. The eeo schema (db/migrations_eeo) -- assumes it can join eeo.candidate_demographics to public.slate_members for adverse impact, but the -- missionmatch_compliance role had NO grant on the public outcome tables, so the join would fail with -- permission denied. This grants the compliance role SELECT only (it can read outcomes, never alter a -- decision) on the tables the adverse impact and screening stage analyses need. The app and scoring -- role still gets NOTHING on the eeo schema, so demographics flow one way: into compliance reporting, -- never into scoring. -- --------------------------------------------------------------------------- -- model_version stamps the frozen decision with the scoring model that produced it, so a slate_members -- row joins to the version in the registry. Existing rows predate versioning; backfill to the first -- version. anonymized_at on candidates supports anonymize in place erasure: PII fields are wiped and -- this is set, while the de identified statistical rows (slate_members, eeo bucket) are retained. -- --------------------------------------------------------------------------- ALTER TABLE slate_members ADD COLUMN model_version text; UPDATE slate_members SET model_version = 'v1.0.0' WHERE model_version IS NULL; ALTER TABLE candidates ADD COLUMN anonymized_at timestamptz; -- --------------------------------------------------------------------------- -- Compliance role can now read the public outcome tables to run the adverse impact join, plus the -- audit trail (for the screening stage analysis over aedt.score.served events). SELECT only, so the -- compliance role can never alter a decision or the trail. -- --------------------------------------------------------------------------- GRANT SELECT ON candidates, slates, slate_members, audit_logs TO missionmatch_compliance; -- --------------------------------------------------------------------------- -- aedt_model_versions: the platform scoring model registry. Global (no org), like the public data -- catalogs: app reads, the migrator and governance path write. Each row snapshots the weights and band -- cuts a version used, so a frozen decision is reproducible. -- --------------------------------------------------------------------------- CREATE TABLE aedt_model_versions ( version text PRIMARY KEY, weights jsonb NOT NULL, band_cuts jsonb NOT NULL, notes text, effective_from timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now() ); GRANT SELECT ON aedt_model_versions TO missionmatch_app; GRANT SELECT ON aedt_model_versions TO missionmatch_compliance; -- --------------------------------------------------------------------------- -- aedt_inventory: the org's register of automated employment decision tools in use, each tied to a -- model version. Tenant scoped under RLS. -- --------------------------------------------------------------------------- CREATE TABLE aedt_inventory ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), organization_id uuid NOT NULL REFERENCES organizations(id), tool_key text NOT NULL, display_name text NOT NULL, purpose text NOT NULL, model_version text, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_aedt_inventory_org_tool UNIQUE (organization_id, tool_key) ); CREATE INDEX idx_aedt_inventory_org ON aedt_inventory (organization_id, created_at DESC); ALTER TABLE aedt_inventory ENABLE ROW LEVEL SECURITY; ALTER TABLE aedt_inventory FORCE ROW LEVEL SECURITY; CREATE POLICY aedt_inventory_isolation ON aedt_inventory 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); GRANT SELECT, INSERT, UPDATE ON aedt_inventory TO missionmatch_app; -- --------------------------------------------------------------------------- -- aedt_candidate_notices: the candidate notice and disclosure record (what the tool assesses, the data -- categories used, the retention disclosed). Public schema, tenant scoped, because the candidate -- consent itself lives in the unreadable eeo schema and the notice must be readable by the app. No PII -- beyond the candidate link. -- --------------------------------------------------------------------------- CREATE TABLE aedt_candidate_notices ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), organization_id uuid NOT NULL, candidate_id uuid NOT NULL, notice_version text NOT NULL, data_categories jsonb NOT NULL DEFAULT '[]', retention_days int NOT NULL, disclosed_by uuid, disclosed_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT fk_notice_candidate FOREIGN KEY (organization_id, candidate_id) REFERENCES candidates (organization_id, id) ON DELETE CASCADE ); CREATE INDEX idx_notice_candidate ON aedt_candidate_notices (organization_id, candidate_id); ALTER TABLE aedt_candidate_notices ENABLE ROW LEVEL SECURITY; ALTER TABLE aedt_candidate_notices FORCE ROW LEVEL SECURITY; CREATE POLICY aedt_notices_isolation ON aedt_candidate_notices 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); GRANT SELECT, INSERT ON aedt_candidate_notices TO missionmatch_app; -- Seed the first model version snapshot (the fixed engine weights and band cuts). INSERT INTO aedt_model_versions (version, weights, band_cuts, notes) VALUES ( 'v1.0.0', '{"clearance":0.25,"skills":0.25,"certifications":0.15,"customer_experience":0.15,"experience":0.10,"location_availability":0.10}', '{"green":90,"yellow":75}', 'Initial fixed weight deterministic scoring engine. Clearance is a self reported indicator, never an input proxy for a protected attribute.' ) ON CONFLICT (version) DO NOTHING; -- +goose Down DROP TABLE IF EXISTS aedt_candidate_notices; DROP TABLE IF EXISTS aedt_inventory; DROP TABLE IF EXISTS aedt_model_versions; REVOKE SELECT ON candidates, slates, slate_members, audit_logs FROM missionmatch_compliance; ALTER TABLE candidates DROP COLUMN IF EXISTS anonymized_at; ALTER TABLE slate_members DROP COLUMN IF EXISTS model_version;