@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,149 @@
1
+ -- 108_authz_write_guards.sql
2
+ --
3
+ -- Closes the two privilege escalations the Phase 0 acceptance audit reproduced
4
+ -- against a live database (#196, #197). Both are the same shape: a rule that
5
+ -- exists in ONE writer, while the table underneath accepts the write from
6
+ -- anywhere else.
7
+ --
8
+ -- #196 — `members.manage` was enough to become owner. `memberships_insert` and
9
+ -- `memberships_delete` already carry `user_id <> auth.uid()`; `memberships_update`
10
+ -- does not, and `public.v_authz_memberships` is auto-updatable and granted UPDATE
11
+ -- to `authenticated` (043). So one PostgREST call promoted a seeded manager to
12
+ -- owner and handed her `billing.manage`. Two rules were missing, and neither is
13
+ -- new to this codebase — `invite_member` (052) has enforced both since #134:
14
+ -- (a) you never write your own membership row;
15
+ -- (b) you never grant a role holding a permission you do not hold.
16
+ --
17
+ -- #197 — the owner role was immutable only inside `role_set_permissions` (062).
18
+ -- `trg_legacy_overrides_redirect` (063) resolves the role by key with no
19
+ -- `is_system` filter and is SECURITY INVOKER, so an INSERT into the deprecated
20
+ -- `public.tenant_role_overrides` facade revoked `financial.read` from the salon
21
+ -- owner, permanently — `permissions_grant_owner` only fires for NEW catalog keys.
22
+ --
23
+ -- Why triggers and not policies. `app.memberships` and `app.role_permissions` are
24
+ -- FORCE RLS, but `postgres` holds BYPASSRLS, which is how the SECURITY DEFINER
25
+ -- RPCs (`create_tenant`, `accept_invitation`, `role_set_permissions`, the seed)
26
+ -- write these tables at all. A policy therefore cannot see those writers, and a
27
+ -- policy on the base table would not have closed #197 either — the facade writes
28
+ -- as the *invoker*. Triggers fire for everyone, which is what these rules need.
29
+ --
30
+ -- Why `current_user = 'authenticated'` is the gate. It separates the two worlds
31
+ -- exactly, with no session flag to set and no existing function to rewrite:
32
+ -- · SECURITY DEFINER RPCs owned by postgres run as `postgres` → skipped, they
33
+ -- carry their own guards (and `create_tenant` legitimately inserts the
34
+ -- caller's OWN owner membership, which rule (a) would otherwise refuse);
35
+ -- · `service_role` and migrations/seeds run as themselves → skipped,
36
+ -- matching the posture of every `TO authenticated` policy in the chain;
37
+ -- · a PostgREST call, and the SECURITY INVOKER legacy projections and facade,
38
+ -- run as `authenticated` → enforced.
39
+ --
40
+ -- Replay-safe: functions are CREATE OR REPLACE, triggers are dropped and
41
+ -- recreated, no data is touched.
42
+
43
+ -- ── #196: memberships ─────────────────────────────────────────────────────
44
+ CREATE OR REPLACE FUNCTION app.authz_membership_guard()
45
+ RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
46
+ DECLARE v_actor uuid;
47
+ BEGIN
48
+ IF current_user <> 'authenticated' THEN
49
+ RETURN NEW;
50
+ END IF;
51
+ v_actor := (select auth.uid());
52
+ IF v_actor IS NULL THEN
53
+ RETURN NEW;
54
+ END IF;
55
+
56
+ -- (a) your own membership is never written directly: not the row you are on,
57
+ -- and not a row you are moving onto yourself. Scope and role of the actor
58
+ -- change through create_tenant, an invitation, or another member's action.
59
+ IF NEW.user_id = v_actor OR (TG_OP = 'UPDATE' AND OLD.user_id = v_actor) THEN
60
+ RAISE EXCEPTION
61
+ 'app.memberships: you cannot write your own membership (self-promotion and self-widening are refused; use an invitation or another member with members.manage)'
62
+ USING ERRCODE = '42501';
63
+ END IF;
64
+
65
+ -- (b) no escalation: every permission of the granted role must be held by the
66
+ -- actor, tenant-wide. Same rule and same wording as invite_member (052).
67
+ IF EXISTS (SELECT 1 FROM app.role_permissions rp
68
+ WHERE rp.tenant_id = NEW.tenant_id AND rp.role_id = NEW.role_id
69
+ AND NOT app.has_permission(rp.permission)) THEN
70
+ RAISE EXCEPTION
71
+ 'app.memberships: cannot grant a role holding permissions you do not have'
72
+ USING ERRCODE = '42501';
73
+ END IF;
74
+
75
+ RETURN NEW;
76
+ END $$;
77
+ REVOKE ALL ON FUNCTION app.authz_membership_guard() FROM public, anon, authenticated;
78
+
79
+ DROP TRIGGER IF EXISTS memberships_write_guard ON app.memberships;
80
+ CREATE TRIGGER memberships_write_guard
81
+ BEFORE INSERT OR UPDATE ON app.memberships
82
+ FOR EACH ROW EXECUTE FUNCTION app.authz_membership_guard();
83
+
84
+ -- The same two rules on the per-unit half: `membership_units` carries the unit
85
+ -- scope and an optional `role_override_id`, so without this a manager could widen
86
+ -- her own scope one unit at a time, or override herself into a stronger role.
87
+ CREATE OR REPLACE FUNCTION app.authz_membership_unit_guard()
88
+ RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
89
+ DECLARE v_actor uuid; v_row record;
90
+ BEGIN
91
+ IF current_user <> 'authenticated' THEN
92
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
93
+ END IF;
94
+ v_actor := (select auth.uid());
95
+ IF v_actor IS NULL THEN
96
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
97
+ END IF;
98
+ v_row := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
99
+
100
+ IF v_row.user_id = v_actor OR (TG_OP = 'UPDATE' AND OLD.user_id = v_actor) THEN
101
+ RAISE EXCEPTION
102
+ 'app.membership_units: you cannot write your own unit scope'
103
+ USING ERRCODE = '42501';
104
+ END IF;
105
+
106
+ IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.role_override_id IS NOT NULL
107
+ AND EXISTS (SELECT 1 FROM app.role_permissions rp
108
+ WHERE rp.tenant_id = NEW.tenant_id AND rp.role_id = NEW.role_override_id
109
+ AND NOT app.has_permission(rp.permission)) THEN
110
+ RAISE EXCEPTION
111
+ 'app.membership_units: cannot override to a role holding permissions you do not have'
112
+ USING ERRCODE = '42501';
113
+ END IF;
114
+
115
+ RETURN v_row;
116
+ END $$;
117
+ REVOKE ALL ON FUNCTION app.authz_membership_unit_guard() FROM public, anon, authenticated;
118
+
119
+ DROP TRIGGER IF EXISTS membership_units_write_guard ON app.membership_units;
120
+ CREATE TRIGGER membership_units_write_guard
121
+ BEFORE INSERT OR UPDATE OR DELETE ON app.membership_units
122
+ FOR EACH ROW EXECUTE FUNCTION app.authz_membership_unit_guard();
123
+
124
+ -- ── #197: the owner role is immutable, from every direction ───────────────
125
+ -- The guard moves off role_set_permissions and onto the data, so the RPC, the
126
+ -- deprecated facade, and any future writer all inherit it. `permissions_grant_owner`
127
+ -- (043) keeps working: it runs as the definer while a catalog key is added.
128
+ CREATE OR REPLACE FUNCTION app.authz_owner_role_immutable()
129
+ RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
130
+ DECLARE v_role uuid;
131
+ BEGIN
132
+ IF current_user <> 'authenticated' THEN
133
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
134
+ END IF;
135
+ v_role := coalesce(NEW.role_id, OLD.role_id);
136
+ IF EXISTS (SELECT 1 FROM app.roles r
137
+ WHERE r.id = v_role AND r.is_system AND r.key = 'owner') THEN
138
+ RAISE EXCEPTION
139
+ 'app.role_permissions: the owner role is immutable — it always holds the whole catalog'
140
+ USING ERRCODE = '42501';
141
+ END IF;
142
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
143
+ END $$;
144
+ REVOKE ALL ON FUNCTION app.authz_owner_role_immutable() FROM public, anon, authenticated;
145
+
146
+ DROP TRIGGER IF EXISTS role_permissions_owner_immutable ON app.role_permissions;
147
+ CREATE TRIGGER role_permissions_owner_immutable
148
+ BEFORE INSERT OR UPDATE OR DELETE ON app.role_permissions
149
+ FOR EACH ROW EXECUTE FUNCTION app.authz_owner_role_immutable();
@@ -0,0 +1,126 @@
1
+ -- 109_catalog_migration_promoted_columns.sql
2
+ --
3
+ -- Every migrated V1 service would have arrived 30 minutes long. Fix for #198.
4
+ --
5
+ -- 091 maps `duration_minutes` and `service_kind` into the JSONB payload, with the
6
+ -- note "until #139 promotes it". #139 promoted both to real columns on
7
+ -- public.products (075:41-42) -- and `migration.upsert_product` was never taught
8
+ -- about them, so the destination wrote neither. `v_public_services` then does
9
+ -- COALESCE(duration_minutes, 30), so the entire migrated catalogue silently
10
+ -- became half-hour appointments with the schedulable state flattened to
11
+ -- 'standard'. Nothing raised; the ledger recorded a successful upsert.
12
+ --
13
+ -- Two changes, and they belong together -- repointing the allowlist without
14
+ -- teaching the writer just moves the drop one layer down:
15
+ -- 1. the `services` allowlist row maps both to the real columns;
16
+ -- 2. upsert_product writes them, translating V1's vocabulary to V2's
17
+ -- (`service` -> `standard`, the rest pass through) and QUARANTINING rather
18
+ -- than correcting anything outside it -- an unknown service_kind, one on a
19
+ -- good, or a non-positive duration. Silent correction is what #148 forbids,
20
+ -- and a defaulted duration is exactly the bug being fixed here.
21
+ --
22
+ -- Replay-safe: `_allow` upserts by source_table, the function is CREATE OR
23
+ -- REPLACE, no data is touched.
24
+
25
+ -- ── 1. the allowlist tells the truth about where these two land ───────────
26
+ -- The row is frozen, and the freeze exists to say exactly this: "core-A
27
+ -- allowlist changes are a migration file, not a write". This is that file, so it
28
+ -- takes the documented unlock. set_config(..., true) is transaction-scoped and a
29
+ -- DO block is one transaction, so the unlock cannot leak past these lines.
30
+ DO $unlock$
31
+ BEGIN
32
+ PERFORM set_config('migration.allowlist_unlock', 'on', true);
33
+ PERFORM migration._allow('services', 'public.products', 'catalog', 'product', '{"kind":"service"}', '{}',
34
+ '{"uuid":"id","tenant_id":"-","name":"name","description":"description","internal_code":"sku","category_id":"category_id@service_categories","price":"price","active":"is_active","duration_minutes":"duration_minutes","color":"jsonb:catalog.color","service_kind":"service_kind","revision_count":"jsonb:beauty.revision_count","revision_price":"jsonb:beauty.revision_price","revision_deadline_days":"jsonb:beauty.revision_deadline_days","validity_days":"jsonb:catalog.validity_days","contract_text":"jsonb:beauty.contract_text","post_service_text":"jsonb:beauty.post_service_text","reminder_text":"jsonb:beauty.reminder_text","scheduling_alert_text":"jsonb:beauty.scheduling_alert_text","always_ask_used_products":"jsonb:beauty.always_ask_used_products","suggest_quote_on_execution":"jsonb:beauty.suggest_quote_on_execution","category":"-","search_text":"-"}',
35
+ 'PRD 04 R1: a service is a catalog item kind=service (public.services is drift, folded by #139). duration_minutes and service_kind are PROMOTED COLUMNS since #139 (ADR 0009, RN-CAT-03) -- they are core columns, not metadata; an unknown service_kind is quarantined, never defaulted (#198).');
36
+ END $unlock$;
37
+
38
+ -- ── 2. the writer that actually puts them there ───────────────────────────
39
+ CREATE OR REPLACE FUNCTION migration.upsert_product(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
40
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
41
+ DECLARE
42
+ v_kind text := coalesce(p_args ->> 'kind', 'good');
43
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
44
+ -- #139 promoted these two out of JSONB and onto public.products; 091 still
45
+ -- described them as metadata and this writer wrote neither, so every migrated
46
+ -- service arrived with duration_minutes NULL and v_public_services COALESCEd
47
+ -- it to 30. See #198.
48
+ v_duration integer := nullif(btrim(coalesce(p_row ->> 'duration_minutes', '')), '')::integer;
49
+ -- V1 vocabulary is service|evaluation|return; V2's is standard|evaluation|return
50
+ -- ('service' was renamed because `kind` had already taken the archetype word).
51
+ -- 04-catalog-contract.md §V1 map, row `service_kind`.
52
+ v_service_kind_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'service_kind', ''))), '');
53
+ v_service_kind text := CASE v_service_kind_v1
54
+ WHEN 'service' THEN 'standard'
55
+ ELSE v_service_kind_v1
56
+ END;
57
+ v_existing_tenant uuid;
58
+ v_created boolean := false;
59
+ v_active boolean := coalesce((p_row ->> 'is_active')::boolean, true);
60
+ v_standalone boolean := migration._standalone();
61
+ BEGIN
62
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
63
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
64
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a product needs an id and a name');
65
+ END IF;
66
+ -- No silent correction (#148): a service_kind outside the vocabulary is a
67
+ -- decision for a human, not a default. A good never carries one.
68
+ IF v_service_kind IS NOT NULL AND v_kind <> 'service' THEN
69
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
70
+ 'reason', format('service_kind %L on a %L: only a service carries a schedulable state', v_service_kind_v1, v_kind));
71
+ END IF;
72
+ IF v_service_kind IS NOT NULL AND v_service_kind NOT IN ('standard', 'evaluation', 'return') THEN
73
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
74
+ 'reason', format('unknown service_kind %L (V1 vocabulary is service|evaluation|return)', v_service_kind_v1));
75
+ END IF;
76
+ IF v_duration IS NOT NULL AND v_duration <= 0 THEN
77
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
78
+ 'reason', format('duration_minutes %s is not a duration', v_duration));
79
+ END IF;
80
+ v_meta := migration._deep_set(v_meta, ARRAY['catalog', 'kind'], to_jsonb(v_kind));
81
+ SELECT tenant_id INTO v_existing_tenant FROM public.products WHERE id = p_id;
82
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
83
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('product %s already exists in another tenant', p_id));
84
+ END IF;
85
+ IF v_existing_tenant IS NULL THEN
86
+ INSERT INTO public.products (id, tenant_id, category_id, name, description, sku, price, cost, currency, unit, image_url, status, is_active, metadata,
87
+ duration_minutes, service_kind)
88
+ VALUES (p_id, p_tenant, (p_row ->> 'category_id')::uuid, p_row ->> 'name', p_row ->> 'description', p_row ->> 'sku',
89
+ (p_row ->> 'price')::numeric, (p_row ->> 'cost')::numeric, coalesce(p_row ->> 'currency', 'BRL'), p_row ->> 'unit',
90
+ p_row ->> 'image_url', CASE WHEN v_active THEN 'active' ELSE 'inactive' END, v_active, v_meta,
91
+ v_duration, NULL); -- service_kind is set below, after `kind` lands
92
+ v_created := true;
93
+ ELSE
94
+ UPDATE public.products SET
95
+ name = p_row ->> 'name',
96
+ category_id = CASE WHEN p_row ? 'category_id' THEN (p_row ->> 'category_id')::uuid ELSE category_id END,
97
+ description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
98
+ sku = CASE WHEN p_row ? 'sku' THEN p_row ->> 'sku' ELSE sku END,
99
+ price = CASE WHEN p_row ? 'price' THEN (p_row ->> 'price')::numeric ELSE price END,
100
+ cost = CASE WHEN p_row ? 'cost' THEN (p_row ->> 'cost')::numeric ELSE cost END,
101
+ unit = CASE WHEN p_row ? 'unit' THEN p_row ->> 'unit' ELSE unit END,
102
+ image_url = CASE WHEN p_row ? 'image_url' THEN p_row ->> 'image_url' ELSE image_url END,
103
+ status = CASE WHEN p_row ? 'is_active' THEN (CASE WHEN v_active THEN 'active' ELSE 'inactive' END) ELSE status END,
104
+ is_active = CASE WHEN p_row ? 'is_active' THEN v_active ELSE is_active END,
105
+ metadata = migration._merge_meta(metadata, v_meta),
106
+ duration_minutes = CASE WHEN p_row ? 'duration_minutes' THEN v_duration ELSE duration_minutes END
107
+ WHERE id = p_id;
108
+ END IF;
109
+ IF migration._has_column('public.products', 'kind') THEN
110
+ EXECUTE 'UPDATE public.products SET kind = $1 WHERE id = $2' USING v_kind, p_id;
111
+ END IF;
112
+ -- ORDER MATTERS. `kind` is applied in its own statement above, so anything
113
+ -- written to service_kind before this point is undone by
114
+ -- app.trg_catalog_products_normalise: it clears the column for a good and
115
+ -- re-defaults it to 'standard' for a service. Writing it here, after kind has
116
+ -- landed, is the only point where the value survives — and only when the source
117
+ -- actually carried one, so a service that arrived without a state keeps the
118
+ -- trigger's 'standard' rather than being reset to NULL against the pairing CHECK.
119
+ IF v_service_kind IS NOT NULL AND v_kind = 'service'
120
+ AND migration._has_column('public.products', 'service_kind') THEN
121
+ EXECUTE 'UPDATE public.products SET service_kind = $1 WHERE id = $2' USING v_service_kind, p_id;
122
+ END IF;
123
+ PERFORM migration._set_unit('public.products', p_id, (p_row ->> 'unit_id')::uuid);
124
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.products', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
125
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
126
+ END $$;
@@ -0,0 +1,120 @@
1
+ -- 110_people_kind_vocabulary.sql
2
+ --
3
+ -- A Person with no role at all. Fix for #204.
4
+ --
5
+ -- `person_kind_to_role` (070) maps the legacy `people.kind` vocabulary onto the
6
+ -- roles this slice made canonical, and returns NULL for anything outside it --
7
+ -- deliberately, so an app's own 'teacher' or 'patient' is left alone rather than
8
+ -- guessed at. But two shipped plugins default to a kind that IS meant to be in
9
+ -- the vocabulary and simply is not spelled the same way:
10
+ --
11
+ -- plugins/plugin-financial/src/index.ts:228 archetypeKind ?? 'client'
12
+ -- plugins/plugin-crm/src/data/supabase.ts:766 archetypeKind ?? 'client'
13
+ --
14
+ -- so those Persons resolve to no role. The seed already contains one. It is
15
+ -- invisible while readers query public.people directly and appears the moment a
16
+ -- reader moves to v_people_active_roles -- which is the contract #137 tells
17
+ -- everyone to use. At that point those customers are simply not there.
18
+ --
19
+ -- `client` is a synonym of `customer`, not a new concept, so it joins the map
20
+ -- rather than the CHECK. The plugin defaults are corrected in the same commit;
21
+ -- the alias is what covers the rows already written by the versions in the wild.
22
+ --
23
+ -- Second fix, same slice and the same class of bug: the role -> kind convergence
24
+ -- had nothing behind `is_primary`. `trg_people_kind_from_roles` (070) clears the
25
+ -- other primaries and sets one, which is correct in a single transaction and
26
+ -- races in two -- both can read "no primary" and both write one, after which
27
+ -- `people.kind` stops being derivable. Fixed in two layers: promotion demotes the
28
+ -- others atomically (so callers stop having to order two statements correctly,
29
+ -- which is what made the race reachable), and a partial unique index stands
30
+ -- behind it for any writer that skips the trigger.
31
+ --
32
+ -- Replay-safe: CREATE OR REPLACE + CREATE UNIQUE INDEX IF NOT EXISTS. The index
33
+ -- is created against existing data, so a pool that already raced will refuse it
34
+ -- -- which is the right outcome: that data needs a human, not a silent pick.
35
+
36
+ -- A PHONE IS NOT AN IDENTITY, AND THIS IS THE FILE THAT PROVES IT.
37
+ --
38
+ -- agenda/001 put a UNIQUE index on people (tenant_id, phone) WHERE kind =
39
+ -- 'customer' — sound while `kind` was typed by hand and only the public booking
40
+ -- form wrote customers. This file is what breaks that: `client` becomes
41
+ -- `customer`, so every Person an import ever labelled 'client' arrives under the
42
+ -- index at once, and a salon pool refuses the whole migration on eight people
43
+ -- who share the placeholder 21111111111 somebody typed to get past a required
44
+ -- field. They are eight different women with eight different appointment
45
+ -- histories, and no rule that treats them as one row is worth keeping.
46
+ --
47
+ -- Identity in this slice is the CPF (081, people_tenant_document_uidx). Phone is
48
+ -- a way to REACH someone: a mother books for a daughter, a couple shares a
49
+ -- landline, an import fills the column with 1s.
50
+ --
51
+ -- The booking upsert does not need the index — agenda/003 already replaced it
52
+ -- with a per-(tenant, phone) advisory lock, precisely because a
53
+ -- tenant-configured client kind could not be arbitrated by a customer-only
54
+ -- index. 001 now creates the index non-unique (it was always about lookup speed)
55
+ -- and 009 takes the same lock instead of ON CONFLICT. Dropping it here is what
56
+ -- lets a pool that already ran the old 001 move.
57
+ DROP INDEX IF EXISTS public.people_tenant_customer_phone;
58
+
59
+ CREATE OR REPLACE FUNCTION public.person_kind_to_role(p_kind text)
60
+ RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
61
+ SELECT CASE p_kind
62
+ WHEN 'customer' THEN 'customer'
63
+ WHEN 'client' THEN 'customer' -- plugin-financial / plugin-crm default (#204)
64
+ WHEN 'staff' THEN 'employee'
65
+ WHEN 'employee' THEN 'employee'
66
+ WHEN 'professional' THEN 'professional'
67
+ WHEN 'lead' THEN 'lead'
68
+ WHEN 'supplier' THEN 'supplier'
69
+ WHEN 'vendor' THEN 'supplier'
70
+ WHEN 'contact' THEN 'contact'
71
+ END
72
+ $$;
73
+
74
+ -- Promotion becomes one atomic act: naming a primary demotes the others in the
75
+ -- same statement, so a caller no longer has to clear the old hat first and there
76
+ -- is no window where a person has two or none. Two concurrent promotions now
77
+ -- serialise on the row lock the demote takes, instead of both succeeding.
78
+ CREATE OR REPLACE FUNCTION app.trg_person_roles_single_primary()
79
+ RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
80
+ BEGIN
81
+ IF NEW.is_primary THEN
82
+ UPDATE public.person_roles r SET is_primary = false
83
+ WHERE r.tenant_id = NEW.tenant_id AND r.person_id = NEW.person_id
84
+ AND r.is_primary AND r.id <> NEW.id;
85
+ END IF;
86
+ RETURN NEW;
87
+ END $$;
88
+ DROP TRIGGER IF EXISTS person_roles_single_primary ON public.person_roles;
89
+ CREATE TRIGGER person_roles_single_primary
90
+ BEFORE INSERT OR UPDATE OF is_primary ON public.person_roles
91
+ FOR EACH ROW WHEN (NEW.is_primary) EXECUTE FUNCTION app.trg_person_roles_single_primary();
92
+
93
+ -- …and the backstop the trigger cannot be, if a future writer bypasses it.
94
+ CREATE UNIQUE INDEX IF NOT EXISTS person_roles_one_primary_uidx
95
+ ON public.person_roles (tenant_id, person_id) WHERE is_primary;
96
+
97
+ -- Backfill the roles the alias was silently denying. Only for people whose kind
98
+ -- resolves to a role and who have none at all -- a person who already carries
99
+ -- hats is left exactly as she is.
100
+ DO $$
101
+ DECLARE v_added integer;
102
+ BEGIN
103
+ WITH missing AS (
104
+ SELECT p.tenant_id, p.id, public.person_kind_to_role(p.kind) AS role, p.unit_id
105
+ FROM public.people p
106
+ WHERE public.person_kind_to_role(p.kind) IS NOT NULL
107
+ AND NOT EXISTS (SELECT 1 FROM public.person_roles r
108
+ WHERE r.tenant_id = p.tenant_id AND r.person_id = p.id)
109
+ ), ins AS (
110
+ INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
111
+ SELECT tenant_id, id, role, unit_id, true, jsonb_build_object('source', 'migration:100')
112
+ FROM missing
113
+ ON CONFLICT DO NOTHING
114
+ RETURNING 1
115
+ )
116
+ SELECT count(*)::int INTO v_added FROM ins;
117
+ IF v_added > 0 THEN
118
+ RAISE NOTICE '110_people_kind_vocabulary: gave a primary role to % person(s) whose kind resolved but had none', v_added;
119
+ END IF;
120
+ END $$;