@volter/supercode-teams 0.3.156 → 0.3.158

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.
package/README.md CHANGED
@@ -58,6 +58,22 @@ mail history and individual records can still be large. The
58
58
  [allocation evidence](../../docs/evidence/mail-transcript-stream-2026-10-08.md) names
59
59
  the source boundary and the remaining runtime qualification.
60
60
 
61
+ The connector's redaction memo keeps serialized redacted JSON, with at most
62
+ 200,000 entries and 32 MiB of accounted string payload (two bytes per code unit,
63
+ including keys and digests). An entry over 1 MiB is not retained. Oldest entries
64
+ are evicted to fit; cache misses use a held digest observed under the same checked
65
+ redaction basis, or go through the redaction worker. These are
66
+ memo bounds, not total connector memory limits. The
67
+ [source investigation](../../docs/evidence/connector-redaction-memo-2026-10-08.md)
68
+ does not attribute the observed process peak or claim a measured runtime improvement.
69
+
70
+ Memo entries are discarded when the observed custody/rules basis changes. A
71
+ capture and its worker use one matching custody snapshot; a basis mismatch
72
+ rejects the capture before queueing and leaves it owed for retry. Older basis
73
+ labels cannot authorize held-digest reuse. Custody still refreshes on its existing
74
+ 30-second interval; queued and published captures are not retroactively redacted.
75
+ [Source boundary and qualification](../../docs/evidence/connector-redaction-basis-2026-10-08.md).
76
+
61
77
  `supercode teams rpc mail.statistics` reads local storage counts and import/retention receipts. It is refused remotely
62
78
  and through the app door. Writer groups count stored envelope copies, not call attempts or quotas; database page
63
79
  counts exclude physical WAL size. Successful imports record their actual source reads, bytes, batch sizes and time.
@@ -46,6 +46,7 @@ const covers=(root,cwd)=>{
46
46
  };
47
47
  // An error of the connection itself (the outbox, authentication, the server), not of one session.
48
48
  const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
49
+ const retryCapture=error=>connectionError(error)||error?.code==='redaction_basis_changed';
49
50
  const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
50
51
  const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
51
52
  /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
@@ -103,14 +104,39 @@ export function foldConnectorState(stateDir){
103
104
  }
104
105
  // What leaves for the server is redacted (@volter/teams/redact), and every digest this connector keeps or sends (what it
105
106
  // observed, a baseline, the carried prefix) is of that form: a digest of the local text named a prefix the server never
106
- // held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted once
107
- // per process, in the connector's redaction worker: by its digest, nothing kept for one without a secret (most), its
108
- // redacted form for one with. Each loaded message's digest (of the form sent) is taken once and read where needed.
109
- const REDACTED_MEMO=200000;
107
+ // held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted
108
+ // in the connector's redaction worker, with a bounded digest memo: no body for one without a secret (most), serialized
109
+ // redacted JSON for one with. A miss uses the same-basis held-digest proof or redacts again. Each loaded message's digest (of the form
110
+ // sent) is taken once and read where needed. The byte budget counts two bytes per retained string code unit, including
111
+ // keys and digests; the entry bound separately limits Map/record overhead. Neither is a total-process heap bound.
112
+ const REDACTED_MEMO=200000,REDACTED_MEMO_BYTES=32*1024*1024,REDACTED_MEMO_ENTRY_BYTES=1024*1024;
110
113
  const redactions=new Map();
114
+ let redactionBytes=0,redactionMemoBasis=null;
115
+ function useRedactionMemo(basis){
116
+ if(redactionMemoBasis!==basis){redactions.clear();redactionBytes=0;redactionMemoBasis=basis;}
117
+ }
118
+ function requireRedactionBasis(basis){
119
+ if(redactionBasis()!==basis)throw Object.assign(new Error('redaction basis changed; capture needs fresh redaction'),{code:'redaction_basis_changed'});
120
+ }
121
+ // Older queued/committed captures could label a result with the basis read after its worker returned. They do not
122
+ // establish the checked observation needed for held-digest reuse, even when that old label equals today's basis.
123
+ const checkedRedactionBasis=basis=>`checked-v1:${basis}`;
111
124
  const sentDigests=new WeakMap();
