@volter/supercode-teams 0.3.157 → 0.3.159

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
@@ -61,11 +61,32 @@ the source boundary and the remaining runtime qualification.
61
61
  The connector's redaction memo keeps serialized redacted JSON, with at most
62
62
  200,000 entries and 32 MiB of accounted string payload (two bytes per code unit,
63
63
  including keys and digests). An entry over 1 MiB is not retained. Oldest entries
64
- are evicted to fit; cache misses still go through the redaction worker. These are
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
65
66
  memo bounds, not total connector memory limits. The
66
67
  [source investigation](../../docs/evidence/connector-redaction-memo-2026-10-08.md)
67
68
  does not attribute the observed process peak or claim a measured runtime improvement.
68
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.
74
+ [Source boundary and qualification](../../docs/evidence/connector-redaction-basis-2026-10-08.md).
75
+
76
+ Session captures reread the custody registry and declared files before each
77
+ transfer, including rate-limit retries. A stale queued capture is retained
78
+ locally and replaced by a fresh full capture; its carried prefix, parts and
79
+ native references are not reused. Original artifact files stay held until a
80
+ replacement commit acknowledges the native bundle too. Other queued sessions
81
+ can proceed. `supercode teams status` and `supercode auth status` show the holds
82
+ and explain that registration does not purge already transferred or published
83
+ data. A Teams administrator uses `TeamsClient.purgeSessionText` / `POST
84
+ /api/v1/teams/:team/sessions/purge` for retrospective session-text cleanup.
85
+ Direct writes after the last check and requests already in flight have **no
86
+ bounded exposure time**. This is capture-transfer source behavior, with runtime
87
+ and single-machine qualification still open.
88
+ [Transfer scope and retained obligations](../../docs/evidence/connector-custody-egress-2026-10-08.md).
89
+
69
90
  `supercode teams rpc mail.statistics` reads local storage counts and import/retention receipts. It is refused remotely
70
91
  and through the app door. Writer groups count stored envelope copies, not call attempts or quotas; database page
71
92
  counts exclude physical WAL size. Successful imports record their actual source reads, bytes, batch sizes and time.
