@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.3 — 2026-10-06
4
+
5
+ Install: `npm install --global @redue/cli@alpha`.
6
+
7
+ - Fix selected execution outcomes remaining hidden behind an older observer/cache
8
+ after a missed reload notification. A newer failure must not leave an older
9
+ passing receipt eligible for reuse.
10
+ - Withhold applicability during incomplete receipt persistence, unreadable or
11
+ changing selection, and client connections to an older observer.
12
+ - Preserve immutable run history. Upgrade requires an observer stop/start;
13
+ receipts need not be deleted. See the [maintainer notice](docs/user/receipt-selection-notice.md).
14
+
3
15
  ## 0.1.0-alpha.2 — 2026-10-06
4
16
 
5
17
  Install: `npm install --global @redue/cli@alpha`.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # REDUE
2
2
 
3
+ Maintainer notice: alpha.2 users should review the
4
+ [receipt-selection correction and interim guidance](docs/user/receipt-selection-notice.md).
5
+
3
6
  **Know what still holds. Redo what's due.**
4
7
 
5
8
  You ran the checks. Then the code changed—or a new coding agent started with no
@@ -0,0 +1,60 @@
1
+ # Receipt selection correction in alpha.3
2
+
3
+ **Known affected release: `@redue/cli@0.1.0-alpha.2`. Fixed in
4
+ `@redue/cli@0.1.0-alpha.3`.** No exploit or affected-user count is established.
5
+ REDUE remains an alpha.
6
+
7
+ If a wrapper persisted a newer outcome but its observer reload notification
8
+ failed, the observer could continue selecting an older passing receipt. Cached
9
+ and synchronized status could then present that older result as CURRENT and
10
+ eligible for reuse. A separate reproduced write-failure window left a new
11
+ immutable outcome recorded while replacing the selected receipt failed.
12
+
13
+ Until upgraded, do not rely solely on affected CURRENT output to skip checks.
14
+ Run verification when needed and inspect its actual outcome; a successful status
15
+ command is not proof that the latest execution passed.
16
+
17
+ ## What changes
18
+
19
+ The observer refreshes REDUE-owned receipt selection before publishing status.
20
+ Clients check that a response represents the current readable selection. Missing,
21
+ unreadable, malformed, incompatible, or replaced selection cannot authorize
22
+ reuse. A new client connecting to an alpha.2 observer returns UNVERIFIED and
23
+ asks for a restart, even when the old response says CURRENT.
24
+
25
+ A small owned pending-update record also withholds applicability after an
26
+ incomplete receipt write. Successful recording clears only that check's pending
27
+ entry. It does not discard another check's incomplete outcome or automatically
28
+ promote an orphaned journal entry. Inspect the failed run and storage error, fix
29
+ the storage problem, then record that check again. An unreadable/malformed pending
30
+ record requires operator inspection; do not delete receipts to obtain green status.
31
+
32
+ Historical PASS/FAIL and immutable `runs-v1` files remain intact. Stable applicable
33
+ FAIL remains a failure and is never reuse-eligible. This guard tracks owned
34
+ receipt replacement; it does not replace input-content validation or promise an
35
+ atomic decision against concurrent edits/writes.
36
+
37
+ ## Upgrade and restart
38
+
39
+ From each initialized project, stop its observer before replacing the installation.
40
+ After all observers for that installation have stopped, upgrade it once, then
41
+ restart each observer with its existing configuration and state selection:
42
+
43
+ ```sh
44
+ redue stop
45
+ npm install --global @redue/cli@0.1.0-alpha.3
46
+ redue --version
47
+ redue start
48
+ redue status --sync --json
49
+ ```
50
+
51
+ Use the same npm `--prefix`, project `--config`, and `--state-dir` you already use,
52
+ if customized. On Windows use `npm.cmd`/`redue.cmd` where required by the existing
53
+ shell policy. Stop all observers belonging to the installation before replacing
54
+ it, then start each with the upgraded binary. For an independently installed
55
+ candidate, its `stop` command can stop the older observer in the same explicitly
56
+ selected owned state. Do not run `remove-state`; receipts need not be deleted.
57
+
58
+ Restart preserves historical receipts and reconciles inputs. It does not promise
59
+ CURRENT: a newer failure, unstable execution, incomplete contract, changed input,
60
+ or observation uncertainty remains visible under the normal rules.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.2",
4
+ "version": "0.1.0-alpha.3",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -2,22 +2,37 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {processAlive} from './process-liveness.mjs';
5
+ import {readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
5
6
  const unknown=(reason,checks)=>({schema:1,state:'unverified',current:0,stale:0,failed:0,observed_failed:0,
6
7
  unverified:checks.length,
7
8
  checks:checks.map(c=>({name:c.name,result:null,freshness:'UNVERIFIED',reason,
8
9
  invocation:null,changed_inputs:[],reuse_eligible:false})),
9
10
  observation:{healthy:false,reason}});
10
11
  export function unavailableCached(state,reason,checks=[]) {
11
- try {const value=JSON.parse(fs.readFileSync(path.join(state,'status.json')));
12
- const checks=(value.checks||[]).map(row=>({...row,freshness:'UNVERIFIED',
13
- reason,reuse_eligible:false,declared_inputs_match:null}));
14
- return {...value,state:'unverified',current:0,stale:0,failed:0,
15
- unverified:Math.max(1,checks.length),checks,
16
- observation:{...value.observation,healthy:false,reason}};
17
- }catch{return unknown(reason,checks);}
12
+ let value;try{value=JSON.parse(fs.readFileSync(path.join(state,'status.json')));}
13
+ catch{value=unknown(reason,checks);}
14
+ if(!value||!Array.isArray(value.checks))value=unknown(reason,checks);
15
+ let receipts=null,revision=null;
16
+ try{const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;revision=snapshot.revision;}
17
+ catch(error){reason+=`; latest receipt unavailable; prior outcome only: ${error.message}`;
18
+ try{receipts=readReceipts(state).checks;}catch{/* retain cached historical rows */}}
19
+ const rows=(value.checks||[]).map(row=>{
20
+ const latest=receipts?.[row.name];
21
+ return {...row,...(receipts?{result:latest?.result||null,invocation:latest?.invocation||null,
22
+ target_provenance:latest?.target||null,coverage_at_run:latest?.coverage||null}:{}),
23
+ freshness:'UNVERIFIED',reason,reuse_eligible:false,declared_inputs_match:null};
24
+ });
25
+ return {...value,receipt_revision:revision,state:'unverified',current:0,stale:0,failed:0,
26
+ observed_failed:rows.filter(row=>row.result==='FAIL').length,
27
+ unverified:Math.max(1,rows.length),checks:rows,
28
+ observation:{...value.observation,healthy:false,reason}};
18
29
  }
19
30
  export function readCachedStatus(state,checks=[]) {
20
31
  try{const value=JSON.parse(fs.readFileSync(path.join(state,'status.json')));
32
+ if(typeof value.receipt_revision!=='string')
33
+ return unavailableCached(state,'receipt selection is unconfirmed; synchronize status or restart an older observer',checks);
34
+ if(value.receipt_revision!==receiptRevision(state))
35
+ return unavailableCached(state,'receipt selection changed; synchronize status to evaluate the latest outcome',checks);
21
36
  if(Date.now()>=value.updated_at&&Date.now()<=value.expires_at&&
22
37
  value.expires_at-value.updated_at<=3000){
23
38
  if(processAlive(value.pid))return value;
package/src/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import {createHash,randomUUID} from 'node:crypto';
15
15
  import {assertOwnedStatePlacement} from './owned-state.mjs';
16
16
  import {permittedRoots} from './installed-inputs.mjs';
17
17
  import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
18
- import {atomicJson,commitReceipt} from './state-store.mjs';
18
+ import {atomicJson,commitReceipt,receiptRevision} from './state-store.mjs';
19
19
  import {timing} from './decision-profile.mjs';
20
20
  import {readCachedStatus,unavailableCached} from './cached-state.mjs';
21
21
  import {renderRun} from './presentation.mjs';
@@ -284,7 +284,7 @@ async function execute() {
284
284
  const persistenceStarted=performance.now();
285
285
  commitReceipt(state,name,receipt);
286
286
  timing('cli.receipt_persistence',persistenceStarted);
287
- try{await call({action:'reload'});}catch{/* next read remains conservative */}
287
+ try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
288
288
  const summary={check:name,result,target:target.status,
289
289
  invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
290
290
  stable,coverage_qualified:receipt.coverage.qualified};
@@ -376,7 +376,18 @@ async function main(){
376
376
  if(action==='sync'||action==='detail'){
377
377
  const started=performance.now();
378
378
  let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
379
- Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));}
379
+ Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));
380
+ // A client upgrade can encounter an older running observer. Its successful
381
+ // control response does not establish that it evaluated the latest receipt.
382
+ if(value.receipt_revision===null){
383
+ value=unavailableCached(state,'latest receipt selection unavailable; inspect receipt state before reuse',config.checks);
384
+ }else if(typeof value.receipt_revision!=='string'){
385
+ value=unavailableCached(state,'observer cannot confirm latest receipt selection; stop and start the observer after upgrading',config.checks);
386
+ process.exitCode=2;
387
+ }else if(value.receipt_revision!==receiptRevision(state)){
388
+ value=unavailableCached(state,'receipt selection changed during synchronization; retry synchronized status',config.checks);
389
+ process.exitCode=2;
390
+ }}
380
391
  catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
381
392
  timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
382
393
  if(action==='sync')console.log(JSON.stringify(value));
package/src/daemon.mjs CHANGED
@@ -14,7 +14,7 @@ import {sha} from './plan.mjs';
14
14
  import {InputIndex} from './index.mjs';
15
15
  import {permittedRoots} from './installed-inputs.mjs';
16
16
  import {assertOwnedStatePlacement} from './owned-state.mjs';
17
- import {atomicJson,readReceipts} from './state-store.mjs';
17
+ import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
18
18
  import {history} from './observation.mjs';
19
19
  import {inputKey} from './decision-validation.mjs';
20
20
  import {timing} from './decision-profile.mjs';
@@ -33,6 +33,7 @@ fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
33
33
  const generation=randomUUID(), startedAt=Date.now();
34
34
  let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
35
35
  let receiptStateError=null;
36
+ let receiptStateRevision=null;
36
37
  const external=new Map();
37
38
  for(const check of config.checks)for(const boundary of permittedRoots(root,check.allowedExternalRoots||[]))
38
39
  if(!external.has(boundary))external.set(boundary,{root:boundary,watcher:null,index:null,
@@ -77,9 +78,20 @@ function event(kind,fields={}) {
77
78
  repository:root,...fields})+'\n');
78
79
  }