112
125
  const digestOf=message=>{let digest=sentDigests.get(message);if(digest===undefined){digest=hash(canonicalJson(message.value));sentDigests.set(message,digest);}return digest;};
113
- const remember=(digest,redacted)=>{if(redactions.size>=REDACTED_MEMO)redactions.clear();redactions.set(digest,redacted);};
126
+ function remember(digest,redacted){
127
+ // Remove the earlier unchanged placeholder even if this redacted body is too large to cache: leaving it would
128
+ // make a later hit reuse the original, unredacted message.
129
+ const previous=redactions.get(digest);
130
+ if(previous){redactionBytes-=previous.bytes;redactions.delete(digest);}
131
+ const kept=redacted?{text:JSON.stringify(redacted.value),digest:redacted.digest}:null;
132
+ const bytes=2*(digest.length+(kept?.digest.length??0)+(kept?.text.length??0));
133
+ if(bytes>REDACTED_MEMO_ENTRY_BYTES)return;
134
+ while(redactions.size>=REDACTED_MEMO||redactionBytes+bytes>REDACTED_MEMO_BYTES){
135
+ const oldest=redactions.keys().next().value;
136
+ redactionBytes-=redactions.get(oldest).bytes;redactions.delete(oldest);
137
+ }
138
+ redactions.set(digest,{kept,bytes});redactionBytes+=bytes;
139
+ }
114
140
  // A capture part is one request, and the server refuses a body over 8 MiB (volter-teams request-json.mjs): a part
115
141
  // holds at most PART_MESSAGES messages (the contract's bound) and PART_BYTES of them, so a run of large tool results
116
142
  // splits across parts instead of failing the whole session. A single message larger than that goes alone.