@@ -8,6 +8,15 @@ const reason = item => `${item.code ? `${item.code}: ` : ''}${item.message}`;
8
8
  export function syncStatusLines(sync, { indent = ' ' } = {}) {
9
9
  if (!sync) return [];
10
10
  const lines = [];
11
+ if(sync.custody_notice)lines.push(`Custody ${sync.custody_notice}`);
12
+ const holds=sync.redaction_held??[];
13
+ if(holds.length){
14
+ lines.push(`Custody holds (${holds.length} capture${holds.length===1?'':'s'} retained locally):`);
15
+ const labels={owed:'fresh full capture owed',native_owed:'replacement native bundle owed',blocked:'payload refused; waiting for source, rule or custody change',cleanup:'replacement committed; local cleanup pending'};
16
+ for(const item of holds.slice(0,SHOWN))lines.push(`${indent}${item.harness} ${item.session_id} · ${item.capture_id} · ${when(item.at)} · ${labels[item.state]??item.state}${item.replacement?` · replacement ${item.replacement}`:''}`);
17
+ if(holds.length>SHOWN)lines.push(`${indent}… and ${holds.length-SHOWN} more (--json lists them all)`);
18
+ }
19
+ if(sync.redaction_held_unread?.length)lines.push(`Custody holds unread (${sync.redaction_held_unread.length}); original files and all artifact spills retained, inspect --json`);
11
20
  const problems = sync.not_syncing ?? [];
12
21
  if (problems.length) {
13
22
  lines.push(`Not syncing (${problems.length} session${problems.length === 1 ? '' : 's'}, most recent first):`);
package/bin/workspace.mjs CHANGED
@@ -402,9 +402,9 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
402
402
  if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
403
403
  say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
404
404
  say(account.machine ? `Machine ${account.machine.name} · ${account.machine.connection ?? 'unknown'} · sharing ${account.machine.folder ?? '?'}${account.machine.service ? ` (${account.machine.service})` : ' (runs while `teams connect` does)'}` : 'Machine not connected (supercode teams connect)');
405
- // Only trouble (a session not syncing, an error in the last day) is worth lines here; `teams status` shows the rest.
405
+ // Custody's published-history boundary is part of account status even without a failed capture.
406
406
  const sync = account.machine?.sync;
407
- if (sync?.not_syncing.length || sync?.not_bound?.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
407
+ if (sync?.custody_notice || sync?.not_syncing.length || sync?.not_bound?.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
408
408
  return;
409
409
  }
410
410
  if (sub === 'switch') {
@@ -65,8 +65,9 @@ export interface ConnectorConfigurationValue {
65
65
 
66
66
  export interface SyncErrorSummary { code: string | null; message: string; at: number }
67
67
  export interface SyncProblem extends SyncErrorSummary { harness: string; session_id: string; rule_id: string | null }
68
+ export interface RedactionHoldStatus { capture_id: string; harness: string; session_id: string; rule_id: string | null; at: number | null; state: 'owed' | 'native_owed' | 'blocked' | 'cleanup'; replacement?: string }
68
69
  /** What `supercode teams status` shows: sessions not syncing (most recent first) and the last error; null without state. */
69
- export declare function readSyncStatus(stateDir: string): { not_syncing: SyncProblem[]; not_bound: { harness: string; session_id: string; reason: string }[]; not_bound_count: number; last_error: SyncErrorSummary | null; queued_captures: number } | null;
70
+ export declare function readSyncStatus(stateDir: string): { custody_notice: string; redaction_held: RedactionHoldStatus[]; redaction_held_unread: { name: string; error: string }[]; not_syncing: SyncProblem[]; not_bound: { harness: string; session_id: string; reason: string }[]; not_bound_count: number; last_error: SyncErrorSummary | null; queued_captures: number } | null;
70
71
 
71
72
  export declare class TeamsConnector {
72
73
  constructor(options: TeamsConnectorOptions);
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSy
2
2
  import { realpathSync } from './path.mjs';
3
3
  import { redactionBasis } from '@volter/teams/redact';
4
4
  import { Redactor } from './redactor.mjs';
5
+ import { RedactionHolds } from './redaction-holds.mjs';
5
6
  import { basename, dirname, join, sep } from 'node:path';
6
7
 
7
8
  /** A path in its real form: its nearest existing ancestor resolved (a file inside a folder this process may not read is
@@ -46,7 +47,9 @@ const covers=(root,cwd)=>{
46
47
  };
47
48
  // An error of the connection itself (the outbox, authentication, the server), not of one session.
48
49
  const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
50
+ const retryCapture=error=>connectionError(error)||['redaction_basis_changed','custody_unreadable'].includes(error?.code);
49
51
  const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
52
+ const CUSTODY_NOTICE='Custody registration does not purge data already transferred or published. A Teams administrator can purge session text through POST /api/v1/teams/:team/sessions/purge (TeamsClient.purgeSessionText). A direct file edit after the last transfer check, or a request already in flight, has no bounded exposure time.';
50
53
  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
54
  /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
52
55
  export function readSyncStatus(stateDir){
@@ -54,7 +57,7 @@ export function readSyncStatus(stateDir){
54
57
  const status=state?.sync_status;
55
58
  // Sessions this machine could not bind to the agent their launch named (bindAgentSessions): waiting, or refused for good.
56
59
  const agentSessions=Object.entries(state?.agent_sessions??{}).filter(([,item])=>item?.state==='pending'||item?.state==='refused').map(([session,item])=>({session,agent:item.agent,state:item.state,reason:item.error?.message??null}));
57
- return {agent_sessions_unbound:agentSessions,index_unread:Object.values(status?.index_unread??{}).flat(),not_syncing:Array.isArray(status?.problems)?status.problems:[],not_bound:Array.isArray(status?.unbound)?status.unbound:[],not_bound_count:Number.isSafeInteger(status?.unbound_count)?status.unbound_count:(Array.isArray(status?.unbound)?status.unbound.length:0),last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:(()=>{try{return readdirSync(join(stateDir,'outbox')).filter(name=>name.endsWith('.json')).length;}catch{return 0;}})()};
60
+ return {custody_notice:CUSTODY_NOTICE,redaction_held:status?.redaction_held??[],redaction_held_unread:status?.redaction_held_unread??[],agent_sessions_unbound:agentSessions,index_unread:Object.values(status?.index_unread??{}).flat(),not_syncing:Array.isArray(status?.problems)?status.problems:[],not_bound:Array.isArray(status?.unbound)?status.unbound:[],not_bound_count:Number.isSafeInteger(status?.unbound_count)?status.unbound_count:(Array.isArray(status?.unbound)?status.unbound.length:0),last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:(()=>{try{return readdirSync(join(stateDir,'outbox')).filter(name=>name.endsWith('.json')).length;}catch{return 0;}})()};
58
61
  }
59
62
  // A tool call still running when the core reads a session has no result yet; the core answers it with a synthesized
60
63
  // placeholder (crates/interchange session/helpers.rs ensure_tool_results_paired: a `tool` message for that call id,
@@ -92,11 +95,14 @@ const itemSpills=item=>(item.artifacts??[]).filter(artifact=>artifact.spill).map
92
95
  * folded state writes them out again (TeamsConnector#loadEntries).
93
96
  */
94
97
  export function foldConnectorState(stateDir){
98
+ if(existsSync(join(stateDir,'redaction-held'))&&readdirSync(join(stateDir,'redaction-held')).some(name=>/^\d+\.json$/.test(name)))throw new Error('retained custody captures prevent folding state for an older connector; resolve the visible redaction holds first');
95
99
  const path=join(stateDir,'connector.json'),state=JSON.parse(readFileSync(path,'utf8'));
96
100
  const origins=join(stateDir,'origins'),outbox=join(stateDir,'outbox'),files=dir=>existsSync(dir)?readdirSync(dir).filter(name=>name.endsWith('.json')).sort():[];
101
+ if(state.outbox?.some(item=>item.redaction_hold))throw new Error('a custody-flagged capture prevents folding state for an older connector');
97
102
  if(state.origins||state.outbox)return {origins:Object.keys(state.origins??{}).length,captures:(state.outbox??[]).length,folded:false};
98
103
  state.origins={};for(const name of files(origins)){const {key,origin}=JSON.parse(readFileSync(join(origins,name),'utf8'));state.origins[key]=origin;}
99
104
  state.outbox=files(outbox).map(name=>JSON.parse(readFileSync(join(outbox,name),'utf8')));delete state.outbox_sequence;
105
+ if(state.outbox.some(item=>item.redaction_hold))throw new Error('a custody-flagged capture prevents folding state for an older connector');
100
106
  atomic(path,JSON.stringify(state,null,2)+'\n');
101
107
  for(const dir of [origins,outbox])rmSync(dir,{recursive:true,force:true});
102
108
  return {origins:Object.keys(state.origins).length,captures:state.outbox.length,folded:true};
@@ -105,12 +111,22 @@ export function foldConnectorState(stateDir){
105
111
  // observed, a baseline, the carried prefix) is of that form: a digest of the local text named a prefix the server never
106
112
  // held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted
107
113
  // in the connector's redaction worker, with a bounded digest memo: no body for one without a secret (most), serialized
108
- // redacted JSON for one with. Evicted or oversized values are redacted again. Each loaded message's digest (of the form
114
+ // 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
109
115
  // sent) is taken once and read where needed. The byte budget counts two bytes per retained string code unit, including
110
116
  // keys and digests; the entry bound separately limits Map/record overhead. Neither is a total-process heap bound.
111
117
  const REDACTED_MEMO=200000,REDACTED_MEMO_BYTES=32*1024*1024,REDACTED_MEMO_ENTRY_BYTES=1024*1024;
112
118
  const redactions=new Map();
113
- let redactionBytes=0;
119
+ let redactionBytes=0,redactionMemoBasis=null;
120
+ const freshRedactionBasis=()=>redactionBasis(undefined,{fresh:true});
121
+ function useRedactionMemo(basis){
122
+ if(redactionMemoBasis!==basis){redactions.clear();redactionBytes=0;redactionMemoBasis=basis;}
123
+ }
124
+ function requireRedactionBasis(basis){
125
+ if(freshRedactionBasis()!==basis)throw Object.assign(new Error('redaction basis changed; capture needs fresh redaction'),{code:'redaction_basis_changed'});
126
+ }
127
+ // Older queued/committed captures could label a result with the basis read after its worker returned. They do not
128
+ // establish the checked observation needed for held-digest reuse, even when that old label equals today's basis.
129
+ const checkedRedactionBasis=basis=>`checked-v1:${basis}`;
114
130
  const sentDigests=new WeakMap();
115
131
  const digestOf=message=>{let digest=sentDigests.get(message);if(digest===undefined){digest=hash(canonicalJson(message.value));sentDigests.set(message,digest);}return digest;};
116
132
  function remember(digest,redacted){
@@ -236,7 +252,7 @@ export class TeamsConnector {
236
252
  for(const dir of [origins,outbox])rmSync(dir,{recursive:true,force:true});
237
253
  for(const dir of [origins,outbox])mkdirSync(dir,{recursive:true,mode:0o700});
238
254
  for(const [key,origin] of Object.entries(this.state.origins??{}))atomic(join(origins,`${hash(key).slice(0,32)}.json`),JSON.stringify({key,origin}));
239
- this.state.outbox_sequence=0;
255
+ this.state.outbox_sequence=existsSync(join(this.stateDir,'redaction-held'))?readdirSync(join(this.stateDir,'redaction-held')).filter(name=>/^\d+\.json$/.test(name)).reduce((sequence,name)=>Math.max(sequence,Number(name.slice(0,-5))),0):0;
240
256
  for(const item of this.state.outbox??[])atomic(join(outbox,`${String(++this.state.outbox_sequence).padStart(12,'0')}.json`),JSON.stringify(item));
241
257
  delete this.state.origins;delete this.state.outbox;atomic(this.statePath,JSON.stringify(this.state,null,2)+'\n');
242
258
  }
@@ -253,6 +269,9 @@ export class TeamsConnector {
253
269
  for(const name of readdirSync(outbox).filter(name=>name.endsWith('.json')).sort()){try{this.outbox.push(Object.assign(JSON.parse(readFileSync(join(outbox,name),'utf8')),{[QUEUED_FILE]:name}));}catch(error){this.outboxUnread.push({name,error:String(error?.message??error)});}}
254
270
  // the sequence is past every queued capture's name on disk, read or unread, so no new one takes an unread one's
255
271
  for(const name of readdirSync(outbox)){const n=Number(/^(\d+)\.json$/.exec(name)?.[1]);if(Number.isSafeInteger(n)&&n>(this.state.outbox_sequence??0))this.state.outbox_sequence=n;}
272
+ this.redactionHolds=new RedactionHolds({stateDir:this.stateDir,atomic,saved:this.state.redaction_holds});
273
+ for(const name of [...this.redactionHolds.items.keys(),...this.redactionHolds.unread.map(item=>item.name)]){const n=Number(name.slice(0,-5));if(Number.isSafeInteger(n)&&n>(this.state.outbox_sequence??0))this.state.outbox_sequence=n;}
274
+ this.updateRedactionHolds();
256
275
  }
257
276
  /**
258
277
  * Where a Codex rollout past the display window is read from: the origin's window_start while it holds (the same
@@ -281,6 +300,33 @@ export class TeamsConnector {
281
300
  // every beat does not rewrite connector.json every beat. Its time stays that of its first occurrence in the run.
282
301
  set lastError(error){this.lastErrorValue=error;const status=this.syncStatus(),summary=errorSummary(error,this.clock.nowMs());if(sameError(status.last_error,summary))return;status.last_error=summary;this.saveQuietly();}
283
302
  syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
303
+ updateRedactionHolds(){
304
+ this.state.redaction_holds=this.redactionHolds.saved();
305
+ const status=this.syncStatus();status.redaction_held=this.redactionHolds.status();status.redaction_held_unread=this.redactionHolds.unread;
306
+ }
307
+ observeCustodyBasis(){
308
+ const basis=freshRedactionBasis();
309
+ if(this.state.custody_basis!==basis){this.state.custody_basis=basis;process.stderr.write(`supercode teams: ${CUSTODY_NOTICE}\n`);this.saveQuietly();}
310
+ return basis;
311
+ }
312
+ requireCaptureTransfer(item){
313
+ const basis=this.observeCustodyBasis();
314
+ if(item.redaction_hold||item.redactionBasis!==checkedRedactionBasis(basis))throw Object.assign(new Error('queued capture needs fresh custody redaction; retained locally for fresh capture'),{code:item.redaction_hold?.code??'redaction_basis_changed'});
315
+ return basis;
316
+ }
317
+ async guardCaptureTransfer(item,json){
318
+ const basis=this.requireCaptureTransfer(item);
319
+ this.redactor??=new Redactor();
320
+ await this.redactor.run('custody_assert',[json],{basis,fresh:true});
321
+ // request invokes this synchronously after the worker answer, with no intervening await before fetch.
322
+ return ()=>this.requireCaptureTransfer(item);
323
+ }
324
+ holdQueuedCapture(item,error){
325
+ this.redactionHolds.hold(item,item[QUEUED_FILE],this.clock.nowMs(),error.code);
326
+ this.outbox.shift(); // Its spill references move to the hold; unqueue would release them.
327
+ this.updateRedactionHolds();this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id},error);
328
+ this.oweCapture({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id},error);this.save();
329
+ }
284
330
  saveQuietly(){try{this.save();}catch{}}
285
331
  noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
286
332
  const status=this.syncStatus(),at=this.clock.nowMs(),summary=errorSummary(error,at);
@@ -295,7 +341,7 @@ export class TeamsConnector {
295
341
  clearSessionProblem(harness,nativeSessionId){
296
342
  const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
297
343
  status.problems=status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId));this.saveQuietly();
298
- } async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)])});
344
+ } async request(method,path,body,{beforeTransfer}={}){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){if(beforeTransfer){const finalCheck=await beforeTransfer(json);finalCheck();}response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)])});
299
345
  // A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
300
346
  // large first sync slows down instead of failing its pass and restarting the connector.
301
347
  if(response.status!==429||attempt>=5)break;// The server names the wait in error.details (wire.mjs validationFailure); a Retry-After header is the fallback.
@@ -654,15 +700,16 @@ export class TeamsConnector {
654
700
  const {locator,descriptor,harness,nativeSessionId,ruleId,epoch}=options,key=`${harness}:${nativeSessionId}`;
655
701
  if(!this.sessionLive(key,ruleId,epoch))return;
656
702
  // A session whose store has not changed since this rule last committed it is in sync, whatever it last failed.
657
- let captured=true;const changed=this.sessionChanged(options);if(!changed)this.clearSessionProblem(harness,nativeSessionId);
703
+ let captured=true,changed;
704
+ try{changed=this.sessionChanged(options);}catch(error){if(retryCapture(error))this.oweCapture(options,error);else this.noteSessionProblem(options,error);return;}
705
+ if(!changed)this.clearSessionProblem(harness,nativeSessionId);
658
706
  // A store this process may not read (macOS denies a launchd job a removable volume, whatever the owner granted the
659
707
  // terminal) is said once with its path and skipped.
660
708
  if(this.unreadable?.has(key))captured=false;
661
709
  else if(changed&&!this.predatesWindow(options))try{await this.capture({...options,live:Boolean(this.watched?.has(key))});}catch(error){
662
710
  if(this.abort.signal.aborted)return;
663
- // An error of the connection itself (the outbox, authentication, the server): the outbox's own retry takes it, and
664
- // this session's capture stays owed until a flush passes (it may have failed before it was queued)
665
- if(connectionError(error)){this.oweCapture(options,error);return;}
711
+ // A connection failure or a changed redaction basis keeps this session owed; the existing retry wake re-reads it.
712
+ if(retryCapture(error)){this.oweCapture(options,error);return;}
666
713
  captured=false;this.noteSessionProblem(options,error);
667
714
  if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
668
715
  }
@@ -782,6 +829,9 @@ export class TeamsConnector {
782
829
  }
783
830
  sessionChanged(options){
784
831
  const stamp=this.sessionStamp(options);options.stamp=stamp;
832
+ const custodyKey=`${options.harness}:${options.nativeSessionId}`;
833
+ if(this.redactionHolds.items.size&&this.redactionHolds.blocked(custodyKey,stamp,options.ruleRevision,checkedRedactionBasis(this.observeCustodyBasis())))return false;
834
+ if(this.redactionHolds.needed(custodyKey))return true;
785
835
  if(stamp===null||this.outbox.length)return true;
786
836
  const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
787
837
  const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
@@ -834,6 +884,7 @@ export class TeamsConnector {
834
884
  if(this.spillRefs)return this.spillRefs;
835
885
  const refs=this.spillRefs=new Map(),add=name=>refs.set(name,(refs.get(name)??0)+1);
836
886
  for(const item of this.outbox)for(const name of itemSpills(item))add(name);
887
+ for(const name of this.redactionHolds.spills())add(name);
837
888
  return refs;
838
889
  }
839
890
  retainSpills(names){const refs=this.spillHolders();for(const name of names){refs.set(name,(refs.get(name)??0)+1);this.freshSpills?.delete(name);}}
@@ -847,7 +898,7 @@ export class TeamsConnector {
847
898
  * whoever else held it. A file kept so is held by nothing counted and goes at the first start whose inventory is whole.
848
899
  */
849
900
  removeSpill(name){
850
- if(this.outboxUnread?.length){this.spillProblem(`${name} was kept: ${this.outboxUnread.length} queued capture(s) could not be read and may name it`);return false;}
901
+ if(this.outboxUnread?.length||this.redactionHolds.unread.length){this.spillProblem(`${name} was kept: a queued or custody-held capture could not be read and may name it`);return false;}
851
902
  try{unlinkSync(join(this.spillDir(),name));return true;}
852
903
  catch(error){if(error?.code==='ENOENT')return true;this.spillProblem(`${name} could not be removed: ${error?.message??error}`);return false;}
853
904
  }
@@ -865,9 +916,9 @@ export class TeamsConnector {
865
916
  removeUnheldSpills(){
866
917
  const status=this.syncStatus();delete status.spill_cleanup;
867
918
  // only against a complete inventory of the outbox: a queued capture this process could not read may name any file
868
- if(this.outboxUnread.length){
919
+ if(this.outboxUnread.length||this.redactionHolds.unread.length){
869
920
  status.outbox_unread=this.outboxUnread.map(({name,error})=>`${name}: ${error}`);
870
- this.spillProblem(`${this.outboxUnread.length} queued capture(s) could not be read (${this.outboxUnread[0].name}: ${this.outboxUnread[0].error}); no spill file is removed until every queued capture is read`);
921
+ this.spillProblem('a queued or custody-held capture could not be read; no spill file is removed until their inventory is complete');
871
922
  return;
872
923
  }
873
924
  delete status.outbox_unread;
@@ -892,8 +943,9 @@ export class TeamsConnector {
892
943
  // Each loaded message in the form sent: the memo's; the loaded one itself when the server holds it unchanged under
893
944
  // this redaction basis (its digest is among those last observed); else what the redaction worker makes of it, all of
894
945
  // a capture's new messages in one request. A digest is taken once per message, of the form sent.
895
- async sentMessages(raw,previous){
896
- const basis=redactionBasis(),held=previous?.redaction_basis===basis&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
946
+ async sentMessages(raw,previous,basis=freshRedactionBasis()){
947
+ requireRedactionBasis(basis);useRedactionMemo(basis);
948
+ const held=previous?.redaction_basis===checkedRedactionBasis(basis)&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
897
949
  const out=new Array(raw.length),missing=[];
898
950
  raw.forEach((message,index)=>{
899
951
  const text=canonicalJson(message.value),digest=hash(text);
@@ -905,8 +957,10 @@ export class TeamsConnector {
905
957
  this.redactor??=new Redactor();
906
958
  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};}
907
959
  // The worker is sent each new message's canonical JSON and answers only those redaction changed.
908
- let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts);let mainMs=performance.now()-started;
960
+ let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts,{basis,fresh:true});let mainMs=performance.now()-started;
909
961
  const changed=await pending;started=performance.now();
962
+ // Another connector/capture can run while this worker answers. Never store its result under a later basis.
963
+ requireRedactionBasis(basis);useRedactionMemo(basis);
910
964
  for(const [index,digest] of missing){remember(digest,null);out[index]=raw[index];sentDigests.set(raw[index],digest);}
911
965
  for(const {index:at,value,digest:sent} of changed){
912
966
  const [index,digest]=missing[at];
@@ -920,15 +974,17 @@ export class TeamsConnector {
920
974
  cost.batches+=1;cost.messages+=missing.length;cost.bytes+=texts.reduce((n,text)=>n+text.length,0);
921
975
  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);
922
976
  }
923
- return out;
977
+ requireRedactionBasis(basis);return out;
924
978
  }
925
- async spillTranslated(translated,defaultName){
979
+ async spillTranslated(translated,defaultName,basis){
926
980
  const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
927
981
  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'});
928
982
  let total=0;const spilled=[];
929
- this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content));
930
- 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)});}
931
- return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
983
+ this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content),{basis,fresh:true});
984
+ const [metadata]=await this.redactor.run('values',[{paths:files.map(file=>file.path),residue:translated.residue??[]}],{basis,fresh:true});
985
+ if(basis!==undefined)requireRedactionBasis(basis);
986
+ 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:metadata.paths[index],role:file.role,...this.spill(bytes)});}
987
+ return {fidelity:translated.fidelity,residue:metadata.residue,files:spilled};
932
988
  }
933
989
  // The outbox artifacts and native_bundle of one capture from a spilled bundle; every capture gets fresh upload ids.
934
990
  bundleArtifacts(bundle,harness){
@@ -956,7 +1012,7 @@ export class TeamsConnector {
956
1012
  const wait=this.watched?.has(key)||options.paced?0:CAPTURE_FLOOR_MS-(now-(this.lastCaptures.get(key)??0));
957
1013
  // one retry wake per session, holding each rule's latest obligation: a newer generation of a rule replaces that
958
1014
  // rule's options, never another rule's; the wake runs every rule held, paced as one
959
- 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;}
1015
+ 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;}
960
1016
  this.lastCaptures.set(key,now);
961
1017
  const {locator}=options,path=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
962
1018
  const artifactBefore=path?statSync(path):null,anchor=this.codexAnchor(key,artifactBefore);
@@ -1080,7 +1136,7 @@ export class TeamsConnector {
1080
1136
  if(this.abort.signal.aborted)return;
1081
1137
  // A load that failed or timed out waits like a slow one, so it does not hold its lane on every pass.
1082
1138
  if(started!==null)this.largeCaptures.set(key,{at:started,ms:this.clock.nowMs()-started});
1083
- if(connectionError(error)){this.oweCapture(options,error);continue;}
1139
+ if(retryCapture(error)){this.oweCapture(options,error);continue;}
1084
1140
  this.noteSessionProblem(options,error);
1085
1141
  if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${options.harness} session ${options.nativeSessionId} not synced: ${error.message}\n`);
1086
1142
  }
@@ -1127,13 +1183,14 @@ export class TeamsConnector {
1127
1183
  }
1128
1184
  // The turns typed through a door here are labelled before redaction, which then reads them as sent.
1129
1185
  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);
1130
- let messages=await this.sentMessages(raw,previous);const loadedMessages=messages,basis=redactionBasis();
1186
+ const basis=this.observeCustodyBasis();let messages=await this.sentMessages(raw,previous,basis);const loadedMessages=messages;
1187
+ const recapturing=this.redactionHolds.needed(key);
1131
1188
  if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
1132
1189
  // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
1133
1190
  const nativeWanted=configuration.native_capture!==false&&backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
1134
1191
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
1135
1192
  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]);
1136
- if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
1193
+ if(unchanged&&!retryNative&&!recapturing){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
1137
1194
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
1138
1195
  // A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
1139
1196
  // whatever a session holds when first seen: a session begun after the rule is published from its first message.
@@ -1172,7 +1229,7 @@ export class TeamsConnector {
1172
1229
  const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
1173
1230
  // Only what the server does not hold goes over the wire: the longest prefix this source last committed unchanged
1174
1231
  // (the outbox is empty here, so that is the session's current version) is named by its digest, not sent again.
1175
- const carried=this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
1232
+ const carried=recapturing?0:this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
1176
1233
  if(carried)begin.carry={count:carried,digest:carryDigest(messages.slice(0,carried))};
1177
1234
  const sent=carried?messages.slice(carried):messages;
1178
1235
  const parts=captureParts(captureId,sent);
@@ -1199,9 +1256,9 @@ export class TeamsConnector {
1199
1256
  // translated for this capture alone: its spilled files live while the capture is queued (outbox-only)
1200
1257
  try{
1201
1258
  const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
1202
- if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource))};
1259
+ if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource),basis)};
1203
1260
  }catch(error){
1204
- if(this.abort.signal.aborted)throw error;
1261
+ if(this.abort.signal.aborted||['redaction_basis_changed','custody_unreadable'].includes(error?.code))throw error;
1205
1262
  this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
1206
1263
  // The same file is not translated again; a write to it clears this.
1207
1264
  if(this.sessionLive(key,ruleId,epoch)){this.state.native_failed[key]=onDisk;this.save();}
@@ -1211,17 +1268,21 @@ export class TeamsConnector {
1211
1268
  if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
1212
1269
  if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
1213
1270
  // A retry that still has no bundle would republish the same messages for nothing.
1214
- if(unchanged&&!nativeBundle){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
1271
+ if(unchanged&&!nativeBundle&&!recapturing){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
1215
1272
  }else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
1216
1273
  const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
1217
1274
  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'});
1218
- ({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
1275
+ ({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`,basis),harness));
1219
1276
  }
