sphica 0.6.13 → 0.6.15

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.
@@ -0,0 +1,1172 @@
1
+ -- Revision 4 → 5 of generation 2: the write boundary, lifecycle transitions, indexes, checks, and unused vocabulary.
2
+ -- `sphica init` runs this in one transaction with foreign keys off (set outside the transaction), then checks foreign_key_check before
3
+ -- committing. Every statement matches db/schema.sql at revision 5; server/test/migrate.test.ts compares a migrated database with a fresh one.
4
+
5
+ -- Triggers, views, and indexes are dropped first and all created again at the end: rebuilding a table fails while a trigger names it.
6
+ drop trigger source_owner_bound;
7
+ drop trigger source_session_project;
8
+ drop trigger source_no_update;
9
+ drop trigger source_fts_ai;
10
+ drop trigger source_fts_ad;
11
+ drop trigger unit_insert_candidate;
12
+ drop trigger unit_run_project;
13
+ drop trigger unit_text_frozen;
14
+ drop trigger unit_lifecycle_via_state;
15
+ drop trigger unit_option_sealed;
16
+ drop trigger unit_option_frozen;
17
+ drop trigger unit_option_no_delete;
18
+ drop trigger unit_adoption_route;
19
+ drop trigger unit_link_frozen;
20
+ drop trigger unit_link_no_delete;
21
+ drop trigger unit_link_supersedes_acyclic;
22
+ drop trigger unit_state_rules;
23
+ drop trigger unit_state_append_only;
24
+ drop trigger unit_state_no_delete;
25
+ drop trigger unit_state_apply;
26
+ drop trigger unit_anchor_frozen;
27
+ drop trigger unit_anchor_no_delete;
28
+ drop trigger unit_alias_terms;
29
+ drop trigger unit_alias_frozen;
30
+ drop trigger unit_alias_no_delete;
31
+ drop trigger field_def_check;
32
+ drop trigger field_def_frozen;
33
+ drop trigger field_def_no_delete;
34
+ drop trigger unit_field_check;
35
+ drop trigger unit_field_frozen;
36
+ drop trigger unit_field_no_delete;
37
+ drop trigger unit_evidence_check;
38
+ drop trigger unit_evidence_retract;
39
+ drop trigger unit_evidence_no_delete;
40
+ drop trigger unit_adoption_no_delete;
41
+ drop trigger unit_evidence_retract_support;
42
+ drop trigger unit_adoption_retract_support;
43
+ drop trigger unit_adoption_check;
44
+ drop trigger unit_adoption_retract;
45
+ drop trigger unit_link_check;
46
+ drop trigger unit_state_project;
47
+ drop trigger unit_anchor_project;
48
+ drop trigger unit_alias_project;
49
+ drop trigger source_processing_project;
50
+ drop trigger external_reference_check;
51
+ drop trigger unit_rev_evidence_i;
52
+ drop trigger unit_rev_evidence_u;
53
+ drop trigger unit_rev_adoption_i;
54
+ drop trigger unit_rev_adoption_u;
55
+ drop trigger unit_rev_evidence_d;
56
+ drop trigger unit_rev_adoption_d;
57
+ drop trigger unit_rev_link_i;
58
+ drop trigger unit_rev_link_u;
59
+ drop trigger unit_rev_anchor_i;
60
+ drop trigger unit_rev_anchor_u;
61
+ drop trigger unit_rev_alias_i;
62
+ drop trigger unit_rev_field_i;
63
+ drop trigger unit_rev_field_d;
64
+ drop trigger unit_fts_ai;
65
+ drop trigger unit_fts_ad;
66
+ drop trigger unit_fts_option_i;
67
+ drop trigger unit_fts_anchor_i;
68
+ drop trigger unit_fts_anchor_u;
69
+ drop trigger unit_fts_anchor_d;
70
+ drop trigger unit_fts_alias_i;
71
+ drop trigger unit_fts_alias_d;
72
+ drop trigger unit_fts_field_i;
73
+ drop trigger unit_fts_field_d;
74
+ drop trigger capture_session_insert;
75
+ drop trigger capture_message_insert;
76
+ drop trigger capture_edit_insert;
77
+ drop trigger capture_delivery_insert;
78
+ drop view unit_search_text;
79
+ drop view capture_session;
80
+ drop view capture_message;
81
+ drop view capture_edit;
82
+ drop view capture_delivery;
83
+ drop index source_artifact;
84
+ drop index source_session;
85
+ drop index source_message_once;
86
+ drop index source_item_once;
87
+ drop index source_forgotten_item;
88
+ drop index edit_observation_path;
89
+ drop index unit_live;
90
+ drop index unit_evidence_unit_once;
91
+ drop index unit_evidence_option_once;
92
+ drop index unit_evidence_source;
93
+ drop index unit_state_order;
94
+ drop index unit_anchor_path;
95
+ drop index unit_anchor_unit;
96
+ drop index unit_alias_unit;
97
+ drop index field_def_source;
98
+ drop index unit_field_def;
99
+ drop index unit_field_source;
100
+ drop index work_open;
101
+ drop index delivery_session;
102
+
103
+ -- Dropping a table forgets its id counter. The counters are put back at the end, so an id once used is never handed out again.
104
+ create temp table sphica_sequence as select name, seq from sqlite_sequence;
105
+
106
+ -- Every row the migration changes or removes is noted here, and `sphica init` prints the notes
107
+ create temp table sphica_migration_note (rule text, item text, action text);
108
+
109
+ -- A run that finished before it started takes its start as its finish (the run table is rebuilt next, with that check)
110
+ insert into sphica_migration_note
111
+ select 'a run that finished before it started', 'run ' || id, 'finished_at set to started_at'
112
+ from extraction_run where finished_at < started_at order by id;
113
+ update extraction_run set finished_at = started_at where finished_at < started_at;
114
+
115
+ create table extraction_run_new (
116
+ id integer primary key autoincrement not null,
117
+ project_id integer not null references project (id) on delete cascade,
118
+ origin text not null check (origin in ('trace', 'harvest', 'glean', 'migration')),
119
+ target text not null,
120
+ session_id text references session (id) on delete set null,
121
+ status text not null check (status in ('running', 'saved')),
122
+ input_bytes integer check (input_bytes >= 0),
123
+ -- The CLI-issued draft this run saves. A saved run's draft saves nothing again; the draft is bound to this run's project and target
124
+ draft_id text unique,
125
+ started_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', started_at) is started_at),
126
+ finished_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', finished_at) is finished_at),
127
+ check (finished_at >= started_at)
128
+ ) strict;
129
+ insert into extraction_run_new (id, project_id, origin, target, session_id, status, input_bytes, draft_id, started_at, finished_at) select id, project_id, origin, target, session_id, status, input_bytes, draft_id, started_at, finished_at from extraction_run;
130
+ drop table extraction_run;
131
+ alter table extraction_run_new rename to extraction_run;
132
+
133
+ -- The repairs below add runs: their ids come after every id a run ever had
134
+ update sqlite_sequence set seq = max(seq, (select s.seq from temp.sphica_sequence s where s.name = sqlite_sequence.name))
135
+ where name in (select name from temp.sphica_sequence);
136
+ insert into sqlite_sequence (name, seq) select s.name, s.seq from temp.sphica_sequence s
137
+ where s.name not in (select name from sqlite_sequence) and s.name in (select name from sqlite_schema where type = 'table');
138
+
139
+ -- Repairs. Every row changed or removed is noted, and `sphica init` prints the notes.
140
+ -- Small values revision 5 checks: each takes the nearest value it accepts, or the row goes when there is none
141
+ insert into sphica_migration_note
142
+ select 'an end line without a start line', 'source ' || id, 'line_end cleared' from source where line_end is not null and line_start is null order by id;
143
+ update source set line_end = null where line_end is not null and line_start is null;
144
+ insert into sphica_migration_note
145
+ select 'an end line without a start line', 'anchor ' || id, 'line_end cleared' from unit_anchor where line_end is not null and line_start is null order by id;
146
+ update unit_anchor set line_end = null where line_end is not null and line_start is null;
147
+ insert into sphica_migration_note
148
+ select 'a web address that is not http or https', 'source ' || id, 'url cleared' from source
149
+ where url is not null and not (url glob 'https://*' or url glob 'http://*') order by id;
150
+ update source set url = null where url is not null and not (url glob 'https://*' or url glob 'http://*');
151
+ insert into sphica_migration_note
152
+ select 'an assistant reply in the search index', 'source ' || id, 'left out of the index' from source
153
+ where author_kind = 'assistant' and indexed = 1 order by id;
154
+ update source set indexed = 0 where author_kind = 'assistant' and indexed = 1;
155
+ insert into sphica_migration_note
156
+ select 'a state dated before its record was made', 'state ' || s.id || ' of unit ' || s.unit_id, 'dated when the record was made'
157
+ from unit_state s join unit u on u.id = s.unit_id where s.at < u.created_at order by s.id;
158
+ update unit_state set at = (select created_at from unit where id = unit_state.unit_id)
159
+ where at < (select created_at from unit where id = unit_state.unit_id);
160
+ insert into sphica_migration_note
161
+ select 'an anchor replaced by itself or by another record''s anchor', 'anchor ' || a.id, 'replacement cleared'
162
+ from unit_anchor a where a.replaced_by is not null and (a.replaced_by = a.id
163
+ or not exists (select 1 from unit_anchor r where r.id = a.replaced_by and r.unit_id = a.unit_id)) order by a.id;
164
+ update unit_anchor set replaced_by = null where replaced_by is not null and (replaced_by = id
165
+ or not exists (select 1 from unit_anchor r where r.id = unit_anchor.replaced_by and r.unit_id = unit_anchor.unit_id));
166
+ insert into sphica_migration_note
167
+ select 'an alias set bound to other words than its record''s', 'alias ' || a.id || ' of unit ' || a.unit_id, 'removed (it was never searched)'
168
+ from unit_alias a join unit u on u.id = a.unit_id where a.content_hash <> u.content_hash order by a.id;
169
+ delete from unit_alias where content_hash <> (select content_hash from unit where id = unit_alias.unit_id);
170
+ insert into sphica_migration_note
171
+ select 'a retraction dated before what it retracts', 'evidence ' || id || ' of unit ' || unit_id, 'retracted when it was added'
172
+ from unit_evidence where retracted_at < added_at order by id;
173
+ update unit_evidence set retracted_at = added_at where retracted_at < added_at;
174
+ insert into sphica_migration_note
175
+ select 'a retraction dated before what it retracts', 'adoption ' || id || ' of unit ' || unit_id, 'retracted when it was added'
176
+ from unit_adoption where retracted_at < added_at order by id;
177
+ update unit_adoption set retracted_at = added_at where retracted_at < added_at;
178
+ -- A span that cuts a character widens to the whole character (a UTF-8 character has at most three continuation bytes). The widened spans
179
+ -- are worked out apart first: adoption keeps its table's unique span through the rebuild, so rows widening onto one span go before any
180
+ -- row moves
181
+ create temp table sphica_widen (tbl text not null, which text not null, id integer not null, src integer not null, s integer not null,
182
+ e integer not null, what text not null, primary key (tbl, which, id));
183
+ insert into sphica_widen select 'unit_evidence', 'span', id, source_id, span_start, span_end, 'evidence' from unit_evidence
184
+ where source_id is not null and (hex(substr((select cast(text as blob) from source where id = source_id), span_start + 1, 1)) between '80' and 'BF'
185
+ or hex(substr((select cast(text as blob) from source where id = source_id), span_end + 1, 1)) between '80' and 'BF');
186
+ insert into sphica_widen select 'unit_adoption', 'span', id, source_id, span_start, span_end, 'adoption' from unit_adoption
187
+ where source_id is not null and (hex(substr((select cast(text as blob) from source where id = source_id), span_start + 1, 1)) between '80' and 'BF'
188
+ or hex(substr((select cast(text as blob) from source where id = source_id), span_end + 1, 1)) between '80' and 'BF');
189
+ insert into sphica_widen select 'field_def', 'span', id, source_id, span_start, span_end, 'field definition' from field_def
190
+ where source_id is not null and (hex(substr((select cast(text as blob) from source where id = source_id), span_start + 1, 1)) between '80' and 'BF'
191
+ or hex(substr((select cast(text as blob) from source where id = source_id), span_end + 1, 1)) between '80' and 'BF');
192
+ insert into sphica_widen select 'unit_field', 'span', id, source_id, span_start, span_end, 'field value' from unit_field
193
+ where source_id is not null and (hex(substr((select cast(text as blob) from source where id = source_id), span_start + 1, 1)) between '80' and 'BF'
194
+ or hex(substr((select cast(text as blob) from source where id = source_id), span_end + 1, 1)) between '80' and 'BF');
195
+ insert into sphica_widen select 'unit_evidence', 'retraction', id, retraction_source_id, retraction_span_start, retraction_span_end, 'evidence retraction reason' from unit_evidence
196
+ where retraction_source_id is not null and (hex(substr((select cast(text as blob) from source where id = retraction_source_id), retraction_span_start + 1, 1)) between '80' and 'BF'
197
+ or hex(substr((select cast(text as blob) from source where id = retraction_source_id), retraction_span_end + 1, 1)) between '80' and 'BF');
198
+ insert into sphica_widen select 'unit_adoption', 'retraction', id, retraction_source_id, retraction_span_start, retraction_span_end, 'adoption retraction reason' from unit_adoption
199
+ where retraction_source_id is not null and (hex(substr((select cast(text as blob) from source where id = retraction_source_id), retraction_span_start + 1, 1)) between '80' and 'BF'
200
+ or hex(substr((select cast(text as blob) from source where id = retraction_source_id), retraction_span_end + 1, 1)) between '80' and 'BF');
201
+ update sphica_widen set s = s - 1 where hex(substr((select cast(text as blob) from source where id = src), s + 1, 1)) between '80' and 'BF';
202
+ update sphica_widen set s = s - 1 where hex(substr((select cast(text as blob) from source where id = src), s + 1, 1)) between '80' and 'BF';
203
+ update sphica_widen set s = s - 1 where hex(substr((select cast(text as blob) from source where id = src), s + 1, 1)) between '80' and 'BF';
204
+ update sphica_widen set e = e + 1 where hex(substr((select cast(text as blob) from source where id = src), e + 1, 1)) between '80' and 'BF';
205
+ update sphica_widen set e = e + 1 where hex(substr((select cast(text as blob) from source where id = src), e + 1, 1)) between '80' and 'BF';
206
+ update sphica_widen set e = e + 1 where hex(substr((select cast(text as blob) from source where id = src), e + 1, 1)) between '80' and 'BF';
207
+ insert into sphica_migration_note
208
+ select 'a span that cuts a character (' || what || ')', tbl || ' ' || id, 'widened to whole characters' from sphica_widen order by tbl, which, id;
209
+
210
+ -- Widening can make two rows cite the same words: a live one stays over a retracted one, then the first
211
+ create temp table sphica_twice (tbl text not null, id integer not null, unit_id integer not null);
212
+ insert into sphica_twice
213
+ select 'unit_evidence', id, unit_id from (
214
+ select e.id, e.unit_id, row_number() over (partition by e.unit_id, e.option_id, e.source_id, coalesce(w.s, e.span_start),
215
+ coalesce(w.e, e.span_end), e.role order by e.retracted_at is null desc, e.id) as n
216
+ from unit_evidence e left join sphica_widen w on w.tbl = 'unit_evidence' and w.which = 'span' and w.id = e.id)
217
+ where n > 1;
218
+ insert into sphica_twice
219
+ select 'unit_adoption', id, unit_id from (
220
+ select a.id, a.unit_id, row_number() over (partition by a.unit_id, a.source_id, coalesce(w.s, a.span_start), coalesce(w.e, a.span_end)
221
+ order by a.retracted_at is null desc, a.id) as n
222
+ from unit_adoption a left join sphica_widen w on w.tbl = 'unit_adoption' and w.which = 'span' and w.id = a.id)
223
+ where n > 1;
224
+ insert into sphica_migration_note
225
+ select case tbl when 'unit_evidence' then 'evidence' else 'adoption' end || ' citing the same words twice after widening',
226
+ case tbl when 'unit_evidence' then 'evidence ' else 'adoption ' end || id || ' of unit ' || unit_id, 'removed'
227
+ from sphica_twice order by tbl, id;
228
+ delete from unit_evidence where id in (select id from sphica_twice where tbl = 'unit_evidence');
229
+ delete from unit_adoption where id in (select id from sphica_twice where tbl = 'unit_adoption');
230
+ drop table temp.sphica_twice;
231
+ update unit_evidence set span_start = (select s from sphica_widen w where w.tbl = 'unit_evidence' and w.which = 'span' and w.id = unit_evidence.id),
232
+ span_end = (select e from sphica_widen w where w.tbl = 'unit_evidence' and w.which = 'span' and w.id = unit_evidence.id)
233
+ where id in (select id from sphica_widen where tbl = 'unit_evidence' and which = 'span');
234
+ update unit_adoption set span_start = (select s from sphica_widen w where w.tbl = 'unit_adoption' and w.which = 'span' and w.id = unit_adoption.id),
235
+ span_end = (select e from sphica_widen w where w.tbl = 'unit_adoption' and w.which = 'span' and w.id = unit_adoption.id)
236
+ where id in (select id from sphica_widen where tbl = 'unit_adoption' and which = 'span');
237
+ update field_def set span_start = (select s from sphica_widen w where w.tbl = 'field_def' and w.which = 'span' and w.id = field_def.id),
238
+ span_end = (select e from sphica_widen w where w.tbl = 'field_def' and w.which = 'span' and w.id = field_def.id)
239
+ where id in (select id from sphica_widen where tbl = 'field_def' and which = 'span');
240
+ update unit_field set span_start = (select s from sphica_widen w where w.tbl = 'unit_field' and w.which = 'span' and w.id = unit_field.id),
241
+ span_end = (select e from sphica_widen w where w.tbl = 'unit_field' and w.which = 'span' and w.id = unit_field.id)
242
+ where id in (select id from sphica_widen where tbl = 'unit_field' and which = 'span');
243
+ update unit_evidence set retraction_span_start = (select s from sphica_widen w where w.tbl = 'unit_evidence' and w.which = 'retraction' and w.id = unit_evidence.id),
244
+ retraction_span_end = (select e from sphica_widen w where w.tbl = 'unit_evidence' and w.which = 'retraction' and w.id = unit_evidence.id)
245
+ where id in (select id from sphica_widen where tbl = 'unit_evidence' and which = 'retraction');
246
+ update unit_adoption set retraction_span_start = (select s from sphica_widen w where w.tbl = 'unit_adoption' and w.which = 'retraction' and w.id = unit_adoption.id),
247
+ retraction_span_end = (select e from sphica_widen w where w.tbl = 'unit_adoption' and w.which = 'retraction' and w.id = unit_adoption.id)
248
+ where id in (select id from sphica_widen where tbl = 'unit_adoption' and which = 'retraction');
249
+ drop table temp.sphica_widen;
250
+
251
+
252
+ -- Paths revision 5 refuses (a control character, or a spelling like `a//b` or `./a` that names a place two ways): an edit observation
253
+ -- or an anchor with one is removed, and a review comment keeps its text without the path. A file excerpt with one stops the migration
254
+ -- before this (0005.check.sql): its path is its identity.
255
+ insert into sphica_migration_note
256
+ select 'an anchor whose path revision 5 refuses', 'anchor ' || id || ' of unit ' || unit_id, 'removed'
257
+ from unit_anchor where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
258
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
259
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*') order by id;
260
+ update unit set revision = revision + 1 where id in (select unit_id from unit_anchor where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
261
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
262
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*'));
263
+ insert into sphica_migration_note
264
+ select 'an anchor replaced by one whose path revision 5 refuses', 'anchor ' || id || ' of unit ' || unit_id, 'no longer points at what replaced it'
265
+ from unit_anchor where replaced_by in (select id from unit_anchor where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
266
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
267
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*')) order by id;
268
+ update unit_anchor set replaced_by = null where replaced_by in (select id from unit_anchor where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
269
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
270
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*'));
271
+ delete from unit_anchor where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
272
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
273
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*');
274
+ insert into sphica_migration_note
275
+ select 'an edit observation whose path revision 5 refuses', 'observation ' || id || ' in session ' || session_id, 'removed'
276
+ from edit_observation where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
277
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
278
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*') order by id;
279
+ update unit_anchor set edit_observation_id = null
280
+ where edit_observation_id in (select id from edit_observation where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
281
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
282
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*'));
283
+ delete from edit_observation where not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
284
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
285
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*');
286
+ insert into sphica_migration_note
287
+ select 'a source whose path revision 5 refuses', 'source ' || id || ' (' || kind || ')', 'path and lines removed; the text stays'
288
+ from source where path is not null and kind <> 'file_excerpt' and not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
289
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
290
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*') order by id;
291
+ update source set path = null, line_start = null, line_end = null
292
+ where path is not null and kind <> 'file_excerpt' and not (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
293
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
294
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*');
295
+
296
+ -- Edit observations repeated for a session, turn (none counting as one), path, and way: the first stays, and anchors citing a later one
297
+ -- cite the first instead
298
+ create temp table sphica_observation (id integer primary key not null, keep integer not null);
299
+ insert into sphica_observation
300
+ select o.id, k.keep from edit_observation o join (
301
+ select session_id, coalesce(turn_id, '') as turn, path, via, min(id) as keep from edit_observation
302
+ group by session_id, coalesce(turn_id, ''), path, via having count(*) > 1) k
303
+ on k.session_id = o.session_id and k.turn = coalesce(o.turn_id, '') and k.path = o.path and k.via = o.via;
304
+ delete from sphica_observation where id = keep;
305
+ insert into sphica_migration_note
306
+ select 'an edit observation recorded twice', 'observation ' || o.id || ' of ' || e.path || ' in session ' || e.session_id,
307
+ 'removed; observation ' || o.keep || ' stays'
308
+ from sphica_observation o join edit_observation e on e.id = o.id order by o.id;
309
+ update unit_anchor set edit_observation_id = (select keep from sphica_observation where id = unit_anchor.edit_observation_id)
310
+ where edit_observation_id in (select id from sphica_observation);
311
+ delete from edit_observation where id in (select id from sphica_observation);
312
+ drop table temp.sphica_observation;
313
+
314
+ -- Live anchors of one unit on the same place: the newest stays live, and each older one is retired and points at it
315
+ create temp table sphica_anchor (id integer primary key not null, keep integer not null);
316
+ insert into sphica_anchor
317
+ select a.id, k.keep from unit_anchor a join (
318
+ select unit_id, path, role, coalesce(commit_sha, '') as c, coalesce(symbol, '') as s,
319
+ coalesce(case when symbol is null then line_start end, 0) as l1, coalesce(case when symbol is null then line_end end, 0) as l2,
320
+ max(id) as keep
321
+ from unit_anchor where retired_at is null
322
+ group by unit_id, path, role, coalesce(commit_sha, ''), coalesce(symbol, ''),
323
+ coalesce(case when symbol is null then line_start end, 0), coalesce(case when symbol is null then line_end end, 0)
324
+ having count(*) > 1) k
325
+ on k.unit_id = a.unit_id and k.path = a.path and k.role = a.role and k.c = coalesce(a.commit_sha, '') and k.s = coalesce(a.symbol, '')
326
+ and k.l1 = coalesce(case when a.symbol is null then a.line_start end, 0)
327
+ and k.l2 = coalesce(case when a.symbol is null then a.line_end end, 0)
328
+ where a.retired_at is null;
329
+ delete from sphica_anchor where id = keep;
330
+ insert into sphica_migration_note
331
+ select 'two live anchors of a record on one place', 'anchor ' || x.id || ' of unit ' || a.unit_id || ' on ' || a.path,
332
+ 'retired; anchor ' || x.keep || ' stays'
333
+ from sphica_anchor x join unit_anchor a on a.id = x.id order by x.id;
334
+ update unit_anchor set retired_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), replaced_by = (select keep from sphica_anchor where id = unit_anchor.id)
335
+ where id in (select id from sphica_anchor);
336
+ update unit set revision = revision + 1 where id in (select a.unit_id from sphica_anchor x join unit_anchor a on a.id = x.id);
337
+ drop table temp.sphica_anchor;
338
+
339
+ -- Supersedes links revision 5 refuses are removed first, so the lifecycle repairs below see what is left: a record replacing one of
340
+ -- another kind (a decision and a constraint may replace each other), and every live successor of a record but one (an active one first,
341
+ -- then the newest). Withdrawn, quarantined, and unsourced successors keep their links: they hold no place.
342
+ create temp table sphica_link (from_unit integer not null, to_unit integer not null, rule text not null, primary key (from_unit, to_unit));
343
+ insert or ignore into sphica_link
344
+ select l.from_unit, l.to_unit, 'a supersedes link between records of kinds that cannot replace each other'
345
+ from unit_link l join unit a on a.id = l.from_unit join unit b on b.id = l.to_unit
346
+ where l.kind = 'supersedes' and a.kind <> b.kind
347
+ and not (a.kind in ('decision', 'constraint') and b.kind in ('decision', 'constraint'));
348
+ insert or ignore into sphica_link
349
+ select l.from_unit, l.to_unit, 'a second successor of a record whose first successor is not withdrawn'
350
+ from unit_link l join unit s on s.id = l.from_unit
351
+ where l.kind = 'supersedes' and s.lifecycle <> 'withdrawn' and s.extraction = 'supported' and s.unsourced = 0
352
+ and not exists (select 1 from sphica_link x where x.from_unit = l.from_unit and x.to_unit = l.to_unit)
353
+ and l.from_unit <> (select k.from_unit from unit_link k join unit n on n.id = k.from_unit
354
+ where k.to_unit = l.to_unit and k.kind = 'supersedes' and n.lifecycle <> 'withdrawn' and n.extraction = 'supported' and n.unsourced = 0
355
+ and not exists (select 1 from sphica_link x where x.from_unit = k.from_unit and x.to_unit = k.to_unit)
356
+ order by n.lifecycle = 'active' desc, n.id desc limit 1);
357
+ insert into sphica_migration_note
358
+ select x.rule, 'unit ' || a.id || ' ' || a.key || ' supersedes unit ' || b.id || ' ' || b.key, 'link removed'
359
+ from sphica_link x join unit a on a.id = x.from_unit join unit b on b.id = x.to_unit order by x.from_unit, x.to_unit;
360
+ delete from unit_link where kind = 'supersedes' and exists (select 1 from sphica_link x
361
+ where x.from_unit = unit_link.from_unit and x.to_unit = unit_link.to_unit);
362
+ update unit set revision = revision + 1 where id in (select from_unit from sphica_link union select to_unit from sphica_link);
363
+ drop table temp.sphica_link;
364
+
365
+ -- Units whose lifecycle the new rules cannot have reached go back to candidate. The state history is kept and one state is added,
366
+ -- from a run that names this migration, since the triggers that would apply it are dropped here.
367
+ create temp table sphica_lifecycle (unit_id integer primary key not null, rule text not null);
368
+ insert or ignore into sphica_lifecycle
369
+ select u.id, 'a superseded record whose successors are all withdrawn, or that has none'
370
+ from unit u where u.lifecycle = 'superseded' and not exists (
371
+ select 1 from unit_link l join unit s on s.id = l.from_unit
372
+ where l.to_unit = u.id and l.kind = 'supersedes' and s.lifecycle <> 'withdrawn' and s.extraction = 'supported' and s.unsourced = 0);
373
+ -- The support rule of revision 5, only while this asks it (the view is created for good with the others at the end)
374
+ create view unit_support as
375
+ select u.id as unit_id, case
376
+ when u.kind in ('decision', 'constraint') and (
377
+ not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null)
378
+ or not exists (select 1 from unit_adoption a where a.unit_id = u.id and a.retracted_at is null))
379
+ then 'an active decision or constraint needs unretracted evidence and adoption'
380
+ when u.kind = 'implementation' and not (
381
+ exists (select 1 from unit_evidence e join source s on s.id = e.source_id where e.unit_id = u.id and e.option_id is null
382
+ and e.retracted_at is null and e.role = 'implements' and s.kind in ('commit_message', 'file_excerpt'))
383
+ or exists (select 1 from unit_anchor a where a.unit_id = u.id and a.retired_at is null and a.role = 'evidence'
384
+ and (a.commit_sha is not null or (a.edit_observation_id is not null and exists (select 1 from unit_evidence e
385
+ join source s on s.id = e.source_id join edit_observation o on o.id = a.edit_observation_id
386
+ where e.unit_id = u.id and e.option_id is null and e.retracted_at is null and e.role = 'implements'
387
+ and s.session_id = o.session_id)))))
388
+ then 'an active implementation needs code or commit evidence'
389
+ when u.kind in ('finding', 'dead_end', 'question')
390
+ and not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null)
391
+ then 'an active unit needs unretracted evidence'
392
+ end as missing
393
+ from unit u;
394
+ insert or ignore into sphica_lifecycle
395
+ select u.id, 'an active record without the support an active record needs'
396
+ from unit u join unit_support s on s.unit_id = u.id where u.lifecycle = 'active' and s.missing is not null;
397
+ drop view unit_support;
398
+
399
+ insert into extraction_run (project_id, origin, target, status, started_at, finished_at)
400
+ select distinct u.project_id, 'migration', 'revision:5', 'saved', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
401
+ from unit u join sphica_lifecycle f on f.unit_id = u.id;
402
+ insert into unit_state (unit_id, from_state, to_state, at, reason, run_id)
403
+ select u.id, u.lifecycle, 'candidate', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 'schema revision 5: ' || f.rule,
404
+ (select r.id from extraction_run r where r.project_id = u.project_id and r.origin = 'migration' and r.target = 'revision:5')
405
+ from unit u join sphica_lifecycle f on f.unit_id = u.id;
406
+ insert into sphica_migration_note
407
+ select f.rule, 'unit ' || u.id || ' ' || u.key || ' (' || u.lifecycle || ')', 'back to candidate'
408
+ from unit u join sphica_lifecycle f on f.unit_id = u.id order by u.id;
409
+ update unit set lifecycle = 'candidate', revision = revision + 1 where id in (select unit_id from sphica_lifecycle);
410
+ drop table temp.sphica_lifecycle;
411
+
412
+ -- Rebuild the remaining tables whose definition changed.
413
+ create table source_new (
414
+ id integer primary key autoincrement not null,
415
+ project_id integer not null references project (id) on delete cascade,
416
+ kind text not null check (kind in ('session_message', 'pr_body', 'issue_body', 'pr_comment', 'issue_comment', 'review',
417
+ 'review_comment', 'commit_message', 'pr_event', 'file_excerpt')),
418
+ -- The artifact it belongs to: `session:<uuid>`, `pr:<n>`, `issue:<n>`, `commit:<sha>`, `file:<path>`
419
+ artifact text not null check (artifact <> ''),
420
+ external_id text not null check (external_id <> ''),
421
+ revision integer not null check (revision > 0),
422
+ session_id text references session (id) on delete cascade,
423
+ turn_id text,
424
+ author_kind text not null check (author_kind in ('owner', 'assistant', 'person', 'bot')),
425
+ author_login text,
426
+ author_external_id text,
427
+ author_association text,
428
+ -- The comment or thread this replies to
429
+ parent_external_id text,
430
+ -- For pr_event: merged (the one event harvest records)
431
+ event_kind text check (event_kind in ('merged')),
432
+ -- Where it lives on the web; any other scheme (javascript:, file:) is never kept, since readers may show it as a link
433
+ url text check (url glob 'https://*' or url glob 'http://*'),
434
+ created_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', created_at) is created_at),
435
+ available_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', available_at) is available_at),
436
+ captured_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', captured_at) is captured_at),
437
+ text text not null,
438
+ truncated integer not null default 0 check (truncated in (0, 1)),
439
+ redacted integer not null default 0 check (redacted in (0, 1)),
440
+ original_bytes integer not null check (original_bytes >= 0),
441
+ content_hash blob not null check (length(content_hash) = 32),
442
+ -- Code position of a review comment or a file excerpt: a normalized repository-relative path with forward slashes
443
+ path text check (path is null or (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
444
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
445
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*')),
446
+ line_start integer check (line_start > 0),
447
+ line_end integer check (line_end is null or (line_start is not null and line_end >= line_start)),
448
+ diff_hunk text,
449
+ commit_sha text check (commit_sha is null or (length(commit_sha) = 40 and commit_sha not glob '*[^0-9a-f]*')),
450
+ blob_sha text check (blob_sha is null or (length(blob_sha) = 40 and blob_sha not glob '*[^0-9a-f]*')),
451
+ -- 1 when searched in the source index (owner words and third-party text; assistant replies are not)
452
+ indexed integer not null check (indexed in (0, 1)),
453
+ check ((kind = 'session_message') = (session_id is not null)),
454
+ check ((kind = 'pr_event') = (event_kind is not null)),
455
+ check (kind <> 'session_message' or author_kind in ('owner', 'assistant')),
456
+ check (kind = 'session_message' or author_kind <> 'assistant'),
457
+ check (author_kind <> 'assistant' or indexed = 0),
458
+ check (kind <> 'file_excerpt' or (path is not null and commit_sha is not null and blob_sha is not null
459
+ and line_start is not null and line_end is not null)),
460
+ check (truncated = 1 or redacted = 1 or original_bytes = length(cast(text as blob)))
461
+ ) strict;
462
+ insert into source_new (id, project_id, kind, artifact, external_id, revision, session_id, turn_id, author_kind, author_login, author_external_id, author_association, parent_external_id, event_kind, url, created_at, available_at, captured_at, text, truncated, redacted, original_bytes, content_hash, path, line_start, line_end, diff_hunk, commit_sha, blob_sha, indexed) select id, project_id, kind, artifact, external_id, revision, session_id, turn_id, author_kind, author_login, author_external_id, author_association, parent_external_id, event_kind, url, created_at, available_at, captured_at, text, truncated, redacted, original_bytes, content_hash, path, line_start, line_end, diff_hunk, commit_sha, blob_sha, indexed from source;
463
+ drop table source;
464
+ alter table source_new rename to source;
465
+
466
+ create table edit_observation_new (
467
+ id integer primary key autoincrement not null,
468
+ session_id text not null references session (id) on delete cascade,
469
+ turn_id text,
470
+ tool_event_id text,
471
+ path text not null check (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
472
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
473
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*'),
474
+ via text not null check (via in ('tool', 'status')),
475
+ observed_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', observed_at) is observed_at)
476
+ ) strict;
477
+ insert into edit_observation_new (id, session_id, turn_id, tool_event_id, path, via, observed_at) select id, session_id, turn_id, tool_event_id, path, via, observed_at from edit_observation;
478
+ drop table edit_observation;
479
+ alter table edit_observation_new rename to edit_observation;
480
+
481
+ create table source_processing_new (
482
+ source_id integer not null references source (id) on delete cascade,
483
+ run_id integer not null references extraction_run (id) on delete cascade,
484
+ outcome text not null check (outcome in ('units', 'no_unit')),
485
+ primary key (source_id, run_id)
486
+ ) strict;
487
+ insert into source_processing_new (source_id, run_id, outcome) select source_id, run_id, outcome from source_processing;
488
+ drop table source_processing;
489
+ alter table source_processing_new rename to source_processing;
490
+
491
+ create table unit_evidence_new (
492
+ id integer primary key autoincrement not null,
493
+ unit_id integer not null references unit (id) on delete cascade,
494
+ option_id integer,
495
+ source_id integer not null references source (id) on delete cascade,
496
+ span_start integer not null check (span_start >= 0),
497
+ span_end integer not null check (span_end > span_start),
498
+ role text not null check (role in ('states', 'proposes', 'rejects', 'explains', 'implements', 'reconsiders')),
499
+ -- A third party the owner reported ("X said ..."): hearsay by the owner, never X's own statement
500
+ reported_speaker text,
501
+ run_id integer not null references extraction_run (id),
502
+ added_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', added_at) is added_at),
503
+ retracted_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', retracted_at) is retracted_at),
504
+ retraction_reason text,
505
+ retraction_source_id integer references source (id) on delete cascade,
506
+ retraction_span_start integer,
507
+ retraction_span_end integer,
508
+ foreign key (unit_id, option_id) references unit_option (unit_id, id) on delete cascade,
509
+ check ((retracted_at is null) = (retraction_reason is null)),
510
+ check ((retracted_at is null) = (retraction_source_id is null)),
511
+ check ((retraction_source_id is null) = (retraction_span_start is null)),
512
+ check ((retraction_source_id is null) = (retraction_span_end is null)),
513
+ check (retraction_span_end is null or retraction_span_end > retraction_span_start),
514
+ check (retraction_span_start >= 0),
515
+ check (retracted_at >= added_at)
516
+ ) strict;
517
+ insert into unit_evidence_new (id, unit_id, option_id, source_id, span_start, span_end, role, reported_speaker, run_id, added_at, retracted_at, retraction_reason, retraction_source_id, retraction_span_start, retraction_span_end) select id, unit_id, option_id, source_id, span_start, span_end, role, reported_speaker, run_id, added_at, retracted_at, retraction_reason, retraction_source_id, retraction_span_start, retraction_span_end from unit_evidence;
518
+ drop table unit_evidence;
519
+ alter table unit_evidence_new rename to unit_evidence;
520
+
521
+ create table unit_adoption_new (
522
+ id integer primary key autoincrement not null,
523
+ unit_id integer not null references unit (id) on delete cascade,
524
+ route text not null check (route in ('owner_statement', 'explicit')),
525
+ source_id integer not null references source (id) on delete cascade,
526
+ span_start integer not null check (span_start >= 0),
527
+ span_end integer not null check (span_end > span_start),
528
+ run_id integer not null references extraction_run (id),
529
+ added_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', added_at) is added_at),
530
+ retracted_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', retracted_at) is retracted_at),
531
+ retraction_reason text,
532
+ retraction_source_id integer references source (id) on delete cascade,
533
+ retraction_span_start integer,
534
+ retraction_span_end integer,
535
+ unique (unit_id, source_id, span_start, span_end),
536
+ check ((retracted_at is null) = (retraction_reason is null)),
537
+ check ((retracted_at is null) = (retraction_source_id is null)),
538
+ check ((retraction_source_id is null) = (retraction_span_start is null)),
539
+ check ((retraction_source_id is null) = (retraction_span_end is null)),
540
+ check (retraction_span_end is null or retraction_span_end > retraction_span_start),
541
+ check (retraction_span_start >= 0),
542
+ check (retracted_at >= added_at)
543
+ ) strict;
544
+ insert into unit_adoption_new (id, unit_id, route, source_id, span_start, span_end, run_id, added_at, retracted_at, retraction_reason, retraction_source_id, retraction_span_start, retraction_span_end) select id, unit_id, route, source_id, span_start, span_end, run_id, added_at, retracted_at, retraction_reason, retraction_source_id, retraction_span_start, retraction_span_end from unit_adoption;
545
+ drop table unit_adoption;
546
+ alter table unit_adoption_new rename to unit_adoption;
547
+
548
+ create table unit_link_new (
549
+ from_unit integer not null references unit (id) on delete cascade,
550
+ to_unit integer not null references unit (id) on delete cascade,
551
+ kind text not null check (kind in ('supersedes', 'conflicts')),
552
+ run_id integer not null references extraction_run (id),
553
+ added_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', added_at) is added_at),
554
+ -- A conflict stays unresolved (and suppresses automatic delivery of both) until resolved with a reason
555
+ resolved_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', resolved_at) is resolved_at),
556
+ resolution text,
557
+ primary key (from_unit, to_unit, kind),
558
+ check (from_unit <> to_unit),
559
+ check ((resolved_at is null) = (resolution is null)),
560
+ check (kind = 'conflicts' or resolved_at is null)
561
+ ) strict;
562
+ insert into unit_link_new (from_unit, to_unit, kind, run_id, added_at, resolved_at, resolution) select from_unit, to_unit, kind, run_id, added_at, resolved_at, resolution from unit_link;
563
+ drop table unit_link;
564
+ alter table unit_link_new rename to unit_link;
565
+
566
+ create table unit_anchor_new (
567
+ id integer primary key autoincrement not null,
568
+ unit_id integer not null references unit (id) on delete cascade,
569
+ path text not null check (path <> '' and path <> '.' and path <> '..' and path not glob '/*' and path not glob '[A-Za-z]:*' and path not glob '*\*'
570
+ and path not glob '*//*' and path not glob './*' and path not glob '../*' and path not glob '*/./*' and path not glob '*/../*'
571
+ and path not glob '*/.' and path not glob '*/..' and path not glob '*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || ']*'),
572
+ symbol text,
573
+ commit_sha text check (commit_sha is null or (length(commit_sha) = 40 and commit_sha not glob '*[^0-9a-f]*')),
574
+ line_start integer check (line_start > 0),
575
+ line_end integer check (line_end is null or (line_start is not null and line_end >= line_start)),
576
+ excerpt text,
577
+ role text not null check (role in ('applies_to', 'evidence')),
578
+ -- For work recorded before a commit: the edit observation of this path in the session, checked against the working tree when saved
579
+ edit_observation_id integer references edit_observation (id),
580
+ run_id integer not null references extraction_run (id),
581
+ added_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', added_at) is added_at),
582
+ retired_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', retired_at) is retired_at),
583
+ replaced_by integer references unit_anchor (id)
584
+ ) strict;
585
+ insert into unit_anchor_new (id, unit_id, path, symbol, commit_sha, line_start, line_end, excerpt, role, edit_observation_id, run_id, added_at, retired_at, replaced_by) select id, unit_id, path, symbol, commit_sha, line_start, line_end, excerpt, role, edit_observation_id, run_id, added_at, retired_at, replaced_by from unit_anchor;
586
+ drop table unit_anchor;
587
+ alter table unit_anchor_new rename to unit_anchor;
588
+
589
+ drop table external_reference;
590
+
591
+ update sqlite_sequence set seq = max(seq, (select s.seq from temp.sphica_sequence s where s.name = sqlite_sequence.name))
592
+ where name in (select name from temp.sphica_sequence);
593
+ insert into sqlite_sequence (name, seq) select s.name, s.seq from temp.sphica_sequence s
594
+ where s.name not in (select name from sqlite_sequence) and s.name in (select name from sqlite_schema where type = 'table');
595
+ drop table temp.sphica_sequence;
596
+
597
+ create index source_artifact on source (project_id, artifact, created_at);
598
+ create index source_session on source (session_id, created_at) where session_id is not null;
599
+ create unique index source_message_once on source (session_id, external_id, revision) where session_id is not null;
600
+ create unique index source_item_once on source (project_id, kind, external_id, revision) where session_id is null;
601
+ create trigger source_owner_bound before insert on source
602
+ when new.kind <> 'session_message' and new.author_kind = 'owner'
603
+ and not exists (select 1 from owner_identity where provider = 'github' and external_id = new.author_external_id) begin
604
+ select raise(abort, 'owner authorship needs a bound owner identity');
605
+ end;
606
+ create trigger session_cited before delete on session
607
+ when exists (select 1 from project where id = old.project_id) and (
608
+ exists (select 1 from source s where s.session_id = old.id and (
609
+ exists (select 1 from unit_evidence e where e.source_id = s.id or e.retraction_source_id = s.id)
610
+ or exists (select 1 from unit_adoption a where a.source_id = s.id or a.retraction_source_id = s.id)
611
+ or exists (select 1 from field_def d where d.source_id = s.id)
612
+ or exists (select 1 from unit_field f where f.source_id = s.id)
613
+ or exists (select 1 from unit_state t where t.source_id = s.id)))
614
+ or exists (select 1 from edit_observation o join unit_anchor a on a.edit_observation_id = o.id where o.session_id = old.id)) begin
615
+ select raise(abort, 'records cite this session; forget its messages with /sphica:forget rather than deleting it');
616
+ end;
617
+ create trigger source_session_project before insert on source when new.session_id is not null
618
+ and not exists (select 1 from session where id = new.session_id and project_id = new.project_id) begin
619
+ select raise(abort, 'source and session belong to different projects');
620
+ end;
621
+ create trigger source_no_update before update on source begin
622
+ select raise(abort, 'sources are never rewritten; capture a new revision');
623
+ end;
624
+ create trigger source_fts_ai after insert on source when new.indexed = 1 begin
625
+ insert into source_fts (rowid, lexemes) values (new.id, sphica_terms(new.text));
626
+ end;
627
+ create trigger source_fts_ad after delete on source when old.indexed = 1 begin
628
+ delete from source_fts where rowid = old.id;
629
+ end;
630
+ create index forget_batch_project on forget_batch (project_id);
631
+ create index source_forgotten_batch on source_forgotten (batch_id);
632
+ create index source_forgotten_item on source_forgotten (project_id, artifact, kind, external_id, content_hash);
633
+ create unique index edit_observation_once on edit_observation (session_id, coalesce(turn_id, ''), path, via);
634
+ create index edit_observation_path on edit_observation (path);
635
+ create index extraction_run_project on extraction_run (project_id);
636
+ create index extraction_run_session on extraction_run (session_id) where session_id is not null;
637
+ create trigger extraction_run_frozen before update on extraction_run
638
+ when new.id is not old.id or new.project_id is not old.project_id or new.origin is not old.origin or new.target is not old.target
639
+ or new.input_bytes is not old.input_bytes or new.draft_id is not old.draft_id
640
+ or new.started_at is not old.started_at
641
+ or (new.session_id is not old.session_id and (new.session_id is not null or exists (select 1 from session where id = old.session_id)))
642
+ or ((new.status is not old.status or new.finished_at is not old.finished_at) and old.status <> 'running') begin
643
+ select raise(abort, 'a run changes once, when it finishes');
644
+ end;
645
+ create index source_processing_run on source_processing (run_id);
646
+ create index unit_live on unit (project_id, lifecycle, kind);
647
+ create index unit_content on unit (project_id, content_hash);
648
+ create index unit_run on unit (run_id);
649
+ create trigger unit_insert_candidate before insert on unit when new.lifecycle <> 'candidate' begin
650
+ select raise(abort, 'units start as candidates');
651
+ end;
652
+ create trigger unit_run_project before insert on unit
653
+ when not exists (select 1 from extraction_run where id = new.run_id and project_id = new.project_id) begin
654
+ select raise(abort, 'unit and run belong to different projects');
655
+ end;
656
+ create trigger unit_text_frozen before update of project_id, key, kind, stance, text, why, scope_note, revisit_when, no_code_surface,
657
+ extraction, extraction_reason, unsourced, run_id, created_at, content_hash
658
+ on unit begin
659
+ select raise(abort, 'unit text is never rewritten; record a successor');
660
+ end;
661
+ create trigger unit_revision_step before update of revision on unit when new.revision is not old.revision + 1 begin
662
+ select raise(abort, 'a unit''s revision rises by one with each change to its relations');
663
+ end;
664
+ create trigger unit_lifecycle_via_state before update of lifecycle on unit
665
+ when new.lifecycle is not (select to_state from unit_state where unit_id = new.id order by id desc limit 1) begin
666
+ select raise(abort, 'lifecycle changes only through unit_state');
667
+ end;
668
+ create trigger unit_option_sealed before insert on unit_option
669
+ when exists (select 1 from unit_state where unit_id = new.unit_id) or exists (select 1 from unit_alias where unit_id = new.unit_id) begin
670
+ select raise(abort, 'options are written with the unit, before its first state; record a successor instead');
671
+ end;
672
+ create trigger unit_option_frozen before update on unit_option begin
673
+ select raise(abort, 'options are never rewritten; record a successor');
674
+ end;
675
+ create trigger unit_option_no_delete before delete on unit_option
676
+ when exists (select 1 from unit where id = old.unit_id) begin
677
+ select raise(abort, 'options are never removed on their own');
678
+ end;
679
+ create unique index unit_evidence_unit_once on unit_evidence (unit_id, source_id, span_start, span_end, role) where option_id is null;
680
+ create unique index unit_evidence_option_once on unit_evidence (option_id, source_id, span_start, span_end, role) where option_id is not null;
681
+ create index unit_evidence_source on unit_evidence (source_id);
682
+ create index unit_evidence_option on unit_evidence (unit_id, option_id);
683
+ create index unit_evidence_retraction on unit_evidence (retraction_source_id) where retraction_source_id is not null;
684
+ create index unit_evidence_run on unit_evidence (run_id);
685
+ create index unit_adoption_source on unit_adoption (source_id);
686
+ create index unit_adoption_retraction on unit_adoption (retraction_source_id) where retraction_source_id is not null;
687
+ create index unit_adoption_run on unit_adoption (run_id);
688
+ create trigger unit_adoption_route before insert on unit_adoption begin
689
+ select raise(abort, 'owner_statement adoption needs an owner-authored source')
690
+ where new.route = 'owner_statement' and not exists (select 1 from source where id = new.source_id and author_kind = 'owner');
691
+ select raise(abort, 'explicit adoption needs the owner or a maintainer (OWNER, MEMBER, COLLABORATOR association)')
692
+ where new.route = 'explicit' and not exists (select 1 from source where id = new.source_id
693
+ and (author_kind = 'owner' or author_association in ('OWNER', 'MEMBER', 'COLLABORATOR')));
694
+ select raise(abort, 'a merge is not adoption')
695
+ where exists (select 1 from source where id = new.source_id and kind = 'pr_event');
696
+ select raise(abort, 'adoption applies to decisions and constraints')
697
+ where not exists (select 1 from unit where id = new.unit_id and kind in ('decision', 'constraint'));
698
+ end;
699
+ create index unit_link_to on unit_link (to_unit, kind);
700
+ create index unit_link_run on unit_link (run_id);
701
+ create trigger unit_link_frozen before update on unit_link begin
702
+ select raise(abort, 'links are frozen; only an unresolved conflict can be resolved, once')
703
+ where new.from_unit is not old.from_unit or new.to_unit is not old.to_unit or new.kind is not old.kind
704
+ or new.run_id is not old.run_id or new.added_at is not old.added_at or old.resolved_at is not null or old.kind <> 'conflicts';
705
+ end;
706
+ create trigger unit_link_no_delete before delete on unit_link
707
+ when exists (select 1 from unit where id = old.from_unit) and exists (select 1 from unit where id = old.to_unit) begin
708
+ select raise(abort, 'links are never removed on their own');
709
+ end;
710
+ create trigger unit_link_supersedes_acyclic before insert on unit_link when new.kind = 'supersedes' begin
711
+ select raise(abort, 'supersedes links cannot form a cycle')
712
+ where exists (
713
+ with recursive chain(id) as (
714
+ select new.to_unit union select l.to_unit from unit_link l join chain on l.from_unit = chain.id where l.kind = 'supersedes')
715
+ select 1 from chain where id = new.from_unit);
716
+ end;
717
+ create index unit_state_order on unit_state (unit_id, id);
718
+ create index unit_state_source on unit_state (source_id) where source_id is not null;
719
+ create index unit_state_run on unit_state (run_id) where run_id is not null;
720
+ create index unit_state_forget on unit_state (forget_id) where forget_id is not null;
721
+ create trigger unit_state_rules before insert on unit_state begin
722
+ select raise(abort, 'the first state of a unit is candidate, from no state')
723
+ where not exists (select 1 from unit_state where unit_id = new.unit_id)
724
+ and (new.from_state is not null or new.to_state <> 'candidate');
725
+ select raise(abort, 'from_state must be the current lifecycle')
726
+ where new.from_state is not (select lifecycle from unit where id = new.unit_id)
727
+ and exists (select 1 from unit_state where unit_id = new.unit_id);
728
+ -- A successor's state is read from its history, not its lifecycle column: a row written in the same statement may not be applied yet
729
+ select raise(abort, 'not a lifecycle change a unit can make: withdrawn is final, and a superseded unit only returns to candidate once every successor is withdrawn')
730
+ where exists (select 1 from unit_state where unit_id = new.unit_id) and not (
731
+ (new.from_state = 'candidate' and new.to_state in ('active', 'superseded', 'withdrawn'))
732
+ or (new.from_state = 'active' and new.to_state in ('candidate', 'superseded', 'withdrawn'))
733
+ or (new.from_state = 'superseded' and new.to_state = 'candidate' and not exists (
734
+ select 1 from unit_link l join unit s on s.id = l.from_unit where l.to_unit = new.unit_id and l.kind = 'supersedes'
735
+ and s.extraction = 'supported' and s.unsourced = 0
736
+ and (select to_state from unit_state where unit_id = l.from_unit order by id desc limit 1) is not 'withdrawn')));
737
+ select raise(abort, 'a quarantined or unsourced unit cannot become active')
738
+ where new.to_state = 'active' and exists (select 1 from unit where id = new.unit_id and (extraction <> 'supported' or unsourced = 1));
739
+ select raise(abort, (select missing from unit_support where unit_id = new.unit_id))
740
+ where new.to_state = 'active' and (select missing from unit_support where unit_id = new.unit_id) is not null;
741
+ -- A reconsider condition is the owner's: each needs a quote of the owner, written when the unit is saved. A quote retracted later, or
742
+ -- forgotten (forget's recheck is exempt, since the row is gone), leaves the unit as it was, and readers show the condition as unsupported
743
+ select raise(abort, 'a reconsider condition needs a quote of the owner')
744
+ where new.to_state = 'active' and new.forget_id is null and exists (select 1 from unit_option o where o.unit_id = new.unit_id
745
+ and o.reconsider_when is not null and not exists (select 1 from unit_evidence e join source s on s.id = e.source_id
746
+ where e.option_id = o.id and e.role = 'reconsiders' and s.author_kind = 'owner'));
747
+ select raise(abort, 'superseded needs a supersedes link from an active successor')
748
+ where new.to_state = 'superseded' and not exists (select 1 from unit_link l join unit s on s.id = l.from_unit
749
+ where l.to_unit = new.unit_id and l.kind = 'supersedes' and s.lifecycle = 'active');
750
+ end;
751
+ create trigger unit_state_append_only before update on unit_state
752
+ when not (new.source_id is null and old.source_id is not null and not exists (select 1 from source where id = old.source_id)
753
+ and new.id is old.id and new.unit_id is old.unit_id and new.from_state is old.from_state and new.to_state is old.to_state
754
+ and new.at is old.at and new.reason is old.reason and new.run_id is old.run_id and new.forget_id is old.forget_id) begin
755
+ select raise(abort, 'state history is append-only');
756
+ end;
757
+ create trigger unit_state_no_delete before delete on unit_state when exists (select 1 from unit where id = old.unit_id) begin
758
+ select raise(abort, 'state history is append-only');
759
+ end;
760
+ create trigger unit_state_apply after insert on unit_state begin
761
+ update unit set lifecycle = new.to_state, revision = revision + 1 where id = new.unit_id;
762
+ end;
763
+ create trigger unit_state_restore after insert on unit_state when new.to_state = 'withdrawn' begin
764
+ insert into unit_state (unit_id, from_state, to_state, at, reason, source_id, run_id, forget_id)
765
+ select o.id, 'superseded', 'candidate', new.at, 'its successor was withdrawn', new.source_id, new.run_id, new.forget_id
766
+ from unit_link l join unit o on o.id = l.to_unit
767
+ where l.from_unit = new.unit_id and l.kind = 'supersedes' and o.lifecycle = 'superseded'
768
+ and not exists (select 1 from unit_link k join unit s on s.id = k.from_unit where k.to_unit = o.id and k.kind = 'supersedes'
769
+ and k.from_unit <> new.unit_id and s.extraction = 'supported' and s.unsourced = 0
770
+ and (select to_state from unit_state where unit_id = k.from_unit order by id desc limit 1) is not 'withdrawn');
771
+ end;
772
+ create index unit_anchor_path on unit_anchor (path, role) where retired_at is null;
773
+ create index unit_anchor_unit on unit_anchor (unit_id, retired_at);
774
+ create unique index unit_anchor_live_once on unit_anchor (unit_id, path, role, coalesce(commit_sha, ''), coalesce(symbol, ''),
775
+ coalesce(case when symbol is null then line_start end, 0), coalesce(case when symbol is null then line_end end, 0))
776
+ where retired_at is null;
777
+ create index unit_anchor_observation on unit_anchor (edit_observation_id) where edit_observation_id is not null;
778
+ create index unit_anchor_replaced on unit_anchor (replaced_by) where replaced_by is not null;
779
+ create index unit_anchor_run on unit_anchor (run_id);
780
+ create trigger unit_anchor_frozen before update on unit_anchor begin
781
+ select raise(abort, 'anchors are replaced, not edited; retirement happens once')
782
+ where new.unit_id is not old.unit_id or new.path is not old.path or new.symbol is not old.symbol or new.commit_sha is not old.commit_sha
783
+ or new.line_start is not old.line_start or new.line_end is not old.line_end or new.excerpt is not old.excerpt or new.role is not old.role
784
+ or new.edit_observation_id is not old.edit_observation_id or new.run_id is not old.run_id or new.added_at is not old.added_at
785
+ or old.retired_at is not null or new.retired_at is null;
786
+ select raise(abort, 'an anchor is replaced by another anchor of the same record')
787
+ where new.replaced_by is not null and (new.replaced_by = new.id
788
+ or not exists (select 1 from unit_anchor where id = new.replaced_by and unit_id = new.unit_id));
789
+ end;
790
+ create trigger unit_anchor_no_delete before delete on unit_anchor when exists (select 1 from unit where id = old.unit_id) begin
791
+ select raise(abort, 'anchors are retired, never deleted');
792
+ end;
793
+ create view unit_support as
794
+ select u.id as unit_id, case
795
+ when u.kind in ('decision', 'constraint') and (
796
+ not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null)
797
+ or not exists (select 1 from unit_adoption a where a.unit_id = u.id and a.retracted_at is null))
798
+ then 'an active decision or constraint needs unretracted evidence and adoption'
799
+ when u.kind = 'implementation' and not (
800
+ exists (select 1 from unit_evidence e join source s on s.id = e.source_id where e.unit_id = u.id and e.option_id is null
801
+ and e.retracted_at is null and e.role = 'implements' and s.kind in ('commit_message', 'file_excerpt'))
802
+ or exists (select 1 from unit_anchor a where a.unit_id = u.id and a.retired_at is null and a.role = 'evidence'
803
+ and (a.commit_sha is not null or (a.edit_observation_id is not null and exists (select 1 from unit_evidence e
804
+ join source s on s.id = e.source_id join edit_observation o on o.id = a.edit_observation_id
805
+ where e.unit_id = u.id and e.option_id is null and e.retracted_at is null and e.role = 'implements'
806
+ and s.session_id = o.session_id)))))
807
+ then 'an active implementation needs code or commit evidence'
808
+ when u.kind in ('finding', 'dead_end', 'question')
809
+ and not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null)
810
+ then 'an active unit needs unretracted evidence'
811
+ end as missing
812
+ from unit u;
813
+ create index unit_alias_unit on unit_alias (unit_id, id);
814
+ create index unit_alias_run on unit_alias (run_id);
815
+ create trigger unit_alias_terms before insert on unit_alias begin
816
+ select raise(abort, 'an alias set is bound to the words of its unit as they are')
817
+ where new.content_hash is not (select content_hash from unit where id = new.unit_id);
818
+ select raise(abort, 'each alias is a non-empty string of at most 40 characters')
819
+ where exists (select 1 from json_each(new.terms) where type <> 'text' or length(trim(value)) = 0 or length(value) > 40);
820
+ end;
821
+ create trigger unit_alias_frozen before update on unit_alias begin
822
+ select raise(abort, 'alias sets are replaced by a newer set, not edited');
823
+ end;
824
+ create trigger unit_alias_no_delete before delete on unit_alias when exists (select 1 from unit where id = old.unit_id) begin
825
+ select raise(abort, 'alias sets are append-only; write an empty set to clear');
826
+ end;
827
+ create index field_def_source on field_def (source_id);
828
+ create index field_def_run on field_def (run_id);
829
+ create trigger field_def_check before insert on field_def begin
830
+ select raise(abort, 'a field definition, its source, and its run belong to one project')
831
+ where new.project_id is not (select project_id from source where id = new.source_id)
832
+ or new.project_id is not (select project_id from extraction_run where id = new.run_id);
833
+ select raise(abort, 'fields are defined by trace')
834
+ where (select origin from extraction_run where id = new.run_id) is not 'trace';
835
+ select raise(abort, 'a field definition quotes the owner')
836
+ where not exists (select 1 from source where id = new.source_id and author_kind = 'owner');
837
+ select raise(abort, 'field definition span is outside the source text')
838
+ where new.span_end > (select length(cast(text as blob)) from source where id = new.source_id);
839
+ select raise(abort, 'a span starts or ends inside a character')
840
+ where hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_start + 1, 1)) between '80' and 'BF'
841
+ or hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_end + 1, 1)) between '80' and 'BF';
842
+ select raise(abort, 'enum values are distinct non-empty strings of at most 100 characters')
843
+ where new.enum_values is not null and (exists (select 1 from json_each(new.enum_values)
844
+ where type <> 'text' or length(trim(value)) = 0 or length(value) > 100)
845
+ or (select count(distinct value) from json_each(new.enum_values)) <> json_array_length(new.enum_values));
846
+ select raise(abort, 'field kinds are distinct unit kinds')
847
+ where exists (select 1 from json_each(new.kinds) where type <> 'text'
848
+ or value not in ('decision', 'implementation', 'finding', 'dead_end', 'question', 'constraint'))
849
+ or (select count(distinct value) from json_each(new.kinds)) <> json_array_length(new.kinds);
850
+ end;
851
+ create trigger field_def_frozen before update on field_def begin
852
+ select raise(abort, 'field definitions are never rewritten');
853
+ end;
854
+ create trigger field_def_no_delete before delete on field_def
855
+ when exists (select 1 from project where id = old.project_id) and exists (select 1 from source where id = old.source_id) begin
856
+ select raise(abort, 'field definitions are removed only with their quoted source');
857
+ end;
858
+ create index unit_field_def on unit_field (field_def_id);
859
+ create index unit_field_source on unit_field (source_id);
860
+ create index unit_field_run on unit_field (run_id);
861
+ create trigger unit_field_check before insert on unit_field begin
862
+ select raise(abort, 'a field value, its unit, definition, source, and run belong to one project')
863
+ where (select project_id from unit where id = new.unit_id) is not (select project_id from field_def where id = new.field_def_id)
864
+ or (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id)
865
+ or (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id);
866
+ select raise(abort, 'field values are written by trace')
867
+ where (select origin from extraction_run where id = new.run_id) is not 'trace';
868
+ select raise(abort, 'field values are written with the unit, before its first state; record a successor instead')
869
+ where exists (select 1 from unit_state where unit_id = new.unit_id) or exists (select 1 from unit_alias where unit_id = new.unit_id);
870
+ select raise(abort, 'field value span is outside the source text')
871
+ where new.span_end > (select length(cast(text as blob)) from source where id = new.source_id);
872
+ select raise(abort, 'a span starts or ends inside a character')
873
+ where hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_start + 1, 1)) between '80' and 'BF'
874
+ or hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_end + 1, 1)) between '80' and 'BF';
875
+ select raise(abort, 'the field does not apply to this kind of unit')
876
+ where exists (select 1 from field_def d where d.id = new.field_def_id and json_array_length(d.kinds) > 0
877
+ and not exists (select 1 from json_each(d.kinds) j where j.value = (select kind from unit where id = new.unit_id)));
878
+ select raise(abort, 'an integer field value is an optional minus sign and digits')
879
+ where (select type from field_def where id = new.field_def_id) = 'integer'
880
+ and (new.value not glob '[0-9]*' and new.value not glob '-[0-9]*' or substr(new.value, 2) glob '*[^0-9]*');
881
+ select raise(abort, 'a date field value is a valid YYYY-MM-DD date')
882
+ where (select type from field_def where id = new.field_def_id) = 'date'
883
+ and (length(new.value) <> 10 or date(new.value) is not new.value);
884
+ select raise(abort, 'an enum field value is one of its values')
885
+ where (select type from field_def where id = new.field_def_id) = 'enum' and not exists (select 1 from json_each(
886
+ (select enum_values from field_def where id = new.field_def_id)) where value = new.value);
887
+ end;
888
+ create trigger unit_field_frozen before update on unit_field begin
889
+ select raise(abort, 'field values are never rewritten; record a successor');
890
+ end;
891
+ create trigger unit_field_no_delete before delete on unit_field
892
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
893
+ and exists (select 1 from field_def where id = old.field_def_id) begin
894
+ select raise(abort, 'field values are removed only with their unit or a quoted source');
895
+ end;
896
+ create trigger unit_evidence_check before insert on unit_evidence begin
897
+ select raise(abort, 'evidence and unit belong to different projects')
898
+ where (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id)
899
+ or (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id);
900
+ select raise(abort, 'evidence span is outside the source text')
901
+ where new.span_end > (select length(cast(text as blob)) from source where id = new.source_id);
902
+ -- A retraction cites the owner's words, which only the retraction's own update checks: a row is never written already retracted
903
+ select raise(abort, 'evidence is written live, then retracted')
904
+ where new.retracted_at is not null or new.retraction_source_id is not null;
905
+ select raise(abort, 'a span starts or ends inside a character')
906
+ where hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_start + 1, 1)) between '80' and 'BF'
907
+ or hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_end + 1, 1)) between '80' and 'BF';
908
+ select raise(abort, 'a reported speaker is the owner reporting someone else, so it must cite an owner session message')
909
+ where new.reported_speaker is not null and (trim(new.reported_speaker) = '' or not exists (select 1 from source
910
+ where id = new.source_id and kind = 'session_message' and author_kind = 'owner'));
911
+ select raise(abort, 'reconsiders quotes the owner on a rejected option that has a reconsider condition')
912
+ where new.role = 'reconsiders' and (new.option_id is null
913
+ or not exists (select 1 from unit_option where id = new.option_id and reconsider_when is not null)
914
+ or not exists (select 1 from source where id = new.source_id and author_kind = 'owner'));
915
+ end;
916
+ create trigger unit_evidence_retract before update on unit_evidence begin
917
+ select raise(abort, 'evidence is only ever retracted, once')
918
+ where old.retracted_at is not null or new.unit_id is not old.unit_id or new.option_id is not old.option_id
919
+ or new.source_id is not old.source_id or new.span_start is not old.span_start or new.span_end is not old.span_end
920
+ or new.role is not old.role or new.reported_speaker is not old.reported_speaker or new.run_id is not old.run_id
921
+ or new.added_at is not old.added_at or new.retracted_at is null;
922
+ select raise(abort, 'the retraction must cite an owner span of the same project')
923
+ where not exists (select 1 from source s where s.id = new.retraction_source_id and s.author_kind = 'owner'
924
+ and s.project_id = (select project_id from unit where id = new.unit_id)
925
+ and new.retraction_span_end <= length(cast(s.text as blob)));
926
+ select raise(abort, 'a span starts or ends inside a character')
927
+ where hex(substr((select cast(text as blob) from source where id = new.retraction_source_id), new.retraction_span_start + 1, 1)) between '80' and 'BF'
928
+ or hex(substr((select cast(text as blob) from source where id = new.retraction_source_id), new.retraction_span_end + 1, 1)) between '80' and 'BF';
929
+ end;
930
+ create trigger unit_evidence_no_delete before delete on unit_evidence
931
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
932
+ and not (old.retracted_at is not null and not exists (select 1 from source where id = old.retraction_source_id)) begin
933
+ select raise(abort, 'evidence is retracted, never deleted');
934
+ end;
935
+ create trigger unit_adoption_no_delete before delete on unit_adoption
936
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
937
+ and not (old.retracted_at is not null and not exists (select 1 from source where id = old.retraction_source_id)) begin
938
+ select raise(abort, 'adoption is retracted, never deleted');
939
+ end;
940
+ create trigger unit_evidence_retract_support after update of retracted_at on unit_evidence
941
+ when exists (select 1 from unit u join unit_support s on s.unit_id = u.id
942
+ where u.id = new.unit_id and u.lifecycle = 'active' and s.missing is not null) begin
943
+ select raise(abort, 'move the unit back to candidate before retracting its last evidence');
944
+ end;
945
+ create trigger unit_adoption_retract_support after update of retracted_at on unit_adoption
946
+ when exists (select 1 from unit u join unit_support s on s.unit_id = u.id
947
+ where u.id = new.unit_id and u.lifecycle = 'active' and s.missing is not null) begin
948
+ select raise(abort, 'move the unit back to candidate before retracting its last adoption');
949
+ end;
950
+ create trigger unit_anchor_retire_support after update of retired_at on unit_anchor
951
+ when exists (select 1 from unit u join unit_support s on s.unit_id = u.id
952
+ where u.id = new.unit_id and u.lifecycle = 'active' and s.missing is not null) begin
953
+ select raise(abort, 'move the unit back to candidate before retiring its last code anchor');
954
+ end;
955
+ create trigger unit_adoption_check before insert on unit_adoption begin
956
+ select raise(abort, 'adoption and unit belong to different projects')
957
+ where (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id)
958
+ or (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id);
959
+ select raise(abort, 'adoption span is outside the source text')
960
+ where new.span_end > (select length(cast(text as blob)) from source where id = new.source_id);
961
+ select raise(abort, 'adoption is written live, then retracted')
962
+ where new.retracted_at is not null or new.retraction_source_id is not null;
963
+ select raise(abort, 'a span starts or ends inside a character')
964
+ where hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_start + 1, 1)) between '80' and 'BF'
965
+ or hex(substr((select cast(text as blob) from source where id = new.source_id), new.span_end + 1, 1)) between '80' and 'BF';
966
+ end;
967
+ create trigger unit_adoption_retract before update on unit_adoption begin
968
+ select raise(abort, 'adoption is only ever retracted, once')
969
+ where old.retracted_at is not null or new.unit_id is not old.unit_id or new.route is not old.route
970
+ or new.source_id is not old.source_id or new.span_start is not old.span_start or new.span_end is not old.span_end
971
+ or new.run_id is not old.run_id or new.added_at is not old.added_at or new.retracted_at is null;
972
+ select raise(abort, 'the retraction must cite an owner span of the same project')
973
+ where not exists (select 1 from source s where s.id = new.retraction_source_id and s.author_kind = 'owner'
974
+ and s.project_id = (select project_id from unit where id = new.unit_id)
975
+ and new.retraction_span_end <= length(cast(s.text as blob)));
976
+ select raise(abort, 'a span starts or ends inside a character')
977
+ where hex(substr((select cast(text as blob) from source where id = new.retraction_source_id), new.retraction_span_start + 1, 1)) between '80' and 'BF'
978
+ or hex(substr((select cast(text as blob) from source where id = new.retraction_source_id), new.retraction_span_end + 1, 1)) between '80' and 'BF';
979
+ end;
980
+ create trigger unit_link_check before insert on unit_link begin
981
+ select raise(abort, 'linked units belong to different projects')
982
+ where (select project_id from unit where id = new.from_unit) is not (select project_id from unit where id = new.to_unit)
983
+ or (select project_id from unit where id = new.from_unit) is not (select project_id from extraction_run where id = new.run_id);
984
+ select raise(abort, 'a record supersedes one of its own kind; a decision and a constraint can replace each other')
985
+ where new.kind = 'supersedes' and not exists (select 1 from unit a join unit b on b.id = new.to_unit where a.id = new.from_unit
986
+ and (a.kind = b.kind or (a.kind in ('decision', 'constraint') and b.kind in ('decision', 'constraint'))));
987
+ -- One live successor at a time. A withdrawn one gives its place up, and a quarantined or unsourced one never takes it: it can never
988
+ -- become active, nor be withdrawn. States are read from history, as in unit_state_rules
989
+ select raise(abort, 'the record already has a successor that is not withdrawn')
990
+ where new.kind = 'supersedes'
991
+ and exists (select 1 from unit n where n.id = new.from_unit and n.extraction = 'supported' and n.unsourced = 0)
992
+ and exists (select 1 from unit_link l join unit s on s.id = l.from_unit
993
+ where l.to_unit = new.to_unit and l.kind = 'supersedes' and s.extraction = 'supported' and s.unsourced = 0
994
+ and (select to_state from unit_state where unit_id = l.from_unit order by id desc limit 1) is not 'withdrawn');
995
+ end;
996
+ create trigger unit_state_project before insert on unit_state begin
997
+ select raise(abort, 'a state comes after its unit was created')
998
+ where new.at < (select created_at from unit where id = new.unit_id);
999
+ select raise(abort, 'state and unit belong to different projects')
1000
+ where (new.run_id is not null
1001
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id))
1002
+ or (new.forget_id is not null
1003
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from forget_batch where id = new.forget_id))
1004
+ or (new.source_id is not null
1005
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id));
1006
+ end;
1007
+ create trigger unit_anchor_project before insert on unit_anchor begin
1008
+ select raise(abort, 'an anchor is replaced by another anchor of the same record')
1009
+ where new.replaced_by is not null and not exists (select 1 from unit_anchor where id = new.replaced_by and unit_id = new.unit_id);
1010
+ select raise(abort, 'anchor and unit belong to different projects')
1011
+ where (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id);
1012
+ select raise(abort, 'the edit observation must be of this path in a session of the same project')
1013
+ where new.edit_observation_id is not null and not exists (select 1 from edit_observation o join session s on s.id = o.session_id
1014
+ where o.id = new.edit_observation_id and o.path = new.path and s.project_id = (select project_id from unit where id = new.unit_id));
1015
+ end;
1016
+ create trigger unit_alias_project before insert on unit_alias begin
1017
+ select raise(abort, 'alias and unit belong to different projects')
1018
+ where (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id);
1019
+ end;
1020
+ create trigger source_processing_project before insert on source_processing begin
1021
+ select raise(abort, 'source and run belong to different projects')
1022
+ where (select project_id from source where id = new.source_id) is not (select project_id from extraction_run where id = new.run_id);
1023
+ end;
1024
+ create trigger unit_rev_evidence_i after insert on unit_evidence begin update unit set revision = revision + 1 where id = new.unit_id; end;
1025
+ create trigger unit_rev_evidence_u after update on unit_evidence begin update unit set revision = revision + 1 where id = new.unit_id; end;
1026
+ create trigger unit_rev_adoption_i after insert on unit_adoption begin update unit set revision = revision + 1 where id = new.unit_id; end;
1027
+ create trigger unit_rev_adoption_u after update on unit_adoption begin update unit set revision = revision + 1 where id = new.unit_id; end;
1028
+ create trigger unit_rev_evidence_d after delete on unit_evidence when exists (select 1 from unit where id = old.unit_id) begin
1029
+ update unit set revision = revision + 1 where id = old.unit_id;
1030
+ end;
1031
+ create trigger unit_rev_adoption_d after delete on unit_adoption when exists (select 1 from unit where id = old.unit_id) begin
1032
+ update unit set revision = revision + 1 where id = old.unit_id;
1033
+ end;
1034
+ create trigger unit_rev_link_i after insert on unit_link begin
1035
+ update unit set revision = revision + 1 where id in (new.from_unit, new.to_unit);
1036
+ end;
1037
+ create trigger unit_rev_link_u after update on unit_link begin
1038
+ update unit set revision = revision + 1 where id in (new.from_unit, new.to_unit);
1039
+ end;
1040
+ create trigger unit_rev_anchor_i after insert on unit_anchor begin update unit set revision = revision + 1 where id = new.unit_id; end;
1041
+ create trigger unit_rev_anchor_u after update on unit_anchor begin update unit set revision = revision + 1 where id = new.unit_id; end;
1042
+ create trigger unit_rev_alias_i after insert on unit_alias begin update unit set revision = revision + 1 where id = new.unit_id; end;
1043
+ create trigger unit_rev_field_i after insert on unit_field begin update unit set revision = revision + 1 where id = new.unit_id; end;
1044
+ create trigger unit_rev_field_d after delete on unit_field when exists (select 1 from unit where id = old.unit_id) begin
1045
+ update unit set revision = revision + 1 where id = old.unit_id;
1046
+ end;
1047
+ create view unit_search_text as
1048
+ select u.id,
1049
+ sphica_terms(u.text || char(10) || coalesce(u.why, '') || char(10) || coalesce(u.scope_note, '') || char(10)
1050
+ || coalesce(u.revisit_when, '') || char(10)
1051
+ || coalesce((select group_concat(o.text || ' ' || coalesce(o.why, '') || ' ' || coalesce(o.reconsider_when, ''), char(10))
1052
+ from (select text, why, reconsider_when from unit_option where unit_id = u.id order by position) o), '') || char(10)
1053
+ || coalesce((select group_concat(f.name || ' ' || f.value, char(10))
1054
+ from (select d.name, v.value from unit_field v join field_def d on d.id = v.field_def_id where v.unit_id = u.id order by v.id) f), ''))
1055
+ as body,
1056
+ sphica_terms(coalesce((select group_concat(a.path || ' ' || coalesce(a.symbol, ''), char(10))
1057
+ from (select path, symbol from unit_anchor where unit_id = u.id and retired_at is null order by id) a), '')) as ident,
1058
+ sphica_terms(coalesce((select group_concat(j.value, ' ')
1059
+ from json_each((select terms from unit_alias where unit_id = u.id and content_hash = u.content_hash order by id desc limit 1)) j), ''))
1060
+ as alias
1061
+ from unit u;
1062
+ create trigger unit_fts_ai after insert on unit begin
1063
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.id;
1064
+ end;
1065
+ create trigger unit_fts_ad after delete on unit begin
1066
+ delete from unit_fts where rowid = old.id;
1067
+ end;
1068
+ create trigger unit_fts_option_i after insert on unit_option begin
1069
+ delete from unit_fts where rowid = new.unit_id;
1070
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
1071
+ end;
1072
+ create trigger unit_fts_anchor_i after insert on unit_anchor begin
1073
+ delete from unit_fts where rowid = new.unit_id;
1074
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
1075
+ end;
1076
+ create trigger unit_fts_anchor_u after update on unit_anchor begin
1077
+ delete from unit_fts where rowid = new.unit_id;
1078
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
1079
+ end;
1080
+ create trigger unit_fts_anchor_d after delete on unit_anchor when exists (select 1 from unit where id = old.unit_id) begin
1081
+ delete from unit_fts where rowid = old.unit_id;
1082
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = old.unit_id;
1083
+ end;
1084
+ create trigger unit_fts_alias_i after insert on unit_alias begin
1085
+ delete from unit_fts where rowid = new.unit_id;
1086
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
1087
+ end;
1088
+ create trigger unit_fts_alias_d after delete on unit_alias when exists (select 1 from unit where id = old.unit_id) begin
1089
+ delete from unit_fts where rowid = old.unit_id;
1090
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = old.unit_id;
1091
+ end;
1092
+ create trigger unit_fts_field_i after insert on unit_field begin
1093
+ delete from unit_fts where rowid = new.unit_id;
1094
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
1095
+ end;
1096
+ create trigger unit_fts_field_d after delete on unit_field when exists (select 1 from unit where id = old.unit_id) begin
1097
+ delete from unit_fts where rowid = old.unit_id;
1098
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = old.unit_id;
1099
+ end;
1100
+ create index work_open on work (project_id, updated_at desc) where status in ('active', 'blocked', 'paused');
1101
+ create index work_run on work (run_id) where run_id is not null;
1102
+ create index delivery_session on delivery (session_id, at);
1103
+ create index delivery_unit_unit on delivery_unit (unit_id);
1104
+ create view ingest_source as
1105
+ select project_id, kind, artifact, external_id, revision, author_kind, author_login, author_external_id, author_association,
1106
+ parent_external_id, event_kind, url, created_at, available_at, captured_at, text, truncated, redacted, original_bytes,
1107
+ content_hash, path, line_start, line_end, diff_hunk, commit_sha, blob_sha, indexed
1108
+ from source where session_id is null;
1109
+ create trigger ingest_source_insert instead of insert on ingest_source begin
1110
+ insert into source (project_id, kind, artifact, external_id, revision, author_kind, author_login, author_external_id, author_association,
1111
+ parent_external_id, event_kind, url, created_at, available_at, captured_at, text, truncated, redacted, original_bytes,
1112
+ content_hash, path, line_start, line_end, diff_hunk, commit_sha, blob_sha, indexed, session_id, turn_id)
1113
+ values (new.project_id, new.kind, new.artifact, new.external_id, new.revision, new.author_kind, new.author_login, new.author_external_id,
1114
+ new.author_association, new.parent_external_id, new.event_kind, new.url, new.created_at, new.available_at, new.captured_at,
1115
+ new.text, coalesce(new.truncated, 0), coalesce(new.redacted, 0), new.original_bytes, new.content_hash, new.path, new.line_start,
1116
+ new.line_end, new.diff_hunk, new.commit_sha, new.blob_sha, new.indexed, null, null);
1117
+ end;
1118
+ create view capture_session as select id, project_id, host, external_id, branch, started_at from session;
1119
+ create trigger capture_session_insert instead of insert on capture_session begin
1120
+ select raise(abort, 'the session already exists with different details')
1121
+ where exists (select 1 from session where id = new.id and (project_id <> new.project_id or host <> new.host or external_id <> new.external_id));
1122
+ insert into session (id, project_id, host, external_id, branch, started_at)
1123
+ select new.id, new.project_id, new.host, new.external_id, new.branch, new.started_at
1124
+ where not exists (select 1 from session where id = new.id);
1125
+ end;
1126
+ create view capture_message as
1127
+ select external_id, session_id, turn_id, author_kind as speaker, created_at, captured_at, text, truncated, redacted, original_bytes,
1128
+ content_hash from source where kind = 'session_message';
1129
+ create trigger capture_message_insert instead of insert on capture_message begin
1130
+ select raise(abort, 'unknown session') where not exists (select 1 from session where id = new.session_id);
1131
+ select raise(abort, 'speaker must be owner or assistant') where new.speaker not in ('owner', 'assistant');
1132
+ select raise(abort, 'the message already exists with different content')
1133
+ where exists (select 1 from source where kind = 'session_message' and session_id = new.session_id and external_id = new.external_id
1134
+ and (text is not new.text or author_kind is not new.speaker or turn_id is not new.turn_id or created_at is not new.created_at
1135
+ or truncated is not new.truncated or redacted is not new.redacted or original_bytes is not new.original_bytes
1136
+ or content_hash is not new.content_hash));
1137
+ insert into source (project_id, kind, artifact, external_id, revision, session_id, turn_id, author_kind, created_at, available_at,
1138
+ captured_at, text, truncated, redacted, original_bytes, content_hash, indexed)
1139
+ select s.project_id, 'session_message', 'session:' || s.id, new.external_id, 1, s.id, new.turn_id, new.speaker, new.created_at,
1140
+ new.created_at, new.captured_at, new.text, new.truncated, new.redacted, new.original_bytes, new.content_hash,
1141
+ new.speaker = 'owner'
1142
+ from session s where s.id = new.session_id
1143
+ and not exists (select 1 from source where kind = 'session_message' and session_id = new.session_id and external_id = new.external_id)
1144
+ and not exists (select 1 from source_forgotten f where f.project_id = s.project_id and f.artifact = 'session:' || s.id
1145
+ and f.kind = 'session_message' and f.external_id = new.external_id and f.content_hash = new.content_hash);
1146
+ end;
1147
+ create view capture_edit as select session_id, turn_id, tool_event_id, path, via, observed_at from edit_observation;
1148
+ create trigger capture_edit_insert instead of insert on capture_edit begin
1149
+ insert into edit_observation (session_id, turn_id, tool_event_id, path, via, observed_at)
1150
+ select new.session_id, new.turn_id, new.tool_event_id, new.path, new.via, new.observed_at
1151
+ where exists (select 1 from session where id = new.session_id) on conflict do nothing;
1152
+ end;
1153
+ create view capture_delivery as
1154
+ select session_id, event, outcome, reason, path, eligible, omitted, chars, at, null as units from delivery;
1155
+ create trigger capture_delivery_insert instead of insert on capture_delivery begin
1156
+ select raise(abort, 'the unit and the delivered session belong to different projects')
1157
+ where exists (select 1 from json_each(coalesce(new.units, '[]')) j
1158
+ where (select project_id from unit where id = j.value) is not (select project_id from session where id = new.session_id));
1159
+ insert into delivery (session_id, event, outcome, reason, path, eligible, omitted, chars, at)
1160
+ values (new.session_id, new.event, new.outcome, new.reason, new.path, coalesce(new.eligible, 0), coalesce(new.omitted, 0),
1161
+ coalesce(new.chars, 0), new.at);
1162
+ insert into delivery_unit (delivery_id, unit_id)
1163
+ select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
1164
+ end;
1165
+
1166
+ -- Rows may have left the indexed text, so both full-text indexes are built again
1167
+ insert into unit_fts (unit_fts) values ('delete-all');
1168
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text;
1169
+ insert into source_fts (source_fts) values ('delete-all');
1170
+ insert into source_fts (rowid, lexemes) select id, sphica_terms(text) from source where indexed = 1;
1171
+
1172
+ pragma user_version = 5;