@@ -644,9 +670,8 @@ export class TeamsConnector {
644
670
  if(this.unreadable?.has(key))captured=false;
645
671
  else if(changed&&!this.predatesWindow(options))try{await this.capture({...options,live:Boolean(this.watched?.has(key))});}catch(error){
646
672
  if(this.abort.signal.aborted)return;
647
- // An error of the connection itself (the outbox, authentication, the server): the outbox's own retry takes it, and
648
- // this session's capture stays owed until a flush passes (it may have failed before it was queued)
649
- if(connectionError(error)){this.oweCapture(options,error);return;}
673
+ // A connection failure or a changed redaction basis keeps this session owed; the existing retry wake re-reads it.
674
+ if(retryCapture(error)){this.oweCapture(options,error);return;}
650
675
  captured=false;this.noteSessionProblem(options,error);
651
676
  if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
652
677
  }
@@ -876,12 +901,13 @@ export class TeamsConnector {
876
901
  // Each loaded message in the form sent: the memo's; the loaded one itself when the server holds it unchanged under
877
902
  // this redaction basis (its digest is among those last observed); else what the redaction worker makes of it, all of
878
903
  // a capture's new messages in one request. A digest is taken once per message, of the form sent.
879
- async sentMessages(raw,previous){
880
- const basis=redactionBasis(),held=previous?.redaction_basis===basis&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
904
+ async sentMessages(raw,previous,basis=redactionBasis()){
905
+ requireRedactionBasis(basis);useRedactionMemo(basis);
906
+ const held=previous?.redaction_basis===checkedRedactionBasis(basis)&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
881
907
  const out=new Array(raw.length),missing=[];
882
908
  raw.forEach((message,index)=>{
883
909
  const text=canonicalJson(message.value),digest=hash(text);
884
- if(redactions.has(digest)){const kept=redactions.get(digest);out[index]=kept?{...message,value:kept.value}:message;sentDigests.set(out[index],kept?kept.digest:digest);return;}
910
+ if(redactions.has(digest)){const {kept}=redactions.get(digest);out[index]=kept?{...message,value:JSON.parse(kept.text)}:message;sentDigests.set(out[index],kept?kept.digest:digest);return;}
885
911
  if(held?.has(digest)){remember(digest,null);out[index]=message;sentDigests.set(message,digest);return;}
886
912
  missing.push([index,digest,text]);
887
913
  });
@@ -889,8 +915,10 @@ export class TeamsConnector {
889
915
  this.redactor??=new Redactor();
890
916
  if(!this.redactionCounted){this.redactionCounted=true;this.syncStatus().redaction={since:new Date(this.clock.nowMs()).toISOString(),batches:0,messages:0,bytes:0,worker_ms:0,main_ms:0,max_main_ms:0};}
891
917
  // The worker is sent each new message's canonical JSON and answers only those redaction changed.
892
- let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts);let mainMs=performance.now()-started;
918
+ let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts,{basis});let mainMs=performance.now()-started;
893
919
  const changed=await pending;started=performance.now();
920
+ // Another connector/capture can run while this worker answers. Never store its result under a later basis.
921
+ requireRedactionBasis(basis);useRedactionMemo(basis);
894
922
  for(const [index,digest] of missing){remember(digest,null);out[index]=raw[index];sentDigests.set(raw[index],digest);}
895
923
  for(const {index:at,value,digest:sent} of changed){
896
924
  const [index,digest]=missing[at];
@@ -904,13 +932,14 @@ export class TeamsConnector {
904
932
  cost.batches+=1;cost.messages+=missing.length;cost.bytes+=texts.reduce((n,text)=>n+text.length,0);
905
933
  cost.worker_ms=Math.round(this.redactor.workerMs);cost.main_ms=Math.round((cost.main_ms+mainMs)*10)/10;cost.max_main_ms=Math.max(cost.max_main_ms,Math.round(mainMs*10)/10);
906
934
  }
907
- return out;
935
+ requireRedactionBasis(basis);return out;
908
936
  }
909
- async spillTranslated(translated,defaultName){
937
+ async spillTranslated(translated,defaultName,basis){
910
938
  const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
911
939
  if(files.filter(file=>file.role==='primary').length!==1)throw Object.assign(new Error('native codec bundle must contain exactly one primary file'),{code:'fidelity_unavailable'});
912
940
  let total=0;const spilled=[];
913
- this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content));
941
+ this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content),{basis});
942
+ if(basis!==undefined)requireRedactionBasis(basis);
914
943
  for(const [index,file] of files.entries()){const bytes=Buffer.from(texts[index],'utf8');total+=bytes.length;if(total>256*1024*1024)throw Object.assign(new Error('native artifact bundle exceeds 256 MiB'),{code:'request_too_large'});spilled.push({path:file.path,role:file.role,...this.spill(bytes)});}
915
944
  return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
916
945
  }