1220
- 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}:{})};
1277
+ // Descriptive metadata shares the capture's custody snapshot too. Structural identifiers cannot be rewritten;
1278
+ // the request guard refuses them if they themselves contain a custody value.
1279
+ this.redactor??=new Redactor();const [metadata]=await this.redactor.run('values',[{title:descriptor?.title??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage}],{basis,fresh:true});
1280
+ const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',...metadata,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
1221
1281
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
1222
1282
  // A session gone or no longer covered while it loaded queues nothing (its spilled bundle, held by nothing, goes at the next start).
1223
1283
  if(!this.sessionLive(key,ruleId,epoch))return null;
1224
- 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;
1284
+ requireRedactionBasis(basis);
1285
+ 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;
1225
1286
  }
1226
1287
  /**
1227
1288
  * One session's oldest observation published. A session Teams refused (lineage not declared yet, or any error) is
@@ -1318,13 +1379,13 @@ export class TeamsConnector {
1318
1379
  */
1319
1380
  scheduleFlush(error){
1320
1381
  if(this.flushTimer||this.abort.signal.aborted||(!this.outbox.length&&!this.owed?.size))return;
1321
- 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`);
1382
+ 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`);
1322
1383
  this.flushBackoff=Math.min(300000,(this.flushBackoff??2500)*2);
