@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,144 @@
1
+ -- ============================================================================
2
+ -- 151_stock_balance_is_an_archetype.sql — how much there is, in one place
3
+ -- (#276, ADR 0024).
4
+ --
5
+ -- Two books have been kept for the same number. `plg_inventory_stock_positions`
6
+ -- counts by (product, location, batch, expiry) in numeric; `plg_shop_stock_
7
+ -- balances` counts by (variant, location, lot) in integers, with its own
8
+ -- reserved/available. 146 measured the consequence and wrote it down: restaurant
9
+ -- disagreed with itself by 20 units, salon by 29, and the ecommerce pool held
10
+ -- 1,200,449 on one book against 12 on the other. Its answer was a register
11
+ -- saying which book wins per tenant — the right answer for that day, and not an
12
+ -- answer to the question, which is that a number with two writers has no owner.
13
+ --
14
+ -- THE GRAIN, and why it is not the finer one. The archetype counts by
15
+ -- (variant × UNIT), not by (variant × shelf). A unit is a base concept every
16
+ -- plugin already shares — it is where the tenant operates. A shelf, a lot, a
17
+ -- batch and an expiry date are how a particular plugin organises what sits in
18
+ -- that unit, and they stay with that plugin, summing into the archetype. That
19
+ -- is the same shape the whole model uses: the base owns the form every plugin
20
+ -- needs, the plugin owns its own detail.
21
+ --
22
+ -- THE WRITER. One: `public.stock_apply`. RLS denies direct writes to the table
23
+ -- outright, so "who moved the stock" has exactly one answer and the movement
24
+ -- that caused it is recorded on the row. plugin-inventory calls it from the
25
+ -- trigger it already has on its movements; the shop's own decrement keeps
26
+ -- running for now and its cutover is #276's remaining half, gated on the
27
+ -- reconciliation view below reading zero.
28
+ --
29
+ -- WHAT THIS FILE DELIBERATELY DOES NOT DO. It does not revoke the shop's writes
30
+ -- to its own book. Doing that in the same migration that introduces the
31
+ -- archetype would be a cutover with no way back on a pool holding a million
32
+ -- units — and 148 already set the precedent for the honest order: introduce,
33
+ -- reconcile, then retire with the count in hand.
34
+ --
35
+ -- Idempotent and replay-safe.
36
+ -- ============================================================================
37
+
38
+ -- ─────────────────────────────────────────────────────────────────────────
39
+ -- §1 The balance
40
+ -- ─────────────────────────────────────────────────────────────────────────
41
+
42
+ CREATE TABLE IF NOT EXISTS public.stock_balances (
43
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
44
+ variant_id uuid NOT NULL REFERENCES public.product_variants(id) ON DELETE CASCADE,
45
+ unit_id uuid,
46
+ on_hand numeric(14,4) NOT NULL DEFAULT 0,
47
+ reserved numeric(14,4) NOT NULL DEFAULT 0 CHECK (reserved >= 0),
48
+ available numeric(14,4) GENERATED ALWAYS AS (on_hand - reserved) STORED,
49
+ updated_at timestamptz NOT NULL DEFAULT now(),
50
+ updated_by text,
51
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid()
52
+ );
53
+
54
+ COMMENT ON TABLE public.stock_balances IS
55
+ 'How much of a variant a unit has (151, ADR 0024). The archetype: one number, one writer (public.stock_apply). Batches, lots, shelves and expiry dates are plugin detail that sums into this — the base owns the form, the plugin owns its own grain.';
56
+ COMMENT ON COLUMN public.stock_balances.unit_id IS
57
+ 'The operating unit holding it. NULL is the tenant-wide balance, for a tenant that does not separate stock by unit.';
58
+ COMMENT ON COLUMN public.stock_balances.updated_by IS
59
+ 'Which book last moved this number. With one writer this is a breadcrumb, not arbitration — 146 needed arbitration because there were two.';
60
+
61
+ -- A tenant-wide balance is a real thing (a tenant that does not separate stock
62
+ -- by unit), and NULL is how it is spelled — which a composite PRIMARY KEY cannot
63
+ -- hold, because every PK column is NOT NULL. The identity is therefore an
64
+ -- expression index, and `stock_apply` upserts on exactly that expression: one
65
+ -- row per (tenant, variant, unit-or-tenant-wide), NULL included.
66
+ CREATE UNIQUE INDEX IF NOT EXISTS stock_balances_identity
67
+ ON public.stock_balances (tenant_id, variant_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid));
68
+
69
+ ALTER TABLE public.stock_balances ENABLE ROW LEVEL SECURITY;
70
+
71
+ -- 002's generic sweep gives every public table carrying tenant_id the four
72
+ -- `tenant_isolation_*` policies, and on a REPLAY it meets this table and hands
73
+ -- it an INSERT, an UPDATE and a DELETE — which would make "one writer" a
74
+ -- comment rather than a rule. It runs before this file on every pass, so
75
+ -- dropping them here is what holds, and holds again on the next replay.
76
+ DROP POLICY IF EXISTS tenant_isolation_select ON public.stock_balances;
77
+ DROP POLICY IF EXISTS tenant_isolation_insert ON public.stock_balances;
78
+ DROP POLICY IF EXISTS tenant_isolation_update ON public.stock_balances;
79
+ DROP POLICY IF EXISTS tenant_isolation_delete ON public.stock_balances;
80
+
81
+ -- Read for the tenant; NOBODY writes directly. The absence of an INSERT/UPDATE
82
+ -- policy is the point: `stock_apply` is SECURITY DEFINER and is the only path.
83
+ DROP POLICY IF EXISTS stock_balances_read ON public.stock_balances;
84
+ CREATE POLICY stock_balances_read ON public.stock_balances
85
+ FOR SELECT TO authenticated
86
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
87
+ GRANT SELECT ON public.stock_balances TO authenticated;
88
+ GRANT ALL ON public.stock_balances TO service_role;
89
+ REVOKE INSERT, UPDATE, DELETE ON public.stock_balances FROM anon, authenticated;
90
+
91
+ -- ─────────────────────────────────────────────────────────────────────────
92
+ -- §2 The only writer
93
+ -- ─────────────────────────────────────────────────────────────────────────
94
+
95
+ CREATE OR REPLACE FUNCTION public.stock_apply(
96
+ p_tenant uuid,
97
+ p_variant uuid,
98
+ p_unit uuid,
99
+ p_on_hand numeric DEFAULT 0,
100
+ p_reserved numeric DEFAULT 0,
101
+ p_source text DEFAULT 'inventory'
102
+ ) RETURNS jsonb
103
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
104
+ AS $$
105
+ DECLARE
106
+ v_on_hand numeric;
107
+ v_reserved numeric;
108
+ BEGIN
109
+ IF p_tenant IS NULL OR p_variant IS NULL THEN
110
+ RAISE EXCEPTION 'stock_apply: tenant and variant are required' USING ERRCODE = '22023';
111
+ END IF;
112
+
113
+ INSERT INTO public.stock_balances (tenant_id, variant_id, unit_id, on_hand, reserved, updated_by)
114
+ VALUES (p_tenant, p_variant, p_unit, coalesce(p_on_hand, 0), greatest(coalesce(p_reserved, 0), 0), p_source)
115
+ ON CONFLICT (tenant_id, variant_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO UPDATE
116
+ SET on_hand = public.stock_balances.on_hand + coalesce(p_on_hand, 0),
117
+ reserved = greatest(public.stock_balances.reserved + coalesce(p_reserved, 0), 0),
118
+ updated_at = now(),
119
+ updated_by = p_source
120
+ RETURNING on_hand, reserved INTO v_on_hand, v_reserved;
121
+
122
+ -- Below zero is a data question, not a rounding one: the caller that asked for
123
+ -- it is the one that has to answer.
124
+ IF v_on_hand < 0 THEN
125
+ RAISE EXCEPTION 'stock_apply: % would take variant % below zero (%)', p_source, p_variant, v_on_hand
126
+ USING ERRCODE = '23514';
127
+ END IF;
128
+
129
+ RETURN jsonb_build_object('on_hand', v_on_hand, 'reserved', v_reserved, 'available', v_on_hand - v_reserved);
130
+ END $$;
131
+
132
+ COMMENT ON FUNCTION public.stock_apply(uuid, uuid, uuid, numeric, numeric, text) IS
133
+ 'The only writer of public.stock_balances (151). Applies a DELTA — a movement, not a number — which is what makes two concurrent sales add up instead of overwrite each other. Below zero raises: it is a data question, and the caller is who answers it.';
134
+
135
+ REVOKE ALL ON FUNCTION public.stock_apply(uuid, uuid, uuid, numeric, numeric, text) FROM public, anon, authenticated;
136
+ GRANT EXECUTE ON FUNCTION public.stock_apply(uuid, uuid, uuid, numeric, numeric, text) TO service_role;
137
+
138
+ -- ─────────────────────────────────────────────────────────────────────────
139
+ -- §3 Reconciliation lives with the book it compares against
140
+ --
141
+ -- "Where does the archetype disagree with the shop's book?" is half a question
142
+ -- about a plugin's table, so the base cannot ask it: a pool without the shop has
143
+ -- no second book and no reason to carry the view. It is packages/shop 0082.
144
+ -- ─────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,222 @@
1
+ -- ============================================================================
2
+ -- 152_a_file_is_an_archetype.sql — the register is the base's, the bytes are
3
+ -- somebody else's (#282, ADR 0020).
4
+ --
5
+ -- An attachment shows up in every domain: the photo on a record, the receipt on
6
+ -- an invoice, the file on a form answer, the picture of a product, the document
7
+ -- of an employee. It had no owner. `public.documents` exists and is very nearly
8
+ -- the right shape — but plugin-forms creates it, which means a base-looking name
9
+ -- belongs to an optional plugin, and a pool without forms has no attachments at
10
+ -- all.
11
+ --
12
+ -- THE SPLIT, which is the decision: the base owns the REGISTER — who the file
13
+ -- belongs to, what species it is, who may see it — and the BYTES live in
14
+ -- external storage (R2, Supabase Storage), with the row pointing at them. A
15
+ -- database is a bad filesystem, and a filesystem is a terrible permission
16
+ -- system; each keeps what it is good at.
17
+ --
18
+ -- WHAT THIS BUYS, and it is the reason the decision names it: "everything you
19
+ -- have about this person" becomes ONE query. With attachments spread across
20
+ -- plugin tables, an LGPD erasure request is a sweep, and a plugin nobody
21
+ -- remembered is a fine. And "who may see this file" becomes ONE rule instead of
22
+ -- N — which is also why it has to be right, because it is now a single point of
23
+ -- failure for every attachment in the system.
24
+ --
25
+ -- Ownership is transferred, not recreated: the table already exists in pools
26
+ -- with real rows, so this file adopts it — declares the base as its owner,
27
+ -- adds what was missing, and leaves the data alone. plugin-forms' own CREATE
28
+ -- stays as the guarded no-op it already is.
29
+ --
30
+ -- Idempotent and replay-safe.
31
+ -- ============================================================================
32
+
33
+ -- ─────────────────────────────────────────────────────────────────────────
34
+ -- §1 The archetype, adopted
35
+ -- ─────────────────────────────────────────────────────────────────────────
36
+
37
+ CREATE TABLE IF NOT EXISTS public.documents (
38
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
40
+ kind text NOT NULL DEFAULT 'attachment',
41
+ person_id uuid REFERENCES public.people(id) ON DELETE SET NULL,
42
+ title text,
43
+ description text,
44
+ status text NOT NULL DEFAULT 'draft',
45
+ file_url text,
46
+ file_name text,
47
+ file_size integer,
48
+ mime_type text,
49
+ tags text[] DEFAULT '{}',
50
+ notes text,
51
+ is_active boolean NOT NULL DEFAULT true,
52
+ metadata jsonb DEFAULT '{}',
53
+ created_by uuid,
54
+ updated_by uuid,
55
+ created_at timestamptz NOT NULL DEFAULT now(),
56
+ updated_at timestamptz NOT NULL DEFAULT now()
57
+ );
58
+
59
+ -- What the bytes are, and where. `file_url` predates this and stays for the
60
+ -- readers that use it; the three columns below are what a storage service needs
61
+ -- to find, sign and delete an object without parsing a URL.
62
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_provider text;
63
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_bucket text;
64
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_path text;
65
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS checksum text;
66
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS unit_id uuid;
67
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS subject_type text;
68
+ ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS subject_id uuid;
69
+
70
+ COMMENT ON TABLE public.documents IS
71
+ 'The Arquivo archetype (152, ADR 0020): the REGISTER of a file — who it belongs to, what species it is, who may see it. The bytes live in external storage and this row points at them. Owned by the base since 152; plugin-forms'' CREATE is a guarded no-op.';
72
+ COMMENT ON COLUMN public.documents.storage_provider IS
73
+ 'Where the bytes are: r2, supabase, or another service. NULL on a row that predates the split and still answers through file_url.';
74
+ COMMENT ON COLUMN public.documents.storage_path IS
75
+ 'The object key inside the bucket. With provider and bucket, this is everything a storage service needs to sign, fetch or delete it — no URL parsing.';
76
+ COMMENT ON COLUMN public.documents.subject_type IS
77
+ 'What the file is ABOUT when it is not about a person: an invoice, an order, a product. person_id stays a first-class column because the LGPD question is asked about people.';
78
+
79
+ -- N14: a public base table carrying tenant_id has RLS on and forced, no
80
+ -- exceptions. The scaffold in §3 adds the template policies; it does not turn
81
+ -- RLS on for a table it is meeting for the first time, and a pool without
82
+ -- plugin-forms meets this table here.
83
+ ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
84
+ ALTER TABLE public.documents FORCE ROW LEVEL SECURITY;
85
+
86
+ CREATE INDEX IF NOT EXISTS documents_person ON public.documents (tenant_id, person_id) WHERE person_id IS NOT NULL;
87
+ CREATE INDEX IF NOT EXISTS documents_subject ON public.documents (tenant_id, subject_type, subject_id) WHERE subject_id IS NOT NULL;
88
+
89
+ -- ─────────────────────────────────────────────────────────────────────────
90
+ -- §2 Its species registry, in the shape 147 gave every archetype
91
+ -- ─────────────────────────────────────────────────────────────────────────
92
+
93
+ CREATE TABLE IF NOT EXISTS public.document_kinds (
94
+ kind text PRIMARY KEY,
95
+ plugin text NOT NULL,
96
+ description text,
97
+ statuses text[],
98
+ transitions jsonb,
99
+ status text NOT NULL DEFAULT 'declared',
100
+ created_at timestamptz NOT NULL DEFAULT now(),
101
+ updated_at timestamptz NOT NULL DEFAULT now(),
102
+ CONSTRAINT document_kinds_format CHECK (kind ~ '^[a-z][a-z0-9_]*$'),
103
+ CONSTRAINT document_kinds_status CHECK (status IN ('declared', 'adopted'))
104
+ );
105
+
106
+ COMMENT ON TABLE public.document_kinds IS
107
+ 'The species registry of the Arquivo archetype (152): invoice attachment, record photo, form file — each declared by the plugin that owns it.';
108
+
109
+ ALTER TABLE public.document_kinds ENABLE ROW LEVEL SECURITY;
110
+ DROP POLICY IF EXISTS document_kinds_select ON public.document_kinds;
111
+ CREATE POLICY document_kinds_select ON public.document_kinds
112
+ FOR SELECT TO anon, authenticated, service_role USING (true);
113
+ GRANT SELECT ON public.document_kinds TO anon, authenticated, service_role;
114
+ GRANT ALL ON public.document_kinds TO service_role;
115
+ REVOKE INSERT, UPDATE, DELETE ON public.document_kinds FROM anon, authenticated;
116
+
117
+ DROP TRIGGER IF EXISTS document_kinds_updated_at ON public.document_kinds;
118
+ CREATE TRIGGER document_kinds_updated_at BEFORE UPDATE ON public.document_kinds
119
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
120
+
121
+ -- 147's dispatcher learns the archetype that did not exist when it was written.
122
+ CREATE OR REPLACE FUNCTION public.kind_registry_table(p_archetype text)
123
+ RETURNS text
124
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
125
+ AS $$
126
+ SELECT CASE p_archetype
127
+ WHEN 'order' THEN 'order_kinds'
128
+ WHEN 'person_role' THEN 'person_role_kinds'
129
+ WHEN 'person' THEN 'person_kinds'
130
+ WHEN 'category' THEN 'category_kinds'
131
+ WHEN 'appointment' THEN 'appointment_kinds'
132
+ WHEN 'schedule' THEN 'schedule_kinds'
133
+ WHEN 'document' THEN 'document_kinds'
134
+ END
135
+ $$;
136
+
137
+ -- What the base itself files. A plugin's own species is declared in that
138
+ -- plugin's migration.
139
+ SELECT public.register_kind('document', d.kind, 'base', NULL, NULL, d.note)
140
+ FROM (VALUES
141
+ ('attachment', 'A file attached to something, with no more specific meaning'),
142
+ ('image', 'A picture'),
143
+ ('contract', 'A document with a signature and a consequence')
144
+ ) AS d(kind, note);
145
+
146
+ SELECT public.adopt_existing_kinds('document', 'documents', 'kind');
147
+
148
+ DO $$
149
+ BEGIN
150
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'documents_kind_registered') THEN
151
+ ALTER TABLE public.documents
152
+ ADD CONSTRAINT documents_kind_registered FOREIGN KEY (kind) REFERENCES public.document_kinds(kind);
153
+ END IF;
154
+ END $$;
155
+
156
+ -- The census speaks for seven archetypes now.
157
+ CREATE OR REPLACE VIEW public.v_archetype_kinds
158
+ WITH (security_invoker = true) AS
159
+ SELECT 'order'::text AS archetype, kind, plugin, description, statuses, transitions, status FROM public.order_kinds
160
+ UNION ALL SELECT 'person_role', kind, plugin, description, statuses, transitions, status FROM public.person_role_kinds
161
+ UNION ALL SELECT 'person', kind, plugin, description, statuses, transitions, status FROM public.person_kinds
162
+ UNION ALL SELECT 'category', kind, plugin, description, statuses, transitions, status FROM public.category_kinds
163
+ UNION ALL SELECT 'appointment', kind, plugin, description, statuses, transitions, status FROM public.appointment_kinds
164
+ UNION ALL SELECT 'schedule', kind, plugin, description, statuses, transitions, status FROM public.schedule_kinds
165
+ UNION ALL SELECT 'document', kind, plugin, description, statuses, transitions, status FROM public.document_kinds;
166
+
167
+ GRANT SELECT ON public.v_archetype_kinds TO anon, authenticated, service_role;
168
+
169
+ -- ─────────────────────────────────────────────────────────────────────────
170
+ -- §3 One rule for who may see a file
171
+ --
172
+ -- The scaffold is the platform's single authorization template — the same one
173
+ -- every business table went through in the Fase 0 sweep. Putting documents
174
+ -- under it is what makes "who may see this file" one rule rather than N, and
175
+ -- what makes that rule the same one that governs everything else.
176
+ -- ─────────────────────────────────────────────────────────────────────────
177
+
178
+ DO $$
179
+ BEGIN
180
+ IF to_regproc('app.scaffold_table(text,text,text,boolean,text)') IS NULL THEN
181
+ RETURN;
182
+ END IF;
183
+ IF NOT EXISTS (SELECT 1 FROM app.scaffold_registry WHERE table_schema = 'public' AND table_name = 'documents') THEN
184
+ PERFORM app.scaffold_table('public.documents', 'core.document', 'core', true, 'shadow');
185
+ END IF;
186
+ END $$;
187
+
188
+ -- ─────────────────────────────────────────────────────────────────────────
189
+ -- §4 The LGPD question, answered in one place
190
+ -- ─────────────────────────────────────────────────────────────────────────
191
+
192
+ CREATE OR REPLACE VIEW public.v_person_files
193
+ WITH (security_invoker = true) AS
194
+ SELECT d.id, d.tenant_id, d.person_id, d.kind, d.title, d.file_name, d.mime_type, d.file_size,
195
+ d.storage_provider, d.storage_bucket, d.storage_path, d.created_at
196
+ FROM public.documents d
197
+ WHERE d.person_id IS NOT NULL
198
+ AND d.is_active;
199
+
200
+ COMMENT ON VIEW public.v_person_files IS
201
+ 'Every file the platform holds about a Person (152, ADR 0020). One query answers the LGPD request — which is the reason the register is in the base rather than spread across the plugins that produced the files.';
202
+
203
+ GRANT SELECT ON public.v_person_files TO authenticated, service_role;
204
+
205
+ CREATE OR REPLACE FUNCTION public.person_storage_usage(p_person uuid)
206
+ RETURNS jsonb
207
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
208
+ AS $$
209
+ SELECT jsonb_build_object(
210
+ 'person_id', p_person,
211
+ 'files', count(*),
212
+ 'bytes', coalesce(sum(d.file_size), 0)
213
+ )
214
+ FROM public.documents d
215
+ WHERE d.person_id = p_person
216
+ AND d.tenant_id = app.current_tenant_id()
217
+ $$;
218
+
219
+ COMMENT ON FUNCTION public.person_storage_usage(uuid) IS
220
+ 'How many files and how many bytes the platform holds about a Person (152). A direct sum, because there is one register.';
221
+
222
+ GRANT EXECUTE ON FUNCTION public.person_storage_usage(uuid) TO authenticated, service_role;
@@ -0,0 +1,199 @@
1
+ -- ============================================================================
2
+ -- 153_a_plugin_calls_a_service_through_one_door.sql — the credential is the
3
+ -- cluster's, the contract decides, the plugin sees neither (#278, ADR 0018).
4
+ --
5
+ -- The round-3 decision put the valuable half of an open plugin in a SERVICE: the
6
+ -- agenda ships open with the button, and the intelligence that suggests where to
7
+ -- fit an appointment is an API we host. That created a question nobody had
8
+ -- answered — who carries the credential, and who decides whether this tenant may
9
+ -- call at all.
10
+ --
11
+ -- The answer that was chosen: THE CLUSTER SPEAKS FOR THE PLUGIN. The plugin
12
+ -- calls a name; the call leaves from the server with the cluster's credential;
13
+ -- the service reads the tenant's contract and decides. Three things fall out of
14
+ -- that, and they are why it was chosen over the alternatives:
15
+ --
16
+ -- · the plugin never holds a secret, so an open-source plugin can call a
17
+ -- closed service without shipping anything worth stealing
18
+ -- · the customer configures nothing to use what they already bought — the
19
+ -- "paste your API key" flow is for someone else's API, not ours
20
+ -- · it works with no browser present, which the event consumer (#277) needs:
21
+ -- the 2am reaction has no session to borrow
22
+ --
23
+ -- WHAT LIVES HERE and what does not. This file is the REGISTRY and the
24
+ -- ENTITLEMENT CHECK — which services exist, what each one costs in permission
25
+ -- terms, whether this tenant may call it, and the record of every call. The HTTP
26
+ -- leg is not in the database: an edge function reads this to decide and then
27
+ -- makes the request. A migration that opened a socket would be the wrong kind of
28
+ -- clever.
29
+ --
30
+ -- DEGRADING. Every service must have a manual path, or say plainly that it has
31
+ -- none. `manual_fallback` is that declaration, and it is NOT NULL — a service
32
+ -- that cannot answer "what happens when I am down" has not been specified.
33
+ --
34
+ -- Idempotent and replay-safe.
35
+ -- ============================================================================
36
+
37
+ -- ─────────────────────────────────────────────────────────────────────────
38
+ -- §1 The registry of services
39
+ -- ─────────────────────────────────────────────────────────────────────────
40
+
41
+ CREATE TABLE IF NOT EXISTS app.services (
42
+ name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9_.]*$'),
43
+ plugin text NOT NULL,
44
+ description text,
45
+ -- What a caller must hold to reach it. NULL means any member of the tenant.
46
+ permission text,
47
+ -- The entitlement that must be on for this tenant. NULL means "included".
48
+ entitlement text,
49
+ -- What the user can still do when the service is unreachable. Required: a
50
+ -- service with no answer here has not been thought through.
51
+ manual_fallback text NOT NULL,
52
+ timeout_ms integer NOT NULL DEFAULT 8000 CHECK (timeout_ms BETWEEN 100 AND 60000),
53
+ is_active boolean NOT NULL DEFAULT true,
54
+ created_at timestamptz NOT NULL DEFAULT now(),
55
+ updated_at timestamptz NOT NULL DEFAULT now()
56
+ );
57
+
58
+ COMMENT ON TABLE app.services IS
59
+ 'Which services exist and what it takes to call one (153, ADR 0018). The plugin calls a NAME; the cluster carries the credential; this table is what says whether the call is allowed at all.';
60
+ COMMENT ON COLUMN app.services.manual_fallback IS
61
+ 'What the user can still do with the service unreachable. NOT NULL on purpose: "the plugin must work without it" is a rule, and a rule with no answer per service is a slogan.';
62
+
63
+ ALTER TABLE app.services ENABLE ROW LEVEL SECURITY;
64
+ DROP POLICY IF EXISTS services_read ON app.services;
65
+ CREATE POLICY services_read ON app.services FOR SELECT TO authenticated USING (true);
66
+ GRANT SELECT ON app.services TO authenticated;
67
+ GRANT ALL ON app.services TO service_role;
68
+
69
+ DROP TRIGGER IF EXISTS services_updated_at ON app.services;
70
+ CREATE TRIGGER services_updated_at BEFORE UPDATE ON app.services
71
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
72
+
73
+ CREATE OR REPLACE FUNCTION public.register_service(
74
+ p_name text,
75
+ p_plugin text,
76
+ p_manual_fallback text,
77
+ p_permission text DEFAULT NULL,
78
+ p_entitlement text DEFAULT NULL,
79
+ p_description text DEFAULT NULL,
80
+ p_timeout_ms integer DEFAULT 8000
81
+ ) RETURNS void
82
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
83
+ AS $$
84
+ BEGIN
85
+ IF coalesce(btrim(p_manual_fallback), '') = '' THEN
86
+ RAISE EXCEPTION 'register_service: % must declare what works without it', p_name
87
+ USING ERRCODE = '22023',
88
+ HINT = 'If there is genuinely no manual path, say so — "none: the feature does not exist offline" is a valid answer and a visible one.';
89
+ END IF;
90
+ INSERT INTO app.services (name, plugin, description, permission, entitlement, manual_fallback, timeout_ms)
91
+ VALUES (p_name, p_plugin, p_description, p_permission, p_entitlement, p_manual_fallback, p_timeout_ms)
92
+ ON CONFLICT (name) DO UPDATE
93
+ SET plugin = EXCLUDED.plugin,
94
+ description = coalesce(EXCLUDED.description, app.services.description),
95
+ permission = EXCLUDED.permission,
96
+ entitlement = EXCLUDED.entitlement,
97
+ manual_fallback = EXCLUDED.manual_fallback,
98
+ timeout_ms = EXCLUDED.timeout_ms,
99
+ is_active = true;
100
+ END $$;
101
+
102
+ REVOKE ALL ON FUNCTION public.register_service(text, text, text, text, text, text, integer) FROM public, anon, authenticated;
103
+ GRANT EXECUTE ON FUNCTION public.register_service(text, text, text, text, text, text, integer) TO service_role;
104
+
105
+ -- ─────────────────────────────────────────────────────────────────────────
106
+ -- §2 May this call be made?
107
+ --
108
+ -- One answer, given before the request leaves, so a refusal costs nothing and
109
+ -- reads the same everywhere: not the network's job, not each plugin's job.
110
+ -- ─────────────────────────────────────────────────────────────────────────
111
+
112
+ CREATE OR REPLACE FUNCTION public.service_authorize(p_name text)
113
+ RETURNS jsonb
114
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
115
+ AS $$
116
+ DECLARE
117
+ v_service app.services%ROWTYPE;
118
+ v_tenant uuid := app.current_tenant_id();
119
+ BEGIN
120
+ SELECT * INTO v_service FROM app.services WHERE name = p_name;
121
+
122
+ IF NOT FOUND OR NOT v_service.is_active THEN
123
+ RETURN jsonb_build_object('allowed', false, 'reason', 'unknown_service');
124
+ END IF;
125
+ IF v_tenant IS NULL THEN
126
+ RETURN jsonb_build_object('allowed', false, 'reason', 'no_tenant');
127
+ END IF;
128
+ IF v_service.permission IS NOT NULL AND NOT app.has_permission(v_service.permission) THEN
129
+ RETURN jsonb_build_object('allowed', false, 'reason', 'forbidden',
130
+ 'permission', v_service.permission,
131
+ 'manual_fallback', v_service.manual_fallback);
132
+ END IF;
133
+ IF v_service.entitlement IS NOT NULL AND NOT public.tenant_plugin_active(v_service.plugin, v_service.entitlement) THEN
134
+ -- Not an error: an upsell. The caller shows the button and what it costs,
135
+ -- rather than a failure the user cannot act on.
136
+ RETURN jsonb_build_object('allowed', false, 'reason', 'not_contracted',
137
+ 'entitlement', v_service.entitlement,
138
+ 'plugin', v_service.plugin,
139
+ 'manual_fallback', v_service.manual_fallback);
140
+ END IF;
141
+
142
+ RETURN jsonb_build_object('allowed', true, 'service', v_service.name, 'tenant_id', v_tenant,
143
+ 'timeout_ms', v_service.timeout_ms,
144
+ 'manual_fallback', v_service.manual_fallback);
145
+ END $$;
146
+
147
+ COMMENT ON FUNCTION public.service_authorize(text) IS
148
+ 'Whether this caller, in this tenant, may call this service (153). Answered before the request leaves, so a refusal costs no network and reads the same everywhere. `not_contracted` carries the entitlement so the caller can offer the upgrade instead of showing a dead button.';
149
+
150
+ GRANT EXECUTE ON FUNCTION public.service_authorize(text) TO authenticated, service_role;
151
+
152
+ -- ─────────────────────────────────────────────────────────────────────────
153
+ -- §3 What was called, and what happened
154
+ --
155
+ -- Not an audit trail for its own sake: this is what answers "the button did
156
+ -- nothing" without asking the user to reproduce it, and what a quota is
157
+ -- eventually counted from.
158
+ -- ─────────────────────────────────────────────────────────────────────────
159
+
160
+ CREATE TABLE IF NOT EXISTS app.service_calls (
161
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
162
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
163
+ service text NOT NULL,
164
+ caller uuid,
165
+ status text NOT NULL CHECK (status IN ('allowed', 'refused', 'ok', 'failed', 'timeout')),
166
+ reason text,
167
+ duration_ms integer,
168
+ created_at timestamptz NOT NULL DEFAULT now()
169
+ );
170
+
171
+ COMMENT ON TABLE app.service_calls IS
172
+ 'Every service call and what became of it (153). "The button did nothing" is answered here rather than by asking the user to do it again.';
173
+
174
+ CREATE INDEX IF NOT EXISTS service_calls_tenant_time ON app.service_calls (tenant_id, created_at DESC);
175
+
176
+ ALTER TABLE app.service_calls ENABLE ROW LEVEL SECURITY;
177
+ DROP POLICY IF EXISTS service_calls_read ON app.service_calls;
178
+ CREATE POLICY service_calls_read ON app.service_calls
179
+ FOR SELECT TO authenticated
180
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
181
+ GRANT SELECT ON app.service_calls TO authenticated;
182
+ GRANT ALL ON app.service_calls TO service_role;
183
+
184
+ CREATE OR REPLACE FUNCTION public.service_call_record(
185
+ p_service text, p_status text, p_reason text DEFAULT NULL, p_duration_ms integer DEFAULT NULL
186
+ ) RETURNS uuid
187
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
188
+ AS $$
189
+ DECLARE
190
+ v_id uuid;
191
+ BEGIN
192
+ INSERT INTO app.service_calls (tenant_id, service, caller, status, reason, duration_ms)
193
+ VALUES (app.current_tenant_id(), p_service, auth.uid(), p_status, p_reason, p_duration_ms)
194
+ RETURNING id INTO v_id;
195
+ RETURN v_id;
196
+ END $$;
197
+
198
+ REVOKE ALL ON FUNCTION public.service_call_record(text, text, text, integer) FROM public, anon;
199
+ GRANT EXECUTE ON FUNCTION public.service_call_record(text, text, text, integer) TO authenticated, service_role;