@@ -940,7 +969,7 @@ export class TeamsConnector {
940
969
  const wait=this.watched?.has(key)||options.paced?0:CAPTURE_FLOOR_MS-(now-(this.lastCaptures.get(key)??0));
941
970
  // one retry wake per session, holding each rule's latest obligation: a newer generation of a rule replaces that
942
971
  // rule's options, never another rule's; the wake runs every rule held, paced as one
943
- if(wait>0){this.holdObligation(this.retryOptions??=new Map(),key,options);if(!this.captureRetry.has(key))this.captureRetry.set(key,setTimeout(()=>{this.captureRetry.delete(key);for(const latest of this.takeObligations(this.retryOptions,key))this.capture({...latest,paced:true}).catch(error=>{if(connectionError(error))this.oweCapture(latest,error);else this.lastError=error;});},wait).unref?.());return null;}
972
+ if(wait>0){this.holdObligation(this.retryOptions??=new Map(),key,options);if(!this.captureRetry.has(key))this.captureRetry.set(key,setTimeout(()=>{this.captureRetry.delete(key);for(const latest of this.takeObligations(this.retryOptions,key))this.capture({...latest,paced:true}).catch(error=>{if(retryCapture(error))this.oweCapture(latest,error);else this.lastError=error;});},wait).unref?.());return null;}
944
973
  this.lastCaptures.set(key,now);
945
974
  const {locator}=options,path=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
946
975
  const artifactBefore=path?statSync(path):null,anchor=this.codexAnchor(key,artifactBefore);
@@ -1064,7 +1093,7 @@ export class TeamsConnector {
1064
1093
  if(this.abort.signal.aborted)return;
1065
1094
  // A load that failed or timed out waits like a slow one, so it does not hold its lane on every pass.
1066
1095
  if(started!==null)this.largeCaptures.set(key,{at:started,ms:this.clock.nowMs()-started});
1067
- if(connectionError(error)){this.oweCapture(options,error);continue;}
1096
+ if(retryCapture(error)){this.oweCapture(options,error);continue;}
1068
1097
  this.noteSessionProblem(options,error);
1069
1098
  if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${options.harness} session ${options.nativeSessionId} not synced: ${error.message}\n`);
1070
1099
  }
@@ -1111,13 +1140,13 @@ export class TeamsConnector {
1111
1140
  }
1112
1141
  // The turns typed through a door here are labelled before redaction, which then reads them as sent.
1113
1142
  const raw=(loaded.messages??loaded.turns??[]).map((entry,index)=>{const value=entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key)));return{native_key:entry.id??entry.native_key??String(index),recorded_at:recordedAt(entry),value};});this.labelTurns(key,loaded.messages??loaded.turns??[],raw);
1114
- let messages=await this.sentMessages(raw,previous);const loadedMessages=messages,basis=redactionBasis();
1143
+ const basis=redactionBasis();let messages=await this.sentMessages(raw,previous,basis);const loadedMessages=messages;
1115
1144
  if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
1116
1145
  // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
1117
1146
  const nativeWanted=configuration.native_capture!==false&&backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
1118
1147
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
1119
1148
  const unchanged=previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&digestOf(message)===previous.observed_digests?.[index]);
1120
- if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
1149
+ if(unchanged&&!retryNative){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
1121
1150
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
1122
1151
  // A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
1123
1152
  // whatever a session holds when first seen: a session begun after the rule is published from its first message.
@@ -1183,9 +1212,9 @@ export class TeamsConnector {
1183
1212
  // translated for this capture alone: its spilled files live while the capture is queued (outbox-only)
1184
1213
  try{
1185
1214
  const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
1186
- if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource))};
1215
+ if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource),basis)};
1187
1216
  }catch(error){
1188
- if(this.abort.signal.aborted)throw error;
1217
+ if(this.abort.signal.aborted||error?.code==='redaction_basis_changed')throw error;
1189
1218
  this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
1190
1219
  // The same file is not translated again; a write to it clears this.
1191
1220
  if(this.sessionLive(key,ruleId,epoch)){this.state.native_failed[key]=onDisk;this.save();}
@@ -1195,17 +1224,18 @@ export class TeamsConnector {
1195
1224
  if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
1196
1225
  if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
1197
1226
  // A retry that still has no bundle would republish the same messages for nothing.
1198
- if(unchanged&&!nativeBundle){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
1227
+ if(unchanged&&!nativeBundle){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
1199
1228
  }else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
1200
1229
  const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
1201
1230
  const digest=entries=>hash(canonicalJson((entries.messages??entries.turns??[]).map(entry=>entry.value??entry)));if(digest(await this.core.session(locator).load())!==digest(loaded))throw Object.assign(new Error('native session changed while it was being captured'),{code:'resync_required'});
1202
- ({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
1231
+ ({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`,basis),harness));
1203
1232
  }
1204
1233
  const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',title:descriptor?.title??null,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
1205
1234
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
1206
1235
  // A session gone or no longer covered while it loaded queues nothing (its spilled bundle, held by nothing, goes at the next start).
1207
1236
  if(!this.sessionLive(key,ruleId,epoch))return null;