1323
1384
  this.flushTimer=setTimeout(()=>{this.flushTimer=null;void this.flush().catch(failure=>{this.lastError=failure;this.scheduleFlush(failure);});},this.flushBackoff);this.flushTimer.unref?.();
1324
1385
  }
1325
1386
  /**
1326
- * A capture that failed on the connection, wherever it ran (a pass, a floor's wake, a large lane): its session's
1327
- * capture is owed until a flush passes (it may have failed before it was queued), and the outbox's wake is armed
1387
+ * A capture that failed on the connection or a changed redaction basis, wherever it ran (a pass, a floor's wake,
1388
+ * 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
1328
1389
  * for it even when nothing is queued.
1329
1390
  */
1330
1391
  oweCapture(options,error){
@@ -1341,7 +1402,7 @@ export class TeamsConnector {
1341
1402
  async flushSerial(){
1342
1403
  let receipt;
1343
1404
  try{receipt=await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}
1344
- // the connection answers again: every session whose capture failed on it is captured now
1405
+ // Every owed session is re-read, including one whose redaction basis changed before queueing.
1345
1406
  if(this.owed?.size){const owed=[...this.owed];this.owed.clear();for(const key of owed)this.resee(key);}
1346
1407
  return receipt;
1347
1408
  }
@@ -1405,19 +1466,19 @@ export class TeamsConnector {
1405
1466
  item.commit={...item.commit,capture_id:captureId,parts:item.parts.length};
1406
1467
  const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.rewriteQueued(item);this.save();
1407
1468
  }
1408
- async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);
1469
+ async sendCapture(item){const request=(method,path,body)=>this.request(method,path,body,{beforeTransfer:json=>this.guardCaptureTransfer(item,json)});const begun=await request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);
1409
1470
  // A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
1410
1471
  for(const artifact of item.artifacts??[])for(const chunk of artifact.spill?this.spilledChunks(artifact):artifact.chunks){
1411
1472
  if(!chunk)throw Object.assign(new Error('a spilled native artifact changed on disk while it was sent'),{code:'resync_required'});
1412
1473
  const path=`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`;
1413
1474
  let reused=false;
1414
1475
  if(this.chunkReuse!==false)try{
1415
- await this.request('PUT',path,{upload_id:chunk.upload_id,index:chunk.index,sha256:chunk.sha256,reuse:true});reused=true;this.chunkReuse=true;
1476
+ await request('PUT',path,{upload_id:chunk.upload_id,index:chunk.index,sha256:chunk.sha256,reuse:true});reused=true;this.chunkReuse=true;
1416
1477
  }catch(error){
1417
1478
  if(error.code==='invalid_request'&&error.status===400)this.chunkReuse=false; // older server
1418
1479
  else if(error.code!=='chunk_missing')throw error;
1419
1480
  }
1420
- if(!reused)await this.request('PUT',path,chunk);
1481
+ if(!reused)await request('PUT',path,chunk);
1421
1482
  this.state.native_transfer??={sent_bytes:0,reused_bytes:0,chunks:0};
1422
1483
  this.state.native_transfer[reused?'reused_bytes':'sent_bytes']+=Buffer.byteLength(chunk.bytes_base64,'base64');
1423
1484
  this.state.native_transfer.chunks++;
@@ -1428,7 +1489,7 @@ export class TeamsConnector {
1428
1489
  transfer.chunks++;transfer.at=this.clock.nowMs();
1429
1490
  for(const expired of Object.keys(transfers).sort((a,b)=>transfers[b].at-transfers[a].at).slice(128))delete transfers[expired];
1430
1491
  }
1431
- const receipt=await this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);
1492
+ const receipt=await request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);
1432
1493
  const transfer=this.state.native_transfers?.[`${item.begin.harness}:${item.begin.native_session_id}`];
