@fayz-ai/db 0.12.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 (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,205 @@
1
+ -- 125_migration_authz_people_writers.sql
2
+ --
3
+ -- The last of the extractor's destinations get an answer. #137 / #136 / #148.
4
+ --
5
+ -- Three writers, and six decisions. The decisions are the point of this file:
6
+ -- every one of them was a row that would otherwise have sat `pending` forever,
7
+ -- and most of them turn out to say the same thing.
8
+ --
9
+ -- THE IDENTITY BRIDGE THAT DOES NOT EXIST. `app.memberships.user_id` is an FK to
10
+ -- `auth.users`. Nothing in the allowlist maps a V1 login to a V2 one -- `profiles`
11
+ -- explicitly drops its id (`"id":"-"`) -- so there is no way to say which V2
12
+ -- account a V1 membership belongs to. That single gap is why `license_users`,
13
+ -- `employee_permission_rules`, `professional_permission_rules` and (in 114)
14
+ -- `cash_register_sessions` are all deferred: each needs to attribute something to
15
+ -- a person's LOGIN, and the login does not cross. It is one missing mapping, not
16
+ -- four missing writers, and it is worth naming as such rather than four times.
17
+ --
18
+ -- WHAT IS WRITTEN:
19
+ --
20
+ -- permission_rules -> app.roles. A V1 rule IS a tenant role, and 091 says so.
21
+ -- permission_rule_details -> app.role_permissions, TRANSLATED through
22
+ -- `app.v1_permission_map` -- the table #136 built for exactly this. A V1 code
23
+ -- with no V2 key quarantines rather than being invented, which is what the
24
+ -- map's `backlog` rows already mean.
25
+ -- client_registrations -> public.person_registrations, which is where a
26
+ -- pre-registration belongs. 091 pointed it at `public.people`, and that is
27
+ -- wrong in a way that matters: a registration is a QUEUE ENTRY awaiting dedup
28
+ -- (#137's whole slice), not a Person. Writing it as a Person would skip the
29
+ -- dedup and mint duplicates of people who already exist.
30
+ --
31
+ -- WHAT IS NOT, AND WHY:
32
+ --
33
+ -- licenses and system_permissions become `verify_only`. Both destinations are
34
+ -- built by something other than the extractor -- a tenant is provisioned by a
35
+ -- human before `batch_open`, and the permission catalog is GENERATED from
36
+ -- declaredFeatures (#136) -- so the extractor's job is to prove they agree,
37
+ -- not to write them. Their own notes already said so.
38
+ -- service_locations is deferred by its own note: "a service location is a place
39
+ -- INSIDE a unit, not a unit". Pointing it at `app.units` would turn every
40
+ -- treatment room into a branch.
41
+ -- client_access_tokens: the note says the token value never migrates and is
42
+ -- re-issued in V2, which leaves nothing to write.
43
+ -- professions is a reference list, like origins and partnerships (#110).
44
+ -- profiles: its own note ends "no writer" -- the login profile is folded into
45
+ -- the Person of the member.
46
+ --
47
+ -- Replay-safe: `_allow` upserts under the documented unlock; writers are
48
+ -- CREATE OR REPLACE.
49
+
50
+ ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;
51
+ ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
52
+ CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice',
53
+ 'price_table', 'price_table_item', 'package', 'package_item',
54
+ 'field_rule', 'holiday', 'facet',
55
+ 'fin_account', 'fin_payment_method_type', 'fin_payment_method',
56
+ 'fin_card_brand', 'fin_chart_account', 'fin_cost_center',
57
+ 'inv_measurement_unit', 'inv_unit_conversion', 'inv_location', 'inv_movement',
58
+ 'order_item', 'fin_movement',
59
+ 'authz_role', 'authz_role_permission', 'person_registration'));
60
+
61
+ DO $unlock$
62
+ BEGIN
63
+ PERFORM set_config('migration.allowlist_unlock', 'on', true);
64
+
65
+ UPDATE migration.allowlist SET writer = 'authz_role' WHERE source_table = 'permission_rules';
66
+ UPDATE migration.allowlist SET writer = 'authz_role_permission' WHERE source_table = 'permission_rule_details';
67
+
68
+ UPDATE migration.allowlist SET writer = 'person_registration', target_table = 'public.person_registrations',
69
+ notes = 'PRD 03 R6: a pre-registration is a QUEUE ENTRY awaiting dedup, not a Person. 091 pointed it at public.people, which would skip the dedup and mint duplicates of people who already exist (#115).'
70
+ WHERE source_table = 'client_registrations';
71
+
72
+ UPDATE migration.allowlist SET writer_args = '{"verify_only":true}'::jsonb,
73
+ notes = 'ADR 0002: a tenant is provisioned by a human before batch_open. The extractor proves the map, it does not create tenants — this row''s own note said so (#115).'
74
+ WHERE source_table = 'licenses';
75
+ UPDATE migration.allowlist SET writer_args = '{"verify_only":true}'::jsonb,
76
+ notes = '#136: the permission catalog is GENERATED from declaredFeatures and seeded by migrations, not migrated row by row. The V1 codes are translated through app.v1_permission_map (#115).'
77
+ WHERE source_table = 'system_permissions';
78
+
79
+ DELETE FROM migration.allowlist
80
+ WHERE source_table IN ('service_locations', 'license_users', 'employee_permission_rules',
81
+ 'professional_permission_rules', 'client_access_tokens', 'professions', 'profiles');
82
+ END $unlock$;
83
+
84
+ INSERT INTO migration.excluded_tables (source_table, reason) VALUES
85
+ ('service_locations', 'Deferred by its own note: "a service location is a place INSIDE a unit, not a unit". 091 pointed it at app.units, which would turn every treatment room into a branch. It belongs to the owning plugin as an extension (#115).'),
86
+ ('license_users', 'Deferred: app.memberships.user_id is an FK to auth.users, and NOTHING in the allowlist maps a V1 login to a V2 one — profiles explicitly drops its id. Until that bridge exists a membership cannot say whose it is (#115).'),
87
+ ('employee_permission_rules', 'Deferred with license_users: same missing V1->V2 login mapping (#115).'),
88
+ ('professional_permission_rules', 'Deferred with license_users: same missing V1->V2 login mapping (#115).'),
89
+ ('client_access_tokens', 'Deferred by its own note: portal tokens are opaque and re-issued in V2, so the token value never migrates — which leaves nothing to write (#115).'),
90
+ ('professions', 'Deferred: a reference list folded into the professional role, like origins and partnerships (#110). The value reaches V2 inside the person that references it (#115).'),
91
+ ('profiles', 'Deferred by its own note: the login profile is folded into the Person of the member (memberships.person_id, PRD 03 R3). There is no separate row to write (#115).')
92
+ ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
93
+
94
+ CREATE OR REPLACE FUNCTION migration.upsert_authz_role(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
95
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
96
+ DECLARE
97
+ v_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');
98
+ v_key text;
99
+ v_existing uuid; v_created boolean := false;
100
+ v_standalone boolean := migration._standalone();
101
+ BEGIN
102
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
103
+ IF p_id IS NULL OR v_name IS NULL THEN
104
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a role needs an id and a name');
105
+ END IF;
106
+ -- the key is derived from the name, because V1 rules have no key. A collision
107
+ -- with one of the system roles is refused rather than merged: 'owner' means
108
+ -- something specific in V2 and a V1 rule called Owner is not it.
109
+ -- fold the accents first: a plain [^a-z0-9] strip turns "Caixa Sênior" into
110
+ -- caixa_s_nior. translate() rather than unaccent() because the extension is not
111
+ -- guaranteed on a pool and a key is not worth a dependency.
112
+ v_key := translate(lower(v_name),
113
+ 'áàâãäéèêëíìîïóòôõöúùûüçñ',
114
+ 'aaaaaeeeeiiiiooooouuuucn');
115
+ v_key := regexp_replace(v_key, '[^a-z0-9]+', '_', 'g');
116
+ v_key := btrim(v_key, '_');
117
+ IF v_key = '' THEN v_key := 'role_' || left(replace(p_id::text, '-', ''), 8); END IF;
118
+ IF EXISTS (SELECT 1 FROM app.roles r WHERE r.tenant_id = p_tenant AND r.key = v_key AND r.is_system AND r.id <> p_id) THEN
119
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
120
+ 'reason', format('a V1 rule named %L collides with the system role %L, which means something specific in V2', v_name, v_key));
121
+ END IF;
122
+ SELECT tenant_id INTO v_existing FROM app.roles WHERE id = p_id;
123
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
124
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('role %s already exists in another tenant', p_id));
125
+ END IF;
126
+ IF v_existing IS NULL THEN
127
+ INSERT INTO app.roles (id, tenant_id, key, name, is_system) VALUES (p_id, p_tenant, v_key, v_name, false);
128
+ v_created := true;
129
+ ELSE
130
+ UPDATE app.roles SET name = v_name WHERE id = p_id AND NOT is_system;
131
+ END IF;
132
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'app.roles', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
133
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
134
+ END $$;
135
+
136
+ CREATE OR REPLACE FUNCTION migration.upsert_authz_role_permission(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
137
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
138
+ DECLARE
139
+ v_role uuid := (p_row ->> 'role_id')::uuid;
140
+ v_v1 text := nullif(btrim(coalesce(p_row ->> 'permission', '')), '');
141
+ v_key text; v_state text;
142
+ v_standalone boolean := migration._standalone();
143
+ BEGIN
144
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
145
+ IF v_role IS NULL OR v_v1 IS NULL THEN
146
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a role permission needs its role and its V1 code');
147
+ END IF;
148
+ IF NOT EXISTS (SELECT 1 FROM app.roles r WHERE r.id = v_role AND r.tenant_id = p_tenant) THEN
149
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', format('role %s is not in this tenant yet', v_role));
150
+ END IF;
151
+ -- #136 built app.v1_permission_map for exactly this translation. A code with no
152
+ -- V2 key is not invented: the map's own `backlog` rows say those codes have no
153
+ -- V2 surface yet, and granting a key that does not exist would fail the FK
154
+ -- anyway — quarantining says which code, which is actionable.
155
+ SELECT m.v2_key, m.status INTO v_key, v_state FROM app.v1_permission_map m WHERE m.v1_code = v_v1;
156
+ IF v_key IS NULL THEN
157
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
158
+ 'reason', format('V1 permission %L has no V2 key in app.v1_permission_map (status %s)', v_v1, coalesce(v_state, 'unmapped')));
159
+ END IF;
160
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission) VALUES (p_tenant, v_role, v_key)
161
+ ON CONFLICT DO NOTHING;
162
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'app.role_permissions', v_role, jsonb_build_object('reason', 'granted', 'permission', v_key)); END IF;
163
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_role, 'reason', format('granted %s', v_key));
164
+ END $$;
165
+
166
+ CREATE OR REPLACE FUNCTION migration.upsert_person_registration(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
167
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
168
+ DECLARE
169
+ v_payload jsonb;
170
+ v_existing uuid; v_created boolean := false;
171
+ v_standalone boolean := migration._standalone();
172
+ BEGIN
173
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
174
+ IF p_id IS NULL THEN
175
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a registration needs an id');
176
+ END IF;
177
+ -- the queue entry keeps the form as submitted; dedup happens later, through
178
+ -- person_registrations_process, which is the whole point of not writing a Person
179
+ v_payload := jsonb_strip_nulls(jsonb_build_object(
180
+ 'name', p_row ->> 'name', 'email', p_row ->> 'email', 'phone', p_row ->> 'phone',
181
+ 'document_number', p_row ->> 'document_number', 'source', 'migration:v1'));
182
+ SELECT tenant_id INTO v_existing FROM public.person_registrations WHERE id = p_id;
183
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
184
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('registration %s already exists in another tenant', p_id));
185
+ END IF;
186
+ IF v_existing IS NULL THEN
187
+ INSERT INTO public.person_registrations (id, tenant_id, unit_id, payload)
188
+ VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_payload);
189
+ v_created := true;
190
+ ELSE
191
+ UPDATE public.person_registrations SET payload = payload || v_payload WHERE id = p_id AND status = 'pending';
192
+ END IF;
193
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.person_registrations', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
194
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
195
+ END $$;
196
+
197
+ DO $$
198
+ DECLARE r record;
199
+ BEGIN
200
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
201
+ WHERE n.nspname = 'migration' AND p.proname IN ('upsert_authz_role', 'upsert_authz_role_permission', 'upsert_person_registration') LOOP
202
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', r.sig);
203
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', r.sig);
204
+ END LOOP;
205
+ END $$;
@@ -0,0 +1,247 @@
1
+ -- 126_owner_scoping_is_configuration.sql
2
+ --
3
+ -- `owner_scoping` stops being a column nobody reads. #135.
4
+ --
5
+ -- The criterion is "vertical differences are configuration: salon template
6
+ -- owner_id off, clinic on". What shipped records `owner_scoping` per table in
7
+ -- `app.scaffold_registry` and then never consults it — the audit's finding, and
8
+ -- `053`'s own comment says it in the future tense ("owner_scoping is recorded so
9
+ -- a template CAN say which"). The actual difference between a salon and a clinic
10
+ -- today is a DATA convention: the clinic fills `owner_id`, the salon leaves it
11
+ -- null, and the identical policy therefore behaves differently. That works, and
12
+ -- it is not configuration — nothing declares it, nothing can check it, and a
13
+ -- salon row that acquires an owner_id by accident silently becomes private.
14
+ --
15
+ -- One table serves every tenant, so DDL cannot vary per tenant: the variation
16
+ -- has to be a LOOKUP. `app.owner_scoped(resource_type)` reads the tenant's
17
+ -- `role_template` and answers for that resource, and the policy template asks it
18
+ -- before applying the owner clause at all. A salon's people are visible to the
19
+ -- unit because its vertical says so, not because every row happens to be
20
+ -- unowned.
21
+ --
22
+ -- Defaults are the safe direction: an unknown template, or a resource with no
23
+ -- row, falls back to the registry's own `owner_scoping` flag, which defaults
24
+ -- true. Configuration can only ever RELAX the clause, never tighten it by
25
+ -- omission.
26
+ --
27
+ -- Every registered table is re-scaffolded at the end so the new template lands
28
+ -- on all of them at once — the registry records the arguments each was created
29
+ -- with, which is what makes that possible.
30
+ --
31
+ -- Replay-safe: CREATE OR REPLACE + an idempotent seed + a re-scaffold that
32
+ -- rewrites the same four policies.
33
+
34
+ CREATE TABLE IF NOT EXISTS app.vertical_owner_scoping (
35
+ role_template text NOT NULL,
36
+ resource_type text NOT NULL,
37
+ owner_scoped boolean NOT NULL,
38
+ note text,
39
+ PRIMARY KEY (role_template, resource_type)
40
+ );
41
+ ALTER TABLE app.vertical_owner_scoping ENABLE ROW LEVEL SECURITY;
42
+ GRANT SELECT ON app.vertical_owner_scoping TO authenticated;
43
+ DROP POLICY IF EXISTS vertical_owner_scoping_select ON app.vertical_owner_scoping;
44
+ CREATE POLICY vertical_owner_scoping_select ON app.vertical_owner_scoping FOR SELECT TO authenticated USING (true);
45
+
46
+ COMMENT ON TABLE app.vertical_owner_scoping IS
47
+ 'Which resource types a vertical scopes by owner (#135). One table serves every tenant, so this is the only place the salon/clinic difference can live as configuration rather than as a data convention. Absent row ⇒ the scaffold registry''s own flag, which defaults true.';
48
+
49
+ INSERT INTO app.vertical_owner_scoping (role_template, resource_type, owner_scoped, note) VALUES
50
+ ('clinic', 'people.person', true, 'A professional sees her own patients; PRD 03 / the clinic template.'),
51
+ ('salon', 'people.person', false, 'The unit decides; a salon''s people are shared across the team.'),
52
+ ('restaurant', 'people.person', false, 'The unit decides.'),
53
+ ('generic', 'people.person', false, 'The unit decides, until a vertical says otherwise.'),
54
+ -- THE DIARY IS THE OTHER HALF OF THE SAME DECISION, and it was left out.
55
+ -- `agenda.appointment` is the only other owner-scoped resource in the
56
+ -- registry, and with no row here it fell through to the default — true — for
57
+ -- every vertical including salon. That is backwards for most of them: a
58
+ -- receptionist books for the whole team, and a shared diary that hides the
59
+ -- owner's own appointments from her staff is not a stricter salon, it is a
60
+ -- broken one. 128 refused to flip a live clinic pool over exactly one such
61
+ -- row, which is the brake doing its job.
62
+ ('clinic', 'agenda.appointment', true, 'A professional sees her own consultations; the same PRD 03 rule as her patients.'),
63
+ ('salon', 'agenda.appointment', false, 'One diary for the floor — whoever is at the desk books for everyone.'),
64
+ ('restaurant', 'agenda.appointment', false, 'One book of reservations, read by whoever answers the phone.'),
65
+ ('generic', 'agenda.appointment', false, 'The unit decides, until a vertical says otherwise.')
66
+ ON CONFLICT (role_template, resource_type) DO UPDATE SET owner_scoped = EXCLUDED.owner_scoped, note = EXCLUDED.note;
67
+
68
+ CREATE OR REPLACE FUNCTION app.owner_scoped(p_resource_type text)
69
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
70
+ SELECT coalesce(
71
+ (SELECT v.owner_scoped FROM app.vertical_owner_scoping v
72
+ JOIN public.tenants t ON t.role_template = v.role_template
73
+ WHERE t.id = app.current_tenant_id() AND v.resource_type = p_resource_type),
74
+ -- no configuration for this vertical: the table's own flag, which defaults
75
+ -- true. Configuration relaxes; it never tightens by omission.
76
+ (SELECT r.owner_scoping FROM app.scaffold_registry r WHERE r.resource_type = p_resource_type LIMIT 1),
77
+ true)
78
+ $$;
79
+ REVOKE ALL ON FUNCTION app.owner_scoped(text) FROM public;
80
+ GRANT EXECUTE ON FUNCTION app.owner_scoped(text) TO authenticated, service_role;
81
+
82
+ CREATE OR REPLACE FUNCTION app.scaffold_table(
83
+ p_table regclass,
84
+ p_resource_type text,
85
+ p_plugin text,
86
+ p_owner_scoping boolean DEFAULT true,
87
+ p_mode text DEFAULT 'enforce'
88
+ )
89
+ RETURNS void
90
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
91
+ AS $$
92
+ DECLARE
93
+ v_schema text;
94
+ v_name text;
95
+ v_q text; -- quoted qualified name
96
+ v_prev text[] := '{}';
97
+ v_rows bigint;
98
+ r record;
99
+ v_scope text; -- the row-scope predicate shared by every policy
100
+ BEGIN
101
+ IF p_mode NOT IN ('shadow', 'enforce') THEN
102
+ RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
103
+ END IF;
104
+ SELECT n.nspname, c.relname INTO v_schema, v_name
105
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
106
+ v_q := format('%I.%I', v_schema, v_name);
107
+
108
+ -- ── columns
109
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
110
+ RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
111
+ END IF;
112
+ EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
113
+ 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
114
+ EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
115
+ IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
116
+ END IF;
117
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
118
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
119
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
120
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
121
+
122
+ -- ── unique(tenant_id, id) — enables composite FKs from other tables
123
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
124
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
125
+ END IF;
126
+ -- ── composite FKs: unit within tenant, owner a member of the tenant
127
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
128
+ 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');
129
+ END IF;
130
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
131
+ 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');
132
+ END IF;
133
+
134
+ -- ── tenant-first indexes
135
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
136
+ 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);
137
+
138
+ -- ── updated_at
139
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
140
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
141
+
142
+ -- ── RLS
143
+ EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
144
+ EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
145
+ IF p_mode = 'enforce' THEN
146
+ FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
147
+ AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
148
+ v_prev := v_prev || r.policyname;
149
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
150
+ END LOOP;
151
+ END IF;
152
+
153
+ -- The owner clause is now CONDITIONAL ON THE TENANT'S VERTICAL, which is what
154
+ -- "vertical differences are configuration" has to mean when one table serves
155
+ -- every tenant: DDL cannot vary per tenant, so the variation has to be a
156
+ -- lookup. app.owner_scoped() reads the tenant's role_template and answers for
157
+ -- this resource type; when a vertical does not scope by owner the clause drops
158
+ -- out entirely instead of relying on every row happening to have a null
159
+ -- owner_id. Wrapped in (select …) like every other helper, so it is evaluated
160
+ -- once per statement and not once per row.
161
+ v_scope := format(
162
+ 'tenant_id = (select app.current_tenant_id())'
163
+ || ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))'
164
+ || ' AND (NOT (select app.owner_scoped(%L))'
165
+ || ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
166
+ || ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
167
+ || ' OR (select app.has_permission(%L, unit_id)))',
168
+ p_resource_type, p_resource_type, p_plugin || '.read_all');
169
+
170
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
171
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
172
+ v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
173
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
174
+ 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)))',
175
+ v_name || '_authz_insert', v_q, p_plugin || '.create');
176
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
177
+ 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)))',
178
+ v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
179
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
180
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
181
+ v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
182
+
183
+ INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies)
184
+ VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev)
185
+ ON CONFLICT (table_schema, table_name) DO UPDATE
186
+ SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
187
+ mode = EXCLUDED.mode, applied_at = now(),
188
+ previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
189
+ THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
190
+ ELSE EXCLUDED.previous_policies END;
191
+ END $$;
192
+
193
+ -- Refresh ONLY the four template policies, leaving everything else alone.
194
+ --
195
+ -- The first version of this file re-ran app.scaffold_table over the registry,
196
+ -- and enforce mode does what it says: it drops every policy that is not one of
197
+ -- the four. That wiped `products_available_in` — the RESTRICTIVE release rule
198
+ -- #139 added AFTER the table was scaffolded — which the bench caught. A template
199
+ -- change is not a re-scaffold, and it must not be able to remove a rule another
200
+ -- slice layered on top.
201
+ CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text)
202
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
203
+ DECLARE
204
+ v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
205
+ v_name text := split_part(replace(p_table, '"', ''), '.', 2);
206
+ v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
207
+ r app.scaffold_registry%ROWTYPE;
208
+ v_scope text;
209
+ BEGIN
210
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
211
+ IF NOT FOUND THEN
212
+ RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
213
+ END IF;
214
+
215
+ v_scope := format(
216
+ 'tenant_id = (select app.current_tenant_id())'
217
+ || ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))'
218
+ || ' AND (NOT (select app.owner_scoped(%L))'
219
+ || ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
220
+ || ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
221
+ || ' OR (select app.has_permission(%L, unit_id)))',
222
+ r.resource_type, r.resource_type, r.plugin || '.read_all');
223
+
224
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
225
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
226
+ v_name || '_authz_select', v_q, v_scope, r.plugin || '.read');
227
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
228
+ 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)))',
229
+ v_name || '_authz_update', v_q, v_scope, r.plugin || '.edit', r.plugin || '.edit');
230
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
231
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
232
+ v_name || '_authz_delete', v_q, v_scope, r.plugin || '.delete');
233
+ -- the INSERT policy has no owner clause (there is no row yet to own), so it is
234
+ -- unaffected by this change and is left exactly as scaffold_table wrote it.
235
+ END $$;
236
+ REVOKE ALL ON FUNCTION app.scaffold_refresh_policies(text) FROM public, anon, authenticated;
237
+
238
+ DO $$
239
+ DECLARE r record; v_n integer := 0;
240
+ BEGIN
241
+ FOR r IN SELECT * FROM app.scaffold_registry ORDER BY table_schema, table_name LOOP
242
+ CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
243
+ PERFORM app.scaffold_refresh_policies(format('%I.%I', r.table_schema, r.table_name));
244
+ v_n := v_n + 1;
245
+ END LOOP;
246
+ RAISE NOTICE '116: refreshed the template policies of % table(s) onto the vertical-aware scope', v_n;
247
+ END $$;
@@ -0,0 +1,98 @@
1
+ -- 127_scaffold_audit_hook.sql
2
+ --
3
+ -- The scaffold template gets the audit hook its contract names. #135.
4
+ --
5
+ -- #135 lists an "audit hook" among the things every scaffolded business table
6
+ -- gets. `app.scaffold_table` attaches `handle_updated_at` and nothing else — the
7
+ -- audit slice (`096`) landed separately and was never wired into the template.
8
+ --
9
+ -- OPT-IN, NOT AUTOMATIC, and the reason is not squeamishness about volume. An
10
+ -- audit row is a claim about WHO did something. On a transactional table
11
+ -- (`orders`, `order_items`, `plg_financial_movements`) the interesting history is
12
+ -- already the table itself — those ledgers are append-only, so their past is not
13
+ -- something an audit trail has to reconstruct. Auditing them would double every
14
+ -- write to restate what the row already says. The tables worth auditing are the
15
+ -- ones where the CURRENT value is all you can see and the change is rare and
16
+ -- consequential: a company's fiscal identity, a tenant's settings, a role's
17
+ -- permissions. So the hook is a per-table decision recorded in the registry,
18
+ -- and `company_legal_entities` is its first taker — a CNPJ changing is exactly
19
+ -- the kind of thing you want to be able to ask "who, and when" about.
20
+ --
21
+ -- INSERT AND UPDATE ONLY, and this one IS a hard constraint rather than a
22
+ -- preference: #144's gate refuses an audit row for a destructive action without
23
+ -- a `reason`, and `audit_action_is_destructive` counts anything ending in
24
+ -- `.delete`. A trigger has no reason to give — it would have to invent one, and
25
+ -- an invented reason in an audit log is worse than no row, because it reads like
26
+ -- somebody typed it. A delete that matters goes through an RPC that asks.
27
+ --
28
+ -- Replay-safe: CREATE OR REPLACE, an idempotent column add, and a trigger that
29
+ -- is dropped and recreated.
30
+
31
+ ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS audit_hook boolean NOT NULL DEFAULT false;
32
+ COMMENT ON COLUMN app.scaffold_registry.audit_hook IS
33
+ 'Whether this table carries the template''s audit hook (#135/117). Opt-in: an audit row is a claim about who did something, and on an append-only ledger the table already IS its own history. Reserved for tables where the current value is all you can see and a change is rare and consequential.';
34
+
35
+ CREATE OR REPLACE FUNCTION app.trg_scaffold_audit()
36
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
37
+ DECLARE
38
+ v_rt text := TG_ARGV[0];
39
+ v_before jsonb;
40
+ v_after jsonb;
41
+ BEGIN
42
+ -- Only what actually changed, so an audit row says something. A full row
43
+ -- snapshot on every UPDATE is a diff nobody can read.
44
+ IF TG_OP = 'UPDATE' THEN
45
+ SELECT jsonb_object_agg(o.key, o.value), jsonb_object_agg(n.key, n.value)
46
+ INTO v_before, v_after
47
+ FROM jsonb_each(to_jsonb(OLD)) o
48
+ JOIN jsonb_each(to_jsonb(NEW)) n ON n.key = o.key
49
+ WHERE o.value IS DISTINCT FROM n.value
50
+ AND o.key NOT IN ('updated_at', 'created_at');
51
+ IF v_after IS NULL THEN
52
+ RETURN NEW; -- nothing but a timestamp moved
53
+ END IF;
54
+ END IF;
55
+
56
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, unit_id, before, after)
57
+ VALUES (NEW.tenant_id, auth.uid(),
58
+ v_rt || CASE TG_OP WHEN 'INSERT' THEN '.create' ELSE '.edit' END,
59
+ v_rt, NEW.id::text,
60
+ CASE WHEN to_jsonb(NEW) ? 'unit_id' THEN (to_jsonb(NEW) ->> 'unit_id')::uuid END,
61
+ v_before, coalesce(v_after, to_jsonb(NEW)));
62
+ RETURN NEW;
63
+ END $$;
64
+ REVOKE ALL ON FUNCTION app.trg_scaffold_audit() FROM public, anon, authenticated;
65
+
66
+ CREATE OR REPLACE FUNCTION app.scaffold_attach_audit(p_table text, p_on boolean DEFAULT true)
67
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
68
+ DECLARE
69
+ v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
70
+ v_name text := split_part(replace(p_table, '"', ''), '.', 2);
71
+ v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
72
+ r app.scaffold_registry%ROWTYPE;
73
+ BEGIN
74
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
75
+ IF NOT FOUND THEN
76
+ RAISE EXCEPTION 'scaffold_attach_audit: %.% is not in the scaffold registry — the audit hook is part of the template, not a loose trigger', v_schema, v_name
77
+ USING ERRCODE = 'P0002';
78
+ END IF;
79
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_scaffold_audit', v_q);
80
+ IF p_on THEN
81
+ -- INSERT and UPDATE only: #144's gate refuses a destructive action without a
82
+ -- reason, and a trigger has none to give.
83
+ EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION app.trg_scaffold_audit(%L)',
84
+ v_name || '_scaffold_audit', v_q, r.resource_type);
85
+ END IF;
86
+ UPDATE app.scaffold_registry SET audit_hook = p_on WHERE table_schema = v_schema AND table_name = v_name;
87
+ END $$;
88
+ REVOKE ALL ON FUNCTION app.scaffold_attach_audit(text, boolean) FROM public, anon, authenticated;
89
+
90
+ -- The first taker. A CNPJ or a fiscal address changing is exactly the kind of
91
+ -- thing you want to be able to ask "who, and when" about, and it happens rarely
92
+ -- enough that the trail stays readable.
93
+ DO $$
94
+ BEGIN
95
+ IF EXISTS (SELECT 1 FROM app.scaffold_registry WHERE table_schema = 'public' AND table_name = 'company_legal_entities') THEN
96
+ PERFORM app.scaffold_attach_audit('public.company_legal_entities');
97
+ END IF;
98
+ END $$;
@@ -0,0 +1,93 @@
1
+ -- 128_shadow_tables_enforced.sql
2
+ --
3
+ -- The six shadow tables are flipped, and the report that authorises it is
4
+ -- recorded rather than implied. #135.
5
+ --
6
+ -- #135's acceptance reads "shadow-mode report shows zero unexpected denials on
7
+ -- the synthetic fixture BEFORE force is enabled". Two things were true and
8
+ -- neither was the criterion: the report IS all-zero on the fixture, and it is
9
+ -- all-zero vacuously — all six shadow tables hold zero rows — while
10
+ -- `app.scaffold_set_mode` had never been called by anything. The 41 enforced
11
+ -- tables got there because each owning slice passed 'enforce' to
12
+ -- `scaffold_table` directly.
13
+ --
14
+ -- So this file does the step the criterion describes, and says out loud what the
15
+ -- report is worth:
16
+ --
17
+ -- 1. run `app.shadow_report()` and RECORD it, in a table, with the row counts
18
+ -- beside it — so "zero denials" can be read together with "zero rows" and
19
+ -- nobody mistakes one for the other later;
20
+ -- 2. flip the six with `app.scaffold_set_mode`, which is what it is for;
21
+ -- 3. refuse to flip a table that has rows AND unexpected denials — the guard
22
+ -- the criterion is actually asking for, and the only reading under which
23
+ -- "zero unexpected denials" means anything.
24
+ --
25
+ -- The flip is safe here precisely BECAUSE the tables are empty: an empty table
26
+ -- cannot lose a row to a stricter policy. On a populated pool the guard is what
27
+ -- stops it, and the recorded report is what an operator reads first. That is a
28
+ -- weaker claim than the criterion implies, and it is the true one — the slice's
29
+ -- own `002_shadow_report.sql` asserts the opposite outcome deliberately, by
30
+ -- inserting rows and showing three unexpected denials, because seeing them is
31
+ -- the point of the report.
32
+ --
33
+ -- Replay-safe: the record table is upserted by run, and set_mode re-applies the
34
+ -- same template.
35
+
36
+ CREATE TABLE IF NOT EXISTS app.scaffold_shadow_records (
37
+ id bigserial PRIMARY KEY,
38
+ table_schema text NOT NULL,
39
+ table_name text NOT NULL,
40
+ rows_at_flip bigint NOT NULL,
41
+ report jsonb NOT NULL,
42
+ flipped_at timestamptz NOT NULL DEFAULT now(),
43
+ note text
44
+ );
45
+ COMMENT ON TABLE app.scaffold_shadow_records IS
46
+ 'What the shadow report said at the moment a table was flipped to enforce (#135). rows_at_flip is stored beside it on purpose: a report of zero unexpected denials over zero rows is not evidence, and the two numbers have to be read together.';
47
+
48
+ CREATE OR REPLACE FUNCTION app.scaffold_enforce(p_table regclass, p_note text DEFAULT NULL)
49
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
50
+ DECLARE
51
+ v_schema text; v_name text; v_rows bigint; v_report jsonb; v_unexpected int;
52
+ BEGIN
53
+ SELECT n.nspname, c.relname INTO v_schema, v_name
54
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
55
+ EXECUTE format('SELECT count(*) FROM %I.%I', v_schema, v_name) INTO v_rows;
56
+
57
+ SELECT coalesce(jsonb_agg(to_jsonb(r)), '[]'::jsonb) INTO v_report
58
+ FROM app.shadow_report() r
59
+ WHERE r.table_name = v_name;
60
+ SELECT coalesce(sum((e ->> 'unexpected_denials')::int), 0) INTO v_unexpected
61
+ FROM jsonb_array_elements(v_report) e;
62
+
63
+ -- The guard the criterion is actually asking for. Zero denials over zero rows
64
+ -- proves nothing, so it is only allowed to authorise a flip when there is
65
+ -- something to deny.
66
+ IF v_rows > 0 AND v_unexpected > 0 THEN
67
+ RAISE EXCEPTION
68
+ 'scaffold_enforce: %.% has % row(s) and % unexpected denial(s) in shadow — flipping would take those rows away from someone',
69
+ v_schema, v_name, v_rows, v_unexpected
70
+ USING ERRCODE = '55000',
71
+ HINT = 'Read app.shadow_report() for this table: each unexpected denial is a row the old policy allowed and the template does not.';
72
+ END IF;
73
+
74
+ PERFORM app.scaffold_set_mode(p_table, 'enforce');
75
+ INSERT INTO app.scaffold_shadow_records (table_schema, table_name, rows_at_flip, report, note)
76
+ VALUES (v_schema, v_name, v_rows, v_report,
77
+ coalesce(p_note, CASE WHEN v_rows = 0 THEN 'flipped empty: no row can be lost to a stricter policy' END));
78
+ RETURN jsonb_build_object('table', v_schema || '.' || v_name, 'rows', v_rows, 'unexpected_denials', v_unexpected, 'mode', 'enforce');
79
+ END $$;
80
+ REVOKE ALL ON FUNCTION app.scaffold_enforce(regclass, text) FROM public, anon, authenticated;
81
+ GRANT EXECUTE ON FUNCTION app.scaffold_enforce(regclass, text) TO service_role;
82
+
83
+ DO $$
84
+ DECLARE r record; v_res jsonb;
85
+ BEGIN
86
+ FOR r IN SELECT table_schema, table_name FROM app.scaffold_registry WHERE mode = 'shadow'
87
+ ORDER BY table_schema, table_name
88
+ LOOP
89
+ CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
90
+ v_res := app.scaffold_enforce(format('%I.%I', r.table_schema, r.table_name)::regclass);
91
+ RAISE NOTICE '118: %', v_res;
92
+ END LOOP;
93
+ END $$;