1208
- this.queue({begin,parts,commit,artifacts,windowStart:anchor,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:loadedMessages.map(message=>message.native_key),digests:loadedMessages.map(digestOf)},redactionBasis:basis});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
1237
+ requireRedactionBasis(basis);
1238
+ this.queue({begin,parts,commit,artifacts,windowStart:anchor,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:loadedMessages.map(message=>message.native_key),digests:loadedMessages.map(digestOf)},redactionBasis:checkedRedactionBasis(basis)});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
1209
1239
  }
1210
1240
  /**
1211
1241
  * One session's oldest observation published. A session Teams refused (lineage not declared yet, or any error) is
@@ -1302,13 +1332,13 @@ export class TeamsConnector {
1302
1332
  */
1303
1333
  scheduleFlush(error){
1304
1334
  if(this.flushTimer||this.abort.signal.aborted||(!this.outbox.length&&!this.owed?.size))return;
1305
- if(this.flushBackoff==null)process.stderr.write(`supercode teams: ${this.outbox.length} queued capture(s) and ${this.owed?.size??0} session capture(s) wait for the server (${error?.message??'not sent'}); tried again with backoff\n`);
1335
+ if(this.flushBackoff==null)process.stderr.write(`supercode teams: ${this.outbox.length} queued capture(s) and ${this.owed?.size??0} session capture(s) wait to retry (${error?.message??'not sent'}); tried again with backoff\n`);
1306
1336
  this.flushBackoff=Math.min(300000,(this.flushBackoff??2500)*2);
1307
1337
  this.flushTimer=setTimeout(()=>{this.flushTimer=null;void this.flush().catch(failure=>{this.lastError=failure;this.scheduleFlush(failure);});},this.flushBackoff);this.flushTimer.unref?.();
1308
1338
  }
1309
1339
  /**
1310
- * A capture that failed on the connection, wherever it ran (a pass, a floor's wake, a large lane): its session's
1311
- * capture is owed until a flush passes (it may have failed before it was queued), and the outbox's wake is armed
1340
+ * A capture that failed on the connection or a changed redaction basis, wherever it ran (a pass, a floor's wake,
1341
+ * a large lane): its session's capture is owed until a flush passes (it may have failed before it was queued), and the wake is armed
1312
1342
  * for it even when nothing is queued.
1313
1343
  */
1314
1344
  oweCapture(options,error){
@@ -1325,7 +1355,7 @@ export class TeamsConnector {
1325
1355
  async flushSerial(){
1326
1356
  let receipt;
1327
1357
  try{receipt=await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}
1328
- // the connection answers again: every session whose capture failed on it is captured now
1358
+ // Every owed session is re-read, including one whose redaction basis changed before queueing.
1329
1359
  if(this.owed?.size){const owed=[...this.owed];this.owed.clear();for(const key of owed)this.resee(key);}
1330
1360
  return receipt;
1331
1361
  }
@@ -4,26 +4,34 @@
4
4
  import { Worker, isMainThread, parentPort } from 'node:worker_threads';
5
5
 
6
6
  if (!isMainThread && parentPort) {
7
- const { redactText, redactValue } = await import('@volter/teams/redact');
7
+ const { redactText, redactValue, redactionBasis, redactionSnapshot } = await import('@volter/teams/redact');
8
8
  const { canonicalJson } = await import('../contracts/index.mjs');
9
9
  const { createHash } = await import('node:crypto');
10
10
  // 'canonical': each item is a message value's canonical JSON (the connector has it already, for its digest), a string
11
11
  // copied to this thread for little more than its bytes, where a value's object graph costs the connector's thread a
12
12
  // walk each way. Only what redaction changed comes back, with its new digest.
13
- const changedOf = (items) => {
13
+ const changedOf = (items, custody) => {
14
14
  const changed = [];
15
15
  items.forEach((text, index) => {
16
- const value = redactValue(JSON.parse(text)), sent = canonicalJson(value);
16
+ const value = redactValue(JSON.parse(text), undefined, custody), sent = canonicalJson(value);
17
17
  if (sent !== text) changed.push({ index, value, digest: createHash('sha256').update(sent).digest('hex') });
18
18
  });
19
19
  return changed;
20
20
  };
21
21
  const { performance } = await import('node:perf_hooks');
22
- parentPort.on('message', ({ id, kind, items }) => {
22
+ parentPort.on('message', ({ id, kind, items, basis }) => {
23
23
  // Its own busy time rides back with each answer: what redaction costs, apart from the connector's other work.
24
24
  const started = performance.now();
25
- try { const answer = kind === 'canonical' ? changedOf(items) : items.map((item) => (kind === 'texts' ? redactText(item) : redactValue(item))); parentPort.postMessage({ id, items: answer, busy: performance.now() - started }); }
26
- catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error) }); }
25
+ const requireBasis=()=>{if(basis!==undefined&&redactionBasis()!==basis)throw Object.assign(new Error('redaction worker basis differs from the capture; capture needs fresh redaction'),{code:'redaction_basis_changed'});};
26
+ try {
27
+ // All strings in this checked batch use the same custody observation, even if a batch crosses its refresh interval.
28
+ const snapshot=basis===undefined?null:redactionSnapshot();
29
+ if(snapshot&&snapshot.basis!==basis)throw Object.assign(new Error('redaction worker basis differs from the capture; capture needs fresh redaction'),{code:'redaction_basis_changed'});
30
+ requireBasis();
31
+ const answer = kind === 'canonical' ? changedOf(items,snapshot?.values) : items.map((item) => (kind === 'texts' ? redactText(item,undefined,snapshot?.values) : redactValue(item,undefined,snapshot?.values)));
32
+ requireBasis();
33
+ parentPort.postMessage({ id, items: answer, busy: performance.now() - started });
34
+ } catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error), code: error?.code }); }
27
35
  });
