@@ -224,20 +224,23 @@ INSERT INTO identity.role (permissions, name) VALUES
224224
225225-- AUTH — hot path (chaque requête authentifiée) · fillfactor=70 pour HOT updates
226226-- Layout (ADR-006) — offsets mesurés avec null bitmap (Audit collision DOD/HOT) :
227- -- Null bitmap : 1B (3 colonnes nullable) → header total : 24B (23+1, MAXALIGN→24)
228- -- created_at TSTZ offset 24 (8B)
229- -- last_login_at TSTZ offset 32 (8B)
230- -- modified_at TSTZ offset 40 (8B)
231- -- entity_id INT4 offset 48 (4B)
227+ -- Null bitmap : 1B (2 colonnes nullable) → header total : 24B (23+1, MAXALIGN→24)
228+ -- created_at TSTZ offset 24 (8B) [IMMUTABLE — trigger Audit 3]
229+ -- last_login_at TSTZ offset 32 (8B) [HOT update ✓]
230+ -- modified_at TSTZ offset 40 (8B) [rare write]
231+ -- entity_id INT4 offset 48 (4B) [IMMUTABLE — trigger Audit collision 2]
232232-- role_id INT2 offset 52 (2B)
233233-- is_banned BOOL offset 54 (1B)
234234-- [1B libre offset 55 — slot réservé BOOLEAN, ex : is_email_verified]
235- -- password_hash varlena offset 56 (→ TOAST, référence 4B inline)
236- -- Tuple padded : 88B → 62 tuples/page à ff=70%
237- -- Collision DOD/HOT : aucune. last_login_at, role_id, is_banned, password_hash
238- -- ne sont dans aucun index hors PK. HOT systématiquement éligible.
239- -- Note : commentaire initial indiquait "offsets 0–23" (sans header). Corrigé
240- -- après calcul précis avec null bitmap (Audit collision).
235+ -- password_hash varlena offset 56 (4B hdr + ~97B argon2id inline → 101B)
236+ -- [3B MAXALIGN padding]
237+ -- Tuple padded : 160B (simulation Audit 3 — password_hash inline)
238+ -- 34 tpp @ ff=70% (HOT reserve) | 49 tpp @ ff=100% (scan pur)
239+ -- Densité utile : 53.1% de la page 8kB (4352B utiles / 8192B)
240+ -- Annotation initiale '~51 tpp' calculée à ff=100% implicite (8168/(155+4))
241+ -- malgré le fillfactor=70 déclaré. Corrigé après simulation précise (Audit 3).
242+ -- Collision HOT : aucune (last_login_at, role_id, is_banned, password_hash hors index).
243+ -- STORAGE MAIN sur password_hash : argon2id pseudo-aléatoire, PGLZ ratio ≈ 1.0.
241244CREATE TABLE identity .auth (
242245 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
243246 last_login_at TIMESTAMPTZ NULL ,
@@ -253,6 +256,11 @@ CREATE TABLE identity.auth (
253256 FOREIGN KEY (role_id) REFERENCES identity .role (id)
254257) WITH (fillfactor = 70 );
255258
259+ -- STORAGE MAIN : argon2id est pseudo-aléatoire (entropie maximale) → PGLZ ratio ≈ 1.0.
260+ -- EXTENDED tenterait une compression inutile sur chaque UPDATE record_login (hot path).
261+ -- MAIN : inline garanti (97B << seuil TOAST ~2kB), zéro tentative de compression.
262+ ALTER TABLE identity .auth ALTER COLUMN password_hash SET STORAGE MAIN;
263+
256264CREATE INDEX auth_created_at_brin ON identity .auth USING brin (created_at)
257265 WITH (pages_per_range = 128 );
258266
@@ -747,6 +755,30 @@ CREATE TABLE content.body (
747755
748756ALTER TABLE content .body ALTER COLUMN content SET STORAGE EXTENDED;
749757
758+ -- STORAGE MAIN — colonnes courtes inline, compression sans intérêt (Audit 3)
759+ -- identity.account_core : username/slug (identifiants incompressibles ≤32 chars),
760+ -- language (5 chars fixes), time_zone (~30 chars).
761+ ALTER TABLE identity .account_core ALTER COLUMN username SET STORAGE MAIN;
762+ ALTER TABLE identity .account_core ALTER COLUMN slug SET STORAGE MAIN;
763+ ALTER TABLE identity .account_core ALTER COLUMN language SET STORAGE MAIN;
764+ ALTER TABLE identity .account_core ALTER COLUMN time_zone SET STORAGE MAIN;
765+
766+ -- content.identity : headline/slug/alternative_headline (titres courts, URL-safe slugs).
767+ -- description conserve EXTENDED (≤1000 chars, naturellement compressible).
768+ ALTER TABLE content .identity ALTER COLUMN headline SET STORAGE MAIN;
769+ ALTER TABLE content .identity ALTER COLUMN slug SET STORAGE MAIN;
770+ ALTER TABLE content .identity ALTER COLUMN alternative_headline SET STORAGE MAIN;
771+
772+ -- org.org_identity : name/slug/brand (identifiants courts ≤64-255 chars).
773+ ALTER TABLE org .org_identity ALTER COLUMN name SET STORAGE MAIN;
774+ ALTER TABLE org .org_identity ALTER COLUMN slug SET STORAGE MAIN;
775+ ALTER TABLE org .org_identity ALTER COLUMN brand SET STORAGE MAIN;
776+
777+ -- content.comment : contenu typiquement <2kB (commentaires courts).
778+ -- MAIN : inline pour les cas nominaux, TOAST sans compression si > seuil de page.
779+ -- Évite la tentative PGLZ sur chaque INSERT de commentaire (write path fréquent).
780+ ALTER TABLE content .comment ALTER COLUMN content SET STORAGE MAIN;
781+
750782-- CONTENT REVISION — cold storage des snapshots éditoriaux
751783-- Layout : saved_at TIMESTAMPTZ · document_id INT4 · author_entity_id INT4
752784-- · revision_num SMALLINT · 2B pad | varlena × 5 (snapshot_headline remplace snapshot_name)
@@ -1903,7 +1935,259 @@ $$;
19031935
19041936
19051937-- ==============================================================================
1906- -- SECTION 12 : VUES SÉMANTIQUES (INTERFACE schema.org — snake_case, ADR-028)
1938+ -- SECTION 11b : PROCÉDURES ORPHELINES — Audit Interface de Mutation (ADR-001 rev.)
1939+ -- Composants identifiés sans procédure dédiée. Scellement de l'interface.
1940+ -- ==============================================================================
1941+
1942+ -- GEO : création atomique d'un lieu (place_core + postal_address optionnelle)
1943+ -- Garde : manage_system (524288) — données géographiques de référence.
1944+ -- postal_address est optionnelle : un lieu peut exister sans adresse postale
1945+ -- (ex: coordonnées GPS pures, lieu naturel sans adresse).
1946+ CREATE PROCEDURE geo .create_place (
1947+ p_name VARCHAR (60 ) DEFAULT NULL ,
1948+ p_elevation SMALLINT DEFAULT NULL ,
1949+ p_type_id SMALLINT DEFAULT NULL ,
1950+ p_lat FLOAT8 DEFAULT NULL ,
1951+ p_lng FLOAT8 DEFAULT NULL ,
1952+ p_country_code SMALLINT DEFAULT NULL , -- ISO 3166-1 numérique (ADR-028)
1953+ p_street_address VARCHAR (60 ) DEFAULT NULL ,
1954+ p_postal_code VARCHAR (16 ) DEFAULT NULL ,
1955+ p_locality VARCHAR (64 ) DEFAULT NULL ,
1956+ p_region VARCHAR (64 ) DEFAULT NULL ,
1957+ OUT p_place_id INT
1958+ ) LANGUAGE plpgsql AS $$
1959+ BEGIN
1960+ IF identity .rls_user_id () <> - 1
1961+ AND (identity .rls_auth_bits () & 524288 ) <> 524288 THEN
1962+ RAISE EXCEPTION ' insufficient_privilege: manage_system required to create a place'
1963+ USING ERRCODE = ' 42501' ;
1964+ END IF;
1965+ INSERT INTO geo .place_core (name, elevation, type_id, coordinates)
1966+ VALUES (
1967+ p_name, p_elevation, p_type_id,
1968+ CASE WHEN p_lat IS NOT NULL AND p_lng IS NOT NULL
1969+ THEN ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326 )
1970+ ELSE NULL
1971+ END
1972+ ) RETURNING id INTO p_place_id;
1973+
1974+ -- Composant postal_address : créé uniquement si au moins un champ est fourni
1975+ IF p_country_code IS NOT NULL OR p_street_address IS NOT NULL
1976+ OR p_locality IS NOT NULL OR p_region IS NOT NULL
1977+ OR p_postal_code IS NOT NULL THEN
1978+ INSERT INTO geo .postal_address
1979+ (place_id, country_code, street_address, postal_code, address_locality, address_region)
1980+ VALUES (p_place_id, p_country_code, p_street_address, p_postal_code, p_locality, p_region);
1981+ END IF;
1982+ END;
1983+ $$;
1984+
1985+ -- IDENTITY : création d'un groupe
1986+ -- Garde : manage_groups (bit 9, valeur 512).
1987+ -- L'asymétrie avec content_to_tag (add/remove_tag) justifie add_account_to_group séparé.
1988+ CREATE PROCEDURE identity .create_group (
1989+ p_name VARCHAR (32 ),
1990+ OUT p_group_id INT
1991+ ) LANGUAGE plpgsql AS $$
1992+ BEGIN
1993+ IF identity .rls_user_id () <> - 1
1994+ AND (identity .rls_auth_bits () & 512 ) <> 512 THEN
1995+ RAISE EXCEPTION ' insufficient_privilege: manage_groups required to create a group'
1996+ USING ERRCODE = ' 42501' ;
1997+ END IF;
1998+ INSERT INTO identity .group (name) VALUES (p_name) RETURNING id INTO p_group_id;
1999+ END;
2000+ $$;
2001+
2002+ -- IDENTITY : ajout d'un compte dans un groupe
2003+ -- Garde : manage_groups (512). Idempotent (ON CONFLICT DO NOTHING).
2004+ CREATE PROCEDURE identity .add_account_to_group (p_group_id INT , p_account_entity_id INT )
2005+ LANGUAGE plpgsql AS $$
2006+ BEGIN
2007+ IF identity .rls_user_id () <> - 1
2008+ AND (identity .rls_auth_bits () & 512 ) <> 512 THEN
2009+ RAISE EXCEPTION ' insufficient_privilege: manage_groups required'
2010+ USING ERRCODE = ' 42501' ;
2011+ END IF;
2012+ INSERT INTO identity .group_to_account (group_id, account_entity_id)
2013+ VALUES (p_group_id, p_account_entity_id)
2014+ ON CONFLICT DO NOTHING;
2015+ END;
2016+ $$;
2017+
2018+ -- COMMERCE : création atomique d'un produit (product_core + product_identity)
2019+ -- Garde : manage_commerce (262144).
2020+ -- product_content (description/tags) reste en accès direct marius_admin :
2021+ -- cold path, non applicatif, aucune dépendance de permission fine.
2022+ CREATE PROCEDURE commerce .create_product (
2023+ p_name VARCHAR (64 ),
2024+ p_slug VARCHAR (64 ),
2025+ p_price_cents INT8 DEFAULT NULL ,
2026+ p_stock INT DEFAULT 0 ,
2027+ p_isbn_ean VARCHAR (13 ) DEFAULT NULL ,
2028+ OUT p_product_id INT
2029+ ) LANGUAGE plpgsql AS $$
2030+ BEGIN
2031+ IF identity .rls_user_id () <> - 1
2032+ AND (identity .rls_auth_bits () & 262144 ) <> 262144 THEN
2033+ RAISE EXCEPTION ' insufficient_privilege: manage_commerce required to create a product'
2034+ USING ERRCODE = ' 42501' ;
2035+ END IF;
2036+ INSERT INTO commerce .product_core (price_cents, stock, is_available)
2037+ VALUES (p_price_cents, p_stock, true)
2038+ RETURNING id INTO p_product_id;
2039+
2040+ INSERT INTO commerce .product_identity (product_id, name, slug, isbn_ean)
2041+ VALUES (p_product_id, p_name, p_slug, p_isbn_ean);
2042+ END;
2043+ $$;
2044+
2045+ -- CONTENT : création atomique d'un média (media_core + media_content optionnel)
2046+ -- Garde : upload_files (8192).
2047+ -- media_content (name, description, copyright_notice) est optionnel :
2048+ -- créé uniquement si au moins un champ est fourni.
2049+ CREATE PROCEDURE content .create_media (
2050+ p_author_id INT ,
2051+ p_mime_type VARCHAR (255 ) DEFAULT NULL ,
2052+ p_folder_url VARCHAR (255 ) DEFAULT NULL ,
2053+ p_file_name VARCHAR (255 ) DEFAULT NULL ,
2054+ p_width INT DEFAULT NULL ,
2055+ p_height INT DEFAULT NULL ,
2056+ p_name VARCHAR (255 ) DEFAULT NULL ,
2057+ p_description VARCHAR (255 ) DEFAULT NULL ,
2058+ p_copyright_notice VARCHAR (255 ) DEFAULT NULL ,
2059+ OUT p_media_id INT
2060+ ) LANGUAGE plpgsql AS $$
2061+ BEGIN
2062+ IF identity .rls_user_id () <> - 1 THEN
2063+ IF (identity .rls_auth_bits () & 8192 ) <> 8192 THEN
2064+ RAISE EXCEPTION ' insufficient_privilege: upload_files required to create a media'
2065+ USING ERRCODE = ' 42501' ;
2066+ END IF;
2067+ IF p_author_id <> identity .rls_user_id ()
2068+ AND (identity .rls_auth_bits () & 32768 ) <> 32768 THEN
2069+ RAISE EXCEPTION ' insufficient_privilege: cannot create media attributed to another author'
2070+ USING ERRCODE = ' 42501' ;
2071+ END IF;
2072+ END IF;
2073+ INSERT INTO content .media_core (author_id, mime_type, folder_url, file_name, width, height)
2074+ VALUES (p_author_id, p_mime_type, p_folder_url, p_file_name, p_width, p_height)
2075+ RETURNING id INTO p_media_id;
2076+
2077+ IF p_name IS NOT NULL OR p_description IS NOT NULL OR p_copyright_notice IS NOT NULL THEN
2078+ INSERT INTO content .media_content (media_id, name, description, copyright_notice)
2079+ VALUES (p_media_id, p_name, p_description, p_copyright_notice);
2080+ END IF;
2081+ END;
2082+ $$;
2083+
2084+ -- CONTENT : liaison document↔média (symétrique de add_tag_to_document)
2085+ -- Garde : edit_contents (4) ou edit_others_contents (32768) + ownership.
2086+ -- position : ordre d'affichage dans la galerie du document.
2087+ CREATE PROCEDURE content .add_media_to_document (
2088+ p_document_id INT , p_media_id INT , p_position SMALLINT DEFAULT 0
2089+ ) LANGUAGE plpgsql AS $$
2090+ BEGIN
2091+ IF identity .rls_user_id () <> - 1 THEN
2092+ IF (identity .rls_auth_bits () & 4 ) <> 4
2093+ AND (identity .rls_auth_bits () & 32768 ) <> 32768 THEN
2094+ RAISE EXCEPTION ' insufficient_privilege: edit_contents or edit_others_contents required'
2095+ USING ERRCODE = ' 42501' ;
2096+ END IF;
2097+ IF (identity .rls_auth_bits () & 32768 ) <> 32768 THEN
2098+ PERFORM 1 FROM content .core
2099+ WHERE document_id = p_document_id AND author_entity_id = identity .rls_user_id ();
2100+ IF NOT FOUND THEN
2101+ RAISE EXCEPTION ' insufficient_privilege: cannot add media to another author' ' s document'
2102+ USING ERRCODE = ' 42501' ;
2103+ END IF;
2104+ END IF;
2105+ END IF;
2106+ INSERT INTO content .content_to_media (content_id, media_id, position)
2107+ VALUES (p_document_id, p_media_id, p_position)
2108+ ON CONFLICT (content_id, media_id) DO UPDATE SET position = EXCLUDED .position ;
2109+ END;
2110+ $$;
2111+
2112+ CREATE PROCEDURE content .remove_media_from_document (p_document_id INT , p_media_id INT )
2113+ LANGUAGE plpgsql AS $$
2114+ BEGIN
2115+ IF identity .rls_user_id () <> - 1 THEN
2116+ IF (identity .rls_auth_bits () & 4 ) <> 4
2117+ AND (identity .rls_auth_bits () & 32768 ) <> 32768 THEN
2118+ RAISE EXCEPTION ' insufficient_privilege: edit_contents or edit_others_contents required'
2119+ USING ERRCODE = ' 42501' ;
2120+ END IF;
2121+ IF (identity .rls_auth_bits () & 32768 ) <> 32768 THEN
2122+ PERFORM 1 FROM content .core
2123+ WHERE document_id = p_document_id AND author_entity_id = identity .rls_user_id ();
2124+ IF NOT FOUND THEN
2125+ RAISE EXCEPTION ' insufficient_privilege: cannot remove media from another author' ' s document'
2126+ USING ERRCODE = ' 42501' ;
2127+ END IF;
2128+ END IF;
2129+ END IF;
2130+ DELETE FROM content .content_to_media WHERE content_id = p_document_id AND media_id = p_media_id;
2131+ END;
2132+ $$;
2133+
2134+ -- org.create_organization étendu : org_contact + org_legal optionnels
2135+ -- Plutôt que créer deux procédures supplémentaires, on étend la procédure existante.
2136+ -- Les champs org_contact et org_legal sont optionnels (NULL = non fourni → pas d'INSERT).
2137+ -- Rappel : org.create_organization est recréée plus bas — on la redéfinit ici
2138+ -- avec la signature étendue pour conserver le comportement existant + nouveaux composants.
2139+ -- Note : la signature ALTER PROCEDURE en SECTION 14 doit être mise à jour en conséquence.
2140+
2141+ -- ==============================================================================
2142+ -- SECTION 11c : TRIGGER IMMUABILITÉ entity_id (invariant structurel ADR-001 rev.)
2143+ -- ==============================================================================
2144+ --
2145+ -- entity_id est la FK vers le spine identity.entity. C'est l'invariant de
2146+ -- sous-type ECS : chaque composant est défini par son appartenance au spine.
2147+ -- Un UPDATE entity_id après l'INSERT briserait cette appartenance et
2148+ -- produirait silencieusement un composant orphelin ou mal attaché.
2149+ --
2150+ -- Les procédures SECURITY DEFINER ne font jamais de UPDATE entity_id (audité).
2151+ -- Le seul vecteur résiduel est marius_admin (accès direct). Ce trigger
2152+ -- ferme cette faille structurelle sans coût sur les paths nominaux
2153+ -- (la clause WHEN est évaluée sans exécuter le corps si entity_id n'est pas touché).
2154+ -- ==============================================================================
2155+
2156+ CREATE FUNCTION identity .fn_deny_entity_id_update()
2157+ RETURNS TRIGGER LANGUAGE plpgsql AS $$
2158+ BEGIN
2159+ RAISE EXCEPTION
2160+ ' entity_id is immutable on %.%: it is the ECS sub-type key and cannot be reassigned (ADR-001)' ,
2161+ TG_TABLE_SCHEMA, TG_TABLE_NAME
2162+ USING ERRCODE = ' 55000' ;
2163+ END;
2164+ $$;
2165+
2166+ -- Déploiement sur les composants qui portent entity_id comme FK spine
2167+ CREATE TRIGGER auth_deny_entity_id_update
2168+ BEFORE UPDATE ON identity .auth
2169+ FOR EACH ROW WHEN (OLD .entity_id IS DISTINCT FROM NEW .entity_id )
2170+ EXECUTE FUNCTION identity .fn_deny_entity_id_update ();
2171+
2172+ CREATE TRIGGER account_core_deny_entity_id_update
2173+ BEFORE UPDATE ON identity .account_core
2174+ FOR EACH ROW WHEN (OLD .entity_id IS DISTINCT FROM NEW .entity_id )
2175+ EXECUTE FUNCTION identity .fn_deny_entity_id_update ();
2176+
2177+ CREATE TRIGGER person_identity_deny_entity_id_update
2178+ BEFORE UPDATE ON identity .person_identity
2179+ FOR EACH ROW WHEN (OLD .entity_id IS DISTINCT FROM NEW .entity_id )
2180+ EXECUTE FUNCTION identity .fn_deny_entity_id_update ();
2181+
2182+ CREATE TRIGGER core_deny_document_id_update
2183+ BEFORE UPDATE ON content .core
2184+ FOR EACH ROW WHEN (OLD .document_id IS DISTINCT FROM NEW .document_id )
2185+ EXECUTE FUNCTION identity .fn_deny_entity_id_update ();
2186+
2187+ CREATE TRIGGER transaction_core_deny_id_update
2188+ BEFORE UPDATE ON commerce .transaction_core
2189+ FOR EACH ROW WHEN (OLD .id IS DISTINCT FROM NEW .id )
2190+ EXECUTE FUNCTION identity .fn_deny_entity_id_update ();
19072191-- Couche de traduction : composants ECS physiques → contrat d'accès public.
19082192-- snake_case systématique : pas de guillemets requis dans les requêtes SQL.
19092193-- Suffixes DOD conservés : _at (TIMESTAMPTZ), _cents (INT8), _id (FK), _code.
@@ -2494,6 +2778,33 @@ ALTER PROCEDURE commerce.create_transaction(integer, integer, smallint, smallint
24942778ALTER PROCEDURE commerce .create_transaction_item (integer , integer , integer )
24952779 SECURITY DEFINER SET search_path = ' commerce' , ' pg_catalog' ;
24962780
2781+ -- Nouvelles procédures Section 11b (Audit interface de mutation ADR-001 rev.)
2782+ ALTER PROCEDURE geo .create_place (
2783+ character varying, smallint , smallint , double precision , double precision ,
2784+ smallint , character varying, character varying, character varying, character varying
2785+ ) SECURITY DEFINER SET search_path = ' geo' , ' identity' , ' public' , ' pg_catalog' ;
2786+
2787+ ALTER PROCEDURE identity .create_group (character varying)
2788+ SECURITY DEFINER SET search_path = ' identity' , ' pg_catalog' ;
2789+
2790+ ALTER PROCEDURE identity .add_account_to_group (integer , integer )
2791+ SECURITY DEFINER SET search_path = ' identity' , ' pg_catalog' ;
2792+
2793+ ALTER PROCEDURE commerce .create_product (
2794+ character varying, character varying, bigint , integer , character varying
2795+ ) SECURITY DEFINER SET search_path = ' commerce' , ' identity' , ' pg_catalog' ;
2796+
2797+ ALTER PROCEDURE content .create_media (
2798+ integer , character varying, character varying, character varying,
2799+ integer , integer , character varying, character varying, character varying
2800+ ) SECURITY DEFINER SET search_path = ' content' , ' identity' , ' pg_catalog' ;
2801+
2802+ ALTER PROCEDURE content .add_media_to_document (integer , integer , smallint )
2803+ SECURITY DEFINER SET search_path = ' content' , ' identity' , ' pg_catalog' ;
2804+
2805+ ALTER PROCEDURE content .remove_media_from_document (integer , integer )
2806+ SECURITY DEFINER SET search_path = ' content' , ' identity' , ' pg_catalog' ;
2807+
24972808
24982809-- ==============================================================================
24992810-- SECTION 15 : ROW-LEVEL SECURITY (RLS) — Pattern Stateless GUC (ADR-002)
0 commit comments