diff --git a/docker/migrations/forward/_ledger/application-migrations.tsv b/docker/migrations/forward/_ledger/application-migrations.tsv index 8701fedc35..1918ba03ae 100644 --- a/docker/migrations/forward/_ledger/application-migrations.tsv +++ b/docker/migrations/forward/_ledger/application-migrations.tsv @@ -1,5 +1,6 @@ nodes/node_canary_score_reducer/0001_create_capability_scores.sql node:node_canary_score_reducer node:node_canary_score_reducer omninode_internal node:node_canary_score_reducer:0001_create_capability_scores.sql 7195b07a7fae809f141a136a67b799ad492e79d280f54c325ac14875dcfcc2cd nodes/node_canary_score_reducer/0002_capability_scores_tenant_id_and_rls.sql node:node_canary_score_reducer node:node_canary_score_reducer tenant node:node_canary_score_reducer:0002_capability_scores_tenant_id_and_rls.sql 74d38cca4463ed4f4d20169164d788c5afc79eea58b7f2abd02ef23093388ba4 +nodes/node_canary_score_reducer/0003_capability_scores_tenant_id_to_uuid.sql node:node_canary_score_reducer node:node_canary_score_reducer tenant node:node_canary_score_reducer:0003_capability_scores_tenant_id_to_uuid.sql d1eeefff11fbec255216154ba26aa6f0f29602a0b6fd8fd89e8de5d8d3282b2c nodes/node_contract_registry/0000_create_contract_registry.sql node:node_contract_registry node:node_contract_registry omninode_internal node:node_contract_registry:0000_create_contract_registry.sql bff49882c3c724de24f75b97c847ab78581064a72b77429a2251a02448fad668 nodes/node_deployment_evidence_reducer/0001_create_deployment_evidence_projection_tables.sql node:node_deployment_evidence_reducer node:node_deployment_evidence_reducer omninode_internal node:node_deployment_evidence_reducer:0001_create_deployment_evidence_projection_tables.sql 1f3d9bcc7b90bfd9bed76bac2f0b815d742c11b3deadbf5b24a682350ccbbbcd nodes/node_evidence_dashboard_reducer/0001_create_evidence_dashboard_projection_tables.sql node:node_evidence_dashboard_reducer node:node_evidence_dashboard_reducer omninode_internal node:node_evidence_dashboard_reducer:0001_create_evidence_dashboard_projection_tables.sql b3c4be68802a73d5f865b6730b191a15fa9d2729d3b8a5e75ed82ce3c94b70e8 @@ -73,6 +74,7 @@ nodes/node_projection_registration/0000_create_node_service_registry.sql node:no nodes/node_projection_registration/0001_add_heartbeat_columns.sql node:node_projection_registration node:node_projection_registration omninode_internal node:node_projection_registration:0001_add_heartbeat_columns.sql 2cda779401d041ae1383937df3f4c614df74d09c80c78f24f7993685ba7a6aed nodes/node_projection_registration/0002_node_service_registry_tenant_rls.sql node:node_projection_registration node:node_projection_registration omninode_internal node:node_projection_registration:0002_node_service_registry_tenant_rls.sql 3e8a3c12dbefc4432262a1c2533667f4a5f342c39149915bd80b45241c182e98 nodes/node_projection_registration/0003_reconcile_heartbeat_observability.sql node:node_projection_registration node:node_projection_registration omninode_internal node:node_projection_registration:0003_reconcile_heartbeat_observability.sql 0bc9286ec9b0f7ad36e1d29e54d34bc11fe2be64cf5b874d7432384869ade69e +nodes/node_projection_registration/0004_node_service_registry_no_force_rls.sql node:node_projection_registration node:node_projection_registration omninode_internal node:node_projection_registration:0004_node_service_registry_no_force_rls.sql adb21c75ec2503b50a7272857e660ff07cfda16374698cc245af93047e3da6f2 nodes/node_projection_routing_decision/0021_create_agent_routing_decisions.sql node:node_projection_routing_decision node:node_projection_routing_decision omninode_internal node:node_projection_routing_decision:0021_create_agent_routing_decisions.sql 46d169e595f65ad89e7f524efffedb59eb0e1a37f9527e67f1ff2e8eaaa7711e nodes/node_projection_routing_decision/0022_agent_routing_decisions_tenant_id_and_rls.sql node:node_projection_routing_decision node:node_projection_routing_decision tenant node:node_projection_routing_decision:0022_agent_routing_decisions_tenant_id_and_rls.sql bac8be9bbe6167601ca805d35a78f469a6bce9ca9fc22e88a363c50dac0b7f83 nodes/node_projection_sandbox_decisions/0001_create_sandbox_decisions.sql node:node_projection_sandbox_decisions node:node_projection_sandbox_decisions omninode_internal node:node_projection_sandbox_decisions:0001_create_sandbox_decisions.sql dcf92194d262bb957dd5537a2d9e16083b89d2d1c259f08f869460b76145b96c diff --git a/docker/migrations/forward/nodes/node_canary_score_reducer/0003_capability_scores_tenant_id_to_uuid.sql b/docker/migrations/forward/nodes/node_canary_score_reducer/0003_capability_scores_tenant_id_to_uuid.sql new file mode 100644 index 0000000000..903d9fe82a --- /dev/null +++ b/docker/migrations/forward/nodes/node_canary_score_reducer/0003_capability_scores_tenant_id_to_uuid.sql @@ -0,0 +1,172 @@ +-- OMN-15356: convert capability_scores.tenant_id from the legacy TEXT slug +-- to the canonical UUID identity, for the classified-TENANT relation set. +-- +-- OMN-15732 AC2 ADJUDICATION (2026-08-08, no gate widened) +-- An earlier revision of this migration split the mapping into a +-- standalone, schema-qualified `platform_catalog.house_tenant_map_slug_to_ +-- uuid` function so it could be ownership-declared as a new application +-- object. That placement was rejected on adjudication: the migration-ledger +-- `domain` column is the *declared target schema domain* (omnibase_infra +-- docker/migrations/forward/_ledger/bootstrap.sql), and `platform_catalog` +-- (topology domain PLATFORM_CATALOG) is inadmissible for a `node:%` +-- migration stream at both the static gate +-- (scripts/validation/validate_application_migration_manifest.py) and the +-- runtime `schema_migrations_stream_domain_check` CHECK constraint, which +-- together restrict node-stream domains to exactly {tenant, +-- omninode_internal}. `tenant` and `omninode_internal` both fail +-- apply-time schema presence today (the former needs the still-pending +-- OMN-15359 cutover; the latter is created only by the cutover-only +-- initializer, never by the forward-migration stream). The function has +-- exactly one consumer -- this file's own DDL-time `USING` clause -- and no +-- runtime caller; the runtime implementation is the single Python +-- function `omnimarket.projection.tenant_isolation.resolve_tenant_uuid`. +-- A persistent catalog object read once by one ALTER buys nothing here and +-- cannot be given a truthful ownership/domain declaration on every surface +-- at once. The mapping is therefore inlined below with no CREATE FUNCTION +-- and no new schema-qualified object; OMN-15359's governed tenant/internal +-- cutover is the right place to promote this into a shared, +-- truthfully-declared `tenant.house_tenant_map_slug_to_uuid` alongside the +-- other classified-TENANT relations' conversions. +-- +-- WHY THIS TABLE FIRST +-- capability_scores is the relation the OMN-15356 identity module +-- (`omnimarket.projection.tenant_isolation.resolve_tenant_uuid`) and its +-- test (`test_the_tenant_classified_relations_carry_a_tenant_id_migration`) +-- already use as the canonical worked example. This migration lands the +-- full end-to-end pattern -- inline fail-closed mapping, conversion, +-- index/constraint preservation, RLS policy cast -- for ONE relation as the +-- reviewable shape. The remaining classified-TENANT relations (see the +-- `expected` table in `test_house_tenant_identity.py` plus +-- delegation_events/delegation_judge_verdict_events/delegation_budget_state/ +-- inference_response_text/savings_estimates/node_service_registry) convert +-- via the identical mechanical pattern in follow-up migrations under this +-- same ticket -- NOT silently dropped, deferred and named in the PR body. +-- +-- FAIL-CLOSED: NO SENTINEL SURVIVES (two independent guards) +-- (1) A pre-guard `IF EXISTS` check below RAISEs before any DDL runs if any +-- row's tenant_id is not the one closed mapping key this codebase knows +-- about. (2) Even if that guard were ever bypassed, the `ALTER COLUMN ... +-- TYPE UUID USING (CASE ... END)` below has no ELSE branch: an unmapped +-- value evaluates the CASE to NULL, which then violates the column's +-- pre-existing NOT NULL constraint (migration 0002) and aborts the +-- statement. Postgres evaluates the `USING` expression for every row as +-- part of the single `ALTER TABLE` DDL statement, and because DDL in +-- PostgreSQL is transactional, either guard failing rolls back the entire +-- migration transaction. There is no partial conversion, no invented UUID, +-- and no 'unmapped rows keep their old value' fallback: the column stays +-- TEXT until every row maps, exactly the "no sentinel/default survives" +-- acceptance criterion. +-- +-- CONSTRAINTS AND INDEXES SURVIVE THE TYPE CHANGE +-- `ALTER COLUMN ... TYPE` rewrites the column in place; the pre-existing +-- `idx_capability_scores_tenant_id` index and the +-- `capability_scores_model_key_task_type_key` UNIQUE constraint (on +-- unrelated columns, untouched here) are NOT dropped and NOT recreated by +-- this migration -- PostgreSQL preserves an index across a column type +-- change automatically when the new type has a binary-compatible or +-- USING-expressed conversion path, which this is. The Docker proof in +-- omnibase_infra (docker/tenant-uuid-conversion-proof) asserts this +-- directly rather than assuming it. +-- +-- RLS POLICY: THE GUC COMPARISON GAINS AN EXPLICIT CAST +-- `current_setting('app.tenant_id', true)` always returns TEXT (GUCs have +-- no native UUID type); the policy predicate must cast it explicitly now +-- that the column itself is UUID. `::uuid` on a non-UUID-shaped GUC value +-- raises rather than silently coercing, so an unset or malformed GUC still +-- fails closed -- proving that behavior is OMN-15416's scope (real +-- non-owner pools), not duplicated here. +-- +-- Idempotent: the column-type guard only runs the ALTER when the column is +-- not already UUID, so a second application is a no-op. + +DO $$ +DECLARE + v_current_type TEXT; +BEGIN + SELECT atttypid::regtype::text INTO v_current_type + FROM pg_attribute + WHERE attrelid = 'public.capability_scores'::regclass + AND attname = 'tenant_id' + AND NOT attisdropped; + + IF v_current_type IS NULL THEN + RAISE EXCEPTION + 'OMN-15356: public.capability_scores.tenant_id column not found -- ' + 'expected migration 0002 to have already landed it'; + ELSIF v_current_type = 'uuid' THEN + RAISE NOTICE + 'public.capability_scores.tenant_id is already uuid; skipping conversion'; + ELSIF v_current_type = 'text' THEN + -- Pre-guard: refuse before any DDL runs if any row carries a + -- tenant_id value outside the closed mapping (today: 'omninode' + -- only). This is the first of the two independent fail-closed + -- guards described in the file header above. + IF EXISTS ( + SELECT 1 FROM public.capability_scores + WHERE tenant_id IS DISTINCT FROM 'omninode' + ) THEN + RAISE EXCEPTION + 'OMN-15356: no canonical UUID mapping for tenant value % -- ' + 'refusing to invent or default one; extend the closed mapping ' + 'in this migration only after confirming this is a real, ' + 'reviewed tenant identity', + ( + SELECT tenant_id FROM public.capability_scores + WHERE tenant_id IS DISTINCT FROM 'omninode' + LIMIT 1 + ); + END IF; + + -- The pre-existing tenant_isolation POLICY (migration 0002) depends + -- on this column -- PostgreSQL refuses ALTER COLUMN ... TYPE while + -- any policy references it, so the policy must be dropped first and + -- recreated (with the ::uuid cast) after the type change, not just + -- before/after this DO block. Caught by the Docker fixture proof + -- (docker/tenant-uuid-conversion-proof) before this ever reached a + -- real database. + DROP POLICY IF EXISTS tenant_isolation ON public.capability_scores; + + -- The pre-existing TEXT DEFAULT ('omninode') is not automatically + -- castable to uuid -- Postgres tries to cast the DEFAULT expression + -- itself (not just the stored rows) when the column type changes, + -- and 'omninode'::uuid is not a valid uuid literal, so the DEFAULT + -- must be dropped before the TYPE change and a new uuid-typed + -- DEFAULT set after it. Also caught by the same fixture proof. + ALTER TABLE public.capability_scores + ALTER COLUMN tenant_id DROP DEFAULT; + -- Second, independent fail-closed guard: the CASE has no ELSE, so + -- any value that reaches this point despite the pre-guard above + -- (e.g. a row inserted between the guard and the ALTER within the + -- same transaction) evaluates to NULL and is rejected by the + -- column's existing NOT NULL constraint, aborting the statement. + ALTER TABLE public.capability_scores + ALTER COLUMN tenant_id TYPE UUID + USING ( + CASE tenant_id + WHEN 'omninode' THEN '820272f9-4aaf-5add-a2df-0af942852ab2'::uuid + END + ); + ALTER TABLE public.capability_scores + ALTER COLUMN tenant_id SET DEFAULT '820272f9-4aaf-5add-a2df-0af942852ab2'::uuid; + ELSE + RAISE EXCEPTION + 'OMN-15356: public.capability_scores.tenant_id has unexpected type %, ' + 'expected text or uuid -- operator schema ruling required', + v_current_type; + END IF; +END$$; + +-- Idempotent whether or not the DO block above dropped it (the 'uuid' +-- branch above leaves the existing (already-cast) policy in place, so this +-- DROP + CREATE additionally covers a hand-repaired or partially-applied +-- prior state without erroring). +DROP POLICY IF EXISTS tenant_isolation ON public.capability_scores; +CREATE POLICY tenant_isolation ON public.capability_scores + FOR ALL + USING (tenant_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid); + +-- OMN-14894 ratchet: every file that (re)creates this policy must grant +-- app_dashboard SELECT in the same file. Idempotent; already granted by +-- migration 0002, restated here so this file alone satisfies the ratchet. +GRANT SELECT ON public.capability_scores TO app_dashboard; diff --git a/docker/migrations/forward/nodes/node_projection_registration/0004_node_service_registry_no_force_rls.sql b/docker/migrations/forward/nodes/node_projection_registration/0004_node_service_registry_no_force_rls.sql new file mode 100644 index 0000000000..f63fc1acb3 --- /dev/null +++ b/docker/migrations/forward/nodes/node_projection_registration/0004_node_service_registry_no_force_rls.sql @@ -0,0 +1,57 @@ +-- OMN-15336 item 4 (required-fix #4) / operator ruling R-q (2026-08-05, +-- rolling ledger 23:45Z): registry and orchestration state +-- (node_service_registry) is omninode_internal runtime state, not tenant +-- data. FORCE ROW LEVEL SECURITY on internal-domain runtime state is the +-- wrong posture: it forces EVERY reader, including the table owner, through +-- a tenant_id predicate that internal registry rows do not carry +-- meaningfully (0002 stamped a DEFAULT 'omninode' single-tenant value — +-- there is no real multi-tenant partition of "which node is running where"). +-- +-- This reverses ONLY the FORCE half of 0002_node_service_registry_tenant_rls +-- (leaves RLS ENABLEd and the tenant_isolation POLICY in place, so a future +-- tenant-scoped consumer is not blocked from reintroducing FORCE explicitly +-- with a considered ruling of its own) — narrower than DISABLE ROW LEVEL +-- SECURITY or DROP POLICY, matching item 4's own recommendation "(b) strip +-- FORCE-RLS" precisely rather than removing the RLS posture wholesale. +-- +-- Idempotent on both paths, matching 0003's own precedent: the registration +-- trio (0000/0001/0002) is operator-fenced by default (OMN-15379/OMN-15349) +-- and released only on the compose dev lane and the k8s onex-dev Job +-- (ruling 21) — a fresh, still-fenced database has no node_service_registry +-- table at all, and this migration must be a deterministic no-op there, not +-- an error. +-- +-- Does NOT need a fence-manifest entry: the OMN-15336 item-4 guard +-- (migration_declares_unclassified_force_rls in run-forward-migrations.sh) +-- only refuses migrations that ENABLE FORCE ROW LEVEL SECURITY without a +-- fence entry. `NO FORCE ROW LEVEL SECURITY` is the disabling form and is +-- explicitly excluded from that match — the guard exists to gate the +-- hazard this migration removes, not to block its own remedy. +-- +-- Reconciles with ruling 15 (OMN-15379, "the registration trio moves +-- together") and ruling 21 (OMN-15332 comment 1a067542, the k8s durable +-- release): neither ruling is reversed by this migration. Both rulings +-- govern whether 0000/0001/0002 apply at all (fence membership); this +-- migration only changes the FORCE posture of a table that has already been +-- released and created, and ships unfenced so it applies on every lane +-- where the trio has already landed, converging them to the corrected +-- posture without re-litigating the release decision itself. +-- +-- Ticket: OMN-15336 (item 4). Introduced-by: OMN-15343 (0002's FORCE). +-- Domain corroboration: node_projection_registration/contract.yaml +-- db_io.schema=omninode_internal; 2026-08-02 operator domain ruling +-- ("registry... stays omninode_internal"); OMN-15656's landed +-- grants-derivation correction ("node_service_registry/live_events are +-- internal not tenant"); R-q's generalized precedent ("FORCE-RLS on +-- internal-domain runtime state is wrong"). +DO $unforce_registry_rls$ +BEGIN + IF to_regclass('public.node_service_registry') IS NOT NULL THEN + ALTER TABLE public.node_service_registry NO FORCE ROW LEVEL SECURITY; + ELSE + RAISE NOTICE + 'node_service_registry is absent; FORCE-RLS reversal is a no-op ' + '(the registration trio is still fenced on this database)'; + END IF; +END +$unforce_registry_rls$; diff --git a/scripts/ci/check_application_database_sql.py b/scripts/ci/check_application_database_sql.py index 3b010908b4..29bb5c21da 100644 --- a/scripts/ci/check_application_database_sql.py +++ b/scripts/ci/check_application_database_sql.py @@ -42,6 +42,36 @@ "docker/migrations/forward/nodes/node_canary_score_reducer/" "0002_capability_scores_tenant_id_and_rls.sql" ), + # OMN-15732: 0003 is the direct sequel to 0002 above -- it continues + # the OMN-15356 tenant_id TEXT->UUID conversion on the SAME physical + # public.capability_scores table 0002 already exempted for the + # identical reason (physical table intentionally remains in public + # until the governed OMN-15359 schema cutover). OMN-15732 AC2 + # adjudication (2026-08-08) rejected a standalone, schema-qualified + # mapping function as inadmissible for a node migration stream (no + # `node:%` domain -- {tenant, omninode_internal} -- admits + # platform_catalog); the mapping is now inlined into this same file's + # `ALTER COLUMN ... USING` clause, so there is no separate object to + # qualify. node-migration-sync (OMN-13332) forces this file to be + # vendored verbatim from omnimarket dev regardless of this gate, so + # exempting it here (not editing the SQL) is the canonical fix -- see + # docs/tracking/ROLLING_WORK_LEDGER.md 2026-08-08 + # [mergesweep-0808-deadlock] for the full deadlock analysis. + Path( + "docker/migrations/forward/nodes/node_canary_score_reducer/" + "0003_capability_scores_tenant_id_to_uuid.sql" + ), + # OMN-15732: node_service_registry has been unqualified (default/ + # public schema) since its 0000 CREATE TABLE; 0004 only flips its + # FORCE ROW LEVEL SECURITY posture (OMN-15336 item 4 / operator + # ruling R-q, 2026-08-05) inside a DO block that references the same + # already-unqualified table -- it introduces no new physical-schema + # authority. Same node-migration-sync-forced-vendoring rationale as + # above. + Path( + "docker/migrations/forward/nodes/node_projection_registration/" + "0004_node_service_registry_no_force_rls.sql" + ), Path( "docker/migrations/forward/nodes/node_projection_context_roi/" "003_context_roi_scores_tenant_id_and_rls.sql" diff --git a/tests/unit/scripts/validation/test_application_migration_manifest.py b/tests/unit/scripts/validation/test_application_migration_manifest.py index 81df3a5c2d..81152dc4e4 100644 --- a/tests/unit/scripts/validation/test_application_migration_manifest.py +++ b/tests/unit/scripts/validation/test_application_migration_manifest.py @@ -99,10 +99,13 @@ def _minimal_fixture(tmp_path: Path) -> tuple[Path, Path]: def test_checked_in_manifest_is_exact_and_all_blockers_are_explicit() -> None: result = _validate() - # 94 as of the OMN-14894 vendor-parity repair: the manifest mirrors the - # vendored node migration tree after restoring the nine house-tenant RLS - # migrations that are still source-owned in omnimarket dev. - assert len(result.declarations) == 94 + # 96 as of OMN-15732: 94 from the OMN-14894 vendor-parity repair (the + # manifest mirrors the vendored node migration tree after restoring the + # nine house-tenant RLS migrations that are still source-owned in + # omnimarket dev), +2 for node_canary_score_reducer/0003 and + # node_projection_registration/0004 (see OMN-15361 exemption fix above + # for why these are the deadlock-triggering vendor files). + assert len(result.declarations) == 96 assert result.blocked == () assert len(result.cloud_aliases) == 30