1433
1494
  if(transfer&&item.artifacts?.length){transfer.captures++;transfer.last_bundle_bytes=item.commit.artifacts.reduce((sum,a)=>sum+a.byte_length,0);}
1434
1495
  return receipt;}
@@ -1443,15 +1504,33 @@ export class TeamsConnector {
1443
1504
  if(!Array.isArray(configuration?.rules))return false;
1444
1505
  return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
1445
1506
  }
1446
- async flushOutbox(){let latest=null;while(this.outbox.length){const item=this.outbox[0];let receipt;this.dropLostArtifacts(item);this.refitParts(item);try{receipt=await this.sendCapture(item);}catch(error){if(error.code==='storage_pressure'&&this.deferNativeUnderPressure(item))continue;if(error.status===409&&error.code==='carry_mismatch'){const key=`${item.begin.harness}:${item.begin.native_session_id}`;this.lastError=error;process.stderr.write(`supercode teams: the server does not hold what a capture of ${item.begin.harness} session ${item.begin.native_session_id} carried; it is sent whole again\n`);this.unqueue();if(this.origins[key])this.origins[key]={...this.origins[key],observed_keys:[],observed_digests:[],carry_refused:true,capture_stamps:{}};this.save();this.resee(key);continue;}if(error.status===409&&error.code==='batch_gap'&&error.details&&Number.isSafeInteger(error.details.sequence)){
1507
+ async flushOutbox(){let latest=null;while(this.outbox.length){const item=this.outbox[0];let receipt;try{this.requireCaptureTransfer(item);this.dropLostArtifacts(item);this.refitParts(item);receipt=await this.sendCapture(item);}catch(error){
1508
+ // A failed request may have awaited long enough to observe a different policy. Retain its whole original
1509
+ // before an existing server-error branch can drop a part, native reference or queued item.
1510
+ if(!['redaction_basis_changed','redaction_capture_unsafe'].includes(error.code))try{this.requireCaptureTransfer(item);}catch(changed){error=changed;}
1511
+ if(['redaction_basis_changed','redaction_capture_unsafe'].includes(error.code)){this.holdQueuedCapture(item,error);continue;}
1512
+ if(error.code==='storage_pressure'&&this.deferNativeUnderPressure(item))continue;
1513
+ if(error.status===409&&error.code==='carry_mismatch'){const key=`${item.begin.harness}:${item.begin.native_session_id}`;this.lastError=error;process.stderr.write(`supercode teams: the server does not hold what a capture of ${item.begin.harness} session ${item.begin.native_session_id} carried; it is sent whole again\n`);this.unqueue();if(this.origins[key])this.origins[key]={...this.origins[key],observed_keys:[],observed_digests:[],carry_refused:true,capture_stamps:{}};this.save();this.resee(key);continue;}if(error.status===409&&error.code==='batch_gap'&&error.details&&Number.isSafeInteger(error.details.sequence)){
1447
1514
  // The server holds a different last capture of this session than this connector recorded (a capture it
1448
1515
  // committed while the connector stopped before recording it, for one): this connector takes the server's
1449
1516
  // revision, drops the stale capture and sends the session whole again at once (resee).
1450
1517
  const key=`${item.begin.harness}:${item.begin.native_session_id}`,expected=error.details;this.lastError=error;
1451
1518
  process.stderr.write(`supercode teams: ${item.begin.harness} session ${item.begin.native_session_id} resumes at the server's revision ${expected.generation}/${expected.sequence}; it is sent whole again\n`);
1452
1519
  this.unqueue();this.origins[key]={...this.origins[key],generation:expected.generation,sequence:expected.sequence-1,digest:expected.digest??null,observed_keys:[],observed_digests:[],carry_refused:true,capture_stamps:{}};this.save();this.resee(key);continue;}
1453
- if(error.status===400&&error.code==='invalid_request'||error.status===413){this.lastError=error;this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id??null},error);(this.droppedCaptures??=new Set()).add(`${item.begin.harness}:${item.begin.native_session_id}`);if(error.status===413)(this.state.set_aside??={})[`${item.begin.harness}:${item.begin.native_session_id}`]={at:this.clock.nowMs(),rule_revision:item.begin.rule_revision??null,reason:error.message};process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused${error.status===413?' (one message is larger than a request may be)':' as malformed'}: ${error.message}\n`);this.unqueue();this.save();continue;}if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.unqueue();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,redaction_basis:item.redactionBasis??null,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(item.windowStart)next.window_start=item.windowStart;else delete next.window_start;if(!item.begin.carry)delete next.carry_refused;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.origins[key]=next;this.unqueue();this.save();// A queued capture is often flushed by another session's capture: its own session's problem ends with it.
1454
- this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);
1520
+ if(error.status===400&&error.code==='invalid_request'||error.status===413){this.lastError=error;this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id??null},error);(this.droppedCaptures??=new Set()).add(`${item.begin.harness}:${item.begin.native_session_id}`);if(error.status===413)(this.state.set_aside??={})[`${item.begin.harness}:${item.begin.native_session_id}`]={at:this.clock.nowMs(),rule_revision:item.begin.rule_revision??null,reason:error.message};process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused${error.status===413?' (one message is larger than a request may be)':' as malformed'}: ${error.message}\n`);this.unqueue();this.save();continue;}
1521
+ const rejected=await this.ruleRejected(item,error);
1522
+ try{this.requireCaptureTransfer(item);}catch(changed){if(['redaction_basis_changed','redaction_capture_unsafe'].includes(changed.code)){this.holdQueuedCapture(item,changed);continue;}throw changed;}
1523
+ if(!rejected)throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.unqueue();this.save();continue;}
1524
+ // A commit already issued may finish after custody changes. Its receipt advances the origin, but its local
1525
+ // original remains held and owed; registration gives this source no authority to purge the published revision.
1526
+ let current=true;
1527
+ try{this.requireCaptureTransfer(item);}catch(error){if(!['redaction_basis_changed','redaction_capture_unsafe'].includes(error.code))throw error;this.holdQueuedCapture(item,error);current=false;}
1528
+ const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,redaction_basis:item.redactionBasis??null,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(item.windowStart)next.window_start=item.windowStart;else delete next.window_start;if(!item.begin.carry)delete next.carry_refused;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.origins[key]=next;
1529
+ if(current){
1530
+ if(receipt?.capture_id===item.begin.capture_id&&receipt.digest===item.commit.digest)this.redactionHolds.settle(key,item,receipt,names=>this.releaseSpills(names));
1531
+ this.updateRedactionHolds();this.unqueue();this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);
1532
+ }
1533
+ this.save();
1455
1534
  // The commit is what records (or settles) a native bundle still owed: its wait is armed here.
1456
1535
  this.armNativeIdle(key);latest=receipt;}this.flushBackoff=null;return latest;}
