Skip to content
Closed
6 changes: 5 additions & 1 deletion docs/development/CONTINUOUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ eligibility result is false, so it can be used as a fail-closed CI/preflight
gate; `--help` prints the invocation without probing the machine. Each phase is
`ready`, `blocked`, or `unavailable`; missing provider, scheduler, review,
recovery, or benchmark attestations keep both `continuousEligible` and
`releaseEligible` false. The report also includes every blocked or unavailable
`continuousReleaseEligible` false (`releaseEligible` is a deprecated alias of
it for one release; it does not mean the app release is ok). The separate
`appReleaseEligible` (schema version 2) needs only setup and the machine
interface plus the user's `appReleaseAttested`; it never enables continuous
mode. The report also includes every blocked or unavailable
phase and attestation in `blockers`, with stable labels and duplicate reasons
removed, so a downstream agent does not mistake one early gate for the
complete diagnosis. The audit never starts ProjectA or a provider and never
Expand Down
47 changes: 47 additions & 0 deletions docs/drills/updater-drill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Updater-Drill (W3-03e, Matrixzeile 23)

Du prüfst drei Fälle des Updates an der **installierten Beta** auf deinem
Windows-PC: Update klappt, Update abbrechen, Update scheitert. Bei jedem Fall
bleibt eine gespeicherte Worker-Zeile erhalten. Das Skript liest nur (API, Journal-Datei) und
schreibt einen Beleg-Ordner. Dauer: je Fall etwa 10 Minuten.

Voraussetzung: ProjectA-Beta installiert, mit einer neueren Beta als Update
bereit; PowerShell 7, Node 24 und dieses Repository sind da. Alle Live-Sitzungen
müssen beendet sein: Der Updater verweigert Installationen, solange ein Worker läuft.

**Für jeden der drei Fälle** (`success`, `cancel`, `fail`):

1. **Alte Version notieren.** Öffne „Info" in ProjectA, merke die Versionsnummer.
2. **Worker-Zeile vorbereiten.** Lege einen Worker an und beende seine Live-Sitzung,
ohne den Worker zu löschen. Ohne gespeicherte Worker-Zeile meldet das Skript `FEHLER`.
3. **Skript starten** (zweites Fenster, PowerShell 7 im Repository-Ordner):
`pwsh scripts/drills/updater-drill.ps1 -Scenario success -OldVersion 0.9.0 -NewVersion 0.9.1 -OutDir C:\Belege\updater-success`
Nimm `cancel` oder `fail` statt `success`. Jeder Lauf braucht einen neuen
oder leeren Zielordner. Das Skript wartet höchstens 10 Minuten (`-TimeoutMin`).
4. **Jetzt in ProjectA handeln** (das Skript beobachtet mit):
- `success`: „Update installieren" klicken, Neustart abwarten. Erwartet: die App
schließt und kommt von selbst wieder; die Worker-Zeile steht danach in der Liste.
- `cancel`: „Update installieren" klicken und den Download **abbrechen**.
Erwartet: die App bleibt offen und zeigt wieder „Update verfügbar".
- `fail`: Flugmodus an, dann „Update installieren". Erwartet: ProjectA zeigt
eine Fehlermeldung, die App bleibt offen. Flugmodus danach wieder aus.
5. **Anzeige abschreiben.** Schau in ProjectA und im Dev-HQ auf den Update-Status
und schreibe den Text in eine Datei `angezeigt.txt` im Beleg-Ordner.
6. **Ausgabe lesen.** Erwartet: vier `OK`-Zeilen und `Ergebnis: pass`. Bei
`FEHLER` den Ordner nicht löschen und mir melden.
7. **Beleg prüfen.** `manifest.json` (Schritte, Exit-Codes, Ergebnis, Nicht-abgedeckt),
`before.json`/`after.json` (Worker-Zeilen, Journal-Zusammenfassung),
`updater-phases.json` (Phasen mit Zeit), `processes.txt`. Alles ist geschwärzt;
die Zugangsdaten aus `projecta-api.json` werden nie gespeichert.

