ReviewOS

also looking at this

stacks/drone

feat: continue unresolved runtime update maintenance

#59
Merged chris wants to merge codex/runtime-maintenance-continuations into main
21 files +1097 -40

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+31-0
Changes to .github/workflows/ci.yml
@@ -43,9 +43,21 @@ jobs:
4343 if-no-files-found: ignore
4444 retention-days: 7
4545 path: ${{ runner.temp }}/runtime-host-file-limit-evidence
46 - name: Verify explicit maintenance handoff interruption and fresh-process recovery
47 env:
48 RUNTIME_MAINTENANCE_EVIDENCE: ${{ runner.temp }}/runtime-maintenance-evidence
49 run: bun test tests/unit/updates-maintenance-cuts.test.ts
50 - uses: actions/upload-artifact@v4
51 if: always()
52 with:
53 name: runtime-maintenance-evidence
54 if-no-files-found: ignore
55 retention-days: 7
56 path: ${{ runner.temp }}/runtime-maintenance-evidence
4657 - run: ./buddy test
4758 env:
4859 RUNTIME_HOST_FILE_LIMIT_EVIDENCE: ${{ runner.temp }}/runtime-host-file-limit-full-evidence
60 RUNTIME_MAINTENANCE_EVIDENCE: ${{ runner.temp }}/runtime-maintenance-full-evidence
4961 - uses: actions/upload-artifact@v4
5062 if: always()
5163 with:
@@ -53,6 +65,13 @@ jobs:
5365 if-no-files-found: ignore
5466 retention-days: 7
5567 path: ${{ runner.temp }}/runtime-host-file-limit-full-evidence
68 - uses: actions/upload-artifact@v4
69 if: always()
70 with:
71 name: runtime-maintenance-full-evidence
72 if-no-files-found: ignore
73 retention-days: 7
74 path: ${{ runner.temp }}/runtime-maintenance-full-evidence
5675 - run: bun run runtime:cli
5776 - name: Verify signed test-image staging through the portable CLI outside the checkout
5877 run: bun tests/fixtures/updates/qualify.ts "$RUNNER_TEMP/runtime-update-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
@@ -68,6 +87,18 @@ jobs:
6887 run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
6988 - name: Verify host slots, observed health and interrupted recovery with a copied standalone CLI
7089 run: bun tests/fixtures/updates/qualify-host-slots.ts "$RUNNER_TEMP/runtime-host-slots-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$GITHUB_WORKSPACE"
90 - name: Verify explicit maintenance and format-4 runtime health through a copied CLI
91 run: bun tests/fixtures/updates/qualify-maintenance.ts "$RUNNER_TEMP/runtime-maintenance-cli" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
92 - uses: actions/upload-artifact@v4
93 if: always()
94 with:
95 name: runtime-maintenance-cli-evidence
96 if-no-files-found: ignore
97 include-hidden-files: true
98 retention-days: 7
99 path: |
100 ${{ runner.temp }}/runtime-maintenance-cli
101 !${{ runner.temp }}/runtime-maintenance-cli/drone
71102 - uses: actions/upload-artifact@v4
72103 if: always()
73104 with:
app/Commands/UpdatesMaintain.tsadded+23-0
Changes to app/Commands/UpdatesMaintain.ts
@@ -0,0 +1,23 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { continueRuntimeTrustMaintenance } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:maintain <directory>',
8 description: 'Explicitly retain an unresolved attempt and require fresh signed metadata verification without resetting trust',
9 options: {
10 ...storeReferenceFlags,
11 '--attempt <id>': 'Exact unresolved attempt identity from independently pinned store readback',
12 '--acknowledge-unresolved-inputs': 'Acknowledge that prior inputs remain unresolved and retained for investigation',
13 '--repository <path>': 'Bounded offline metadata repository for full fresh verification',
14 '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation',
15 },
16 async handle(options, directory) {
17 try {
18 if (options.acknowledgeUnresolvedInputs !== true || typeof options.attempt !== 'string' || typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit attempt, acknowledgement, repository and trusted clock source required')
19 await printAsync(JSON.stringify(await continueRuntimeTrustMaintenance({ ...storeCommandReference(options, directory), repository: options.repository, previousAttemptId: options.attempt, acknowledgeUnresolvedInputs: true, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
20 }
21 catch { await storeCommandFailure() }
22 },
23})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -27,6 +27,7 @@ import UpdatesStoreInit from '../app/Commands/UpdatesStoreInit'
2727import UpdatesStore from '../app/Commands/UpdatesStore'
2828import UpdatesRefresh from '../app/Commands/UpdatesRefresh'
2929import UpdatesRecover from '../app/Commands/UpdatesRecover'
30import UpdatesMaintain from '../app/Commands/UpdatesMaintain'
3031import UpdatesPrepare from '../app/Commands/UpdatesPrepare'
3132import UpdatesReserve from '../app/Commands/UpdatesReserve'
3233import UpdatesCandidates from '../app/Commands/UpdatesCandidates'
@@ -105,6 +106,7 @@ UpdatesStoreInit(app)
105106UpdatesStore(app)
106107UpdatesRefresh(app)
107108UpdatesRecover(app)
109UpdatesMaintain(app)
108110UpdatesPrepare(app)
109111UpdatesReserve(app)
110112UpdatesCandidates(app)
docs/adr/0032-runtime-maintenance-continuations.mdadded+56-0
Changes to docs/adr/0032-runtime-maintenance-continuations.md
@@ -0,0 +1,56 @@
1# Explicit continuation of unresolved update attempts
2
3Status: implementation in progress for #42. Physical deployment remains disabled.
4
5A metadata attempt can retain a verified root/timestamp/snapshot prefix and then
6reject later input. Ordinary recovery cannot turn an unknown verifier, crypto or
7resource error into proof of invalid input. Replaying that exact input can fail
8again after the repository has been corrected, leaving no supported fresh path.
9
10Add an explicit local maintenance continuation. The caller supplies independently
11retained enrollment/store pins, the exact unresolved attempt ID, an independently
12trusted current clock, and acknowledgement that the original inputs remain
13unresolved. Existing protected ownership and exclusive-writer checks apply. This
14is an operator action, never automatic error classification or silent retry.
15
16Format 4 adds immutable predecessor/successor handoffs. In one SQLite transaction,
17retain the original attempt unchanged, create a new pending attempt at the current
18committed generation, record the handoff, and retain the new clock observation.
19Exactly one unresolved successor remains active. Older readers reject format 4
20before rewriting either database. The writer database stays format 1. Formats
211/2/3 migrate atomically while preserving all metadata, inputs, floors, candidates,
22slots and their independent store identity. This remains the local protocol
23exception in [ADR 28](0028-runtime-trust-store.md), with native Stacks CLI entrypoints.
24
25Every prior input, accepted observation, phase and release remains retained. A
26handoff says only that the operator requested fresh verification; it does not say
27the prior input was invalid, recovered or successfully verified. Public status
28exposes the chain and bounded input digests/counts for investigation. Chains are
29forward-only, bounded by the existing attempt limit and checked against attempt
30ordering, generations and clocks. There is no automatic deletion or compaction.
31
32The successor uses the existing signed metadata verifier and persistence path
33from the latest committed root and high-water state. No trusted root, metadata
34epoch, release floor or clock floor is reset. New root continuity and role checks
35still follow [TUF's client workflow](https://theupdateframework.github.io/specification/latest/#detailed-client-workflow).
36Only a full fresh metadata verification completes the successor. It neither
37downloads an image nor changes installed/known-good roles. Candidate selection,
38rollback and boot confirmation still require their own fresh-policy checks.
39Unverified predecessor inputs remain retained; the handoff does not automatically
40replay or authenticate them. This differs from historical recovery, which replays
41the active attempt's exact durable inputs. Neither path can decrease a committed
42security floor.
43
44A rejected or interrupted successor remains pending/maintenance and blocks normal
45operations. Historical recovery addresses that active successor only and cannot
46promote a retained predecessor. An explicit continuation can itself be continued
47after another rejection, preserving the complete chain and all security floors.
48Crashes at handoff/input/metadata boundaries and actual concurrent writers need
49qualification. Missing or corrupt state continues to fail closed.
50
51This supplies a supported path after unresolved repository input, including
52authenticated key rotation through existing root continuity. It does not invent
53authority to replace a lost root, decrease a poisoned clock floor, repair corrupt
54history or bypass revoked/expired metadata. Independent recovery authority and
55trusted-clock repair still need their own design and acceptance. Physical board
56installation, anti-rollback and electrical power-loss qualification remain open.
docs/roadmap/issues.jsonmodified+10-1
Changes to docs/roadmap/issues.json
@@ -294,7 +294,16 @@
294294 "docs/adr/0030-host-storage-fault-fixtures.md",
295295 "tests/fixtures/updates/host-volume-full.ts",
296296 "tests/integration/runtime-host-storage.test.ts",
297 "docs/adr/0031-full-host-filesystem-fixture.md"
297 "docs/adr/0031-full-host-filesystem-fixture.md",
298 "docs/adr/0032-runtime-maintenance-continuations.md",
299 "app/Commands/UpdatesMaintain.ts",
300 "tests/unit/updates-maintenance.test.ts",
301 "tests/unit/updates-maintenance-cli.test.ts",
302 "tests/unit/updates-maintenance-cuts.test.ts",
303 "tests/unit/updates-maintenance-host.test.ts",
304 "tests/fixtures/updates/maintenance-scenario.ts",
305 "tests/fixtures/updates/maintenance-interruptions.ts",
306 "tests/fixtures/updates/qualify-maintenance.ts"
298307 ],
299308 "remaining": [
300309 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
docs/runtime-updates.mdmodified+48-0
Changes to docs/runtime-updates.md
@@ -187,6 +187,54 @@ revocation remain in #42. [Owned host slots](#owned-host-slots-and-observed-heal
187187below add durable host roles and observed boot/session health. Physical board
188188selection, boot health and recovery still require their separate qualification.
189189
190### Explicit maintenance continuation
191
192A corrected repository does not make an earlier rejected input valid. If ordinary
193`updates:recover` cannot finish, explicitly retain that unresolved attempt and
194require a new full signed metadata verification:
195
196```sh
197./buddy updates:maintain /path/to/store \
198 --enrollment /path/to/trust \
199 --enrollment-sha256 "$INDEPENDENTLY_RETAINED_ENROLLMENT_SHA256" \
200 --store-sha256 "$INDEPENDENTLY_RETAINED_STORE_SHA256" \
201 --attempt "$EXACT_UNRESOLVED_ATTEMPT_ID" \
202 --acknowledge-unresolved-inputs \
203 --repository /path/to/corrected-offline-repository \
204 --trusted-system-clock-source operator-managed-utc-clock
205```
206
207The same command is in the standalone CLI. It requires the exact active attempt
208and explicit acknowledgement, and retains the original phase, inputs, accepted
209metadata and security floors. It does not mark the earlier failure recovered or
210invalid. Public store status exposes `maintenanceHandoffs`; each entry includes
211the predecessor's bounded input digest/counts, unchanged outcome, successor ID
212and committed starting generation. The latest `attempt` also exposes its input
213digest/counts and accepted metadata count.
214
215Format 4 adds the handoff and new pending attempt atomically. Existing formats
2161/2/3 migrate without resetting their independent store identity or security
217history. The writer database remains format 1. Older readers reject format 4
218before rewriting either database. A pending or rejected successor blocks normal
219updates. Historical recovery acts on the active successor only; another explicit
220continuation can retain it and start a further successor within the same bounded
221history. No handoff deletes, reclassifies or silently drops earlier evidence.
222
223Fresh verification starts at the latest committed root, role epochs and floors.
224Unverified predecessor input stays retained and is not automatically replayed by
225this operation. New root continuity and every current-policy check still apply.
226The command does not download an image, change slot roles or confirm health.
227An active candidate or interrupted host slot still needs its ordinary explicit
228retirement or exact-slot abandonment before a replacement can proceed.
229Subsequent selection and rollback still require their own current verification
230and observed runtime session. Existing known-good files remain retained.
231
232This supports corrected metadata and authenticated key rotation through existing
233root continuity. It cannot replace a lost root, reduce an erroneous clock floor,
234repair corrupt history or authorize expired/revoked images. Those maintenance and
235physical acceptance gates remain open. See the
236[continuation decision](adr/0032-runtime-maintenance-continuations.md).
237
190238## Protected signed-image preparation
191239
192240Use the pinned store and independently trusted current clock to authenticate an
resources/functions/drone/updates/store.tsmodified+88-18
Changes to resources/functions/drone/updates/store.ts
@@ -38,6 +38,9 @@ const slotSchema = [
3838 'CREATE TABLE host_roles (id INTEGER PRIMARY KEY CHECK(id = 1), installed_id TEXT, good_id TEXT, FOREIGN KEY(installed_id) REFERENCES host_slots(id), FOREIGN KEY(good_id) REFERENCES host_slots(id)) STRICT',
3939 "CREATE TABLE host_inputs (slot_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 255), kind TEXT NOT NULL CHECK(kind IN ('tx', 'rx')), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND 160), PRIMARY KEY(slot_id, ordinal), FOREIGN KEY(slot_id) REFERENCES host_slots(id)) STRICT",
4040]
41const maintenanceSchema = [
42 'CREATE TABLE maintenance_handoffs (previous_attempt_id TEXT PRIMARY KEY, next_attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, CHECK(previous_attempt_id != next_attempt_id), FOREIGN KEY(previous_attempt_id) REFERENCES attempts(id), FOREIGN KEY(next_attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT',
43]
4144
4245interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
4346interface StoreReference extends TrustReference { directory: string, storeSha256: string }
@@ -45,6 +48,15 @@ interface Binding { schema: 'drone-runtime-store-v1', storeId: string, enrollmen
4548interface State { store_id: string, enrollment_sha256: string, generation: number, release_floor: number, clock_floor_ms: number }
4649interface MetadataRow { generation: number, attempt_id: string | null, ordinal: number, role: string, version: number, epoch: number, bytes: Uint8Array, sha256: string }
4750interface Attempt { id: string, base_generation: number, started_ms: number, clock_source: string, phase: 'pending' | 'verified' | 'recovered' | 'maintenance', accepted_release: number | null }
51export interface RuntimeUpdateAttemptSummary {
52 id: string
53 phase: Attempt['phase']
54 clockSource: string
55 startedMs: number
56 inputRecords: number
57 inputSha256: string
58 metadataRecords: number
59}
4860export interface RuntimeTrustStoreSummary {
4961 schema: 'drone-runtime-store-status-v1'
5062 storeId: string
@@ -56,7 +68,8 @@ export interface RuntimeTrustStoreSummary {
5668 clockFloorMs: number
5769 retainedMetadataRecords: number
5870 metadata: ReadonlyArray<Readonly<{ role: string, version: number, sha256: string }>>
59 attempt: Readonly<{ id: string, phase: Attempt['phase'], clockSource: string, startedMs: number }> | null
71 attempt: Readonly<RuntimeUpdateAttemptSummary> | null
72 maintenanceHandoffs: ReadonlyArray<Readonly<{ previousAttempt: Readonly<RuntimeUpdateAttemptSummary>, nextAttemptId: string, generation: number }>>
6073 physicalDeploymentAllowed: false
6174}
6275
@@ -86,8 +99,8 @@ function openDatabase(path: string, expectedSchema: string[], allowCandidates =
8699 // Authenticate the protocol format before any configuration that could
87100 // rewrite a foreign database's journal/header format.
88101 const version = databaseVersion(db)
89 const additions = allowCandidates && version === 3 ? [...candidateSchema, ...slotSchema] : allowCandidates && version === 2 ? candidateSchema : []
90 checkSchema(db, [...expectedSchema, ...additions], allowCandidates && (version === 2 || version === 3) ? version : 1)
102 const additions = allowCandidates && version === 4 ? [...candidateSchema, ...slotSchema, ...maintenanceSchema] : allowCandidates && version === 3 ? [...candidateSchema, ...slotSchema] : allowCandidates && version === 2 ? candidateSchema : []
103 checkSchema(db, [...expectedSchema, ...additions], allowCandidates && [2, 3, 4].includes(version) ? version : 1)
91104 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
92105 configure(db)
93106 return db
@@ -136,6 +149,35 @@ function checkedMetadataBytes(row: MetadataRow) {
136149 if (!/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(row.role) || sha256(bytes) !== row.sha256 || signed.version !== row.version || signed._type !== kind) throw new Error('Protected metadata integrity mismatch')
137150 return bytes
138151}
152function activeAttempts(db: Database) {
153 const retained = databaseVersion(db) === 4 ? 'AND id NOT IN (SELECT previous_attempt_id FROM maintenance_handoffs)' : ''
154 return db.query<Attempt, []>(`SELECT * FROM attempts WHERE phase IN ('pending', 'maintenance') ${retained} ORDER BY rowid`).all()
155}
156function attemptObservation(db: Database, row: Attempt): Readonly<RuntimeUpdateAttemptSummary> {
157 const inputs = db.query<InputRow, [string]>('SELECT * FROM inputs WHERE attempt_id = ? ORDER BY ordinal').all(row.id)
158 if (inputs.length > 64 || inputs.some((input, index) => input.ordinal !== index || (input.status === 200 ? !input.bytes || sha256(input.bytes) !== input.sha256 : input.bytes !== null || input.sha256 !== null))) throw new Error('Invalid retained update input history')
159 for (const input of inputs) safePath(input.path)
160 const metadata = db.query<MetadataRow, [string]>('SELECT * FROM metadata WHERE attempt_id = ? ORDER BY ordinal').all(row.id)
161 if (metadata.length > 64 || metadata.some((item, index) => item.ordinal !== index)) throw new Error('Invalid retained metadata observation history')
162 for (const item of metadata) checkedMetadataBytes(item)
163 const digest = sha256(Buffer.from(JSON.stringify(inputs.map(input => [input.ordinal, input.path, input.status, input.sha256]))))
164 return Object.freeze({ id: row.id, phase: row.phase, clockSource: row.clock_source, startedMs: row.started_ms, inputRecords: inputs.length, inputSha256: digest, metadataRecords: metadata.length })
165}
166function maintenanceHandoffs(db: Database): RuntimeTrustStoreSummary['maintenanceHandoffs'] {
167 if (databaseVersion(db) !== 4) return Object.freeze([])
168 const attempts = db.query<Attempt & { position: number }, []>('SELECT rowid AS position, * FROM attempts').all()
169 if (attempts.length > 256) throw new Error('Update attempt history budget exceeded')
170 const index = new Map(attempts.map(row => [row.id, row]))
171 const rows = db.query<{ previous_attempt_id: string, next_attempt_id: string, generation: number }, []>('SELECT * FROM maintenance_handoffs ORDER BY rowid').all()
172 if (rows.length > 255) throw new Error('Maintenance handoff history budget exceeded')
173 return Object.freeze(rows.map(row => {
174 const previous = index.get(row.previous_attempt_id), next = index.get(row.next_attempt_id)
175 if (!previous || !next || !['pending', 'maintenance'].includes(previous.phase) || previous.position >= next.position || previous.started_ms > next.started_ms || row.generation !== next.base_generation || row.generation < previous.base_generation) throw new Error('Invalid retained maintenance handoff')
176 const preceding = db.query<{ generation: number }, [number]>('SELECT coalesce(max(m.generation), 0) AS generation FROM metadata m LEFT JOIN attempts a ON a.id = m.attempt_id WHERE m.attempt_id IS NULL OR a.rowid < ?').get(next.position)!
177 if (row.generation !== preceding.generation) throw new Error('Maintenance handoff skipped committed metadata history')
178 return Object.freeze({ previousAttempt: attemptObservation(db, previous), nextAttemptId: next.id, generation: row.generation })
179 }))
180}
139181function summary(db: Database, binding: Binding, storeSha256: string): Readonly<RuntimeTrustStoreSummary> {
140182 const current = state(db, binding), metadata = activeMetadata(db, current.generation)
141183 const anchor = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
@@ -143,11 +185,13 @@ function summary(db: Database, binding: Binding, storeSha256: string): Readonly<
143185 for (const row of [anchor, ...metadata]) checkedMetadataBytes(row)
144186 const counts = db.query<{ attempts: number, metadata: number, inputs: number }, []>('SELECT (SELECT count(*) FROM attempts) AS attempts, (SELECT count(*) FROM metadata) AS metadata, (SELECT count(*) FROM inputs) AS inputs').get()!
145187 if (counts.attempts > 256 || counts.metadata !== current.generation + 1 || counts.metadata > maximumGeneration + 1 || counts.inputs > 16384) throw new Error('Update-store history budget exceeded or metadata history is incomplete')
146 const clockQuery = databaseVersion(db) === 3 ? 'max(coalesce((SELECT max(started_ms) FROM attempts), 0), coalesce((SELECT max(checked_ms) FROM host_slots), 0))' : 'coalesce((SELECT max(started_ms) FROM attempts), 0)'
188 const handoffs = maintenanceHandoffs(db)
189 const clockQuery = databaseVersion(db) >= 3 ? 'max(coalesce((SELECT max(started_ms) FROM attempts), 0), coalesce((SELECT max(checked_ms) FROM host_slots), 0))' : 'coalesce((SELECT max(started_ms) FROM attempts), 0)'
147190 const floors = db.query<{ clock: number, release: number, generation: number }, []>(`SELECT ${clockQuery} AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation`).get()!
148 if (current.clock_floor_ms !== floors.clock || current.release_floor < floors.release || current.generation !== floors.generation || db.query<{ count: number }, []>("SELECT count(*) AS count FROM attempts WHERE phase IN ('pending', 'maintenance')").get()!.count > 1) throw new Error('Inconsistent update-store floors or attempts')
149191 const attempt = db.query<Attempt, []>('SELECT * FROM attempts ORDER BY rowid DESC LIMIT 1').get()
150 return Object.freeze({ schema: 'drone-runtime-store-status-v1', storeId: binding.storeId, storeSha256, enrollmentSha256: binding.enrollmentSha256, generation: current.generation, epoch: metadata.find(row => row.role === 'root')!.epoch, releaseFloor: current.release_floor, clockFloorMs: current.clock_floor_ms, retainedMetadataRecords: counts.metadata, metadata: Object.freeze(metadata.map(row => Object.freeze({ role: row.role, version: row.version, sha256: row.sha256 }))), attempt: attempt ? Object.freeze({ id: attempt.id, phase: attempt.phase, clockSource: attempt.clock_source, startedMs: attempt.started_ms }) : null, physicalDeploymentAllowed: false })
192 const unresolved = activeAttempts(db)
193 if (current.clock_floor_ms !== floors.clock || current.release_floor < floors.release || current.generation !== floors.generation || unresolved.length > 1 || (unresolved.length === 1 && unresolved[0]!.id !== attempt?.id)) throw new Error('Inconsistent update-store floors or attempts')
194 return Object.freeze({ schema: 'drone-runtime-store-status-v1', storeId: binding.storeId, storeSha256, enrollmentSha256: binding.enrollmentSha256, generation: current.generation, epoch: metadata.find(row => row.role === 'root')!.epoch, releaseFloor: current.release_floor, clockFloorMs: current.clock_floor_ms, retainedMetadataRecords: counts.metadata, metadata: Object.freeze(metadata.map(row => Object.freeze({ role: row.role, version: row.version, sha256: row.sha256 }))), attempt: attempt ? attemptObservation(db, attempt) : null, maintenanceHandoffs: handoffs, physicalDeploymentAllowed: false })
151195}
152196
153197async function withStore<T>(options: StoreReference, operation: (db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string) => T | Promise<T>): Promise<T> {
@@ -181,10 +225,10 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b
181225 summary(db, binding, options.storeSha256)
182226 for (const row of candidateRows(db)) ownedDescription(db, row, trusted)
183227 if (readdirSync(path).includes('host-slots')) {
184 if (databaseVersion(db) !== 3) throw new Error('Host slot files require an owned format3 store')
228 if (databaseVersion(db) < 3) throw new Error('Host slot files require an owned format3 or later store')
185229 protectedDirectory(join(path, 'host-slots'))
186230 }
187 if (databaseVersion(db) === 3) slotsSummary(db, binding, trusted, path, options.storeSha256)
231 if (databaseVersion(db) >= 3) slotsSummary(db, binding, trusted, path, options.storeSha256)
188232 return await operation(db, binding, trusted, path)
189233 }
190234 finally {
@@ -233,7 +277,7 @@ export interface RuntimeUpdateClock {
233277}
234278
235279export interface RuntimeUpdateProgress {
236 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired' | 'slot-selected' | 'slot-image-written' | 'runtime-started' | 'runtime-input-committed' | 'final-clock-committed' | 'runtime-confirmed'
280 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired' | 'slot-selected' | 'slot-image-written' | 'runtime-started' | 'runtime-input-committed' | 'final-clock-committed' | 'runtime-confirmed' | 'maintenance-handoff-committed'
237281 ordinal: number
238282}
239283type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
@@ -300,11 +344,15 @@ interface VerifiedSelection {
300344 path: string
301345}
302346
303async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
347async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void, previousAttemptId?: string): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
304348 options = { ...options }
305349 return withStore(options, async (db, binding, trusted, path) => {
306350 before?.(db)
307 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
351 const unresolved = activeAttempts(db)
352 if (previousAttemptId) {
353 if (unresolved.length !== 1 || unresolved[0]!.id !== previousAttemptId) throw new Error('Exact unresolved update attempt required for maintenance continuation')
354 }
355 else if (unresolved.length) throw new Error('An unresolved update attempt requires recovery')
308356 const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms)
309357 if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached')
310358 const source = realpathSync(options.repository); directory(source)
@@ -312,9 +360,24 @@ async function refreshAndComplete<T>(options: RefreshOptions, complete: (selecti
312360 let requests = 0, received = 0, observations = 0
313361 try {
314362 db.transaction(() => {
363 if (previousAttemptId && databaseVersion(db) !== 4) {
364 const version = databaseVersion(db)
365 if (version < 2) for (const sql of candidateSchema) db.exec(sql)
366 if (version < 3) {
367 for (const sql of slotSchema) db.exec(sql)
368 db.exec('INSERT INTO host_roles VALUES(1, NULL, NULL)')
369 }
370 for (const sql of maintenanceSchema) db.exec(sql)
371 db.exec('PRAGMA user_version=4')
372 }
315373 db.query("INSERT INTO attempts VALUES(?, ?, ?, ?, 'pending', NULL)").run(attempt, current.generation, now.time.getTime(), now.source)
316374 db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(now.time.getTime())
375 if (previousAttemptId) db.query('INSERT INTO maintenance_handoffs VALUES(?, ?, ?)').run(previousAttemptId, attempt, current.generation)
317376 })()
377 if (previousAttemptId) {
378 checkSchema(db, [...schema, ...candidateSchema, ...slotSchema, ...maintenanceSchema], 4)
379 await options.onProgress?.(Object.freeze({ phase: 'maintenance-handoff-committed', ordinal: 0 }))
380 }
318381 const fetcher = {
319382 async downloadBytes(url: string, limit: number) {
320383 const address = new URL(url), path = address.pathname.slice('/metadata/'.length)
@@ -370,6 +433,13 @@ export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise
370433 return (await refreshAndComplete(options, async () => undefined)).store
371434}
372435
436/** Explicitly retain an unresolved attempt and require a fresh fully verified successor. No trust/floor reset. */
437export async function continueRuntimeTrustMaintenance(options: RefreshOptions & { previousAttemptId: string, acknowledgeUnresolvedInputs: true }): Promise<Readonly<RuntimeTrustStoreSummary>> {
438 options = { ...options }
439 if (options.acknowledgeUnresolvedInputs !== true || typeof options.previousAttemptId !== 'string' || !/^[a-f0-9]{32}$/.test(options.previousAttemptId)) throw new Error('Explicit acknowledgement and exact unresolved attempt identity required')
440 return (await refreshAndComplete(options, async () => undefined, undefined, options.previousAttemptId)).store
441}
442
373443export interface PreparedRuntimeFirmware {
374444 schema: 'drone-runtime-prepared-image-v1'
375445 store: Readonly<RuntimeTrustStoreSummary>
@@ -427,7 +497,7 @@ export interface RuntimeFirmwareCandidate {
427497}
428498export interface RuntimeFirmwareCandidates {
429499 schema: 'drone-runtime-candidates-v1'
430 stateFormat: 1 | 2 | 3
500 stateFormat: 1 | 2 | 3 | 4
431501 store: Readonly<RuntimeTrustStoreSummary>
432502 retainedCandidates: number
433503 /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */
@@ -511,7 +581,7 @@ function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<Run
511581 candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false })
512582 }
513583 }
514 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
584 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3 | 4, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
515585}
516586
517587/** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */
@@ -579,7 +649,7 @@ export interface RuntimeHostSlot {
579649}
580650export interface RuntimeFirmwareSlots {
581651 schema: 'drone-runtime-host-slots-v1'
582 stateFormat: 1 | 2 | 3
652 stateFormat: 1 | 2 | 3 | 4
583653 store: Readonly<RuntimeTrustStoreSummary>
584654 installed: Readonly<RuntimeHostSlot> | null
585655 knownGood: Readonly<RuntimeHostSlot> | null
@@ -639,7 +709,7 @@ function slotsSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeT
639709 slots.set(row.id, Object.freeze({ id: row.id, candidateId: row.candidate_id, phase: row.phase, clockCheckedMs: row.checked_ms, confirmedClockMs: row.completed_ms, description, health: slotHealth(db, row), integrity, currentTrustValidated: false }))
640710 }
641711 let installed: Readonly<RuntimeHostSlot> | null = null, knownGood: Readonly<RuntimeHostSlot> | null = null
642 if (databaseVersion(db) === 3) {
712 if (databaseVersion(db) >= 3) {
643713 const roles = db.query<{ id: number, installed_id: string | null, good_id: string | null }, []>('SELECT * FROM host_roles').all()
644714 if (roles.length !== 1 || roles[0]!.id !== 1) throw new Error('Invalid host slot roles')
645715 const role = roles[0]!
@@ -647,7 +717,7 @@ function slotsSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeT
647717 knownGood = role.good_id ? slots.get(role.good_id) ?? null : null
648718 if ((role.installed_id && !installed) || (role.good_id && (!knownGood || knownGood.phase !== 'confirmed')) || installed?.phase === 'selected') throw new Error('Invalid installed or known-good host role')
649719 }
650 return Object.freeze({ schema: 'drone-runtime-host-slots-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3, store: summary(db, binding, storeSha256), installed, knownGood, trial: rows.length ? slots.get(rows.at(-1)!.id)! : null, retainedSlots: rows.length, physicalDeploymentAllowed: false })
720 return Object.freeze({ schema: 'drone-runtime-host-slots-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3 | 4, store: summary(db, binding, storeSha256), installed, knownGood, trial: rows.length ? slots.get(rows.at(-1)!.id)! : null, retainedSlots: rows.length, physicalDeploymentAllowed: false })
651721}
652722
653723/** Historical host role/evidence inspection. This never authorizes use under current trust. */
@@ -778,7 +848,7 @@ async function runHostTrial(options: RefreshOptions & { project: string }, candi
778848 if (hostRows(db).some(row => row.phase === 'selected' || row.phase === 'running')) throw new Error('Interrupted host trial requires explicit recovery')
779849 if (hostRows(db).length >= 128) throw new Error('Host slot history budget reached')
780850 if (!candidateId) {
781 if (databaseVersion(db) !== 3) throw new Error('No retained known-good host image for rollback')
851 if (databaseVersion(db) < 3) throw new Error('No retained known-good host image for rollback')
782852 const role = db.query<{ good_id: string | null }, []>('SELECT good_id FROM host_roles WHERE id = 1').get()
783853 const previous = hostRows(db).find(row => row.id === role?.good_id && row.phase === 'confirmed')
784854 if (!previous) throw new Error('No retained known-good host image for rollback')
@@ -802,7 +872,7 @@ function causedBy(error: unknown, marker: Error) {
802872export async function recoverRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> {
803873 options = { ...options }
804874 return withStore(options, async (db, binding, trusted) => {
805 const pending = db.query<Attempt, []>("SELECT * FROM attempts WHERE phase IN ('pending', 'maintenance')").all()
875 const pending = activeAttempts(db)
806876 if (pending.length !== 1) throw new Error('Exactly one unresolved attempt is required for recovery')
807877 const attempt = pending[0]!, current = state(db, binding)
808878 const inputs = db.query<InputRow, [string]>('SELECT * FROM inputs WHERE attempt_id = ? ORDER BY ordinal').all(attempt.id)
tests/fixtures/updates/maintenance-interruptions.tsadded+122-0
Changes to tests/fixtures/updates/maintenance-interruptions.ts
@@ -0,0 +1,122 @@
1import assert from 'node:assert/strict'
2import { randomBytes } from 'node:crypto'
3import { mkdir, readFile, writeFile } from 'node:fs/promises'
4import { basename, dirname, join, resolve } from 'node:path'
5import { continueRuntimeTrustMaintenance, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
6import { runBoundedHostProcess } from '../../../resources/functions/host/process'
7import { maintenanceScenario } from './maintenance-scenario'
8
9const cuts = ['maintenance-handoff-committed', 'input-committed', 'metadata-committed'] as const
10const clock = { source: 'synthetic-independent-maintenance-clock', read: () => new Date('2030-01-01T00:00:00Z') }
11
12function object(value: unknown) { assert.ok(value && typeof value === 'object' && !Array.isArray(value)); return value as Record<string, unknown> }
13function text(value: unknown) { assert.equal(typeof value, 'string'); return value as string }
14function hex(value: unknown, length: 32 | 64) { const result = text(value); assert.match(result, new RegExp(`^[a-f0-9]{${length}}$`)); return result }
15async function input(path: string) {
16 const root = dirname(resolve(path)); assert.equal(basename(path), 'worker.json')
17 assert.ok(Bun.file(path).size < 16384)
18 const value = object(JSON.parse(await readFile(path, 'utf8'))), marker = object(await Bun.file(join(root, 'fixture.json')).json())
19 assert.equal(marker.kind, 'maintenance-process-cut-v1'); assert.equal(value.nonce, marker.nonce); hex(value.nonce, 32)
20 assert.equal(value.repository, join(root, 'repository')); assert.ok(cuts.some(cut => cut === value.cut))
21 const reference = object(value.reference)
22 assert.equal(reference.directory, join(root, 'store')); assert.equal(reference.enrollmentDirectory, join(root, 'enrollment'))
23 return { cut: value.cut, repository: text(value.repository), previousAttemptId: hex(value.previousAttemptId, 32), reference: { directory: text(reference.directory), enrollmentDirectory: text(reference.enrollmentDirectory), enrollmentSha256: hex(reference.enrollmentSha256, 64), storeSha256: hex(reference.storeSha256, 64) } }
24}
25
26async function worker(mode: string, path: string) {
27 const value = await input(path)
28 if (mode === 'contend') {
29 try { await inspectRuntimeTrustStore(value.reference); process.exitCode = 18 }
30 catch (error) {
31 const code = error && typeof error === 'object' ? Object.getOwnPropertyDescriptor(error, 'code')?.value : undefined
32 console.log(JSON.stringify({ blocked: code === 'SQLITE_BUSY', code })); process.exitCode = code === 'SQLITE_BUSY' ? 17 : 19
33 }
34 }
35 else if (mode === 'recover') console.log(JSON.stringify(await recoverRuntimeTrustStore(value.reference)))
36 else {
37 assert.equal(mode, 'hold')
38 await continueRuntimeTrustMaintenance({ ...value.reference, repository: value.repository, previousAttemptId: value.previousAttemptId, acknowledgeUnresolvedInputs: true, clock, async onProgress(event) {
39 if (event.phase !== value.cut || event.ordinal !== 0) return
40 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
41 await Bun.stdin.text()
42 } })
43 throw new Error('Held maintenance worker unexpectedly completed')
44 }
45}
46
47/** Real SIGKILL, competing writer and fresh-process active-successor recovery. */
48export async function qualifyMaintenanceInterruptions(directory: string) {
49 directory = resolve(directory); await mkdir(directory, { mode: 0o700 })
50 const results = []
51 for (const cut of cuts) {
52 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
53 const scenario = await maintenanceScenario(root)
54 await scenario.signed.publish({ image: scenario.signed.image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
55 const path = join(root, 'worker.json'), config = join(root, 'worker.bunfig.toml'), nonce = randomBytes(16).toString('hex')
56 await writeFile(join(root, 'fixture.json'), JSON.stringify({ kind: 'maintenance-process-cut-v1', nonce }), { mode: 0o600 })
57 await writeFile(path, JSON.stringify({ nonce, cut, reference: scenario.reference, repository: scenario.source, previousAttemptId: scenario.failed.attempt!.id }), { mode: 0o600 })
58 await writeFile(config, 'preload=[]\n', { mode: 0o600 })
59 const argv = [process.execPath, '--no-env-file', `--config=${config}`, import.meta.path]
60 const env = { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }
61 const child = Bun.spawn([...argv, 'hold', path], { cwd: root, env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
62 let deadlineFired = false
63 const timer = setTimeout(() => { deadlineFired = true; child.kill('SIGKILL') }, 15000)
64 const reader = child.stdout.getReader(), decoder = new TextDecoder()
65 let stdout = '', stderr: string | undefined
66 try {
67 while (!stdout.includes('\n')) {
68 const part = await reader.read()
69 if (part.done) throw new Error('Maintenance worker exited before its requested cut')
70 stdout += decoder.decode(part.value, { stream: true }); assert.ok(stdout.length <= 4096)
71 }
72 assert.deepEqual(JSON.parse(stdout.trim()), { ready: true, phase: cut, ordinal: 0 })
73 const contender = await runBoundedHostProcess([...argv, 'contend', path], { cwd: root, env, timeoutMs: 10000, stdoutBytes: 4096, stderrBytes: 4096, label: 'Competing maintenance writer' })
74 await writeFile(join(root, 'contender.json'), JSON.stringify(contender, null, 2))
75 assert.equal(contender.exitCode, 17); assert.equal(contender.stderr, '')
76 assert.deepEqual(JSON.parse(contender.stdout), { blocked: true, code: 'SQLITE_BUSY' })
77 child.kill('SIGKILL'); const exitCode = await child.exited
78 clearTimeout(timer)
79 assert.equal(child.signalCode, 'SIGKILL'); assert.notEqual(exitCode, 0); assert.equal(deadlineFired, false)
80 stderr = await new Response(child.stderr).text(); assert.ok(stderr.length <= 65536); assert.equal(stderr, '')
81 const interrupted = await inspectRuntimeTrustStore(scenario.reference)
82 assert.equal(interrupted.attempt!.phase, 'pending')
83 assert.equal(interrupted.maintenanceHandoffs.length, 1)
84 assert.deepEqual(interrupted.maintenanceHandoffs[0]!.previousAttempt, scenario.failed.attempt)
85 assert.equal(interrupted.maintenanceHandoffs[0]!.nextAttemptId, interrupted.attempt!.id)
86 assert.equal(interrupted.releaseFloor, 9)
87 await writeFile(join(root, 'interrupted.json'), JSON.stringify(interrupted, null, 2))
88 const recoveredProcess = await runBoundedHostProcess([...argv, 'recover', path], { cwd: root, env, timeoutMs: 15000, stdoutBytes: 65536, stderrBytes: 4096, label: 'Fresh maintenance recovery process' })
89 await writeFile(join(root, 'recovery-process.json'), JSON.stringify(recoveredProcess, null, 2))
90 assert.equal(recoveredProcess.exitCode, 0); assert.equal(recoveredProcess.stderr, '')
91 const recovered = await inspectRuntimeTrustStore(scenario.reference)
92 assert.deepEqual(JSON.parse(recoveredProcess.stdout), recovered)
93 assert.equal(recovered.attempt!.phase, 'recovered'); assert.equal(recovered.attempt!.id, interrupted.attempt!.id)
94 assert.deepEqual(recovered.maintenanceHandoffs[0]!.previousAttempt, scenario.failed.attempt)
95 assert.equal(recovered.metadata.find(row => row.role === 'root')!.version, cut === 'maintenance-handoff-committed' ? 1 : 2)
96 assert.equal(recovered.releaseFloor, 9)
97 const fresh = await refreshRuntimeTrustStore({ ...scenario.reference, repository: scenario.source, clock })
98 assert.equal(fresh.attempt!.phase, 'verified'); assert.equal(fresh.releaseFloor, 9)
99 assert.deepEqual(fresh.maintenanceHandoffs[0]!.previousAttempt, scenario.failed.attempt)
100 const prepared = await reserveRuntimeFirmwareCandidate({ ...scenario.reference, repository: scenario.source, clock })
101 assert.equal(prepared.stateFormat, 4); assert.equal(prepared.candidate!.phase, 'prepared')
102 assert.equal(prepared.candidate!.integrity, 'recorded-bytes-match'); assert.equal(prepared.candidate!.currentTrustValidated, false)
103 results.push({ cut, pid: child.pid, exitCode, signal: child.signalCode, deadlineFired, contenderExit: contender.exitCode, previous: scenario.failed, interrupted, recovered, fresh, prepared })
104 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'maintenance-host-process-cuts-v1', results, physicalDeploymentAllowed: false }, null, 2))
105 }
106 finally {
107 clearTimeout(timer)
108 if (child.exitCode === null) child.kill('SIGKILL')
109 await child.exited; reader.releaseLock()
110 await writeFile(join(root, 'worker.stdout'), stdout)
111 if (stderr === undefined) stderr = await new Response(child.stderr).text()
112 await writeFile(join(root, 'worker.stderr'), stderr)
113 }
114 }
115 return { evidence: 'maintenance-host-process-cuts-v1', cuts: results.length, physicalDeploymentAllowed: false }
116}
117
118if (import.meta.main) {
119 if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
120 else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyMaintenanceInterruptions(process.argv[2]!)))
121 else throw new Error('Use maintenance-interruptions.ts NEW_DIRECTORY')
122}
tests/fixtures/updates/maintenance-scenario.tsadded+19-0
Changes to tests/fixtures/updates/maintenance-scenario.ts
@@ -0,0 +1,19 @@
1import assert from 'node:assert/strict'
2import { join } from 'node:path'
3import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
4import { createRuntimeTrustStore, inspectRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
5import { platform, repository, target } from './repository'
6
7/** Real signed release 9 followed by a deliberately wrong-key targets signature. */
8export async function maintenanceScenario(root: string, clock = { source: 'independent-maintenance-clock', read: () => new Date('2030-01-01T00:00:00Z') }) {
9 const source = join(root, 'repository'), signed = await repository(source, { custom: { releaseSequence: 9 } })
10 const enrollmentDirectory = join(root, 'enrollment')
11 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
12 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
13 const reference = { directory: join(root, 'store'), enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
14 const good = await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
15 await signed.publish({ image: signed.image, version: 2, custom: { releaseSequence: 9 }, badTargetsSignature: true })
16 await assert.rejects(refreshRuntimeTrustStore({ ...reference, repository: source, clock }))
17 const failed = await inspectRuntimeTrustStore(reference)
18 return { source, signed, reference, clock, good, failed }
19}
tests/fixtures/updates/qualify-maintenance.tsadded+92-0
Changes to tests/fixtures/updates/qualify-maintenance.ts
@@ -0,0 +1,92 @@
1import assert from 'node:assert/strict'
2import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
3import { join, resolve } from 'node:path'
4import { buildRuntime } from '../../../resources/functions/drone/runtime/build'
5import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
6import { createRuntimeTrustStore, inspectRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, trialRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
7import { runBoundedHostProcess } from '../../../resources/functions/host/process'
8import { createHostSourceProject } from './host-project'
9import { digest, repository, target } from './repository'
10
11/** Copied real executable outside the checkout, signed owned host image and format-4 health. */
12export async function qualifyMaintenanceCommands(directory: string, executable: string, olderExecutable?: string) {
13 directory = resolve(directory); executable = resolve(executable)
14 await mkdir(directory, { mode: 0o700 })
15 const binary = join(directory, 'drone'), binaryHash = digest(await readFile(executable))
16 await copyFile(executable, binary); await chmod(binary, 0o700)
17 assert.equal(digest(await readFile(binary)), binaryHash)
18 const { project, sourceRevision } = await createHostSourceProject(directory)
19 const built = await buildRuntime({ project }), image = await readFile(built.artifact)
20 const source = join(directory, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision, releaseSequence: 9 } })
21 const enrollmentDirectory = join(directory, 'enrollment')
22 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
23 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(directory, 'store') })
24 const reference = { directory: join(directory, 'store'), enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
25 const clock = { source: 'operator-managed-command-fixture-clock', read: () => new Date() }
26 const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
27 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: candidate.candidate!.id })
28 await signed.publish({ image, version: 2, custom: { releaseSequence: 9 }, badTargetsSignature: true })
29 await assert.rejects(refreshRuntimeTrustStore({ ...reference, repository: source, clock }))
30 const failed = await inspectRuntimeTrustStore(reference)
31 await signed.publish({ image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
32 const flags = ['--enrollment', enrollmentDirectory, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256]
33 const freshFlags = [...flags, '--repository', source, '--trusted-system-clock-source', clock.source]
34 const operations: { args: string[], exitCode: number }[] = []
35 async function command(args: string[], expected = 0, artifact = binary) {
36 const result = await runBoundedHostProcess([artifact, ...args], { cwd: directory, timeoutMs: 30000, stdoutBytes: 262144, stderrBytes: 65536, label: 'Copied maintenance CLI qualification' })
37 const ordinal = operations.length
38 await writeFile(join(directory, `command-${ordinal}.stdout`), result.stdout)
39 await writeFile(join(directory, `command-${ordinal}.stderr`), result.stderr)
40 operations.push({ args, exitCode: result.exitCode })
41 await writeFile(join(directory, 'operations.json'), JSON.stringify(operations, null, 2))
42 assert.equal(result.exitCode, expected, result.stderr)
43 if (expected === 0) { assert.equal(result.stderr, ''); return JSON.parse(result.stdout) }
44 assert.equal(result.stdout, '')
45 assert.equal(result.stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n')
46 return null
47 }
48 const maintain = ['updates:maintain', reference.directory, ...freshFlags, '--attempt', failed.attempt!.id]
49 await command(maintain, 1)
50 assert.deepEqual(await inspectRuntimeTrustStore(reference), failed)
51 const verified = await command([...maintain, '--acknowledge-unresolved-inputs'])
52 assert.equal(verified.attempt.phase, 'verified'); assert.equal(verified.releaseFloor, 9)
53 assert.deepEqual(verified.maintenanceHandoffs[0].previousAttempt, failed.attempt)
54 assert.deepEqual(await command(['updates:store', reference.directory, ...flags]), verified)
55 const maintained = await command(['updates:slots', reference.directory, ...flags])
56 assert.equal(maintained.stateFormat, 4); assert.deepEqual(maintained.knownGood, good.knownGood)
57 assert.deepEqual(maintained.installed, good.installed)
58 await command([...maintain, '--acknowledge-unresolved-inputs'], 1)
59 await command(['updates:recover', reference.directory, ...flags], 1)
60 const prepared = await command(['updates:reserve', reference.directory, ...freshFlags])
61 assert.equal(prepared.stateFormat, 4); assert.equal(prepared.candidate.phase, 'prepared')
62 const fresh = await command(['updates:trial', reference.directory, ...freshFlags, '--candidate', prepared.candidate.id, '--project', project])
63 assert.equal(fresh.stateFormat, 4); assert.equal(fresh.knownGood.health.cleanProcessExit, true)
64 assert.notEqual(fresh.knownGood.id, good.knownGood!.id)
65 assert.notEqual(fresh.knownGood.health.bootId, good.knownGood!.health!.bootId)
66 assert.notEqual(fresh.knownGood.health.sessionId, good.knownGood!.health!.sessionId)
67 const rollback = await command(['updates:rollback', reference.directory, ...freshFlags, '--project', project])
68 assert.notEqual(rollback.knownGood.health.bootId, fresh.knownGood.health.bootId)
69 assert.equal(rollback.knownGood.health.cleanProcessExit, true)
70 assert.equal(rollback.physicalDeploymentAllowed, false)
71 const oldImage = join(reference.directory, 'host-slots', good.knownGood!.id, 'image.bin')
72 assert.deepEqual(await readFile(oldImage), image)
73 let oldReader: { qualified: false } | { qualified: true, executableSha256: string, databasesUnchanged: true } = { qualified: false }
74 if (olderExecutable) {
75 olderExecutable = resolve(olderExecutable)
76 const oldHash = digest(await readFile(olderExecutable)), paths = ['state.sqlite', 'writer.sqlite'].map(name => join(reference.directory, name))
77 const before = await Promise.all(paths.map(async path => digest(await readFile(path))))
78 await command(['updates:store', reference.directory, ...flags], 1, olderExecutable)
79 assert.deepEqual(await Promise.all(paths.map(async path => digest(await readFile(path)))), before)
80 assert.equal(digest(await readFile(olderExecutable)), oldHash)
81 oldReader = { qualified: true, executableSha256: oldHash, databasesUnchanged: true }
82 }
83 assert.equal(digest(await readFile(binary)), binaryHash)
84 const result = { evidence: 'copied-maintenance-command-v1', sourceRevision, executableSha256: binaryHash, operations: operations.length, retainedAttempt: failed.attempt, maintenanceHandoffs: rollback.store.maintenanceHandoffs, previousBootId: good.knownGood!.health!.bootId, freshBootId: fresh.knownGood.health.bootId, rollbackBootId: rollback.knownGood.health.bootId, releaseFloor: rollback.store.releaseFloor, oldReader, physicalDeploymentAllowed: rollback.physicalDeploymentAllowed }
85 await writeFile(join(directory, 'qualification.json'), JSON.stringify(result, null, 2))
86 return result
87}
88
89if (import.meta.main) {
90 assert.ok(process.argv.length === 4 || process.argv.length === 5)
91 console.log(JSON.stringify(await qualifyMaintenanceCommands(process.argv[2]!, process.argv[3]!, process.argv[4])))
92}
tests/fixtures/updates/repository.tsmodified+2-2
Changes to tests/fixtures/updates/repository.ts
@@ -59,7 +59,7 @@ export async function repository(directory: string, options: { image?: Uint8Arra
5959 const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${imageHash}.pico2-bench.bin`)
6060 await writeFile(imagePath, image)
6161 let publishedTargetsSigner = keys.targets, publishedRootVersion = root.version
62 async function publish(update: { image: Uint8Array, version: number, custom?: Record<string, string | number | boolean>, rotateTargets?: boolean }) {
62 async function publish(update: { image: Uint8Array, version: number, custom?: Record<string, string | number | boolean>, rotateTargets?: boolean, badTargetsSignature?: true }) {
6363 if (update.rotateTargets) {
6464 if (options.rotate) throw new Error('Fixture target rotation requires its original root signer')
6565 publishedTargetsSigner = signer()
@@ -71,7 +71,7 @@ export async function repository(directory: string, options: { image?: Uint8Arra
7171 }
7272 const bytes = Buffer.from(update.image), hash = digest(bytes), revision = { ...fields, version: update.version }
7373 const nextTargets = new Metadata(new Targets({ ...revision, targets: { [target]: new TargetFile({ path: target, length: bytes.length, hashes: { sha256: hash }, unrecognizedFields: { custom: { drone: { ...custom, ...update.custom } } } }) } }))
74 nextTargets.sign(publishedTargetsSigner.sign)
74 nextTargets.sign(update.badTargetsSignature ? signer().sign : publishedTargetsSigner.sign)
7575 const targetsBytes = Buffer.from(JSON.stringify(nextTargets.toJSON()))
7676 const nextSnapshot = new Metadata(new Snapshot({ ...revision, meta: { 'targets.json': new MetaFile({ version: update.version, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } }))
7777 nextSnapshot.sign(keys.snapshot.sign)
tests/unit/updates-maintenance-cli.test.tsadded+36-0
Changes to tests/unit/updates-maintenance-cli.test.ts
@@ -0,0 +1,36 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { inspectRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
7import { maintenanceScenario } from '../fixtures/updates/maintenance-scenario'
8
9test('native maintenance requires explicit unresolved-input acknowledgement and verifies fresh metadata with retained pins', async () => {
10 const root = await mkdtemp(join(tmpdir(), 'drone-maintenance-cli-'))
11 let completed = false
12 try {
13 const item = await maintenanceScenario(root, { source: 'operator-managed-test-clock', read: () => new Date() })
14 await item.signed.publish({ image: item.signed.image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
15 const flags = ['--enrollment', item.reference.enrollmentDirectory, '--enrollment-sha256', item.reference.enrollmentSha256, '--store-sha256', item.reference.storeSha256, '--repository', item.source, '--trusted-system-clock-source', 'operator-managed-test-clock', '--attempt', item.failed.attempt!.id]
16 const noAcknowledgement = await command(['updates:maintain', item.reference.directory, ...flags]).withTimeout(20000)
17 expect(noAcknowledgement.exitCode).toBe(1)
18 expect(noAcknowledgement.stdout).toBe('')
19 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(item.failed)
20 const result = await command(['updates:maintain', item.reference.directory, ...flags, '--acknowledge-unresolved-inputs']).withTimeout(20000)
21 expect(result.exitCode).toBe(0)
22 const observed = JSON.parse(result.stdout)
23 expect(observed.attempt.phase).toBe('verified')
24 expect(observed.maintenanceHandoffs[0].previousAttempt).toEqual(item.failed.attempt)
25 expect(observed.releaseFloor).toBe(9)
26 expect(observed.physicalDeploymentAllowed).toBe(false)
27 const staleAttempt = await command(['updates:maintain', item.reference.directory, ...flags, '--acknowledge-unresolved-inputs']).withTimeout(20000)
28 expect(staleAttempt.exitCode).toBe(1)
29 expect(staleAttempt.stdout).toBe('')
30 completed = true
31 }
32 finally {
33 if (completed) await rm(root, { recursive: true, force: true })
34 else console.error(`Failed maintenance CLI case retained at ${root}`)
35 }
36}, 120000)
tests/unit/updates-maintenance-cuts.test.tsadded+22-0
Changes to tests/unit/updates-maintenance-cuts.test.ts
@@ -0,0 +1,22 @@
1import { expect, test } from 'bun:test'
2import { mkdir, mkdtemp, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { qualifyMaintenanceInterruptions } from '../fixtures/updates/maintenance-interruptions'
6
7test('actual maintenance process cuts retain unresolved predecessors and recover only the active successor', async () => {
8 const evidence = process.env.RUNTIME_MAINTENANCE_EVIDENCE
9 const root = evidence ? resolve(evidence) : await mkdtemp(join(tmpdir(), 'drone-maintenance-cuts-'))
10 if (evidence) await mkdir(root, { mode: 0o700 })
11 let completed = false
12 try {
13 const result = await qualifyMaintenanceInterruptions(join(root, 'campaign'))
14 expect(result.cuts).toBe(3)
15 expect(result.physicalDeploymentAllowed).toBe(false)
16 completed = true
17 }
18 finally {
19 if (completed && !evidence) await rm(root, { recursive: true, force: true })
20 else console.log(`Maintenance process-cut campaign retained at ${root}`)
21 }
22}, 90000)
tests/unit/updates-maintenance-host.test.tsadded+53-0
Changes to tests/unit/updates-maintenance-host.test.ts
@@ -0,0 +1,53 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, readFile, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { buildRuntime } from '../../resources/functions/drone/runtime/build'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { continueRuntimeTrustMaintenance, createRuntimeTrustStore, inspectRuntimeFirmwareSlots, inspectRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
8import { createHostSourceProject } from '../fixtures/updates/host-project'
9import { repository, target } from '../fixtures/updates/repository'
10
11test('format-4 maintenance preserves the known-good image and needs a fresh actual host session for new confirmation', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-maintenance-host-'))
13 let completed = false
14 try {
15 const { project, sourceRevision } = await createHostSourceProject(root)
16 const built = await buildRuntime({ project }), image = await readFile(built.artifact)
17 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision, releaseSequence: 9 } })
18 const enrollmentDirectory = join(root, 'enrollment')
19 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
20 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
21 const reference = { directory: join(root, 'store'), enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
22 const clock = { source: 'independent-maintenance-host-clock', read: () => new Date('2030-01-01T00:00:00Z') }
23 const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
24 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: candidate.candidate!.id })
25 const previousImage = join(reference.directory, 'host-slots', good.knownGood!.id, 'image.bin')
26 await signed.publish({ image, version: 2, custom: { releaseSequence: 9 }, badTargetsSignature: true })
27 await expect(refreshRuntimeTrustStore({ ...reference, repository: source, clock })).rejects.toThrow()
28 const failed = await inspectRuntimeTrustStore(reference)
29 await signed.publish({ image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
30 await continueRuntimeTrustMaintenance({ ...reference, repository: source, clock, previousAttemptId: failed.attempt!.id, acknowledgeUnresolvedInputs: true })
31 const maintained = await inspectRuntimeFirmwareSlots(reference)
32 expect(maintained.stateFormat).toBe(4)
33 expect(maintained.knownGood).toEqual(good.knownGood)
34 expect(maintained.installed).toEqual(good.installed)
35 expect(await readFile(previousImage)).toEqual(image)
36 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
37 const fresh = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: next.candidate!.id })
38 expect(fresh.stateFormat).toBe(4)
39 expect(fresh.knownGood!.id).not.toBe(good.knownGood!.id)
40 expect(fresh.knownGood!.health!.bootId).not.toBe(good.knownGood!.health!.bootId)
41 expect(fresh.knownGood!.health!.sessionId).not.toBe(good.knownGood!.health!.sessionId)
42 expect(fresh.knownGood!.health!.cleanProcessExit).toBe(true)
43 expect(fresh.store.releaseFloor).toBe(9)
44 expect(fresh.store.maintenanceHandoffs[0]!.previousAttempt).toEqual(failed.attempt!)
45 expect(await readFile(previousImage)).toEqual(image)
46 expect(fresh.physicalDeploymentAllowed).toBe(false)
47 completed = true
48 }
49 finally {
50 if (completed) await rm(root, { recursive: true, force: true })
51 else console.error(`Failed maintenance host case retained at ${root}`)
52 }
53}, 120000)
tests/unit/updates-maintenance.test.tsadded+127-0
Changes to tests/unit/updates-maintenance.test.ts
@@ -0,0 +1,127 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, readFile, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
6import { continueRuntimeTrustMaintenance, createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeFirmwareSlots, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
7import { platform, repository, target } from '../fixtures/updates/repository'
8import { maintenanceScenario } from '../fixtures/updates/maintenance-scenario'
9
10// Existing public coordinator seam: retain an unresolved rejection while a fully
11// authenticated fresh successor succeeds. This does not repair a lost trust root.
12test('explicit maintenance retains rejected input and security floors while fresh signed key rotation completes', async () => {
13 const root = await mkdtemp(join(tmpdir(), 'drone-update-maintenance-'))
14 let completed = false
15 try {
16 const source = join(root, 'repository'), signed = await repository(source, { custom: { releaseSequence: 9 } })
17 const enrollmentDirectory = join(root, 'enrollment')
18 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
19 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
20 const reference = { directory: join(root, 'store'), enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
21 const clock = { source: 'independent-maintenance-clock', read: () => new Date('2030-01-01T00:00:00Z') }
22 const good = await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
23 expect(good.releaseFloor).toBe(9)
24 await signed.publish({ image: signed.image, version: 2, custom: { releaseSequence: 9 }, badTargetsSignature: true })
25 await expect(refreshRuntimeTrustStore({ ...reference, repository: source, clock })).rejects.toThrow()
26 const failed = await inspectRuntimeTrustStore(reference)
27 expect(failed.attempt!.phase).toBe('maintenance')
28 expect(failed.attempt!.inputRecords).toBeGreaterThan(0)
29 await expect(recoverRuntimeTrustStore(reference)).rejects.toThrow()
30 expect(await inspectRuntimeTrustStore(reference)).toEqual(failed)
31 await signed.publish({ image: signed.image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
32 const fresh = await continueRuntimeTrustMaintenance({ ...reference, repository: source, clock, previousAttemptId: failed.attempt!.id, acknowledgeUnresolvedInputs: true })
33 expect(fresh.attempt!.phase).toBe('verified')
34 expect(fresh.attempt!.id).not.toBe(failed.attempt!.id)
35 expect(fresh.maintenanceHandoffs).toEqual([{ previousAttempt: failed.attempt!, nextAttemptId: fresh.attempt!.id, generation: failed.generation }])
36 expect(fresh.releaseFloor).toBe(9)
37 expect(fresh.storeSha256).toBe(reference.storeSha256)
38 expect(fresh.enrollmentSha256).toBe(reference.enrollmentSha256)
39 expect(fresh.clockFloorMs).toBe(failed.clockFloorMs)
40 expect(fresh.retainedMetadataRecords).toBeGreaterThan(failed.retainedMetadataRecords)
41 expect(fresh.metadata.map(row => [row.role, row.version])).toEqual([['root', 2], ['snapshot', 3], ['targets', 3], ['timestamp', 3]])
42 expect(fresh.physicalDeploymentAllowed).toBe(false)
43 const slots = await inspectRuntimeFirmwareSlots(reference)
44 expect(slots.stateFormat).toBe(4)
45 expect(slots.knownGood).toBeNull()
46 expect(slots.installed).toBeNull()
47 expect(await inspectRuntimeTrustStore(reference)).toEqual(fresh)
48 completed = true
49 }
50 finally {
51 if (completed) await rm(root, { recursive: true, force: true })
52 else console.error(`Failed maintenance case retained at ${root}`)
53 }
54}, 30000)
55
56test('format-2 maintenance preserves the prepared candidate until explicit retirement and a new reservation', async () => {
57 const root = await mkdtemp(join(tmpdir(), 'drone-maintenance-candidate-'))
58 let completed = false
59 try {
60 const source = join(root, 'repository'), signed = await repository(source, { custom: { releaseSequence: 9 } })
61 const enrollmentDirectory = join(root, 'enrollment')
62 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
63 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
64 const reference = { directory: join(root, 'store'), enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
65 const clock = { source: 'independent-candidate-maintenance-clock', read: () => new Date('2030-01-01T00:00:00Z') }
66 const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
67 expect(prepared.stateFormat).toBe(2)
68 await signed.publish({ image: signed.image, version: 2, custom: { releaseSequence: 9 }, badTargetsSignature: true })
69 await expect(refreshRuntimeTrustStore({ ...reference, repository: source, clock })).rejects.toThrow()
70 const failed = await inspectRuntimeTrustStore(reference)
71 await signed.publish({ image: signed.image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
72 await continueRuntimeTrustMaintenance({ ...reference, repository: source, clock, previousAttemptId: failed.attempt!.id, acknowledgeUnresolvedInputs: true })
73 const retained = await inspectRuntimeFirmwareCandidates(reference)
74 expect(retained.stateFormat).toBe(4)
75 expect(retained.candidate).toEqual(prepared.candidate)
76 expect(retained.store.storeSha256).toBe(reference.storeSha256)
77 expect(retained.store.enrollmentSha256).toBe(reference.enrollmentSha256)
78 expect((await inspectRuntimeFirmwareSlots(reference)).knownGood).toBeNull()
79 await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })).rejects.toThrow('active candidate')
80 await retireRuntimeFirmwareCandidate({ ...reference, candidateId: prepared.candidate!.id })
81 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
82 expect(next.candidate!.id).not.toBe(prepared.candidate!.id)
83 expect(next.candidate!.description.rootVersion).toBe(2)
84 expect(next.store.releaseFloor).toBe(9)
85 expect(await readFile(join(prepared.candidate!.directory, 'image.bin'))).toEqual(signed.image)
86 completed = true
87 }
88 finally {
89 if (completed) await rm(root, { recursive: true, force: true })
90 else console.error(`Failed format-2 maintenance case retained at ${root}`)
91 }
92}, 30000)
93
94test('a rejected maintenance successor keeps the original history and blocks ordinary refresh until explicit recovery or continuation', async () => {
95 const root = await mkdtemp(join(tmpdir(), 'drone-maintenance-rejection-'))
96 let completed = false
97 try {
98 const item = await maintenanceScenario(root)
99 const options = { ...item.reference, repository: item.source, clock: item.clock, previousAttemptId: item.failed.attempt!.id, acknowledgeUnresolvedInputs: true as const }
100 await item.signed.publish({ image: item.signed.image, version: 3, custom: { releaseSequence: 8 }, rotateTargets: true })
101 await expect(continueRuntimeTrustMaintenance(options)).rejects.toThrow('below the trusted minimum')
102 const rejected = await inspectRuntimeTrustStore(item.reference)
103 expect(rejected.releaseFloor).toBe(9)
104 expect(rejected.attempt!.phase).toBe('maintenance')
105 expect(rejected.attempt!.id).not.toBe(item.failed.attempt!.id)
106 expect(rejected.maintenanceHandoffs[0]!.previousAttempt).toEqual(item.failed.attempt!)
107 await expect(refreshRuntimeTrustStore({ ...item.reference, repository: item.source, clock: item.clock })).rejects.toThrow('requires recovery')
108 await expect(continueRuntimeTrustMaintenance(options)).rejects.toThrow('Exact unresolved')
109 await expect(continueRuntimeTrustMaintenance({ ...options, previousAttemptId: rejected.attempt!.id, clock: { source: 'reversed-maintenance-clock', read: () => new Date('2029-01-01T00:00:00Z') } })).rejects.toThrow('reversed')
110 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(rejected)
111 await item.signed.publish({ image: item.signed.image, version: 4, custom: { releaseSequence: 9 } })
112 const verified = await continueRuntimeTrustMaintenance({ ...options, previousAttemptId: rejected.attempt!.id })
113 expect(verified.maintenanceHandoffs).toHaveLength(2)
114 expect(verified.maintenanceHandoffs[0]!.previousAttempt).toEqual(item.failed.attempt!)
115 expect(verified.maintenanceHandoffs[1]!.previousAttempt).toEqual(rejected.attempt!)
116 expect(verified.maintenanceHandoffs[1]!.nextAttemptId).toBe(verified.attempt!.id)
117 expect(verified.attempt!.phase).toBe('verified')
118 expect(verified.releaseFloor).toBe(9)
119 expect(verified.physicalDeploymentAllowed).toBe(false)
120 expect((await refreshRuntimeTrustStore({ ...item.reference, repository: item.source, clock: item.clock })).attempt!.phase).toBe('verified')
121 completed = true
122 }
123 finally {
124 if (completed) await rm(root, { recursive: true, force: true })
125 else console.error(`Failed maintenance rejection retained at ${root}`)
126 }
127}, 30000)