@supersuit/transcript-md 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ // Version-1 conversation headers only. This module performs no workspace or network IO.
2
+ // Structural rules come from the exported schema; semantic rules below need multiple fields.
3
+ import { readFileSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ const freeze = value => {
7
+ if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); }
8
+ return value;
9
+ };
10
+ export const conversationSchema = freeze(JSON.parse(readFileSync(new URL('./conversation.schema.json', import.meta.url), 'utf8')));
11
+ const keywords = new Set(['type','enum','const','required','properties','additionalProperties','items','oneOf','anyOf','pattern','minLength','minimum']);
12
+ function checkSchema(schema) {
13
+ for (const key of Object.keys(schema)) if (!keywords.has(key)) throw new Error(`unsupported conversation schema keyword: ${key}`);
14
+ for (const child of Object.values(schema.properties ?? {})) checkSchema(child);
15
+ if (schema.items) checkSchema(schema.items);
16
+ for (const key of ['oneOf','anyOf']) for (const child of schema[key] ?? []) checkSchema(child);
17
+ }
18
+ checkSchema(conversationSchema);
19
+ const diagnostic = (code,field,message,severity='error') => ({code,severity,path:null,field,message});
20
+ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
21
+ const protoKeys = new Set(['__proto__','prototype','constructor']);
22
+ function jsonProblem(value, ancestors = new Set()) {
23
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return null;
24
+ if (typeof value === 'number') return Number.isFinite(value) ? null : 'NON_JSON_VALUE';
25
+ if (typeof value !== 'object') return 'NON_JSON_VALUE';
26
+ if (ancestors.has(value)) return 'NON_JSON_VALUE';
27
+ if (!Array.isArray(value) && ![Object.prototype,null].includes(Object.getPrototypeOf(value))) return 'NON_JSON_VALUE';
28
+ if (Array.isArray(value) && (Object.keys(value).length !== value.length || Object.keys(value).some((k,i)=>k!==String(i)))) return 'NON_JSON_VALUE';
29
+ ancestors.add(value);
30
+ for (const key of Reflect.ownKeys(value)) {
31
+ if (typeof key !== 'string') return 'NON_JSON_VALUE';
32
+ if (protoKeys.has(key)) return 'PROTOTYPE_KEY';
33
+ const descriptor = Object.getOwnPropertyDescriptor(value,key);
34
+ if (!('value' in descriptor)) return 'NON_JSON_VALUE';
35
+ if (!descriptor.enumerable && !(Array.isArray(value) && key === 'length')) return 'NON_JSON_VALUE';
36
+ const problem = jsonProblem(descriptor.value,ancestors); if (problem) return problem;
37
+ }
38
+ ancestors.delete(value); return null;
39
+ }
40
+ const decoder = new TextDecoder('utf-8',{fatal:true,ignoreBOM:true});
41
+ // JSON.parse accepts duplicate object keys. Scan valid JSON so none are overwritten.
42
+ function duplicateJsonKeys(raw) {
43
+ let cursor=0, duplicate=false;
44
+ const space=()=>{while(cursor<raw.length&&/\s/.test(raw[cursor]))cursor++;};
45
+ const string=()=>{
46
+ const start=cursor++;
47
+ while(cursor<raw.length) { const ch=raw[cursor++];if(ch==='\\')cursor++;else if(ch==='"')break; }
48
+ return JSON.parse(raw.slice(start,cursor));
49
+ };
50
+ const value=()=>{
51
+ space();const ch=raw[cursor];
52
+ if(ch==='"') { string();return; }
53
+ if(ch==='{'||ch==='[') {
54
+ const map=ch==='{',end=map?'}':']',keys=new Set();cursor++;space();
55
+ while(raw[cursor]!==end) {
56
+ if(map) { const key=string();if(keys.has(key))duplicate=true;keys.add(key);space();cursor++; }
57
+ value();space();if(raw[cursor]===',') { cursor++;space(); }else break;
58
+ }
59
+ cursor++;return;
60
+ }
61
+ while(cursor<raw.length&&!/[\s,\]}]/.test(raw[cursor]))cursor++;
62
+ };
63
+ value();return duplicate;
64
+ }
65
+ /** On refusal, original bytes remain the body and no partial header is returned. */
66
+ export function parseConversationHeader(bytes) {
67
+ if (!Buffer.isBuffer(bytes)) throw new TypeError('conversation input must be a Buffer');
68
+ const refuse = code => ({header:null,body:bytes,rawHeader:null,diagnostics:[diagnostic(code,null,'Conversation header cannot be interpreted safely.')]});
69
+ let text; try { text=decoder.decode(bytes); } catch { return refuse('INVALID_UTF8'); }
70
+ const offset=bytes.subarray(0,3).equals(Buffer.from([0xef,0xbb,0xbf])) ? 3 : 0;
71
+ const open = /^(?:\uFEFF)?---(\r?\n)/.exec(text);
72
+ if (!open) return /^(?:\uFEFF)?---/.test(text) ? refuse('MALFORMED_DELIMITER') : {header:null,body:bytes,rawHeader:null,diagnostics:[]};
73
+ const start=offset+3+Buffer.byteLength(open[1]);
74
+ let cursor=start, closing=null;
75
+ while(cursor<bytes.length) {
76
+ const lf=bytes.indexOf(10,cursor), end=lf<0?bytes.length:lf;
77
+ const line=bytes.subarray(cursor,end).toString('utf8');
78
+ if (line==='---'||line==='---\r') { closing={start:cursor,end:lf<0?end:end+1}; break; }
79
+ cursor=lf<0?bytes.length:lf+1;
80
+ }
81
+ if (!closing) return refuse('MALFORMED_DELIMITER');
82
+ const rawHeader=bytes.subarray(start,closing.start), header={};
83
+ const lines=rawHeader.toString('utf8').split(/\r?\n/); if(lines.at(-1)==='')lines.pop();
84
+ for(const line of lines) {
85
+ const match=/^([a-z][a-z0-9_]*): (.*)$/.exec(line); if(!match)return refuse('INVALID_HEADER_LINE');
86
+ const [,key,raw]=match;
87
+ if(protoKeys.has(key))return refuse('PROTOTYPE_KEY');
88
+ if(Object.hasOwn(header,key))return refuse('DUPLICATE_KEY');
89
+ let value;try { value=JSON.parse(raw); } catch { return refuse('INVALID_JSON_VALUE'); }
90
+ if(duplicateJsonKeys(raw))return refuse('DUPLICATE_KEY');
91
+ const problem=jsonProblem(value);if(problem)return refuse(problem);
92
+ header[key]=value;
93
+ }
94
+ return {header,body:bytes.subarray(closing.end),rawHeader,diagnostics:validateConversation(header)};
95
+ }
96
+ /** Canonical LF header; the body is concatenated without decoding or normalization. */
97
+ export function serializeConversationHeader(header,body) {
98
+ if(!object(header)||!Buffer.isBuffer(body))throw new TypeError('header must be an object and body a Buffer');
99
+ const problem=jsonProblem(header);if(problem)throw new Error(problem);
100
+ const lines=Object.entries(header).map(([key,value])=>{
101
+ if(!/^[a-z][a-z0-9_]*$/.test(key))throw new Error('INVALID_HEADER_KEY');
102
+ return `${key}: ${JSON.stringify(value)}\n`;
103
+ });
104
+ return Buffer.concat([Buffer.from(`---\n${lines.join('')}---\n`),body]);
105
+ }
106
+ const matchesType=(type,value)=>!type || (type==='null'?value===null:type==='object'?object(value):type==='array'?Array.isArray(value):type==='integer'?Number.isInteger(value):type==='number'?typeof value==='number'&&Number.isFinite(value):typeof value===type);
107
+ function schemaErrors(schema,value,field='') {
108
+ const out=[]; const fail=(code='SCHEMA_INVALID')=>out.push(diagnostic(code,field||null,'Value does not satisfy the conversation schema.'));
109
+ const type=schema.type;
110
+ if(!matchesType(type,value)) { fail();return out; }
111
+ if(schema.enum&&!schema.enum.some(v=>Object.is(v,value)))fail();
112
+ if(Object.hasOwn(schema,'const')&&!Object.is(schema.const,value))fail();
113
+ if(typeof value==='string') {
114
+ if(schema.pattern&&!new RegExp(schema.pattern).test(value))fail();
115
+ if(schema.minLength!==undefined&&[...value].length<schema.minLength)fail();
116
+ }
117
+ if(typeof value==='number'&&schema.minimum!==undefined&&value<schema.minimum)fail();
118
+ if(object(value)) {
119
+ for(const key of schema.required??[])if(!Object.hasOwn(value,key))out.push(diagnostic('REQUIRED_FIELD',field?`${field}.${key}`:key,'Required conversation field is missing.'));
120
+ for(const [key,child] of Object.entries(value)) {
121
+ const path=field?`${field}.${key}`:key;
122
+ if(schema.properties?.[key])out.push(...schemaErrors(schema.properties[key],child,path));
123
+ else if(schema.additionalProperties===false)out.push(diagnostic('UNKNOWN_KEY',path,'Unknown conversation field.'));
124
+ }
125
+ }
126
+ if(Array.isArray(value)&&schema.items)value.forEach((child,i)=>out.push(...schemaErrors(schema.items,child,`${field}[${i}]`)));
127
+ for(const union of ['oneOf','anyOf'])if(schema[union]) {
128
+ const branches=schema[union].map(child=>({schema:child,errors:schemaErrors(child,value,field)}));
129
+ const passing=branches.filter(child=>child.errors.length===0).length;
130
+ if(union==='oneOf'?passing!==1:passing===0) {
131
+ const applicable=branches.filter(child=>matchesType(child.schema.type,value));
132
+ const best=(applicable.length?applicable:branches).sort((a,b)=>a.errors.length-b.errors.length)[0];
133
+ if(passing===0&&best.errors.length)out.push(...best.errors);else fail();
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+ const validDay = s => {
139
+ if(typeof s!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(s))return false;
140
+ const [y,m,d]=s.split('-').map(Number);
141
+ const leap=y%4===0&&(y%100!==0||y%400===0);
142
+ return m>=1&&m<=12&&d>=1&&d<=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31][m-1];
143
+ };
144
+ const validInstant=s=>{
145
+ if(typeof s!=='string')return false;
146
+ const m=/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|([+-])(\d{2}):(\d{2}))$/.exec(s);
147
+ return Boolean(m&&validDay(m[1])&&+m[2]<24&&+m[3]<60&&+m[4]<60&&(!m[7]||(+m[7]<24&&+m[8]<60))&&Number.isFinite(Date.parse(s)));
148
+ };
149
+ const repoPath=s=>typeof s==='string'&&s.length>0&&!/^[\\/~]|^[a-zA-Z]:|[\\\x00-\x1f\x7f]/.test(s)&&s.split('/').every(p=>p&&p!=='.'&&p!=='..');
150
+ const validId=s=>typeof s==='string'&&s.length>0&&s===s.normalize('NFC')&&!/[\/\\\p{Cc}]|[ .]$/u.test(s)&&s!=='.'&&s!=='..';
151
+ const decisions=entries=>Array.isArray(entries)&&entries.some(e=>e.kind==='operator-decision');
152
+ /** Pure structural/context checks. Person/group existence and mutation history belong to doors. */
153
+ export function validateConversation(header,context={}) {
154
+ const problem=jsonProblem(header);if(problem)return [diagnostic(problem,null,'Conversation metadata is not safe JSON.')];
155
+ const out=schemaErrors(conversationSchema,header);
156
+ const add=(code,field,message,severity)=>out.push(diagnostic(code,field,message,severity));
157
+ if(!object(header))return out;
158
+ if(header.schema_version!==1)add('UNSUPPORTED_SCHEMA_VERSION','schema_version','Unsupported conversation schema version.');
159
+ if(typeof header.id==='string'&&!validId(header.id))add('INVALID_RECORD_ID','id','Record id is not a portable directory component.');
160
+ if(context.folderId!==undefined&&header.id!==context.folderId)add('ID_FOLDER_MISMATCH','id','Record id must match its parent folder.');
161
+ if(context.newRecord&&typeof header.id==='string'&&!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(header.id))add('INVALID_NEW_ID','id','New record ids must be lowercase kebab case.');
162
+ if(context.newRecord&&header.audience===null)add('AUDIENCE_REQUIRED','audience','New conversations require an intended reader.');
163
+ // Shape failures are already classified; never inspect malformed nested structures.
164
+ if(out.some(d=>d.code==='SCHEMA_INVALID'||d.code==='REQUIRED_FIELD'||d.code==='UNKNOWN_KEY'))return out;
165
+ for(const field of ['title','description','date','start','as_of','audience'])if(header[field]===null)add('UNKNOWN_FACT',field,'Conversation fact is unknown.','warning');
166
+ for(const field of ['date','as_of'])if(header[field]!=null&&!validDay(header[field]))add('INVALID_DATE',field,'Expected a valid calendar day.');
167
+ // Check semantic date/instant/path rules everywhere their typed field occurs.
168
+ const walk=(value,field='')=>{
169
+ // These complete containers are opaque, even when a retained key names a core field.
170
+ // Reserved conversation_revision structure was validated above; its semantics stay below.
171
+ if(field==='extensions'||field==='legacy.fields')return;
172
+ if(Array.isArray(value)) { value.forEach((v,i)=>walk(v,`${field}[${i}]`));return; }
173
+ if(!object(value))return;
174
+ for(const [k,v] of Object.entries(value)) {
175
+ const p=field?`${field}.${k}`:k;
176
+ if(v!=null&&['instant','checked_at','verified_at'].includes(k))if(!validInstant(v))add('INVALID_INSTANT',p,'Expected an RFC 3339 instant with seconds and timezone offset.');
177
+ walk(v,p);
178
+ }
179
+ };walk(header);
180
+ if(object(header.start)) {
181
+ const s=header.start;
182
+ if(s.local!==null&&s.timezone===null&&s.instant===null)add('UNKNOWN_FACT','start.timezone','Local start has no zone or instant; cross-zone arithmetic is unavailable.','warning');
183
+ if(s.local===null&&s.instant===null)add('START_REQUIRED','start','Start requires a local time or an instant.');
184
+ if(s.local!=null&&!/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/.test(s.local))add('INVALID_LOCAL_TIME','start.local','Expected a real local clock time.');
185
+ let formatter=null;
186
+ if(s.timezone!=null)try {
187
+ // Recent ICU also accepts numeric offsets, which are not IANA identifiers.
188
+ if(!/^[A-Za-z][A-Za-z0-9._+-]*(?:\/[A-Za-z0-9._+-]+)*$/.test(s.timezone))throw new Error('not an IANA identifier');
189
+ formatter=new Intl.DateTimeFormat('en-CA',{timeZone:s.timezone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});
190
+ }catch{add('INVALID_TIMEZONE','start.timezone','Expected a validated IANA timezone.');}
191
+ if(s.local!=null&&s.precision==='second'&&s.local.length!==8)add('START_PRECISION_MISMATCH','start.precision','Second precision requires local seconds.');
192
+ if(formatter&&validDay(header.date)&&validInstant(s.instant)&&typeof s.local==='string') {
193
+ const parts=Object.fromEntries(formatter.formatToParts(new Date(s.instant)).map(p=>[p.type,p.value]));
194
+ const date=`${parts.year}-${parts.month}-${parts.day}`,local=`${parts.hour}:${parts.minute}:${parts.second}`;
195
+ if(date!==header.date||local.slice(0,s.local.length)!==s.local)add('START_CONTRADICTION','start','Known date, local time, zone and instant disagree.');
196
+ }
197
+ }
198
+ if(object(header.keep)) {
199
+ const k=header.keep;
200
+ if(k.scope==='unknown')add('UNKNOWN_FACT','keep.scope','Sharing boundary is unknown; effective boundary is private.','warning');
201
+ if(k.group!=null&&!repoPath(k.group))add('INVALID_GROUP_PATH','keep.group','Group reference must be workspace relative.');
202
+ if(k.scope==='family'&&k.group===null)add('FAMILY_GROUP_REQUIRED','keep.group','Family sharing requires a permitted group.');
203
+ if(k.scope==='family'&&k.decision?.kind!=='operator-decision')add('FAMILY_DECISION_REQUIRED','keep.decision','Family sharing requires an explicit group decision.');
204
+ if(k.scope==='share'&&k.decision?.kind!=='operator-decision')add('SHARE_DECISION_REQUIRED','keep.decision','Sharing requires an explicit operator choice.');
205
+ }
206
+ const unique=(entries,key,field)=>{
207
+ const seen=new Set();for(const [i,item] of (Array.isArray(entries)?entries:[]).entries()) {
208
+ const value=typeof item==='string'?item:item?.[key];
209
+ if(seen.has(value))add('DUPLICATE_ID',`${field}[${i}]`,'Identifiers must be unique.');seen.add(value);
210
+ }
211
+ };
212
+ unique(header.participants,'id','participants');unique(header.sources,'id','sources');
213
+ for(const [i,p] of (Array.isArray(header.participants)?header.participants:[]).entries())if(object(p)) {
214
+ const f=`participants[${i}]`;
215
+ if(p.role==='unknown')add('UNKNOWN_FACT',`${f}.role`,'Participant role is unknown.','warning');
216
+ if(p.status==='unknown'&&p.person!==null)add('UNKNOWN_PERSON_REQUIRED',`${f}.person`,'Unknown identity must remain null.');
217
+ if(p.person===null)add('UNKNOWN_FACT',`${f}.person`,'Participant identity is unknown.','warning');
218
+ if(p.status==='confirmed'&&(p.person===null||!Array.isArray(p.evidence)||p.evidence.length===0))add('CONFIRMED_IDENTITY_EVIDENCE_REQUIRED',f,'Confirmed identity requires identifying evidence.');
219
+ }
220
+ for(const [i,c] of (Array.isArray(header.sources)?header.sources:[]).entries())if(object(c)) {
221
+ const f=`sources[${i}]`;
222
+ if(c.type===null)add('UNKNOWN_FACT',`${f}.type`,'Capture type is unknown.','warning');
223
+ if(c.availability==='unknown')add('UNKNOWN_FACT',`${f}.availability`,'Capture availability is unknown.','warning');
224
+ if(c.roles.includes('unknown'))add('UNKNOWN_FACT',`${f}.roles`,'Source coverage role is unknown.','warning');
225
+ if(c.type==='cloud_recording') {
226
+ if(c.service==null||c.recording_id==null||c.vendor_id!==null)add('CLOUD_IDENTITY_INVALID',f,'Cloud recordings require exact service and recording id with no vendor id.');
227
+ for(const [j,l] of (Array.isArray(c.locators)?c.locators:[]).entries())if(l?.kind==='service'&&l.service_id!==c.recording_id)add('SOURCE_ID_CONFLICT',`${f}.locators[${j}].service_id`,'Service locator identity must match the cloud recording.');
228
+ } else if(c.service!==null||c.recording_id!==null)add('NONCLOUD_IDENTITY_INVALID',f,'Noncloud captures have null cloud identity fields.');
229
+ if(Array.isArray(c.roles)&&(c.roles.length===0||new Set(c.roles).size!==c.roles.length))add('CAPTURE_ROLES_INVALID',`${f}.roles`,'Capture roles are a nonempty subset.');
230
+ for(const [j,l] of (Array.isArray(c.locators)?c.locators:[]).entries())if(object(l)) {
231
+ const p=`${f}.locators[${j}]`;
232
+ if(l.kind==='service'?(l.path!==null||(!l.url&&!l.service_id)):(l.path===null||l.url!==null||l.service_id!==null))add('LOCATOR_IDENTITY_INVALID',p,'Locator requires only its applicable identifiers.');
233
+ if(l.kind==='bundled'&&!repoPath(l.path))add('INVALID_BUNDLED_PATH',`${p}.path`,'Bundled path must be relative without traversal.');
234
+ if(l.media_type!=null&&!/^[^\s/]+\/[^\s/]+$/.test(l.media_type))add('INVALID_MEDIA_TYPE',`${p}.media_type`,'Expected a MIME type.');
235
+ const b=l.backup;
236
+ if(object(b)&&b.status==='unverified'&&[b.locator,b.sha256,b.verified_at].some(v=>v!==null))add('BACKUP_UNVERIFIED_FIELDS',`${p}.backup`,'Unverified backup cannot claim verification fields.');
237
+ if(object(b)&&b.status==='verified-owned'&&(!b.locator||!b.sha256||!b.verified_at||b.sha256!==l.sha256||[l.path,l.url,l.service_id].includes(b.locator)))add('BACKUP_VERIFICATION_REQUIRED',`${p}.backup`,'Owned backup requires a distinct locator, matching readable checksum and verification instant.');
238
+ }
239
+ }
240
+ for(const [i,a] of (Array.isArray(header.aliases)?header.aliases:[]).entries())if(object(a)) {
241
+ if(a.kind==='source-id'?!a.namespace:a.namespace!==null)add('ALIAS_NAMESPACE_INVALID',`aliases[${i}].namespace`,'Only source aliases require an identity namespace.');
242
+ if(a.kind==='record-id'&&!validId(a.value))add('INVALID_RECORD_ID',`aliases[${i}].value`,'Record alias must be a portable id.');
243
+ }
244
+ const checkCitation=(c,f)=>{
245
+ if(!object(c))return;
246
+ if(!repoPath(c.path)||!validId(c.record_id))add('CITATION_PATH_INVALID',f,'Citation requires a relative path and portable record id.');
247
+ if(c.revision?.kind==='git'&&!c.repository)add('CITATION_REPOSITORY_REQUIRED',f,'Git citations require repository identity.');
248
+ if(c.selector?.kind==='lines'&&c.selector.end<c.selector.start)add('CITATION_RANGE_INVALID',f,'Citation end must not precede its start.');
249
+ if(c.selector?.kind==='turn'&&(!c.selector.ids?.length||new Set(c.selector.ids).size!==c.selector.ids.length))add('CITATION_TURNS_INVALID',f,'Turn citation needs distinct turn ids.');
250
+ for(const s of Array.isArray(c.sources)?c.sources:[]) {
251
+ // Historical citations refer to a different revision; do not resolve against live captures.
252
+ if(s.end_ms!=null&&(s.start_ms==null||s.end_ms<s.start_ms))add('SOURCE_RANGE_INVALID',f,'Capture span end requires an ordered start.');
253
+ }
254
+ };
255
+ for(const [i,c] of (Array.isArray(header.connections)?header.connections:[]).entries())if(object(c)) {
256
+ if(c.target?.kind==='path'&&!repoPath(c.target.id))add('CONNECTION_PATH_INVALID',`connections[${i}].target`,'Path connection must be relative.');
257
+ checkCitation(c.citation,`connections[${i}].citation`);
258
+ }
259
+ if(object(header.review))for(const dimension of ['transcription','attribution'])if(header.review[dimension]==='human-reviewed'&&!decisions(header.review.evidence))add('REVIEW_EVIDENCE_REQUIRED',`review.${dimension}`,'Human review requires applicable operator evidence.');
260
+ if(object(header.legacy)) {
261
+ const l=header.legacy;
262
+ if((l.raw_header_base64===null)!==(l.raw_header_sha256===null))add('LEGACY_HEADER_PROOF_INVALID','legacy','Raw legacy header and hash must both be present or both null.');
263
+ if(typeof l.raw_header_base64==='string') {
264
+ const bytes=Buffer.from(l.raw_header_base64,'base64');
265
+ if(bytes.toString('base64')!==l.raw_header_base64||createHash('sha256').update(bytes).digest('hex')!==l.raw_header_sha256)add('LEGACY_HEADER_PROOF_INVALID','legacy','Legacy header hash must match exact retained bytes.');
266
+ }
267
+ }
268
+ const state=header.extensions?.conversation_revision;
269
+ if(object(state)) {
270
+ unique(state.retired_turn_ids,null,'extensions.conversation_revision.retired_turn_ids');
271
+ unique(state.protected_turns,'turn_id','extensions.conversation_revision.protected_turns');
272
+ for(const [i,p] of (Array.isArray(state.protected_turns)?state.protected_turns:[]).entries())if(object(p)) {
273
+ const f=`extensions.conversation_revision.protected_turns[${i}]`;
274
+ if(!p.reviewed_versions?.length)add('REVIEWED_ORIGIN_REQUIRED',f,'Protected turns require retained reviewed versions.');
275
+ for(const v of Array.isArray(p.reviewed_versions)?p.reviewed_versions:[]) {
276
+ if(!decisions(v.evidence))add('REVIEW_EVIDENCE_REQUIRED',f,'Reviewed versions require actual operator decisions.');
277
+ if(!v.dimensions?.length||new Set(v.dimensions).size!==v.dimensions.length)add('REVIEW_DIMENSIONS_REQUIRED',f,'Reviewed versions require distinct review dimensions.');
278
+ if(!repoPath(v.entry_path))add('REVIEW_ENTRY_PATH_INVALID',f,'Reviewed entry path must be relative.');
279
+ }
280
+ for(const a of Array.isArray(p.authorized_changes)?p.authorized_changes:[])if(a.decision?.kind!=='operator-decision')add('REVISION_AUTHORIZATION_REQUIRED',f,'Authorized transitions require an operator decision.');
281
+ }
282
+ }
283
+ return out;
284
+ }