Das Wiederherstellungs-Journal (`update-recovery.json`, W3-02) wird nur als
Phase, Größe und Hash festgehalten, nicht im Wortlaut. Fehlt die Datei, steht im
Beleg „absent": die installierte Version schreibt es dann noch nicht.

## Nicht abgedeckt (bleibt offen)

- **Signierter Produktions-Build (W3-07, dein Teil):** Signatur-Prüfung und das
echte Release-Update gehen erst mit einem signierten Build. Bis dahin steht die
Zeile „NICHT ABGEDECKT"; die Beta beweist nur den Ablauf.
- Netzabbruch (Flugmodus) und Abbrechen-Klick machst du von Hand.
- Update-Text in Fenster und HQ liest das Skript nicht, du schreibst ihn in `angezeigt.txt`.
5 changes: 3 additions & 2 deletions scripts/continuous-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ export async function buildReport(root = process.cwd()) {
benchmarkAccepted: false,
continuousExecutionEnabled: false,
stablePromotionAuthorized: false,
// Written only by the user in .pa/release_attestation_v1.5.0.json (E20).
appReleaseAttested: false,
},
});
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
command: 'dev:continuous-audit',
...readiness,
Expand All @@ -57,7 +58,7 @@ export async function main(argv = process.argv.slice(2)) {
}
const report = await buildReport();
console.log(JSON.stringify(report, null, options.json ? 2 : 0));
return report.continuousEligible && report.releaseEligible ? 0 : 1;
return report.continuousEligible && report.continuousReleaseEligible ? 0 : 1;
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
Expand Down
27 changes: 27 additions & 0 deletions scripts/drills/updater-drill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// CLI entry of the updater drill (see docs/drills/updater-drill.md).
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { fileURLToPath } from 'node:url';
import { defaultOutDir } from '../lib/drill-kit.mjs';
import { runUpdaterDrill } from '../lib/updater-drill.mjs';

const { values: v } = parseArgs({
options: {
'app-dir': { type: 'string' }, out: { type: 'string' }, scenario: { type: 'string' },
'old-version': { type: 'string' }, 'new-version': { type: 'string' }, commit: { type: 'string' },
'observed-ui': { type: 'string' }, 'process-list': { type: 'string' }, 'timeout-min': { type: 'string' },
},
});
if (!v['app-dir'] || !v.scenario) { console.error('FEHLER: --app-dir und --scenario (success|cancel|fail) sind Pflicht'); process.exit(2); }
const commit = v.commit ?? execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: fileURLToPath(new URL('../..', import.meta.url)), encoding: 'utf8' }).trim();
const manifest = await runUpdaterDrill({
appDir: v['app-dir'], outDir: v.out ?? defaultOutDir(`updater-${v.scenario}`), scenario: v.scenario,
appVersion: v['old-version'], newVersion: v['new-version'], commit, observedUi: v['observed-ui'] ?? '',
timeoutMs: Number(v['timeout-min'] ?? 10) * 60000,
processList: v['process-list'] ? readFileSync(v['process-list'], 'utf8') : '',
});
for (const s of manifest.steps) console.log(`${s.exitCode === 0 ? 'OK ' : 'FEHLER'} ${s.n}. ${s.name}${s.detail ? ` - ${s.detail}` : ''}`);
console.log(`Ergebnis: ${manifest.result}. Beleg-Ordner: ${v.out ?? '(Zeitstempel-Ordner)'}`);
process.exit(manifest.result === 'pass' ? 0 : 1);
26 changes: 26 additions & 0 deletions scripts/drills/updater-drill.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Updater drill launcher (PowerShell 7); steps: docs/drills/updater-drill.md
param(
[Parameter(Mandatory)][ValidateSet("success", "cancel", "fail")][string]$Scenario,
[string]$OutDir,
[string]$AppDir = (Join-Path $env:APPDATA "com.projecta.app"),
[string]$OldVersion = "unknown",
[string]$NewVersion = "unknown",
[string]$ObservedUi = "",
[int]$TimeoutMin = 10
)
$ErrorActionPreference = "Stop"
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw "Node 24+ wird gebraucht (node nicht gefunden)" }

