@fayz-ai/db 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -0,0 +1,358 @@
1
+ -- ============================================================================
2
+ -- 136_unit_id_means_locality.sql — `unit_id` stops meaning two things (#225).
3
+ --
4
+ -- The column carried both "this row belongs to a unit" (cardinality 1, a fact
5
+ -- about the row) and "this row is available in a unit" (cardinality N, a
6
+ -- relation that changes without the row changing). Two consequences, both
7
+ -- verifiable before this file: there were TWO ways to say "only at Centro"
8
+ -- (`unit_id = centro`, or tenant-wide plus one `available_in` tuple) resolved by
9
+ -- different code paths with nothing preventing the wrong one; and the scaffold
10
+ -- had never once generated `unit_id NOT NULL` — one table in the whole repo had
11
+ -- it, added by hand.
12
+ --
13
+ -- THREE CLASSES, NOT TWO — and the schema is what says so.
14
+ --
15
+ -- The decision as written said `scope` was `unit | tenant`. Classifying the 47
16
+ -- registered tables did not survive that: `holidays`, `field_rules`,
17
+ -- `price_tables` and `plg_inventory_product_settings` each document, in their own
18
+ -- COMMENT, that `unit_id NULL` means "the network's" and a value means "that
19
+ -- unit's own". That is not the conflation this file exists to remove — both
20
+ -- readings are LOCALITY, at cardinality 0-or-1. It is a third class, and
21
+ -- pretending otherwise would have forced a wrong constraint onto four tables
22
+ -- that were already right.
23
+ --
24
+ -- unit locality required → unit_id NOT NULL, and the policy drops
25
+ -- its `unit_id IS NULL OR` branch
26
+ -- unit_optional locality optional → nothing enforced, but DECLARED: the NULL
27
+ -- now means something a reader can check
28
+ -- tenant no locality at all → CHECK (unit_id IS NULL); distribution is
29
+ -- `available_in`, and there is one way to
30
+ -- say it
31
+ --
32
+ -- NOT NULL IS APPLIED ONLY WHERE THE DATA ALLOWS, AND THE REST IS NAMED.
33
+ -- A table classified `unit` whose rows still hold NULLs is not silently skipped
34
+ -- and not force-migrated: it lands in `app.scaffold_scope_pending` with the count
35
+ -- that has to be backfilled, and 900_verification asserts the list, so it cannot
36
+ -- grow quietly.
37
+ --
38
+ -- Replay-safe: additive column, idempotent seed, constraints guarded by
39
+ -- existence checks.
40
+ -- ============================================================================
41
+
42
+ -- ── the registry records the class ──────────────────────────────────────
43
+ ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS scope text;
44
+ DO $$
45
+ BEGIN
46
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'app.scaffold_registry'::regclass AND conname = 'scaffold_registry_scope_chk') THEN
47
+ ALTER TABLE app.scaffold_registry
48
+ ADD CONSTRAINT scaffold_registry_scope_chk CHECK (scope IS NULL OR scope IN ('unit', 'unit_optional', 'tenant'));
49
+ END IF;
50
+ END $$;
51
+ COMMENT ON COLUMN app.scaffold_registry.scope IS
52
+ 'What unit_id means on this table (#225): unit = locality required (NOT NULL); unit_optional = locality optional, NULL is the network default; tenant = no locality, distribution is available_in and unit_id is CHECKed NULL.';
53
+
54
+ CREATE TABLE IF NOT EXISTS app.scaffold_scope_pending (
55
+ table_schema text NOT NULL,
56
+ table_name text NOT NULL,
57
+ scope text NOT NULL,
58
+ offending bigint NOT NULL,
59
+ noticed_at timestamptz NOT NULL DEFAULT now(),
60
+ PRIMARY KEY (table_schema, table_name)
61
+ );
62
+ COMMENT ON TABLE app.scaffold_scope_pending IS
63
+ 'Tables classified `unit` whose rows still hold a NULL unit_id, with the count that has to be backfilled before NOT NULL can land (#225). Empty is the goal; 900_verification asserts the contents so the list cannot grow without someone noticing.';
64
+
65
+ -- ── the predicate already knows the class ──────────────────────────────
66
+ -- app.scaffold_scope_sql has taken a third argument since 124, defaulted and
67
+ -- unused until now. What changes here is who passes it.
68
+
69
+ -- 134's refresh, now passing the class through
70
+ CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text)
71
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
72
+ DECLARE
73
+ v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
74
+ v_name text := split_part(replace(p_table, '"', ''), '.', 2);
75
+ v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
76
+ r app.scaffold_registry%ROWTYPE;
77
+ v_scope text;
78
+ v_class text;
79
+ BEGIN
80
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
81
+ IF NOT FOUND THEN
82
+ RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
83
+ END IF;
84
+
85
+ -- The `unit` rendering drops the `unit_id IS NULL OR` branch, which is only
86
+ -- sound when the column really is NOT NULL. Declaring a table `unit` while its
87
+ -- writers still produce NULLs is exactly the state this file leaves most tables
88
+ -- in — so the optimisation follows the CONSTRAINT, never the label. Getting
89
+ -- this wrong hid every unit-less financial movement and took
90
+ -- v_financial_customer_credit to 0.00.
91
+ SELECT CASE WHEN c.is_nullable = 'NO' THEN 'unit' ELSE NULL END INTO v_class
92
+ FROM information_schema.columns c
93
+ WHERE c.table_schema = v_schema AND c.table_name = v_name AND c.column_name = 'unit_id';
94
+ v_scope := app.scaffold_scope_sql(r.resource_type, r.plugin, v_class);
95
+
96
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
97
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
98
+ v_name || '_authz_select', v_q, v_scope, r.plugin || '.read');
99
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
100
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
101
+ v_name || '_authz_update', v_q, v_scope, r.plugin || '.edit', r.plugin || '.edit');
102
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
103
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
104
+ v_name || '_authz_delete', v_q, v_scope, r.plugin || '.delete');
105
+ END $$;
106
+ REVOKE ALL ON FUNCTION app.scaffold_refresh_policies(text) FROM public, anon, authenticated;
107
+
108
+
109
+ -- ── scaffold_table, re-emitted so registration always declares a class ──
110
+ -- Lifted from 124 by transformation again; the only change is the registry
111
+ -- INSERT.
112
+ CREATE OR REPLACE FUNCTION app.scaffold_table(
113
+ p_table regclass,
114
+ p_resource_type text,
115
+ p_plugin text,
116
+ p_owner_scoping boolean DEFAULT true,
117
+ p_mode text DEFAULT 'enforce'
118
+ )
119
+ RETURNS void
120
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
121
+ AS $$
122
+ DECLARE
123
+ v_schema text;
124
+ v_name text;
125
+ v_q text; -- quoted qualified name
126
+ v_prev text[] := '{}';
127
+ v_rows bigint;
128
+ r record;
129
+ v_scope text; -- the row-scope predicate shared by every policy
130
+ BEGIN
131
+ IF p_mode NOT IN ('shadow', 'enforce') THEN
132
+ RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
133
+ END IF;
134
+ SELECT n.nspname, c.relname INTO v_schema, v_name
135
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
136
+ v_q := format('%I.%I', v_schema, v_name);
137
+
138
+ -- ── columns
139
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
140
+ RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
141
+ END IF;
142
+ EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
143
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'tenant_id') THEN
144
+ EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
145
+ IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
146
+ END IF;
147
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
148
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
149
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
150
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
151
+
152
+ -- ── unique(tenant_id, id) — enables composite FKs from other tables
153
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
154
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
155
+ END IF;
156
+ -- ── composite FKs: unit within tenant, owner a member of the tenant
157
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
158
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL', v_q, v_name || '_unit_fk');
159
+ END IF;
160
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
161
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL', v_q, v_name || '_owner_fk');
162
+ END IF;
163
+
164
+ -- ── tenant-first indexes
165
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
166
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, owner_id) WHERE owner_id IS NOT NULL', v_name || '_tenant_owner_idx', v_q);
167
+
168
+ -- ── updated_at
169
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
170
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
171
+
172
+ -- ── RLS
173
+ EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
174
+ EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
175
+ IF p_mode = 'enforce' THEN
176
+ FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
177
+ AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
178
+ v_prev := v_prev || r.policyname;
179
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
180
+ END LOOP;
181
+ END IF;
182
+
183
+ v_scope := app.scaffold_scope_sql(p_resource_type, p_plugin);
184
+
185
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
186
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
187
+ v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
188
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
189
+ EXECUTE format('CREATE POLICY %I ON %s FOR INSERT TO authenticated WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
190
+ v_name || '_authz_insert', v_q, p_plugin || '.create');
191
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
192
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
193
+ v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
194
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
195
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
196
+ v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
197
+
198
+ -- scope defaults on REGISTRATION, so a table cannot exist in the registry
199
+ -- unclassified. A plugin's tables are scaffolded after the core chain has run,
200
+ -- so 136's DO block could never have reached them — they arrive here instead,
201
+ -- declared `unit_optional` until the plugin says otherwise.
202
+ INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, scope)
203
+ VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev, 'unit_optional')
204
+ ON CONFLICT (table_schema, table_name) DO UPDATE
205
+ SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
206
+ scope = coalesce(app.scaffold_registry.scope, EXCLUDED.scope),
207
+ mode = EXCLUDED.mode, applied_at = now(),
208
+ previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
209
+ THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
210
+ ELSE EXCLUDED.previous_policies END;
211
+ END $$;
212
+
213
+ -- ── declare a class, and make the schema say it ─────────────────────────
214
+ CREATE OR REPLACE FUNCTION app.scaffold_scope(p_table regclass, p_scope text, p_enforce boolean DEFAULT false)
215
+ RETURNS void
216
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
217
+ AS $$
218
+ DECLARE
219
+ v_schema text; v_name text; v_q text; v_bad bigint;
220
+ BEGIN
221
+ IF p_scope NOT IN ('unit', 'unit_optional', 'tenant') THEN
222
+ RAISE EXCEPTION 'scaffold_scope: scope must be unit | unit_optional | tenant';
223
+ END IF;
224
+ SELECT n.nspname, c.relname INTO v_schema, v_name
225
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
226
+ v_q := format('%I.%I', v_schema, v_name);
227
+
228
+ UPDATE app.scaffold_registry SET scope = p_scope
229
+ WHERE table_schema = v_schema AND table_name = v_name;
230
+ IF NOT FOUND THEN
231
+ RAISE EXCEPTION 'scaffold_scope: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
232
+ END IF;
233
+ DELETE FROM app.scaffold_scope_pending WHERE table_schema = v_schema AND table_name = v_name;
234
+
235
+ IF p_scope = 'unit' THEN
236
+ EXECUTE format('SELECT count(*) FROM %s WHERE unit_id IS NULL', v_q) INTO v_bad;
237
+ IF NOT p_enforce THEN
238
+ -- Declared, not enforced — and recorded, so it is a list and not a silence.
239
+ -- p_enforce is an explicit decision because "the table has no offending
240
+ -- rows" turned out to be worthless evidence: nine suites went red on the
241
+ -- first attempt at NOT NULL, and every one of them was an EMPTY table whose
242
+ -- writers do not fill unit_id. The constraint is about the writers.
243
+ INSERT INTO app.scaffold_scope_pending (table_schema, table_name, scope, offending)
244
+ VALUES (v_schema, v_name, p_scope, v_bad);
245
+ ELSIF v_bad > 0 THEN
246
+ RAISE EXCEPTION 'scaffold_scope: % was declared enforceable but holds % row(s) without a unit', v_q, v_bad;
247
+ ELSE
248
+ EXECUTE format('ALTER TABLE %s ALTER COLUMN unit_id SET NOT NULL', v_q);
249
+ END IF;
250
+
251
+ ELSIF p_scope = 'tenant' THEN
252
+ EXECUTE format('SELECT count(*) FROM %s WHERE unit_id IS NOT NULL', v_q) INTO v_bad;
253
+ IF v_bad = 0 THEN
254
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_id_null_chk') THEN
255
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I CHECK (unit_id IS NULL)', v_q, v_name || '_unit_id_null_chk');
256
+ END IF;
257
+ ELSE
258
+ INSERT INTO app.scaffold_scope_pending (table_schema, table_name, scope, offending)
259
+ VALUES (v_schema, v_name, p_scope, v_bad);
260
+ RAISE NOTICE '126: % has % row(s) with a unit_id but is classified tenant (recorded)', v_q, v_bad;
261
+ END IF;
262
+ END IF;
263
+
264
+ PERFORM app.scaffold_refresh_policies(v_q);
265
+ -- a masked view renders the same predicate, so it has to learn the class too
266
+ PERFORM app.masked_view_refresh(p_table, m.context, m.reference)
267
+ FROM app.masked_views m
268
+ WHERE m.table_schema = v_schema AND m.table_name = v_name;
269
+ END $$;
270
+ REVOKE ALL ON FUNCTION app.scaffold_scope(regclass, text, boolean) FROM public, anon, authenticated;
271
+
272
+ COMMENT ON FUNCTION app.scaffold_scope(regclass, text, boolean) IS
273
+ 'Declare what unit_id means on a scaffolded table and make the schema enforce it (#225). Folding this into app.scaffold_table''s signature waits for #224, which changes that signature anyway to add `distributable` — one signature change instead of two.';
274
+
275
+ -- ── classify all 47 ─────────────────────────────────────────────────────
276
+ -- ENFORCEMENT IS EVIDENCE, NOT INFERENCE.
277
+ --
278
+ -- The first version of this block applied NOT NULL wherever no row violated it.
279
+ -- Nine suites went red — orders, the migration ledger's extractor writers, two
280
+ -- financial suites — and every one of those tables was EMPTY when the constraint
281
+ -- landed. An empty table proves nothing about whether its writers fill the
282
+ -- column; it only proves nobody has written yet. So NOT NULL is applied to the
283
+ -- tables whose writers are demonstrated by the suites to fill a unit, and every
284
+ -- other `unit` table is declared and listed instead.
285
+ --
286
+ -- The declaration is most of the value on its own: it is what makes the NULL on
287
+ -- `holidays` legibly different from the NULL on `orders`, and it is what a
288
+ -- reader (and #224) can check.
289
+ DO $$
290
+ DECLARE
291
+ -- writers proven to fill a unit: the inventory ledger and the access grants
292
+ v_unit_enforced text[] := ARRAY[
293
+ 'plg_inventory_stock_locations','plg_inventory_stock_movements',
294
+ 'plg_inventory_stock_positions','plg_inventory_reservations','access_grants'];
295
+ -- declared `unit` — this is where it happened — but the writers have not been
296
+ -- audited yet, so the column stays nullable and the table stays on the list
297
+ v_unit_declared text[] := ARRAY[
298
+ 'appointments','appointment_items','orders','order_items','schedules',
299
+ 'plg_financial_movements','plg_financial_invoices','plg_financial_invoice_installments',
300
+ 'plg_financial_allocations','plg_financial_nfe','plg_financial_cash_register_sessions',
301
+ 'plg_financial_cash_session_reviewers'];
302
+ -- `transactions` is deliberately in neither: it is the pre-plugin money row
303
+ -- (#187) and its writers never learned about units. Declaring it `unit` would
304
+ -- describe an intention nobody holds. unit_optional until #187 moves that money
305
+ -- into the ledger, which is the change that would give each row a branch
306
+ -- honestly.
307
+ -- NOTHING in core is `tenant` today, and finding that out is the useful part.
308
+ --
309
+ -- The catalogue was going to be: 075 says in prose that it is "a tenant-wide
310
+ -- resource: unit_id is null by default", so a CHECK looked like that sentence
311
+ -- made enforceable. 085's own pgTAP says otherwise — 002_release_visibility
312
+ -- creates "Vela B2", a product that BELONGS to a branch, and asserts it behaves
313
+ -- that way. The RESTRICTIVE policy's `unit_id IS NOT NULL OR available_in…`
314
+ -- exists for exactly that row.
315
+ --
316
+ -- So "two ways to say only at Centro" was overstated, and this file is where it
317
+ -- gets corrected: `unit_id` set means the branch's OWN item, which is never
318
+ -- shared; a grant means the NETWORK's item, released there. Same visible
319
+ -- effect, different things, and only the second is distribution. `tenant` stays
320
+ -- in the vocabulary for #224 to apply where distribution really is the only
321
+ -- mechanism — the class is exercised by 055/004 either way.
322
+ v_tenant text[] := ARRAY[]::text[];
323
+ t text; v_n integer := 0;
324
+ BEGIN
325
+ FOREACH t IN ARRAY v_unit_enforced LOOP
326
+ CONTINUE WHEN to_regclass('public.' || t) IS NULL;
327
+ -- The spine runs before the plugins, so on a FRESH pool a plugin's table is
328
+ -- simply absent here and the guard above skips it. On a pool that already
329
+ -- carries an older install of that plugin the table IS present while its own
330
+ -- scaffold file has not run yet, and scaffold_scope refuses a table it has
331
+ -- never registered. Skipping is right in both cases: the plugin's scaffold
332
+ -- classifies it when it runs, a few files later in this same chain.
333
+ CONTINUE WHEN NOT EXISTS (
334
+ SELECT 1 FROM app.scaffold_registry
335
+ WHERE table_schema = 'public' AND table_name = t);
336
+ PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit', true); v_n := v_n + 1;
337
+ END LOOP;
338
+ FOREACH t IN ARRAY v_unit_declared LOOP
339
+ CONTINUE WHEN to_regclass('public.' || t) IS NULL;
340
+ CONTINUE WHEN NOT EXISTS (SELECT 1 FROM app.scaffold_registry
341
+ WHERE table_schema = 'public' AND table_name = t);
342
+ PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit', false); v_n := v_n + 1;
343
+ END LOOP;
344
+ FOREACH t IN ARRAY v_tenant LOOP
345
+ CONTINUE WHEN to_regclass('public.' || t) IS NULL;
346
+ CONTINUE WHEN NOT EXISTS (SELECT 1 FROM app.scaffold_registry
347
+ WHERE table_schema = 'public' AND table_name = t);
348
+ PERFORM app.scaffold_scope(('public.' || t)::regclass, 'tenant'); v_n := v_n + 1;
349
+ END LOOP;
350
+ FOR t IN SELECT r.table_name FROM app.scaffold_registry r
351
+ WHERE r.scope IS NULL AND r.table_schema = 'public'
352
+ AND to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NOT NULL LOOP
353
+ -- No registry guard here: this loop reads FROM the registry.
354
+ PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit_optional'); v_n := v_n + 1;
355
+ END LOOP;
356
+ RAISE NOTICE '126: classified % scaffolded table(s); % awaiting a writer audit',
357
+ v_n, (SELECT count(*) FROM app.scaffold_scope_pending);
358
+ END $$;
@@ -0,0 +1,126 @@
1
+ -- ============================================================================
2
+ -- 137_vendor_named_spine_objects.sql — the schema stops naming the vendor (#181).
3
+ --
4
+ -- `public.fayz_projects` holds THE SITE A TENANT PUBLISHES. The provider might be
5
+ -- Fayz, Wix, WordPress or a custom host — that is an attribute of the row, not
6
+ -- the identity of the table. Same for the search registry.
7
+ --
8
+ -- public.fayz_projects → public.published_sites
9
+ -- fayz_project_id → provider_site_id
10
+ -- (new) → provider text NOT NULL DEFAULT 'fayz'
11
+ -- public.fayz_search_sources → public.search_sources
12
+ -- public.fayz_global_search() → public.global_search()
13
+ --
14
+ -- WHAT DELIBERATELY KEEPS THE PREFIX, and why it is not the same smell:
15
+ --
16
+ -- fayz_norm / fayz_digits / fayz_cat — SQL helpers whose names are baked into
17
+ -- the `haystack_expr` STRINGS stored in the registry rows. Renaming them is a
18
+ -- data migration of every deployed row, for functions that are internal
19
+ -- plumbing rather than an entity anyone names. Not worth the blast radius.
20
+ -- fayz_audit_prune / fayz_authz_reconcile / fayz_sync_* — pg_cron job names.
21
+ -- Cron lives in a database shared with everything else on the instance; there
22
+ -- the prefix IS the namespace, which is the opposite of a smell.
23
+ -- fayz_functions_url / fayz_scheduler_secret — vault secret keys. Same
24
+ -- argument: a shared keyspace wants a prefix.
25
+ --
26
+ -- NO COMPAT VIEW UNDER THE OLD NAME. 097 settled this: frozen migrations re-run
27
+ -- `ALTER TABLE` and `CREATE POLICY` against the old name on a replay, and those
28
+ -- fail on a view. A guarded rename converges instead — it fires only when the old
29
+ -- name is there and the new one is not.
30
+ --
31
+ -- ⚠ CROSS-REPO: the platform (ymaia) writes these rows. There is no compat layer,
32
+ -- so ymaia's writers move to `published_sites` / `provider_site_id` in the same
33
+ -- deploy. That coordination is the whole reason #181 carried `decision-needed`.
34
+ --
35
+ -- Replay-safe: every step guarded on the object's current name.
36
+ -- ============================================================================
37
+
38
+ -- ── public.fayz_projects → public.published_sites ───────────────────────
39
+ DO $$
40
+ BEGIN
41
+ IF to_regclass('public.fayz_projects') IS NOT NULL AND to_regclass('public.published_sites') IS NULL THEN
42
+ ALTER TABLE public.fayz_projects RENAME TO published_sites;
43
+
44
+ IF EXISTS (SELECT 1 FROM information_schema.columns
45
+ WHERE table_schema = 'public' AND table_name = 'published_sites' AND column_name = 'fayz_project_id') THEN
46
+ ALTER TABLE public.published_sites RENAME COLUMN fayz_project_id TO provider_site_id;
47
+ END IF;
48
+
49
+ ALTER INDEX IF EXISTS public.fayz_projects_tenant_idx RENAME TO published_sites_tenant_idx;
50
+ ALTER INDEX IF EXISTS public.fayz_projects_one_default_per_tenant RENAME TO published_sites_one_default_per_tenant;
51
+ ALTER INDEX IF EXISTS public.fayz_projects_pkey RENAME TO published_sites_pkey;
52
+
53
+ DROP TRIGGER IF EXISTS fayz_projects_updated_at ON public.published_sites;
54
+ DROP TRIGGER IF EXISTS published_sites_updated_at ON public.published_sites;
55
+ CREATE TRIGGER published_sites_updated_at BEFORE UPDATE ON public.published_sites
56
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
57
+
58
+ DROP POLICY IF EXISTS "fayz_projects_member_all" ON public.published_sites;
59
+ DROP POLICY IF EXISTS "published_sites_member_all" ON public.published_sites;
60
+ CREATE POLICY "published_sites_member_all" ON public.published_sites FOR ALL TO authenticated
61
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
62
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
63
+
64
+ RAISE NOTICE '127: fayz_projects → published_sites';
65
+ END IF;
66
+ END $$;
67
+
68
+ -- the provider becomes a column, which is the point of the rename
69
+ ALTER TABLE public.published_sites ADD COLUMN IF NOT EXISTS provider text NOT NULL DEFAULT 'fayz';
70
+
71
+ COMMENT ON TABLE public.published_sites IS
72
+ 'A site or app a tenant publishes. `provider` says who hosts it (fayz | wix | wordpress | custom | …) and `provider_site_id` is that provider''s own id — the vendor is an attribute here, never the table''s identity (#181).';
73
+ COMMENT ON COLUMN public.published_sites.provider IS
74
+ 'Who hosts this site. Defaults to fayz because every row that existed before #181 was a fayz project.';
75
+ COMMENT ON COLUMN public.published_sites.provider_site_id IS
76
+ 'The provider''s own id for this site (was fayz_project_id). Text: each provider issues its own.';
77
+
78
+ -- ── public.fayz_search_sources → public.search_sources ──────────────────
79
+ DO $$
80
+ BEGIN
81
+ IF to_regclass('public.fayz_search_sources') IS NOT NULL AND to_regclass('public.search_sources') IS NULL THEN
82
+ ALTER TABLE public.fayz_search_sources RENAME TO search_sources;
83
+ ALTER INDEX IF EXISTS public.fayz_search_sources_pkey RENAME TO search_sources_pkey;
84
+ DROP POLICY IF EXISTS "fayz_search_sources_read" ON public.search_sources;
85
+ DROP POLICY IF EXISTS "search_sources_read" ON public.search_sources;
86
+ CREATE POLICY "search_sources_read" ON public.search_sources FOR SELECT TO authenticated USING (true);
87
+ RAISE NOTICE '127: fayz_search_sources → search_sources';
88
+ END IF;
89
+ END $$;
90
+
91
+ COMMENT ON TABLE public.search_sources IS
92
+ 'Registry of searchable relations for public.global_search(). Readable by any member, writable only by the migration role — the expressions are executed as SQL.';
93
+
94
+ -- ── public.fayz_global_search() → public.global_search() ────────────────
95
+ -- Renamed by re-emitting under the new name and dropping the old, rather than
96
+ -- ALTER FUNCTION … RENAME: the body reads `fayz_search_sources`, which no longer
97
+ -- exists, so it has to be rewritten anyway.
98
+ DO $$
99
+ DECLARE v_src text; v_args text;
100
+ BEGIN
101
+ -- Already renamed? Then leave both names alone. 141 puts a compat WRAPPER back
102
+ -- under `fayz_global_search`, so a re-run of this file finds that wrapper and
103
+ -- would try to convert it into `global_search` a second time — over a function
104
+ -- that exists, with a signature that no longer matches, which fails on the
105
+ -- parameter defaults. The rename is done; the old name answering again is the
106
+ -- bridge doing its job, not work left over.
107
+ IF to_regproc('public.global_search') IS NOT NULL THEN
108
+ RETURN;
109
+ END IF;
110
+
111
+ SELECT pg_get_functiondef(p.oid), pg_get_function_identity_arguments(p.oid)
112
+ INTO v_src, v_args
113
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
114
+ WHERE n.nspname = 'public' AND p.proname = 'fayz_global_search'
115
+ LIMIT 1;
116
+
117
+ IF v_src IS NOT NULL THEN
118
+ v_src := replace(v_src, 'public.fayz_global_search', 'public.global_search');
119
+ v_src := replace(v_src, 'fayz_search_sources', 'search_sources');
120
+ EXECUTE v_src;
121
+ EXECUTE format('DROP FUNCTION IF EXISTS public.fayz_global_search(%s)', v_args);
122
+ EXECUTE 'REVOKE ALL ON FUNCTION public.global_search(' || v_args || ') FROM public, anon';
123
+ EXECUTE 'GRANT EXECUTE ON FUNCTION public.global_search(' || v_args || ') TO authenticated, service_role';
124
+ RAISE NOTICE '127: fayz_global_search() → global_search()';
125
+ END IF;
126
+ END $$;
@@ -0,0 +1,178 @@
1
+ -- ============================================================================
2
+ -- 138_grants_inherit_down_the_tree.sql — distribution follows the hierarchy that
3
+ -- access already follows (#227).
4
+ --
5
+ -- `user_unit_access` fans out by subtree: managing `f1` gives you `f1` and every
6
+ -- branch under it, `via = 'subtree'`. `has_grant` did not: it matched a unit
7
+ -- tuple only when the granted unit was LITERALLY one of your access rows. So:
8
+ --
9
+ -- grant names f1, caller manages f1 → matched
10
+ -- grant names b1, caller manages f1 → matched (b1 arrived by subtree)
11
+ -- grant names f1, caller works at b1 → DID NOT MATCH ← the bug
12
+ --
13
+ -- "Release this service to the whole f1 region" reached the regional manager and
14
+ -- not the people who work in its branches — the ones meant to sell it. Nobody
15
+ -- reads that off the schema: you grant to a region, test as someone who holds
16
+ -- the region, and ship.
17
+ --
18
+ -- PRECEDENCE, which is the part that needed deciding
19
+ --
20
+ -- Walking a chain means allow and deny can meet at different depths, so:
21
+ --
22
+ -- MOST SPECIFIC WINS. For each unit in scope, walk from the unit up to the
23
+ -- root and stop at the first level carrying any tuple. Deny beats allow AT
24
+ -- THAT LEVEL.
25
+ --
26
+ -- That is what makes both directions sayable: "the whole f1 region except b2" is
27
+ -- deny at f1 + allow at b2, and "nowhere except b1" is one allow at b1. A blanket
28
+ -- deny-always-wins would make the first one unsayable, and #223's rule ("deny
29
+ -- wins") survives intact — it was always a statement about one level.
30
+ --
31
+ -- User- and role-subject tuples sit outside the walk entirely: the subject is the
32
+ -- person, and a person is not in the tree.
33
+ --
34
+ -- Implementation (a) of the two the issue listed: walk at query time. The tree is
35
+ -- shallow — tenant → region → branch — so this is a handful of index lookups, and
36
+ -- it adds no second projection to keep honest. `user_unit_access` is materialised
37
+ -- because it is read for every row of every query; a grant chain is read once per
38
+ -- resource, which is a different problem.
39
+ --
40
+ -- Replay-safe: CREATE OR REPLACE only.
41
+ -- ============================================================================
42
+
43
+ -- ── the chain from a unit up to its root ────────────────────────────────
44
+ CREATE OR REPLACE FUNCTION app.unit_chain(p_unit uuid)
45
+ RETURNS TABLE (unit_id uuid, depth integer)
46
+ LANGUAGE sql STABLE SECURITY DEFINER
47
+ SET search_path = ''
48
+ AS $$
49
+ WITH RECURSIVE up AS (
50
+ SELECT u.id, u.parent_unit_id, 0 AS depth
51
+ FROM app.units u WHERE u.id = p_unit
52
+ UNION ALL
53
+ SELECT u.id, u.parent_unit_id, up.depth + 1
54
+ FROM app.units u JOIN up ON u.id = up.parent_unit_id
55
+ WHERE up.depth < 32 -- a cycle cannot happen (parent <> id, FK per tenant), but a bound is cheap
56
+ )
57
+ SELECT up.id, up.depth FROM up;
58
+ $$;
59
+ REVOKE ALL ON FUNCTION app.unit_chain(uuid) FROM public, anon;
60
+ GRANT EXECUTE ON FUNCTION app.unit_chain(uuid) TO authenticated, service_role;
61
+
62
+ COMMENT ON FUNCTION app.unit_chain(uuid) IS
63
+ 'A unit and its ancestors, nearest first (#227). Distribution tuples are resolved along this chain so a release to a region reaches its branches.';
64
+
65
+ -- ── what the tuples say about one unit ──────────────────────────────────
66
+ -- 'allow' | 'deny' | 'none'. The first level of the chain carrying any tuple
67
+ -- decides, and deny beats allow at that level.
68
+ CREATE OR REPLACE FUNCTION app.unit_release_verdict(p_type text, p_id uuid, p_unit uuid)
69
+ RETURNS text
70
+ LANGUAGE sql STABLE SECURITY DEFINER
71
+ SET search_path = ''
72
+ AS $$
73
+ SELECT coalesce(
74
+ (SELECT CASE WHEN bool_or(g.relation = 'unavailable_in') THEN 'deny' ELSE 'allow' END
75
+ FROM app.unit_chain(p_unit) c
76
+ JOIN app.resource_grants g
77
+ ON g.tenant_id = app.current_tenant_id()
78
+ AND g.resource_type = p_type AND g.resource_id = p_id
79
+ AND g.subject_type = 'unit' AND g.subject_id = c.unit_id
80
+ AND g.relation IN ('available_in', 'unavailable_in')
81
+ GROUP BY c.depth
82
+ ORDER BY c.depth
83
+ LIMIT 1),
84
+ 'none');
85
+ $$;
86
+ REVOKE ALL ON FUNCTION app.unit_release_verdict(text, uuid, uuid) FROM public, anon;
87
+ GRANT EXECUTE ON FUNCTION app.unit_release_verdict(text, uuid, uuid) TO authenticated, service_role;
88
+
89
+ COMMENT ON FUNCTION app.unit_release_verdict(text, uuid, uuid) IS
90
+ 'allow | deny | none for one unit (#227): the nearest level of its chain that carries a distribution tuple decides, and deny beats allow at that level. Most specific wins, which is what makes "the whole region except one branch" sayable.';
91
+
92
+ -- ── the release rule, resolved along the tree ───────────────────────────
93
+ -- Replaces the flat version of 125. The three shapes it has to keep true:
94
+ -- no tuples at all → available (the default that predates #223)
95
+ -- only inclusions → available where one names a unit in scope
96
+ -- inclusions and exclusions mixed → nearest level decides, per unit in scope
97
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
98
+ RETURNS boolean
99
+ LANGUAGE sql STABLE SECURITY DEFINER
100
+ SET search_path = ''
101
+ AS $$
102
+ WITH me AS (
103
+ SELECT app.current_tenant_id() AS tenant_id,
104
+ auth.uid() AS uid,
105
+ nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
106
+ ),
107
+ tup AS (
108
+ SELECT g.relation, g.subject_type, g.subject_id
109
+ FROM app.resource_grants g, me
110
+ WHERE g.tenant_id = me.tenant_id AND g.resource_type = p_type AND g.resource_id = p_id
111
+ AND g.relation IN ('available_in', 'unavailable_in')
112
+ ),
113
+ -- me, or a role of mine: outside the tree, so it simply decides
114
+ direct AS (
115
+ SELECT t.relation FROM tup t, me
116
+ WHERE (t.subject_type = 'user' AND t.subject_id = me.uid)
117
+ OR (t.subject_type = 'role' AND t.subject_id IN (
118
+ SELECT m.role_id FROM app.memberships m
119
+ WHERE m.tenant_id = me.tenant_id AND m.user_id = me.uid AND m.active
120
+ UNION
121
+ SELECT mu.role_override_id FROM app.membership_units mu
122
+ WHERE mu.tenant_id = me.tenant_id AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL))
123
+ ),
124
+ mine AS (
125
+ SELECT a.unit_id FROM app.user_unit_access a, me
126
+ WHERE a.tenant_id = me.tenant_id AND a.user_id = me.uid
127
+ AND (me.focus IS NULL OR a.unit_id = me.focus)
128
+ ),
129
+ verdicts AS (
130
+ SELECT app.unit_release_verdict(p_type, p_id, mine.unit_id) AS v FROM mine
131
+ )
132
+ SELECT NOT EXISTS (SELECT 1 FROM direct WHERE relation = 'unavailable_in')
133
+ AND (
134
+ EXISTS (SELECT 1 FROM direct WHERE relation = 'available_in')
135
+ OR EXISTS (SELECT 1 FROM verdicts WHERE v = 'allow')
136
+ OR (NOT EXISTS (SELECT 1 FROM tup WHERE relation = 'available_in')
137
+ AND (EXISTS (SELECT 1 FROM verdicts WHERE v = 'none') OR NOT EXISTS (SELECT 1 FROM mine)))
138
+ );
139
+ $$;
140
+
141
+ COMMENT ON FUNCTION app.available_in_my_units(text, uuid) IS
142
+ 'The release rule, resolved along the unit tree (#223 + #227): no tuples ⇒ everywhere; otherwise the nearest level of each in-scope unit''s chain decides, deny beating allow at that level. A caller holding no units is not excluded by a rule about units.';
143
+
144
+ -- ── has_grant: a unit tuple reaches the branches under it ───────────────
145
+ -- 135's version with the ancestor walk. `shared_with` inherits for the same
146
+ -- reason distribution does: sharing a record with a region has to mean sharing it
147
+ -- with the people who work there, or the word means nothing.
148
+ CREATE OR REPLACE FUNCTION app.has_grant(p_type text, p_id uuid, p_relations text[])
149
+ RETURNS boolean
150
+ LANGUAGE sql STABLE SECURITY DEFINER
151
+ SET search_path = ''
152
+ AS $$
153
+ SELECT EXISTS (
154
+ SELECT 1
155
+ FROM app.resource_grants g
156
+ WHERE g.tenant_id = app.current_tenant_id()
157
+ AND g.resource_type = p_type
158
+ AND g.resource_id = p_id
159
+ AND g.relation = ANY (p_relations)
160
+ AND (
161
+ (g.subject_type = 'user' AND g.subject_id = auth.uid())
162
+ OR (g.subject_type = 'unit' AND EXISTS (
163
+ SELECT 1 FROM app.user_unit_access a
164
+ WHERE a.tenant_id = g.tenant_id AND a.user_id = auth.uid()
165
+ AND (app.current_unit_id() IS NULL OR a.unit_id = app.current_unit_id())
166
+ AND EXISTS (SELECT 1 FROM app.unit_chain(a.unit_id) c WHERE c.unit_id = g.subject_id)))
167
+ OR (g.subject_type = 'role' AND g.subject_id IN (
168
+ SELECT m.role_id FROM app.memberships m
169
+ WHERE m.tenant_id = g.tenant_id AND m.user_id = auth.uid() AND m.active
170
+ UNION
171
+ SELECT mu.role_override_id FROM app.membership_units mu
172
+ WHERE mu.tenant_id = g.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL))
173
+ )
174
+ );
175
+ $$;
176
+
177
+ COMMENT ON FUNCTION app.has_grant(text, uuid, text[]) IS
178
+ 'resource_grants where the subject is me, a role of mine, or a unit at or above one in scope (#220/#223/#227). Inheritance runs DOWN the tree: a tuple naming a region reaches the branches under it.';