79
80
  function loadReceipts() {
80
- try {receipts=readReceipts(state).checks;receiptStateError=null;}
81
- catch(e){receipts={};receiptStateError=e.message;event('receipt_state_unavailable',
82
- {classification:e.name||'Error'});}
81
+ try {const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;
82
+ receiptStateRevision=snapshot.revision;receiptStateError=null;}
83
+ catch(e){receiptStateRevision=null;
84
+ // A readable selector can still supply explicitly historical outcomes after
85
+ // restart. The failed snapshot guard remains authoritative for applicability.
86
+ try{receipts=readReceipts(state).checks;}catch{/* retain previous historical rows */}
87
+ const next=`latest receipt unavailable; prior outcome only: ${e.message}`;
88
+ if(receiptStateError!==next)event('receipt_state_unavailable',
89
+ {classification:e.name||'Error'});
90
+ receiptStateError=next;}
91
+ }
92
+ function refreshReceipts() {
93
+ try {if(receiptStateRevision===null||receiptRevision(state)!==receiptStateRevision)loadReceipts();}
94
+ catch {loadReceipts();}
83
95
  }
84
96
  function persistedPlan(bundle) {
85
97
  return {schema:1,provider:bundle.provider,root:bundle.root,discoveredAt:bundle.discoveredAt,
@@ -298,13 +310,16 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
298
310
  });