# Process list for the bundle (names only, no command lines).
$procFile = Join-Path ([IO.Path]::GetTempPath()) ("projecta-procs-" + [guid]::NewGuid().ToString("n") + ".txt")
Get-Process | Sort-Object ProcessName | Select-Object Id, ProcessName | Out-String | Set-Content $procFile

$argList = @("--app-dir", $AppDir, "--scenario", $Scenario, "--process-list", $procFile, "--old-version", $OldVersion,
"--new-version", $NewVersion, "--observed-ui", $ObservedUi, "--timeout-min", $TimeoutMin)
if ($OutDir) { $argList += @("--out", $OutDir) }

& node (Join-Path $repo "scripts/drills/updater-drill.mjs") @argList
$code = $LASTEXITCODE
Remove-Item $procFile -ErrorAction SilentlyContinue
exit $code
1 change: 1 addition & 0 deletions scripts/hq-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ async function setup() {
benchmarkAccepted: false,
continuousExecutionEnabled: false,
stablePromotionAuthorized: false,
appReleaseAttested: false,
},
});
return { generatedAt: new Date().toISOString(), probes, ...checks, continuousReadiness };
Expand Down
22 changes: 18 additions & 4 deletions scripts/lib/continuous-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const ATTESTATION_LABELS = Object.freeze({
reviewAuthority: 'Review authority',
recovery: 'Update recovery',
benchmark: 'Benchmark comparison',
appRelease: 'App release attestation',
});

const state = (value, reason) => ({ state: value, ...(reason ? { reason } : {}) });
Expand Down Expand Up @@ -77,13 +78,21 @@ export function evaluateContinuousReadiness({ setup, runtime, evidence = {} } =

const allReady = Object.values(phases).every(phase => phase.state === 'ready');
const continuousEligible = allReady && observed(evidence, 'continuousExecutionEnabled');
const releaseEligible = continuousEligible && observed(evidence, 'stablePromotionAuthorized');
const continuousReleaseEligible = continuousEligible && observed(evidence, 'stablePromotionAuthorized');
// App release (E20): needs only the `configuration` and `machineInterface`
// phases plus the user's own attestation; it never depends on the continuous
// phases (runtime, delivery, rollout) and never enables continuous mode.
const appReleaseState = explicitState(evidence, 'appReleaseAttested', 'app release');
const appReleaseEligible = phases.configuration.state === 'ready'
&& phases.machineInterface.state === 'ready'
&& appReleaseState.state === 'ready';
const attestations = {
providers: providerState,
scheduler: schedulerState,
reviewAuthority: reviewState,
recovery: recoveryState,
benchmark: benchmarkState,
appRelease: appReleaseState,
};
const blockers = [];
const seenBlockers = new Set();
Expand All @@ -106,14 +115,19 @@ export function evaluateContinuousReadiness({ setup, runtime, evidence = {} } =
}
}
return {
schemaVersion: 1,
schemaVersion: 2,
source: 'local-read-only-observations',
phases: PHASES.map(([id, label]) => ({ id, label, ...phases[id] })),
attestations,
blockers,
continuousEligible,
releaseEligible,
continuousReleaseEligible,
// Deprecated alias of `continuousReleaseEligible` (not the app release);
// kept for one release, then removed. Do not present it as "release ok".
releaseEligible: continuousReleaseEligible,
appReleaseEligible,
continuousMode: continuousEligible ? 'eligible-but-disabled-until-explicit-enable' : 'blocked',
stableRelease: releaseEligible ? 'eligible-after-explicit-release' : 'blocked',
stableRelease: continuousReleaseEligible ? 'eligible-after-explicit-release' : 'blocked',
appRelease: appReleaseEligible ? 'eligible-after-explicit-release' : 'blocked',
};
}
43 changes: 42 additions & 1 deletion scripts/lib/continuous-readiness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ const ready = {
benchmarkAccepted: true,
continuousExecutionEnabled: true,
stablePromotionAuthorized: true,
appReleaseAttested: true,
};