1457
1536
  stop(){void this.redactor?.close();this.daemon?.off?.('sessionActivityEvent',this.activityEvent);this.daemon?.off?.('sessionIndexEvent',this.indexEvent);this.daemon?.off?.('exit',this.daemonExit);clearTimeout(this.flushTimer);if(this.activitySubscription)void this.daemon?.unsubscribeSessionActivity?.(this.activitySubscription).catch(()=>{});for(const feed of this.feeds?.values()??[])void this.daemon?.unsubscribeSessionIndex?.(feed.subscription).catch(()=>{});for(const timer of this.idleTimers?.values()??[])clearTimeout(timer);try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();return this.nativeEffects.close();}
@@ -0,0 +1,59 @@
1
+ // Stale captures stay on this machine while a fresh source observation replaces them. Only headers stay in memory;
2
+ // the original parts and artifact references remain in owner-only files and are never a send queue.
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export class RedactionHolds {
7
+ constructor({ stateDir, atomic, saved = {} }) {
8
+ this.directory = join(stateDir, 'redaction-held'); this.outbox = join(stateDir, 'outbox');
9
+ this.atomic = atomic; this.items = new Map(); this.unread = [];
10
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 });
11
+ for (const name of readdirSync(this.directory).filter(name => /^\d+\.json$/.test(name)).sort()) {
12
+ try {
13
+ const item = JSON.parse(readFileSync(join(this.directory, name), 'utf8'));
14
+ const header = this.header(item, name), prior = saved[name];
15
+ if (prior?.capture_id === header.capture_id && ['native_owed', 'cleanup', 'blocked'].includes(prior.state)) Object.assign(header, { state: prior.state, replacement: prior.replacement });
16
+ this.items.set(name, header);
17
+ } catch (error) { this.unread.push({ name, error: error?.code ?? 'unreadable_hold' }); }
18
+ }
19
+ }
20
+
21
+ header(item, name) {
22
+ return { name, capture_id: item.begin.capture_id, key: `${item.begin.harness}:${item.begin.native_session_id}`,
23
+ harness: item.begin.harness, session_id: item.begin.native_session_id, rule_id: item.begin.rule_id,
24
+ rule_revision: item.begin.rule_revision, at: item.redaction_hold?.at ?? null, state: item.redaction_hold?.code==='redaction_capture_unsafe'?'blocked':'owed',
25
+ stamp: item.stamp??null, basis: item.redactionBasis, code: item.redaction_hold?.code??'redaction_basis_changed',
26
+ native: Boolean(item.artifacts?.length || item.nativeDeferred),
27
+ spills: (item.artifacts ?? []).filter(artifact => artifact.spill).map(artifact => artifact.spill.file) };
28
+ }
29
+
30
+ hold(item, name, at, code) {
31
+ // Persist the no-send flag before moving: a crash between these steps restarts with a flagged outbox item, which
32
+ // the transfer guard also refuses. The move transfers its spill ownership; it must not release any references.
33
+ item.redaction_hold = { at, code };
34
+ this.atomic(join(this.outbox, name), JSON.stringify(item));
35
+ if(existsSync(join(this.directory,name)))throw new Error('a custody-held capture already owns this filename; original outbox item retained');
36
+ renameSync(join(this.outbox, name), join(this.directory, name));
37
+ this.items.set(name, this.header(item, name));
38
+ }
39
+
40
+ needed(key) { return [...this.items.values()].some(item => item.key === key && ['owed','blocked'].includes(item.state)); }
41
+ blocked(key, stamp, revision, basis) { return [...this.items.values()].some(item => item.key === key && item.state === 'blocked' && item.stamp === stamp && item.rule_revision === revision && item.basis === basis); }
42
+ spills() { return [...this.items.values()].flatMap(item => item.spills); }
43
+ saved() { return Object.fromEntries(this.items); }
44
+ status() { return [...this.items.values()].map(({ capture_id, harness, session_id, rule_id, at, state, replacement }) => ({ capture_id, harness, session_id, rule_id, at, state, replacement })); }
45
+
46
+ settle(key, capture, receipt, releaseSpills) {
47
+ for (const [name, held] of this.items) {
48
+ if (held.key !== key || held.rule_id !== capture.begin.rule_id || held.rule_revision !== capture.begin.rule_revision) continue;
49
+ if(capture.begin.carry&&['owed','blocked'].includes(held.state))continue;
50
+ held.replacement = receipt.capture_id;
51
+ // A receipt for messages alone does not discharge a held native bundle. The normal native-deferred retry
52
+ // remains owed; its original files stay held until a replacement artifact is acknowledged too.
53
+ if (held.native && !(capture.artifacts?.length)) { held.state = 'native_owed'; continue; }
54
+ try { unlinkSync(join(this.directory, name)); }
55
+ catch (error) { if (error?.code !== 'ENOENT') { held.state = 'cleanup'; continue; } }
56
+ this.items.delete(name); releaseSpills(held.spills);
57
+ }
58
+ }
59
+ }
@@ -4,26 +4,40 @@
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
+ const holdsCustody = (value, custody) => {
23
+ if (typeof value === 'string') return custody.some(({ value: secret }) => value.includes(secret));
24
+ if (Array.isArray(value)) return value.some((item) => holdsCustody(item, custody));
25
+ return value && typeof value === 'object' && Object.entries(value).some(([key, item]) => holdsCustody(key, custody) || holdsCustody(item, custody));
26
+ };
27
+ parentPort.on('message', ({ id, kind, items, basis, fresh }) => {
23
28
  // Its own busy time rides back with each answer: what redaction costs, apart from the connector's other work.
24
29
  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) }); }
