@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,132 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 159_the_plan_catalogue_gets_its_first_rows.sql — the ceiling stops being an
3
+ -- empty mechanism (#281, ADR 0025).
4
+ --
5
+ -- 154 built the ceiling and deliberately left it un-seeded: `app.plan_grants`
6
+ -- empty means every tenant is planless, and a planless tenant is unrestricted.
7
+ -- That was the right way to land it — a gate whose first act is to lock every
8
+ -- existing customer out gets reverted within the hour, not adopted.
9
+ --
10
+ -- This is the second step: three plans, and what each permits, at the FACET
11
+ -- grain the plugins now declare (#281). Nothing here changes what any existing
12
+ -- tenant can do, because the ceiling only binds a tenant that has been ASSIGNED
13
+ -- a plan, and this file assigns none. What it does is make the ceiling
14
+ -- assignable — `tenant_apply_plan(tenant, 'pro')` now has something to mean.
15
+ --
16
+ -- ── Why the plans are named and not numbered ────────────────────────────────
17
+ -- `essential` / `pro` / `scale` are ids, not prices. What a customer pays and
18
+ -- what a plan is called in a given market are billing's business and change
19
+ -- without a migration; what a plan PERMITS is a structural fact about the
20
+ -- product and belongs where the enforcement reads it.
21
+ --
22
+ -- ── included vs optional ────────────────────────────────────────────────────
23
+ -- `included` comes on the moment the plan is applied. `optional` is permitted
24
+ -- but stays off until somebody turns it on — which is how a plan can allow ten
25
+ -- plugins without imposing ten menus on a customer who wanted two. 154's
26
+ -- `tenant_apply_plan` reads exactly this distinction.
27
+ -- ---------------------------------------------------------------------------
28
+
29
+ DO $$
30
+ BEGIN
31
+ IF to_regprocedure('public.register_plan_grant(text, text, text, text, text)') IS NULL THEN
32
+ RAISE NOTICE '159: register_plan_grant missing (spine < 154) — skipped';
33
+ RETURN;
34
+ END IF;
35
+
36
+ -- ── essential — one business, running ─────────────────────────────────
37
+ -- Everything needed to take money and know what happened. The free halves of
38
+ -- each module, and nothing that assumes a second person in the office.
39
+ PERFORM public.register_plan_grant('essential', 'financial', 'ledger', 'included', 'Lançamentos e o razão — sem isto o plano não é nada.');
40
+ PERFORM public.register_plan_grant('essential', 'inventory', 'stock', 'included', 'Saldo e movimentos.');
41
+ PERFORM public.register_plan_grant('essential', 'crm', 'contacts', 'included', 'A lista de clientes.');
42
+ PERFORM public.register_plan_grant('essential', 'agenda', 'scheduling', 'included', 'A agenda interna.');
43
+ PERFORM public.register_plan_grant('essential', 'tasks', '', 'optional', 'Permitido, desligado por padrão: nem toda operação quer uma lista de tarefas no menu.');
44
+
45
+ -- ── pro — the paid halves, one team ───────────────────────────────────
46
+ PERFORM public.register_plan_grant('pro', 'financial', 'ledger', 'included');
47
+ PERFORM public.register_plan_grant('pro', 'financial', 'reconciliation', 'included', 'A metade paga do financeiro — a razão de o facet existir (ADR 0025).');
48
+ PERFORM public.register_plan_grant('pro', 'financial', 'dre', 'optional');
49
+ PERFORM public.register_plan_grant('pro', 'inventory', 'stock', 'included');
50
+ PERFORM public.register_plan_grant('pro', 'inventory', 'costing', 'included');
51
+ PERFORM public.register_plan_grant('pro', 'inventory', 'purchasing', 'optional');
52
+ PERFORM public.register_plan_grant('pro', 'crm', 'contacts', 'included');
53
+ PERFORM public.register_plan_grant('pro', 'crm', 'pipeline', 'included');
54
+ PERFORM public.register_plan_grant('pro', 'agenda', 'scheduling', 'included');
55
+ PERFORM public.register_plan_grant('pro', 'agenda', 'public_booking', 'included');
56
+ PERFORM public.register_plan_grant('pro', 'tasks', '', 'optional');
57
+ PERFORM public.register_plan_grant('pro', 'forms', '', 'optional');
58
+
59
+ -- ── scale — everything, including what we host ────────────────────────
60
+ -- `smart_fit` is the entitlement ADR 0018's service door checks: the agenda
61
+ -- ships open with the button, and whether the intelligence answers is this row.
62
+ PERFORM public.register_plan_grant('scale', 'financial', 'ledger', 'included');
63
+ PERFORM public.register_plan_grant('scale', 'financial', 'reconciliation', 'included');
64
+ PERFORM public.register_plan_grant('scale', 'financial', 'dre', 'included');
65
+ PERFORM public.register_plan_grant('scale', 'inventory', 'stock', 'included');
66
+ PERFORM public.register_plan_grant('scale', 'inventory', 'costing', 'included');
67
+ PERFORM public.register_plan_grant('scale', 'inventory', 'purchasing', 'included');
68
+ PERFORM public.register_plan_grant('scale', 'crm', 'contacts', 'included');
69
+ PERFORM public.register_plan_grant('scale', 'crm', 'pipeline', 'included');
70
+ PERFORM public.register_plan_grant('scale', 'crm', 'automation', 'included');
71
+ PERFORM public.register_plan_grant('scale', 'agenda', 'scheduling', 'included');
72
+ PERFORM public.register_plan_grant('scale', 'agenda', 'public_booking', 'included');
73
+ PERFORM public.register_plan_grant('scale', 'agenda', 'smart_fit', 'included', 'O serviço que hospedamos (ADR 0018) — o service_authorize lê exatamente esta linha.');
74
+ PERFORM public.register_plan_grant('scale', 'tasks', '', 'optional');
75
+ PERFORM public.register_plan_grant('scale', 'forms', '', 'optional');
76
+ PERFORM public.register_plan_grant('scale', 'marketing', '', 'optional');
77
+ END $$;
78
+
79
+ -- ─────────────────────────────────────────────────────────────────────────
80
+ -- What a plan is worth, in one read
81
+ --
82
+ -- The upgrade prompt's question — "what would I get?" — answered without the
83
+ -- screen having to know the shape of the grants table.
84
+ -- ─────────────────────────────────────────────────────────────────────────
85
+
86
+ CREATE OR REPLACE VIEW public.v_plan_catalogue
87
+ WITH (security_invoker = true) AS
88
+ SELECT g.plan_id,
89
+ g.plugin_id,
90
+ nullif(g.facet, '') AS facet,
91
+ g.mode,
92
+ g.note,
93
+ -- What THIS tenant would gain by moving to that plan: permitted there
94
+ -- and not permitted now. NULL tenant (no session) simply shows all.
95
+ NOT public.plan_permits(g.plugin_id, g.facet) AS is_upgrade
96
+ FROM app.plan_grants g
97
+ ORDER BY g.plan_id, g.plugin_id, g.facet;
98
+
99
+ COMMENT ON VIEW public.v_plan_catalogue IS
100
+ 'Every plan and what it permits, with `is_upgrade` marking what the CURRENT tenant does not have yet (159). What an upgrade prompt reads so it can name the thing rather than say "upgrade for more".';
101
+
102
+ GRANT SELECT ON public.v_plan_catalogue TO authenticated, service_role;
103
+
104
+ -- ─────────────────────────────────────────────────────────────────────────
105
+ -- A plan change must be visible immediately
106
+ --
107
+ -- 154's `tenant_apply_plan` syncs the activations. What was missing is the part
108
+ -- a screen can act on: a stamp that changes when the tenant's entitlements do,
109
+ -- so the shell knows its config is stale without polling the whole thing.
110
+ --
111
+ -- `app_config()` already returns `generated_at`; this makes it move for a plan
112
+ -- change too, which is what turns "the config is cached" from a bug into a
113
+ -- cache with an invalidation.
114
+ -- ─────────────────────────────────────────────────────────────────────────
115
+
116
+ CREATE OR REPLACE FUNCTION public.tenant_config_version(p_tenant uuid DEFAULT NULL)
117
+ RETURNS timestamptz
118
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
119
+ AS $$
120
+ SELECT greatest(
121
+ coalesce(max(tp.updated_at), 'epoch'::timestamptz),
122
+ coalesce(max(tn.updated_at), 'epoch'::timestamptz)
123
+ )
124
+ FROM app.tenant_plugins tp
125
+ FULL JOIN app.tenant_nav tn ON tn.tenant_id = tp.tenant_id
126
+ WHERE coalesce(tp.tenant_id, tn.tenant_id) = coalesce(p_tenant, app.current_tenant_id());
127
+ $$;
128
+
129
+ COMMENT ON FUNCTION public.tenant_config_version(uuid) IS
130
+ 'When this tenant''s configuration last changed (159). A screen polls this — one number — instead of re-reading app_config(), and re-reads only when it moves. What makes "screens change immediately" cheap enough to actually do.';
131
+
132
+ GRANT EXECUTE ON FUNCTION public.tenant_config_version(uuid) TO authenticated, service_role;
@@ -0,0 +1,246 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 160_the_vertical_decides_what_starts_on.sql — the last thing the pool profiles
3
+ -- were still doing, moved to where it belongs (#279, ADR 0022 + ADR 0023).
4
+ --
5
+ -- ── What is left of a pool profile ──────────────────────────────────────────
6
+ -- Before ADR 0022 a profile decided what a CLUSTER contained: the salon pool
7
+ -- had the agenda because its profile listed it, and "does this tenant have
8
+ -- prontuário?" was a question about which database they landed in. That is
9
+ -- gone — every cluster runs the same canonical chain.
10
+ --
11
+ -- What a profile still knows is worth keeping: a salon opening today wants the
12
+ -- agenda on and the shop off, and a restaurant wants the opposite. That is not
13
+ -- a fact about a cluster, it is a sensible STARTING POINT for a new tenant of
14
+ -- that industry — and `tenants.vertical_id` has carried the industry since 001
15
+ -- with nothing ever reading it.
16
+ --
17
+ -- So: the defaults become rows, keyed by vertical, and a new tenant is seeded
18
+ -- from them. Same schema everywhere; different switches per tenant; and the
19
+ -- switch is a row somebody can flip afterwards without a deploy.
20
+ --
21
+ -- ── Why seeding is not the same as entitling ────────────────────────────────
22
+ -- These defaults say what to turn ON, never what is ALLOWED. The plan ceiling
23
+ -- (154) still decides the second question, and it is checked when the seeding
24
+ -- runs: a vertical that would switch on something the tenant's plan does not
25
+ -- permit simply does not switch it on. A default cannot outrank a contract.
26
+ -- ---------------------------------------------------------------------------
27
+
28
+ -- ─────────────────────────────────────────────────────────────────────────
29
+ -- §1 What a new tenant of an industry starts with
30
+ -- ─────────────────────────────────────────────────────────────────────────
31
+
32
+ CREATE TABLE IF NOT EXISTS app.vertical_defaults (
33
+ vertical_id text NOT NULL,
34
+ plugin_id text NOT NULL CHECK (plugin_id ~ '^[a-z][a-z0-9-]*$'),
35
+ facet text NOT NULL DEFAULT '' CHECK (facet = '' OR facet ~ '^[a-z][a-z0-9_-]*$'),
36
+ note text,
37
+ created_at timestamptz NOT NULL DEFAULT now(),
38
+ PRIMARY KEY (vertical_id, plugin_id, facet)
39
+ );
40
+
41
+ COMMENT ON TABLE app.vertical_defaults IS
42
+ 'What a NEW tenant of an industry gets switched on (160, ADR 0022). Not what a cluster contains — every cluster contains the canonical schema — and not what a plan allows, which is app.plan_grants. Just a sensible starting point that somebody can change afterwards without a deploy.';
43
+
44
+ ALTER TABLE app.vertical_defaults ENABLE ROW LEVEL SECURITY;
45
+ DROP POLICY IF EXISTS vertical_defaults_read ON app.vertical_defaults;
46
+ CREATE POLICY vertical_defaults_read ON app.vertical_defaults FOR SELECT TO authenticated USING (true);
47
+ GRANT SELECT ON app.vertical_defaults TO authenticated;
48
+ GRANT ALL ON app.vertical_defaults TO service_role;
49
+
50
+ CREATE OR REPLACE FUNCTION public.register_vertical_default(
51
+ p_vertical text, p_plugin text, p_facet text DEFAULT '', p_note text DEFAULT NULL
52
+ ) RETURNS void
53
+ LANGUAGE sql SECURITY DEFINER SET search_path = ''
54
+ AS $$
55
+ INSERT INTO app.vertical_defaults (vertical_id, plugin_id, facet, note)
56
+ VALUES (p_vertical, p_plugin, coalesce(p_facet, ''), p_note)
57
+ ON CONFLICT (vertical_id, plugin_id, facet) DO UPDATE
58
+ SET note = coalesce(EXCLUDED.note, app.vertical_defaults.note)
59
+ $$;
60
+
61
+ REVOKE ALL ON FUNCTION public.register_vertical_default(text, text, text, text) FROM public, anon, authenticated;
62
+ GRANT EXECUTE ON FUNCTION public.register_vertical_default(text, text, text, text) TO service_role;
63
+
64
+ -- The four industries the pool profiles describe, transcribed. Kept short on
65
+ -- purpose: a default that switches on everything is not a default, it is a
66
+ -- refusal to choose, and the customer pays for it in a menu they did not ask for.
67
+ DO $$
68
+ BEGIN
69
+ PERFORM public.register_vertical_default('salon', 'agenda', 'scheduling', 'A agenda é o produto de um salão.');
70
+ PERFORM public.register_vertical_default('salon', 'agenda', 'public_booking', 'O cliente marca sozinho — o que tira o telefone do balcão.');
71
+ PERFORM public.register_vertical_default('salon', 'crm', 'contacts');
72
+ PERFORM public.register_vertical_default('salon', 'financial', 'ledger');
73
+
74
+ PERFORM public.register_vertical_default('restaurant', 'menu', '', 'O cardápio é o catálogo de um restaurante.');
75
+ PERFORM public.register_vertical_default('restaurant', 'inventory', 'stock', 'Insumo acaba, e acabar sem saber é o custo do restaurante.');
76
+ PERFORM public.register_vertical_default('restaurant', 'financial', 'ledger');
77
+
78
+ PERFORM public.register_vertical_default('ecommerce', 'shop', '', 'A loja é o produto.');
79
+ PERFORM public.register_vertical_default('ecommerce', 'inventory', 'stock');
80
+ PERFORM public.register_vertical_default('ecommerce', 'financial', 'ledger');
81
+
82
+ PERFORM public.register_vertical_default('school', 'courses', '', 'O curso é o produto de uma escola.');
83
+ PERFORM public.register_vertical_default('school', 'agenda', 'scheduling');
84
+ PERFORM public.register_vertical_default('school', 'financial', 'ledger');
85
+
86
+ PERFORM public.register_vertical_default('agency', 'crm', 'contacts', 'Uma agência vive do funil.');
87
+ PERFORM public.register_vertical_default('agency', 'crm', 'pipeline');
88
+ PERFORM public.register_vertical_default('agency', 'financial', 'ledger');
89
+ END $$;
90
+
91
+ -- ─────────────────────────────────────────────────────────────────────────
92
+ -- §2 Seeding a tenant from its industry
93
+ -- ─────────────────────────────────────────────────────────────────────────
94
+
95
+ CREATE OR REPLACE FUNCTION public.tenant_apply_vertical_defaults(p_tenant uuid, p_vertical text DEFAULT NULL)
96
+ RETURNS jsonb
97
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
98
+ AS $$
99
+ DECLARE
100
+ v_vertical text;
101
+ v_row record;
102
+ v_on integer := 0;
103
+ v_skipped text[] := ARRAY[]::text[];
104
+ BEGIN
105
+ IF p_tenant IS NULL THEN
106
+ RAISE EXCEPTION 'tenant_apply_vertical_defaults: no tenant' USING ERRCODE = '22023';
107
+ END IF;
108
+
109
+ v_vertical := coalesce(p_vertical, (SELECT t.vertical_id FROM public.tenants t WHERE t.id = p_tenant));
110
+ IF v_vertical IS NULL THEN
111
+ RETURN jsonb_build_object('tenant_id', p_tenant, 'vertical', NULL, 'enabled', 0,
112
+ 'note', 'no vertical on this tenant — nothing to seed');
113
+ END IF;
114
+
115
+ FOR v_row IN
116
+ SELECT d.plugin_id, d.facet FROM app.vertical_defaults d WHERE d.vertical_id = v_vertical
117
+ LOOP
118
+ -- The ceiling wins over the default, always. A plan that does not permit it
119
+ -- means the tenant does not get it switched on — not that the seeding
120
+ -- fails, and not that the plan is quietly widened.
121
+ IF NOT public.plan_permits(v_row.plugin_id, v_row.facet, p_tenant) THEN
122
+ v_skipped := v_skipped || (v_row.plugin_id || coalesce(nullif('.' || v_row.facet, '.'), ''));
123
+ CONTINUE;
124
+ END IF;
125
+
126
+ INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
127
+ VALUES (p_tenant, v_row.plugin_id, v_row.facet, 'active', 'default')
128
+ -- Never overrides a decision somebody already made: a plugin a human turned
129
+ -- off stays off, even if the industry default says otherwise.
130
+ ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
131
+
132
+ IF FOUND THEN v_on := v_on + 1; END IF;
133
+ END LOOP;
134
+
135
+ RETURN jsonb_build_object('tenant_id', p_tenant, 'vertical', v_vertical,
136
+ 'enabled', v_on, 'not_permitted_by_plan', to_jsonb(v_skipped));
137
+ END $$;
138
+
139
+ COMMENT ON FUNCTION public.tenant_apply_vertical_defaults(uuid, text) IS
140
+ 'Switches on what a new tenant of this industry starts with (160). Never overrides an existing decision, and never outranks the plan ceiling — a default that could widen a contract would not be a default.';
141
+
142
+ REVOKE ALL ON FUNCTION public.tenant_apply_vertical_defaults(uuid, text) FROM public, anon, authenticated;
143
+ GRANT EXECUTE ON FUNCTION public.tenant_apply_vertical_defaults(uuid, text) TO service_role;
144
+
145
+ -- New tenants are seeded on creation. AFTER INSERT rather than BEFORE, because
146
+ -- the defaults read the plan, and the plan is on the row that is being written.
147
+ CREATE OR REPLACE FUNCTION public.trg_tenants_apply_vertical_defaults()
148
+ RETURNS trigger
149
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
150
+ AS $$
151
+ BEGIN
152
+ IF NEW.vertical_id IS NOT NULL THEN
153
+ -- A failure to seed must never fail the creation of a tenant: an account
154
+ -- with no modules switched on is recoverable in one click, an account that
155
+ -- could not be created is a signup lost.
156
+ BEGIN
157
+ PERFORM public.tenant_apply_vertical_defaults(NEW.id, NEW.vertical_id);
158
+ EXCEPTION WHEN OTHERS THEN
159
+ RAISE WARNING 'tenant % created without vertical defaults: %', NEW.id, SQLERRM;
160
+ END;
161
+ END IF;
162
+ RETURN NEW;
163
+ END $$;
164
+
165
+ DROP TRIGGER IF EXISTS tenants_apply_vertical_defaults ON public.tenants;
166
+ CREATE TRIGGER tenants_apply_vertical_defaults AFTER INSERT ON public.tenants
167
+ FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_apply_vertical_defaults();
168
+
169
+ -- ─────────────────────────────────────────────────────────────────────────
170
+ -- §3 A dedicated cluster is the same schema with one tenant
171
+ --
172
+ -- The decision's own words. Worth writing down because the tempting shortcut —
173
+ -- "it's one customer, so trim the schema" — is exactly what ADR 0022 abolished:
174
+ -- a trimmed cluster cannot receive a second tenant, cannot be compared to any
175
+ -- other pool, and turns every support question into "which schema is this?".
176
+ --
177
+ -- So a dedicated cluster differs in ONE thing: how many tenants it will ever
178
+ -- hold. That is a property of the cluster, recorded here, and the only thing
179
+ -- provisioning does differently.
180
+ -- ─────────────────────────────────────────────────────────────────────────
181
+
182
+ CREATE TABLE IF NOT EXISTS app.cluster_profile (
183
+ id boolean PRIMARY KEY DEFAULT true CHECK (id),
184
+ mode text NOT NULL DEFAULT 'shared' CHECK (mode IN ('shared', 'dedicated')),
185
+ vertical_id text,
186
+ note text,
187
+ updated_at timestamptz NOT NULL DEFAULT now()
188
+ );
189
+
190
+ COMMENT ON TABLE app.cluster_profile IS
191
+ 'What kind of cluster this is (160, ADR 0022). One row, by construction. `dedicated` means it holds exactly one tenant — it does NOT mean a different schema, which is the whole point: same chain everywhere, so a pool can be compared, restored and supported like any other.';
192
+
193
+ ALTER TABLE app.cluster_profile ENABLE ROW LEVEL SECURITY;
194
+ DROP POLICY IF EXISTS cluster_profile_read ON app.cluster_profile;
195
+ CREATE POLICY cluster_profile_read ON app.cluster_profile FOR SELECT TO authenticated, service_role USING (true);
196
+ GRANT SELECT ON app.cluster_profile TO authenticated, service_role;
197
+ GRANT ALL ON app.cluster_profile TO service_role;
198
+
199
+ INSERT INTO app.cluster_profile (id, mode, note)
200
+ VALUES (true, 'shared', 'Default. Provisioning sets this to dedicated when a cluster is built for one customer.')
201
+ ON CONFLICT (id) DO NOTHING;
202
+
203
+ CREATE OR REPLACE FUNCTION public.set_cluster_mode(p_mode text, p_vertical text DEFAULT NULL, p_note text DEFAULT NULL)
204
+ RETURNS jsonb
205
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
206
+ AS $$
207
+ DECLARE
208
+ v_tenants integer;
209
+ BEGIN
210
+ IF p_mode NOT IN ('shared', 'dedicated') THEN
211
+ RAISE EXCEPTION 'set_cluster_mode: mode must be shared or dedicated' USING ERRCODE = '22023';
212
+ END IF;
213
+
214
+ SELECT count(*)::int INTO v_tenants FROM public.tenants;
215
+ IF p_mode = 'dedicated' AND v_tenants > 1 THEN
216
+ RAISE EXCEPTION 'set_cluster_mode: this cluster already holds % tenants — dedicated means one', v_tenants
217
+ USING ERRCODE = '23514',
218
+ HINT = 'Move the others first, or leave it shared. Nothing about the SCHEMA changes either way.';
219
+ END IF;
220
+
221
+ INSERT INTO app.cluster_profile (id, mode, vertical_id, note, updated_at)
222
+ VALUES (true, p_mode, p_vertical, p_note, now())
223
+ ON CONFLICT (id) DO UPDATE
224
+ SET mode = EXCLUDED.mode, vertical_id = EXCLUDED.vertical_id,
225
+ note = coalesce(EXCLUDED.note, app.cluster_profile.note), updated_at = now();
226
+
227
+ RETURN jsonb_build_object('mode', p_mode, 'vertical', p_vertical, 'tenants', v_tenants);
228
+ END $$;
229
+
230
+ REVOKE ALL ON FUNCTION public.set_cluster_mode(text, text, text) FROM public, anon, authenticated;
231
+ GRANT EXECUTE ON FUNCTION public.set_cluster_mode(text, text, text) TO service_role;
232
+
233
+ -- A dedicated cluster that grew a second tenant is a provisioning mistake that
234
+ -- should be visible before it becomes a support question.
235
+ CREATE OR REPLACE VIEW public.v_cluster_posture
236
+ WITH (security_invoker = true) AS
237
+ SELECT p.mode,
238
+ p.vertical_id,
239
+ (SELECT count(*) FROM public.tenants) AS tenant_count,
240
+ (p.mode = 'dedicated' AND (SELECT count(*) FROM public.tenants) > 1) AS violates_dedicated
241
+ FROM app.cluster_profile p;
242
+
243
+ COMMENT ON VIEW public.v_cluster_posture IS
244
+ 'What kind of cluster this is and whether it still matches (160). `violates_dedicated` true means a cluster built for one customer is holding somebody else''s data.';
245
+
246
+ GRANT SELECT ON public.v_cluster_posture TO authenticated, service_role;