test('readiness remains fail-closed when observations are missing', () => {
const report = evaluateContinuousReadiness({ setup: {}, runtime: {}, evidence: {} });
assert.equal(report.continuousEligible, false);
assert.equal(report.continuousReleaseEligible, false);
assert.equal(report.releaseEligible, false);
assert.equal(report.appReleaseEligible, false);
assert.equal(report.phases[0].state, 'unavailable');
assert.equal(report.attestations.providers.state, 'unavailable');
assert.ok(report.blockers.some(blocker => blocker.id === 'configuration'));
Expand All @@ -45,11 +48,49 @@ test('a reachable runtime does not bypass provider, review or recovery gates', (
test('all attested gates still require explicit enable and promotion decisions', () => {
const report = evaluateContinuousReadiness({ setup: ready, runtime: { runtimeReady: true }, evidence: ready });
assert.equal(report.continuousEligible, true);
assert.equal(report.releaseEligible, true);
assert.equal(report.continuousReleaseEligible, true);
assert.equal(report.releaseEligible, report.continuousReleaseEligible);
assert.equal(report.appReleaseEligible, true);
assert.equal(report.continuousMode, 'eligible-but-disabled-until-explicit-enable');
assert.deepEqual(report.blockers, []);
});

const appOnly = {
setup: { setupReady: true },
runtime: { runtimeReady: true },
};

test('app release is blocked without the user attestation', () => {
const report = evaluateContinuousReadiness({ ...appOnly, evidence: {} });
assert.equal(report.appReleaseEligible, false);
assert.equal(report.appRelease, 'blocked');
assert.ok(report.blockers.some(blocker => blocker.id === 'attestation:appRelease'));
const denied = evaluateContinuousReadiness({ ...appOnly, evidence: { appReleaseAttested: false } });
assert.equal(denied.appReleaseEligible, false);
});

test('app release attestation does not make continuous mode release-eligible', () => {
const report = evaluateContinuousReadiness({ ...appOnly, evidence: { appReleaseAttested: true } });
assert.equal(report.appReleaseEligible, true);
assert.equal(report.appRelease, 'eligible-after-explicit-release');
assert.equal(report.continuousReleaseEligible, false);
assert.equal(report.releaseEligible, false);
assert.equal(report.continuousEligible, false);
assert.equal(report.continuousMode, 'blocked');
assert.ok(!report.blockers.some(blocker => blocker.id === 'attestation:appRelease'));
});

test('app release still needs configuration and machine interface', () => {
const report = evaluateContinuousReadiness({
setup: { setupReady: false }, runtime: { runtimeReady: true }, evidence: { appReleaseAttested: true },
});
assert.equal(report.appReleaseEligible, false);
});

test('readiness report is schema version 2', () => {
assert.equal(evaluateContinuousReadiness().schemaVersion, 2);
});

test('continuous audit accepts explicit output flags and rejects accidental execution flags', () => {
assert.deepEqual(parseArgs(['--json']), { json: true, help: false });
assert.deepEqual(parseArgs(['--help']), { json: false, help: true });
Expand Down
3 changes: 2 additions & 1 deletion scripts/lib/hq-routes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ test("/__hq/setup runs the checklist and names the stale descriptor as the block
assert.equal(setup.ready, false);
assert.ok(setup.checks.find((c) => c.id === "specs").state === "ok", "spec gate runs from the repo root");
assert.equal(setup.continuousReadiness.continuousEligible, false);
assert.equal(setup.continuousReadiness.releaseEligible, false);
assert.equal(setup.continuousReadiness.continuousReleaseEligible, false);
assert.equal(setup.continuousReadiness.appReleaseEligible, false);
assert.ok(setup.continuousReadiness.blockers.some((blocker) => blocker.id === "attestation:providers"));
});

Expand Down
Loading
Loading