30
+ const requireBasis=()=>{if(basis!==undefined&&redactionBasis(undefined,{fresh})!==basis)throw Object.assign(new Error('redaction worker basis differs from the capture; capture needs fresh redaction'),{code:'redaction_basis_changed'});};
31
+ try {
32
+ // All strings in this checked batch use the same custody observation, even if a batch crosses its refresh interval.
33
+ const snapshot=basis===undefined?null:redactionSnapshot(undefined,{fresh});
34
+ 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'});
35
+ requireBasis();
36
+ if(kind==='custody_assert'&&(!snapshot||items.some(text=>holdsCustody(JSON.parse(text),snapshot.values))))throw Object.assign(new Error('capture still contains a registered custody value; fresh capture required'),{code:'redaction_capture_unsafe'});
37
+ const answer = kind === 'custody_assert' ? [] : kind === 'canonical' ? changedOf(items,snapshot?.values) : items.map((item) => (kind === 'texts' ? redactText(item,undefined,snapshot?.values) : redactValue(item,undefined,snapshot?.values)));
38
+ requireBasis();
39
+ parentPort.postMessage({ id, items: answer, busy: performance.now() - started });
40
+ } catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error), code: error?.code }); }
27
41
  });
28
42
  }
29
43
 
@@ -33,11 +47,11 @@ export class Redactor {
33
47
  start() {
34
48
  // The connector's own node flags are not the worker's (one meant for the entry script refuses to start it).
35
49
  const worker = new Worker(new URL(import.meta.url), { execArgv: [] });
36
- worker.on('message', ({ id, items, error, busy }) => {
50
+ worker.on('message', ({ id, items, error, code, busy }) => {
37
51
  const waiter = this.waiting.get(id); if (!waiter) return;
38
52
  this.waiting.delete(id);
39
53
  this.workerMs += busy ?? 0;
40
- if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: 'redaction_failed' }));
54
+ if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: ['redaction_basis_changed','redaction_capture_unsafe','custody_unreadable'].includes(code) ? code : 'redaction_failed' }));
41
55
  else waiter.resolve(items);
42
56
  if (!this.waiting.size) worker.unref();
43
57
  });
@@ -54,13 +68,15 @@ export class Redactor {
54
68
  }
55
69
 
56
70
  /** `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) {
71
+ * JSON texts) only the changed ones, as `{ index, value, digest }`. An optional basis must match the worker before
72
+ * and after redaction; a mismatch rejects the request rather than returning stale decisions. `custody_assert`
73
+ * accepts serialized request bodies and refuses a custody value anywhere in their parsed strings or keys. */
74
+ run(kind, items, { basis, fresh = false } = {}) {
59
75
  if (!items.length) return Promise.resolve([]);
60
76
  if (!this.worker) this.start();
61
77
  const id = this.next++;
62
78
  this.worker.ref();
63
- return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items }); });
79
+ return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items, basis, fresh }); });
64
80
  }
65
81
 
66
82
  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.157",
3
+ "version": "0.3.159",
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.44",
79
79
  "@xterm/addon-serialize": "^0.14.0",
80
80
  "@xterm/headless": "^6.0.0",
81
81
  "protobufjs": "8.8.0",