28
36
  }
29
37
 
@@ -33,11 +41,11 @@ export class Redactor {
33
41
  start() {
34
42
  // The connector's own node flags are not the worker's (one meant for the entry script refuses to start it).
35
43
  const worker = new Worker(new URL(import.meta.url), { execArgv: [] });
36
- worker.on('message', ({ id, items, error, busy }) => {
44
+ worker.on('message', ({ id, items, error, code, busy }) => {
37
45
  const waiter = this.waiting.get(id); if (!waiter) return;
38
46
  this.waiting.delete(id);
39
47
  this.workerMs += busy ?? 0;
40
- if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: 'redaction_failed' }));
48
+ if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: code === 'redaction_basis_changed' ? code : 'redaction_failed' }));
41
49
  else waiter.resolve(items);
42
50
  if (!this.waiting.size) worker.unref();
43
51
  });
@@ -54,13 +62,14 @@ export class Redactor {
54
62
  }
55
63
 
56
64
  /** `items` redacted, in order: `kind` 'values' (JSON values, every string in them) or 'texts'; for 'canonical' (canonical
57
- * JSON texts) only the changed ones, as `{ index, value, digest }`. */
58
- run(kind, items) {
65
+ * JSON texts) only the changed ones, as `{ index, value, digest }`. An optional basis must match the worker before
66
+ * and after redaction; a mismatch rejects the request rather than returning stale decisions. */
67
+ run(kind, items, { basis } = {}) {
59
68
  if (!items.length) return Promise.resolve([]);
60
69
  if (!this.worker) this.start();
61
70
  const id = this.next++;
62
71
  this.worker.ref();
63
- return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items }); });
72
+ return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items, basis }); });
64
73
  }
65
74
 
66
75
  close() { const worker = this.worker; this.worker = null; return worker ? worker.terminate().then(() => {}) : Promise.resolve(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.156",
3
+ "version": "0.3.158",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -75,7 +75,7 @@
75
75
  "@volter/supercode-health": "^0.1.0",
76
76
  "@volter/supercode-terminal": "^0.2.54",
77
77
  "@volter/supercode-trace": "^0.1.0",
78
- "@volter/teams": "0.2.42",
78
+ "@volter/teams": "0.2.43",
79
79
  "@xterm/addon-serialize": "^0.14.0",
80
80
  "@xterm/headless": "^6.0.0",
81
81
  "protobufjs": "8.8.0",