299
311
  }
300
312
  function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
313
+ // Reload notifications accelerate visibility but are not required for it: a
314
+ // wrapper may persist its outcome even when its control connection is denied.
315
+ refreshReceipts();
301
316
  const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
302
317
  failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
303
318
  observedFailed=checks.filter(r=>r.result==='FAIL').length,
304
319
  stale=checks.filter(r=>r.freshness==='STALE').length,
305
320
  unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
306
321
  const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
307
- const now=Date.now();return {schema:1,state:stateName,current,failed,observed_failed:observedFailed,
322
+ const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
308
323
  stale,unverified,checks,
309
324
  observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
310
325
  generation,observed_at:lastObservation,
@@ -19,19 +19,55 @@ export function readReceipts(state){
19
19
  let value;
20
20
  try{value=JSON.parse(fs.readFileSync(path.join(state,'receipts-v1.json'),'utf8'));}
21
21
  catch(e){if(e.code==='ENOENT')return {schema:STATE_SCHEMA,checks:{}};throw e;}
22
- if(value.schema!==STATE_SCHEMA||!value.checks||typeof value.checks!=='object')
22
+ if(!value||typeof value!=='object'||Array.isArray(value)||value.schema!==STATE_SCHEMA||
23
+ !value.checks||typeof value.checks!=='object'||Array.isArray(value.checks))
23
24
  throw Error(`incompatible receipt state in ${state}; inspect or remove only this vstate state directory`);
24
25
  return value;
25
26
  }
26
27
 
28
+ // This identity detects replacement of REDUE's owned, atomically written receipt
29
+ // selector. It is not an input-content fingerprint or permission to reuse inputs.
30
+ export function receiptRevision(state){
31
+ // A failed/crashed owned writer may have persisted a new immutable outcome
32
+ // without replacing the selector. Never certify its older selection.
33
+ try {fs.lstatSync(path.join(state,'receipt-selection-pending-v1.json'));
34
+ throw Error('receipt selection update incomplete; inspect the failed run, then record a new execution to recover');
35
+ }catch(error){if(error.code!=='ENOENT')throw error;}
36
+ try {const stat=fs.statSync(path.join(state,'receipts-v1.json'),{bigint:true});
37
+ if(!stat.isFile())throw Error('receipt selector is not a file');
38
+ return JSON.stringify(['dev','ino','size','mtimeNs','ctimeNs'].map(key=>String(stat[key])));
39
+ }catch(error){if(error.code==='ENOENT')return 'absent';throw error;}
40
+ }
41
+
42
+ export function readReceiptSnapshot(state){
43
+ const revision=receiptRevision(state),value=readReceipts(state);
44
+ if(receiptRevision(state)!==revision)
45
+ throw Error('receipt selection changed during read; retry status');
46
+ return {revision,value};
47
+ }
48
+
27
49
  export function commitReceipt(state,name,receipt){
28
50
  const prior=readReceipts(state),runId=receipt.invocation?.runId;
29
51
  if(!/^[0-9a-f-]{36}$/.test(runId||''))throw Error('receipt lacks a valid run ID');
30
52
  const runs=path.join(state,'runs-v1');
31
53
  fs.mkdirSync(runs,{recursive:true,mode:0o700});
54
+ // The caller holds the state run lock. Leave this marker on any write failure;
55
+ // only a later successful owned commit may clear it. No journal scan guesses
56
+ // which incomplete execution should become the selected evidence.
57
+ const pending=path.join(state,'receipt-selection-pending-v1.json');
58
+ let updates={schema:STATE_SCHEMA,checks:{}};
59
+ try {updates=JSON.parse(fs.readFileSync(pending,'utf8'));
60
+ if(updates?.schema!==STATE_SCHEMA||!updates.checks||typeof updates.checks!=='object'||Array.isArray(updates.checks))
61
+ throw Error('incompatible incomplete receipt update; inspect owned state before recording');
62
+ }catch(error){if(error.code!=='ENOENT')throw error;}
63
+ updates.checks[name]={runId};
64
+ atomicJson(pending,updates);
32
65
  // One immutable outcome per invocation. Never overwrite an existing run.
33
66
  fs.writeFileSync(path.join(runs,runId+'.json'),
34
67
  JSON.stringify({schema:STATE_SCHEMA,check:name,receipt}),{flag:'wx',mode:0o600});
35
68
  prior.checks[name]=receipt;
36
69
  atomicJson(path.join(state,'receipts-v1.json'),prior);
70
+ delete updates.checks[name];
71
+ if(Object.keys(updates.checks).length)atomicJson(pending,updates);
72
+ else fs.unlinkSync(pending);
37
73
  }