labfy-investigation/database/schema_current.sql

499 lines
25 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/******************************************************************************
* Labfy Investigation
*
* Extensions idempotentes du schéma SQLite courant V20
******************************************************************************/
/*
* Les coordonnées du graphe sont un état de présentation.
*
* Elles restent séparées des données métier de la table entites.
*
* Cette table peut être créée à chaque ouverture grâce à IF NOT EXISTS.
*/
CREATE TABLE IF NOT EXISTS graph_node_positions
(
entity_id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (entity_id)
REFERENCES entites(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CHECK (
length(trim(entity_id)) > 0
),
CHECK (
typeof(x) IN (
'integer',
'real'
)
),
CHECK (
typeof(y) IN (
'integer',
'real'
)
),
CHECK (
x = x
AND abs(x) <= 1.7976931348623157e308
),
CHECK (
y = y
AND abs(y) <= 1.7976931348623157e308
),
CHECK (
length(trim(updated_at)) > 0
)
);
/*
* Disposition générique du graphe.
*
* Contrairement à graph_node_positions, cette table accepte aussi les UUID
* des relations. L'ancienne table reste présente pour assurer la compatibilité
* avec les enquêtes créées avant l'introduction des nœuds de relation.
*/
CREATE TABLE IF NOT EXISTS extractions
(
id TEXT PRIMARY KEY,
evidence_id TEXT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
tool_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE CASCADE,
CHECK (source_kind IN ('evidence', 'entity')),
CHECK (length(trim(source_id)) > 0),
CHECK (length(trim(tool_id)) > 0),
CHECK (length(created_at) = 20)
);
CREATE INDEX IF NOT EXISTS idx_extractions_source
ON extractions(source_kind, source_id);
CREATE TABLE IF NOT EXISTS graph_layout_positions
(
node_id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
updated_at TEXT NOT NULL,
CHECK (length(trim(node_id)) > 0),
CHECK (length(updated_at) = 20)
);
CREATE TABLE IF NOT EXISTS graph_viewport
(
id INTEGER PRIMARY KEY CHECK (id = 1),
zoom REAL NOT NULL CHECK (zoom = zoom AND zoom > 0),
offset_x REAL NOT NULL CHECK (offset_x = offset_x),
offset_y REAL NOT NULL CHECK (offset_y = offset_y),
updated_at TEXT NOT NULL CHECK (length(updated_at) = 20)
);
CREATE TABLE IF NOT EXISTS relation_types
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE,
label TEXT NOT NULL,
normalized_key TEXT NOT NULL UNIQUE,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
CHECK (code IS NULL OR length(trim(code)) > 0),
CHECK (length(trim(label)) > 0),
CHECK (length(normalized_key) > 0)
);
/* Migration idempotente des positions d'entités déjà enregistrées. */
INSERT OR IGNORE INTO graph_layout_positions(node_id, x, y, updated_at)
SELECT entity_id, x, y, updated_at
FROM graph_node_positions;
/* Évite qu'une réinitialisation future ne réimporte des coordonnées obsolètes. */
DELETE FROM graph_node_positions;
/* Une clé étrangère polymorphe n'existe pas dans SQLite : ces triggers
* suppriment donc les positions devenues orphelines. */
CREATE TRIGGER IF NOT EXISTS graph_layout_positions_delete_entity
AFTER DELETE ON entites
FOR EACH ROW
BEGIN
DELETE FROM graph_layout_positions WHERE node_id = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS graph_layout_positions_delete_relation
AFTER DELETE ON relations
FOR EACH ROW
BEGIN
DELETE FROM graph_layout_positions WHERE node_id = OLD.id;
END;
CREATE TABLE IF NOT EXISTS bank_account_entities
(
id TEXT PRIMARY KEY,
iban TEXT NOT NULL,
bic TEXT,
holder_name TEXT,
bank_name TEXT,
bank_address TEXT,
country_code TEXT,
bank_code TEXT,
branch_code TEXT,
account_number TEXT,
rib_key TEXT,
verification_status TEXT NOT NULL DEFAULT 'proposed' CHECK (verification_status IN ('proposed', 'confirmed', 'rejected', 'conflicted', 'invalid')),
provenance_kind TEXT NOT NULL DEFAULT 'ocr' CHECK (provenance_kind IN ('observed', 'ocr', 'header', 'metadata', 'derived', 'manual')),
evidence_id TEXT,
extraction_id TEXT,
created_at TEXT NOT NULL CHECK (length(created_at) = 20),
updated_at TEXT NOT NULL CHECK (length(updated_at) = 20),
FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE SET NULL,
FOREIGN KEY (extraction_id) REFERENCES extractions(id) ON DELETE SET NULL,
CHECK (length(trim(id)) > 0),
CHECK (length(trim(iban)) > 0)
);
CREATE INDEX IF NOT EXISTS idx_bank_account_entities_iban ON bank_account_entities(iban);
CREATE INDEX IF NOT EXISTS idx_bank_account_entities_evidence ON bank_account_entities(evidence_id);
INSERT OR IGNORE INTO relation_types(code, label, normalized_key, description, is_system) VALUES
('sent_from', 'Envoyé depuis', 'envoyé depuis', 'Message e-mail envoyé depuis une adresse ou serveur.', 1),
('sent_to', 'Envoyé à', 'envoyé à', 'Message e-mail envoyé à une adresse.', 1),
('reply_to', 'Répondre à', 'répondre à', 'Adresse de réponse configurée.', 1),
('has_attachment', 'Possède la pièce jointe', 'possède la pièce jointe', 'Preuve ou fichier joint à un e-mail.', 1),
('relayed_by', 'Relayé par', 'relayé par', 'Relais SMTP ayant acheminé le message.', 1),
('uses_domain', 'Utilise le domaine', 'utilise le domaine', 'Adresse e-mail rattachée à un domaine.', 1),
('held_at', 'Tenu auprès de', 'tenu auprès de', 'Compte bancaire ouvert dans une banque.', 1),
('named_as_holder_of', 'Nommé titulaire de', 'nommé titulaire de', 'Personne ou entité observée comme titulaire du RIB.', 1),
('supports', 'Soutient', 'soutient', 'Preuve soutenant une entité ou relation.', 1);
CREATE TABLE IF NOT EXISTS evidence_entity_observations
(
id TEXT PRIMARY KEY,
evidence_id TEXT NOT NULL,
entity_id TEXT,
entity_type TEXT NOT NULL,
value_raw TEXT NOT NULL,
value_normalized TEXT,
value_corrected TEXT,
role TEXT NOT NULL,
provenance_kind TEXT NOT NULL,
source_header TEXT NOT NULL,
occurrence INTEGER NOT NULL DEFAULT 1 CHECK (occurrence > 0),
verification_status TEXT NOT NULL DEFAULT 'proposed',
extraction_id TEXT,
warning TEXT,
observed_at TEXT NOT NULL CHECK (length(observed_at) = 20),
integrated_at TEXT NOT NULL CHECK (length(integrated_at) = 20),
promoted_at TEXT,
promotion_kind TEXT,
UNIQUE (evidence_id, entity_type, value_normalized, role, source_header,
occurrence, provenance_kind, extraction_id),
FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES entites(id) ON DELETE SET NULL,
FOREIGN KEY (extraction_id) REFERENCES extractions(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_evidence_entity_observations_evidence
ON evidence_entity_observations(evidence_id);
CREATE INDEX IF NOT EXISTS idx_evidence_entity_observations_entity
ON evidence_entity_observations(entity_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_evidence_entity_observations_semantic
ON evidence_entity_observations(
evidence_id,entity_type,value_normalized,role,source_header,
occurrence,provenance_kind,COALESCE(extraction_id,''));
CREATE TABLE IF NOT EXISTS preuve_entite_sources
(
id TEXT PRIMARY KEY,
preuve_id TEXT NOT NULL,
entite_id TEXT NOT NULL,
source_kind TEXT NOT NULL CHECK (
source_kind IN ('manual', 'legacy_manual', 'eml_observation')
),
source_uuid TEXT,
created_at TEXT NOT NULL CHECK (length(created_at) = 20),
FOREIGN KEY (preuve_id, entite_id)
REFERENCES preuve_entites(preuve_id, entite_id) ON DELETE CASCADE,
CHECK (
(source_kind = 'eml_observation' AND source_uuid IS NOT NULL) OR
(source_kind <> 'eml_observation' AND source_uuid IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_preuve_entite_sources_unique
ON preuve_entite_sources(
preuve_id, entite_id, source_kind, COALESCE(source_uuid, '')
);
CREATE INDEX IF NOT EXISTS idx_preuve_entite_sources_entity
ON preuve_entite_sources(entite_id);
CREATE INDEX IF NOT EXISTS idx_preuve_entite_sources_source
ON preuve_entite_sources(source_kind, source_uuid);
CREATE TABLE IF NOT EXISTS person_role_assignments
(
id TEXT PRIMARY KEY CHECK (length(trim(id)) > 0),
entity_id TEXT NOT NULL,
role_code TEXT NOT NULL CHECK (length(trim(role_code)) > 0),
evidence_id TEXT,
provenance_kind TEXT NOT NULL CHECK (
provenance_kind IN ('manual', 'legacy_manual')
),
confidence INTEGER CHECK (
confidence IS NULL OR confidence BETWEEN 0 AND 100
),
notes TEXT,
created_at TEXT NOT NULL CHECK (length(created_at) = 20),
updated_at TEXT NOT NULL CHECK (length(updated_at) = 20),
FOREIGN KEY (entity_id) REFERENCES entites(id) ON DELETE CASCADE,
FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_person_role_assignments_semantic
ON person_role_assignments(
entity_id, role_code, COALESCE(evidence_id, ''), provenance_kind
);
CREATE INDEX IF NOT EXISTS idx_person_role_assignments_entity
ON person_role_assignments(entity_id);
CREATE INDEX IF NOT EXISTS idx_person_role_assignments_evidence
ON person_role_assignments(evidence_id);
CREATE TABLE IF NOT EXISTS identity_ocr_runs (
id TEXT PRIMARY KEY, evidence_id TEXT NOT NULL,
expected_sha256 TEXT NOT NULL CHECK(length(expected_sha256)=64),
page_number INTEGER NOT NULL CHECK(page_number>0),
document_type TEXT NOT NULL, document_side TEXT NOT NULL,
engine TEXT NOT NULL, engine_version TEXT, requested_languages TEXT NOT NULL,
available_languages TEXT NOT NULL, parameters TEXT NOT NULL,
preprocessing_profile TEXT NOT NULL, executed_at TEXT NOT NULL,
status TEXT NOT NULL, error_message TEXT,
text_relative_path TEXT NOT NULL, text_sha256 TEXT NOT NULL,
tsv_relative_path TEXT NOT NULL, tsv_sha256 TEXT NOT NULL,
work_image_relative_path TEXT, work_image_sha256 TEXT,
corrected_transcription TEXT,
transcription_is_human INTEGER NOT NULL DEFAULT 0
CHECK(transcription_is_human IN (0,1)),
transcription_corrected_at TEXT,
transcription_origin TEXT
CHECK(transcription_origin IS NULL OR transcription_origin='human'),
FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE);
CREATE INDEX IF NOT EXISTS idx_identity_ocr_runs_evidence
ON identity_ocr_runs(evidence_id);
CREATE TABLE IF NOT EXISTS identity_document_observations (
id TEXT PRIMARY KEY, person_id TEXT NOT NULL, evidence_id TEXT NOT NULL,
ocr_run_id TEXT NOT NULL UNIQUE, document_type TEXT NOT NULL,
issuing_country_declared TEXT, document_side TEXT NOT NULL,
page_number INTEGER NOT NULL, review_state TEXT NOT NULL,
observed_at TEXT NOT NULL, factual_notes TEXT,
FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE CASCADE,
FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE,
FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS identity_field_observations (
id TEXT PRIMARY KEY, observation_id TEXT NOT NULL, field_code TEXT NOT NULL,
raw_value TEXT, corrected_value TEXT, normalized_value TEXT,
confidence REAL, review_status TEXT NOT NULL, origin TEXT NOT NULL
CHECK(origin IN ('ocr','mrz','manual_override','manual_entry')),
evidence_id TEXT NOT NULL, ocr_run_id TEXT NOT NULL,
page_number INTEGER NOT NULL, source_x INTEGER, source_y INTEGER,
source_width INTEGER, source_height INTEGER, source_image_width INTEGER,
source_image_height INTEGER, display_order INTEGER NOT NULL,
reviewed_at TEXT NOT NULL, review_note TEXT,
confirmed_value TEXT,
confirmation_state TEXT NOT NULL DEFAULT 'unconfirmed'
CHECK(confirmation_state IN ('unconfirmed','human_confirmed')),
value_quality TEXT NOT NULL DEFAULT 'complete'
CHECK(value_quality IN ('complete','partial','uncertain','invalid')),
CHECK (
(origin = 'manual_entry' AND raw_value IS NULL
AND corrected_value IS NOT NULL AND confidence IS NULL)
OR
(origin <> 'manual_entry' AND raw_value IS NOT NULL)
),
FOREIGN KEY(observation_id) REFERENCES identity_document_observations(id)
ON DELETE CASCADE,
FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE,
FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE);
CREATE INDEX IF NOT EXISTS idx_identity_fields_observation
ON identity_field_observations(observation_id,display_order);
CREATE TABLE IF NOT EXISTS identification_status_vocabulary(
code TEXT PRIMARY KEY,label TEXT NOT NULL,description TEXT NOT NULL,
display_order INTEGER NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN(0,1)),
requires_justification INTEGER NOT NULL DEFAULT 0 CHECK(requires_justification IN(0,1)),
sensitive INTEGER NOT NULL DEFAULT 0 CHECK(sensitive IN(0,1)));
INSERT OR IGNORE INTO identification_status_vocabulary VALUES
('unknown','Inconnu','Aucune identification disponible.',10,1,0,0),
('unverified','Non vérifié','Identification déclarée, non vérifiée.',20,1,0,0),
('presumed','Présumé','Identification présumée à justifier.',30,1,1,1),
('partially_identified','Partiellement identifié','Identification incomplète.',40,1,0,0),
('confirmed','Confirmé','Identification confirmée à justifier.',50,1,1,1),
('disputed','Contesté','Identification contestée à justifier.',60,1,1,1);
CREATE TABLE IF NOT EXISTS person_role_vocabulary(
code TEXT PRIMARY KEY,label TEXT NOT NULL,description TEXT NOT NULL,
display_order INTEGER NOT NULL UNIQUE,active INTEGER NOT NULL CHECK(active IN(0,1)),
requires_justification INTEGER NOT NULL CHECK(requires_justification IN(0,1)),
sensitive INTEGER NOT NULL CHECK(sensitive IN(0,1)));
INSERT OR IGNORE INTO person_role_vocabulary VALUES
('alleged_author','Auteur présumé','Rôle allégué.',10,1,1,1),
('presented_identity','Identité présentée','Identité déclarée.',20,1,0,0),
('potentially_impersonated_identity','Identité potentiellement usurpée','Hypothèse sensible.',30,1,1,1),
('victim','Victime','Personne déclarée victime.',40,1,0,0),
('witness','Témoin','Personne déclarée témoin.',50,1,0,0),
('declared_bank_holder','Titulaire bancaire déclaré','Titulaire déclaré.',60,1,0,0),
('intermediary','Intermédiaire','Intermédiaire observé.',70,1,0,0),
('mentioned_person','Personne citée','Personne citée.',80,1,0,0),
('other','Autre','Rôle factuel non couvert.',90,1,1,0),
('uncategorized','Non catégorisée','Code historique.',100,1,0,0),
('alleged_scammer','Escroc présumé (historique)','Code historique.',110,0,1,1),
('suspect','Suspect (historique)','Code historique.',120,0,1,1),
('related_person','Personne liée (historique)','Code historique.',130,0,0,0),
('impersonated_identity','Identité usurpée (historique)','Code historique.',140,0,1,1);
CREATE TABLE IF NOT EXISTS document_authenticity_assessments(
id TEXT PRIMARY KEY,evidence_id TEXT NOT NULL,ocr_run_id TEXT,status TEXT NOT NULL
CHECK(status IN('indeterminate','presumed_authentic','suspicious',
'presumed_forged','confirmed_forged')),justification TEXT,
assessed_at TEXT NOT NULL,previous_assessment_id TEXT,technical_note TEXT,
origin TEXT NOT NULL CHECK(origin='human'),
CHECK(status='indeterminate' OR
(justification IS NOT NULL AND length(trim(justification))>0)),
FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE RESTRICT,
FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE SET NULL,
FOREIGN KEY(previous_assessment_id) REFERENCES document_authenticity_assessments(id));
CREATE TABLE IF NOT EXISTS person_evidence_factual_relations(
id TEXT PRIMARY KEY,person_id TEXT NOT NULL,evidence_id TEXT NOT NULL,
ocr_run_id TEXT,relation_type TEXT NOT NULL CHECK(relation_type IN(
'identity_observed_in','document_presented_in_name_of','declared_holder_in',
'data_extracted_from')),factual_note TEXT,observed_at TEXT NOT NULL,
origin TEXT NOT NULL CHECK(origin='human'),active INTEGER NOT NULL DEFAULT 1
CHECK(active IN(0,1)),FOREIGN KEY(person_id) REFERENCES entites(id),
FOREIGN KEY(evidence_id) REFERENCES preuves(id),
FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE SET NULL);
CREATE TABLE IF NOT EXISTS person_identification_assessments(
id TEXT PRIMARY KEY,person_id TEXT NOT NULL,status_code TEXT NOT NULL,
justification TEXT,assessed_at TEXT NOT NULL,origin TEXT NOT NULL CHECK(origin='human'),
previous_assessment_id TEXT,CHECK(status_code<>'disputed' OR
(justification IS NOT NULL AND length(trim(justification))>0)),
FOREIGN KEY(person_id) REFERENCES entites(id),
FOREIGN KEY(status_code) REFERENCES identification_status_vocabulary(code),
FOREIGN KEY(previous_assessment_id) REFERENCES person_identification_assessments(id));
CREATE INDEX IF NOT EXISTS idx_authenticity_evidence_history
ON document_authenticity_assessments(evidence_id,assessed_at,id);
CREATE INDEX IF NOT EXISTS idx_person_evidence_factual_person
ON person_evidence_factual_relations(person_id,observed_at,id);
CREATE INDEX IF NOT EXISTS idx_person_evidence_factual_evidence
ON person_evidence_factual_relations(evidence_id,observed_at,id);
CREATE TRIGGER IF NOT EXISTS authenticity_v18_consistency
BEFORE INSERT ON document_authenticity_assessments BEGIN
SELECT CASE WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS(
SELECT 1 FROM identity_ocr_runs WHERE id=NEW.ocr_run_id
AND evidence_id=NEW.evidence_id)
THEN RAISE(ABORT,'OCR run belongs to another evidence')
WHEN NEW.previous_assessment_id IS NOT NULL AND NOT EXISTS(
SELECT 1 FROM document_authenticity_assessments
WHERE id=NEW.previous_assessment_id AND evidence_id=NEW.evidence_id)
THEN RAISE(ABORT,'previous assessment belongs to another evidence') END;
END;
CREATE TRIGGER IF NOT EXISTS factual_relation_v18_consistency
BEFORE INSERT ON person_evidence_factual_relations BEGIN
SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM entites e JOIN types_entite t
ON t.id=e.type_id WHERE e.id=NEW.person_id AND t.code='person')
THEN RAISE(ABORT,'factual relation requires a person')
WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM identity_ocr_runs
WHERE id=NEW.ocr_run_id AND evidence_id=NEW.evidence_id)
THEN RAISE(ABORT,'OCR run belongs to another evidence') END;
END;
CREATE TRIGGER IF NOT EXISTS identity_fields_v18_insert_guard
BEFORE INSERT ON identity_field_observations BEGIN
SELECT CASE
WHEN NEW.review_status IN('rejected','conflict') AND NEW.confirmed_value IS NOT NULL
THEN RAISE(ABORT,'non-projectable review status')
WHEN NEW.value_quality IN('uncertain','invalid') AND NEW.confirmed_value IS NOT NULL
THEN RAISE(ABORT,'non-projectable quality')
WHEN NEW.confirmed_value IS NOT NULL AND NEW.confirmation_state<>'human_confirmed'
THEN RAISE(ABORT,'human confirmation required')
WHEN NEW.confirmation_state='human_confirmed' AND NEW.confirmed_value IS NULL
THEN RAISE(ABORT,'confirmed value required') END;
END;
CREATE TRIGGER IF NOT EXISTS identity_fields_v18_update_guard
BEFORE UPDATE ON identity_field_observations BEGIN
SELECT CASE
WHEN NEW.review_status IN('rejected','conflict') AND NEW.confirmed_value IS NOT NULL
THEN RAISE(ABORT,'non-projectable review status')
WHEN NEW.value_quality IN('uncertain','invalid') AND NEW.confirmed_value IS NOT NULL
THEN RAISE(ABORT,'non-projectable quality')
WHEN NEW.confirmed_value IS NOT NULL AND NEW.confirmation_state<>'human_confirmed'
THEN RAISE(ABORT,'human confirmation required')
WHEN NEW.confirmation_state='human_confirmed' AND NEW.confirmed_value IS NULL
THEN RAISE(ABORT,'confirmed value required') END;
END;
CREATE TABLE IF NOT EXISTS person_profile_fields(
person_id TEXT NOT NULL,field_code TEXT NOT NULL,value TEXT NOT NULL,
updated_at TEXT NOT NULL CHECK(length(updated_at)=20),
PRIMARY KEY(person_id,field_code),
CHECK(field_code IN('declared_name','surname','given_names','birth_date',
'birth_place','nationality','sex_as_printed','address_as_printed')),
CHECK(length(trim(value))>0),
FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS person_ocr_field_projections(
id TEXT PRIMARY KEY,person_id TEXT NOT NULL,person_field_code TEXT NOT NULL,
previous_value TEXT,new_value TEXT NOT NULL,evidence_id TEXT NOT NULL,
ocr_run_id TEXT NOT NULL,ocr_field_id TEXT NOT NULL,ocr_field_code TEXT NOT NULL,
value_quality TEXT NOT NULL CHECK(value_quality IN('complete','partial')),
review_status TEXT NOT NULL CHECK(review_status IN('accepted','modified')),
strategy TEXT NOT NULL CHECK(strategy IN('fill_empty','replace_existing')),
projected_at TEXT NOT NULL CHECK(length(projected_at)=20),
origin TEXT NOT NULL CHECK(origin='human'),
FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE RESTRICT,
FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE RESTRICT,
FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE RESTRICT,
FOREIGN KEY(ocr_field_id) REFERENCES identity_field_observations(id)
ON DELETE RESTRICT);
CREATE INDEX IF NOT EXISTS idx_person_projection_person
ON person_ocr_field_projections(person_id,projected_at,id);
CREATE TRIGGER IF NOT EXISTS projection_v19_consistency
BEFORE INSERT ON person_ocr_field_projections BEGIN
SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM identity_field_observations f
WHERE f.id=NEW.ocr_field_id AND f.ocr_run_id=NEW.ocr_run_id
AND f.evidence_id=NEW.evidence_id AND f.field_code=NEW.ocr_field_code
AND f.confirmation_state='human_confirmed'
AND length(trim(f.confirmed_value))>0
AND f.review_status IN('accepted','modified')
AND f.value_quality IN('complete','partial')
AND f.confirmed_value=NEW.new_value)
THEN RAISE(ABORT,'OCR field is no longer projectable') END;
END;
/* V20 — évaluations humaines distinctes de lusage abusif didentité. */
CREATE TABLE IF NOT EXISTS document_identity_misuse_assessments(
id TEXT PRIMARY KEY,evidence_id TEXT NOT NULL,ocr_run_id TEXT,
status TEXT NOT NULL CHECK(status IN('indeterminate','presumed','confirmed')),
justification TEXT,assessed_at TEXT NOT NULL CHECK(length(assessed_at)=20),
previous_assessment_id TEXT,origin TEXT NOT NULL CHECK(origin='human'),
CHECK(status='indeterminate' OR length(trim(justification))>0),
FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE RESTRICT,
FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE RESTRICT,
FOREIGN KEY(previous_assessment_id) REFERENCES document_identity_misuse_assessments(id) ON DELETE RESTRICT);
CREATE INDEX IF NOT EXISTS idx_identity_misuse_evidence_history
ON document_identity_misuse_assessments(evidence_id,assessed_at,id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_misuse_previous
ON document_identity_misuse_assessments(previous_assessment_id)
WHERE previous_assessment_id IS NOT NULL;
CREATE TRIGGER IF NOT EXISTS identity_misuse_consistency BEFORE INSERT ON document_identity_misuse_assessments BEGIN
SELECT CASE WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM identity_ocr_runs r WHERE r.id=NEW.ocr_run_id AND r.evidence_id=NEW.evidence_id) THEN RAISE(ABORT,'OCR run does not belong to evidence') END;
SELECT CASE WHEN NEW.previous_assessment_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM document_identity_misuse_assessments p WHERE p.id=NEW.previous_assessment_id AND p.evidence_id=NEW.evidence_id) THEN RAISE(ABORT,'previous assessment does not belong to evidence') END;
SELECT CASE WHEN EXISTS(SELECT 1 FROM document_identity_misuse_assessments p WHERE p.evidence_id=NEW.evidence_id) AND NEW.previous_assessment_id IS NULL THEN RAISE(ABORT,'previous assessment is required') END;
SELECT CASE WHEN NEW.previous_assessment_id IS NOT NULL AND EXISTS(SELECT 1 FROM document_identity_misuse_assessments n WHERE n.previous_assessment_id=NEW.previous_assessment_id) THEN RAISE(ABORT,'previous assessment is not current') END;
END;
CREATE TRIGGER IF NOT EXISTS identity_misuse_append_only_update BEFORE UPDATE ON document_identity_misuse_assessments BEGIN SELECT RAISE(ABORT,'identity misuse history is append-only'); END;
CREATE TRIGGER IF NOT EXISTS identity_misuse_append_only_delete BEFORE DELETE ON document_identity_misuse_assessments BEGIN SELECT RAISE(ABORT,'identity misuse history is append-only'); END;