@dsh-enhanced/plugin-control-plane 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +352 -4
- package/bin/dsh-local-release-adapter.js +80 -16
- package/bin/dsh-npm-registry-adapter.js +613 -0
- package/bin/dsh-source-adoption-authority.js +12 -0
- package/bin/dsh-source-approval-authority.js +16 -0
- package/bin/dsh-source-release-authority.js +12 -0
- package/bin/dsh-systemd-host-attestor.js +707 -0
- package/bin/dsh-task-observation-authority.js +15 -0
- package/lib/adapter-process.d.ts +23 -0
- package/lib/adapter-process.d.ts.map +1 -0
- package/lib/adapter-process.js +305 -0
- package/lib/adapter-process.js.map +1 -0
- package/lib/adoption-coordinator.d.ts +63 -0
- package/lib/adoption-coordinator.d.ts.map +1 -0
- package/lib/adoption-coordinator.js +241 -0
- package/lib/adoption-coordinator.js.map +1 -0
- package/lib/adoption-handoff.d.ts +16 -0
- package/lib/adoption-handoff.d.ts.map +1 -0
- package/lib/adoption-handoff.js +11 -0
- package/lib/adoption-handoff.js.map +1 -0
- package/lib/attestation.d.ts.map +1 -1
- package/lib/attestation.js +49 -1
- package/lib/attestation.js.map +1 -1
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +29 -2
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +33 -9
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +624 -178
- package/lib/cli.js.map +1 -1
- package/lib/deployment-readiness.d.ts +38 -0
- package/lib/deployment-readiness.d.ts.map +1 -0
- package/lib/deployment-readiness.js +154 -0
- package/lib/deployment-readiness.js.map +1 -0
- package/lib/effect-blocked-replay.d.ts +81 -0
- package/lib/effect-blocked-replay.d.ts.map +1 -0
- package/lib/effect-blocked-replay.js +259 -0
- package/lib/effect-blocked-replay.js.map +1 -0
- package/lib/errors.d.ts +6 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +9 -0
- package/lib/errors.js.map +1 -0
- package/lib/foreground-deployment-runtime.d.ts +20 -0
- package/lib/foreground-deployment-runtime.d.ts.map +1 -0
- package/lib/foreground-deployment-runtime.js +79 -0
- package/lib/foreground-deployment-runtime.js.map +1 -0
- package/lib/foreground-deployment.d.ts +17 -0
- package/lib/foreground-deployment.d.ts.map +1 -0
- package/lib/foreground-deployment.js +43 -0
- package/lib/foreground-deployment.js.map +1 -0
- package/lib/host-attestor.d.ts +1 -1
- package/lib/host-attestor.d.ts.map +1 -1
- package/lib/host-attestor.js +51 -57
- package/lib/host-attestor.js.map +1 -1
- package/lib/index.d.ts +25 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +11 -2
- package/lib/index.js.map +1 -1
- package/lib/npm-publish.d.ts +25 -0
- package/lib/npm-publish.d.ts.map +1 -0
- package/lib/npm-publish.js +280 -0
- package/lib/npm-publish.js.map +1 -0
- package/lib/owner-task-gap-types.d.ts +21 -0
- package/lib/owner-task-gap-types.d.ts.map +1 -0
- package/lib/owner-task-gap-types.js +2 -0
- package/lib/owner-task-gap-types.js.map +1 -0
- package/lib/owner-task-gaps.d.ts +24 -0
- package/lib/owner-task-gaps.d.ts.map +1 -0
- package/lib/owner-task-gaps.js +74 -0
- package/lib/owner-task-gaps.js.map +1 -0
- package/lib/post-activation.d.ts +33 -0
- package/lib/post-activation.d.ts.map +1 -0
- package/lib/post-activation.js +219 -0
- package/lib/post-activation.js.map +1 -0
- package/lib/registry-fetch.d.ts +50 -0
- package/lib/registry-fetch.d.ts.map +1 -0
- package/lib/registry-fetch.js +267 -0
- package/lib/registry-fetch.js.map +1 -0
- package/lib/release.d.ts +4 -4
- package/lib/release.d.ts.map +1 -1
- package/lib/release.js +142 -56
- package/lib/release.js.map +1 -1
- package/lib/replay-endpoint-protocol.d.ts +65 -0
- package/lib/replay-endpoint-protocol.d.ts.map +1 -0
- package/lib/replay-endpoint-protocol.js +183 -0
- package/lib/replay-endpoint-protocol.js.map +1 -0
- package/lib/replay-endpoint.d.ts +7 -0
- package/lib/replay-endpoint.d.ts.map +1 -0
- package/lib/replay-endpoint.js +193 -0
- package/lib/replay-endpoint.js.map +1 -0
- package/lib/replay-grant.d.ts +62 -0
- package/lib/replay-grant.d.ts.map +1 -0
- package/lib/replay-grant.js +208 -0
- package/lib/replay-grant.js.map +1 -0
- package/lib/replay-journal.d.ts +25 -0
- package/lib/replay-journal.d.ts.map +1 -0
- package/lib/replay-journal.js +275 -0
- package/lib/replay-journal.js.map +1 -0
- package/lib/runtime-observer-protocol.d.ts +63 -0
- package/lib/runtime-observer-protocol.d.ts.map +1 -0
- package/lib/runtime-observer-protocol.js +280 -0
- package/lib/runtime-observer-protocol.js.map +1 -0
- package/lib/runtime-observer.d.ts +8 -0
- package/lib/runtime-observer.d.ts.map +1 -0
- package/lib/runtime-observer.js +182 -0
- package/lib/runtime-observer.js.map +1 -0
- package/lib/service.d.ts +145 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +734 -6
- package/lib/service.js.map +1 -1
- package/lib/source-adoption-authority.d.ts +65 -0
- package/lib/source-adoption-authority.d.ts.map +1 -0
- package/lib/source-adoption-authority.js +271 -0
- package/lib/source-adoption-authority.js.map +1 -0
- package/lib/source-adoption-runner.d.ts +28 -0
- package/lib/source-adoption-runner.d.ts.map +1 -0
- package/lib/source-adoption-runner.js +197 -0
- package/lib/source-adoption-runner.js.map +1 -0
- package/lib/source-approval-authority.d.ts +50 -0
- package/lib/source-approval-authority.d.ts.map +1 -0
- package/lib/source-approval-authority.js +412 -0
- package/lib/source-approval-authority.js.map +1 -0
- package/lib/source-approval-client.d.ts +32 -0
- package/lib/source-approval-client.d.ts.map +1 -0
- package/lib/source-approval-client.js +175 -0
- package/lib/source-approval-client.js.map +1 -0
- package/lib/source-build.d.ts +60 -0
- package/lib/source-build.d.ts.map +1 -0
- package/lib/source-build.js +311 -0
- package/lib/source-build.js.map +1 -0
- package/lib/source-context.d.ts +24 -0
- package/lib/source-context.d.ts.map +1 -0
- package/lib/source-context.js +99 -0
- package/lib/source-context.js.map +1 -0
- package/lib/source-job-types.d.ts +87 -0
- package/lib/source-job-types.d.ts.map +1 -0
- package/lib/source-job-types.js +2 -0
- package/lib/source-job-types.js.map +1 -0
- package/lib/source-jobs.d.ts +113 -0
- package/lib/source-jobs.d.ts.map +1 -0
- package/lib/source-jobs.js +535 -0
- package/lib/source-jobs.js.map +1 -0
- package/lib/source-release-authority.d.ts +35 -0
- package/lib/source-release-authority.d.ts.map +1 -0
- package/lib/source-release-authority.js +271 -0
- package/lib/source-release-authority.js.map +1 -0
- package/lib/source-release-client.d.ts +10 -0
- package/lib/source-release-client.d.ts.map +1 -0
- package/lib/source-release-client.js +24 -0
- package/lib/source-release-client.js.map +1 -0
- package/lib/source-release-runner.d.ts +31 -0
- package/lib/source-release-runner.d.ts.map +1 -0
- package/lib/source-release-runner.js +125 -0
- package/lib/source-release-runner.js.map +1 -0
- package/lib/source-versioning.d.ts +24 -0
- package/lib/source-versioning.d.ts.map +1 -0
- package/lib/source-versioning.js +213 -0
- package/lib/source-versioning.js.map +1 -0
- package/lib/source-workspace.d.ts +117 -0
- package/lib/source-workspace.d.ts.map +1 -0
- package/lib/source-workspace.js +458 -0
- package/lib/source-workspace.js.map +1 -0
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +509 -7
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +229 -6
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2431 -383
- package/lib/store.js.map +1 -1
- package/lib/task-observation-authority.d.ts +29 -0
- package/lib/task-observation-authority.d.ts.map +1 -0
- package/lib/task-observation-authority.js +370 -0
- package/lib/task-observation-authority.js.map +1 -0
- package/lib/task-observation-client.d.ts +4 -0
- package/lib/task-observation-client.d.ts.map +1 -0
- package/lib/task-observation-client.js +20 -0
- package/lib/task-observation-client.js.map +1 -0
- package/lib/task-observation-runtime.d.ts +59 -0
- package/lib/task-observation-runtime.d.ts.map +1 -0
- package/lib/task-observation-runtime.js +331 -0
- package/lib/task-observation-runtime.js.map +1 -0
- package/lib/task-observation-store.d.ts +17 -0
- package/lib/task-observation-store.d.ts.map +1 -0
- package/lib/task-observation-store.js +115 -0
- package/lib/task-observation-store.js.map +1 -0
- package/lib/task-observation-types.d.ts +67 -0
- package/lib/task-observation-types.d.ts.map +1 -0
- package/lib/task-observation-types.js +2 -0
- package/lib/task-observation-types.js.map +1 -0
- package/lib/trust.d.ts +3 -0
- package/lib/trust.d.ts.map +1 -1
- package/lib/trust.js +0 -0
- package/lib/trust.js.map +1 -1
- package/lib/types.d.ts +275 -8
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +40 -5
package/lib/store.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { basename, isAbsolute } from 'node:path';
|
|
3
3
|
import { discover, parseCatalog } from './catalog.js';
|
|
4
4
|
import { parseApprovalReceipt } from './approval.js';
|
|
5
5
|
import { parseSourcePublishReconciliationReceipt, parseSourcePublishReconciliationRequest, parseSourceReleaseAuthorization, parseSourceReleaseReceipt, parseSourceReleaseRequest, parseVerifiedSourceReleaseAuthorization } from './release.js';
|
|
6
|
-
import { controlPlaneOperationReceiptDigest, openControlPlaneDatabase } from './sqlite.js';
|
|
6
|
+
import { controlPlaneOperationReceiptDigest, controlPlaneSchemaVersion, openControlPlaneDatabase } from './sqlite.js';
|
|
7
|
+
import { validateSourceBuildConfig } from './source-build.js';
|
|
8
|
+
import { validateScopedPluginFiles } from './source-workspace.js';
|
|
9
|
+
import { validateAdoptionHandoffTerms } from './adoption-handoff.js';
|
|
10
|
+
import { assertForegroundDeployment, assertForegroundTask } from './foreground-deployment.js';
|
|
11
|
+
import { assertTaskObservationBatch, getTaskObservationRecord, readTaskObservationContext } from './task-observation-store.js';
|
|
7
12
|
const KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
|
|
8
13
|
const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
9
14
|
const PLUGIN_NAME = /^(?=.{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
10
15
|
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
11
16
|
const COMMIT = /^[a-f0-9]{40}$/u;
|
|
12
17
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
13
|
-
const SOURCE_STATUSES = new Set(['pending-approval', 'approved', 'running-local-checks', 'ready-for-human-review',
|
|
18
|
+
const SOURCE_STATUSES = new Set(['expired', 'pending-approval', 'approved', 'running-local-checks', 'ready-for-human-review',
|
|
14
19
|
'local-checks-failed', 'awaiting-pr', 'awaiting-review', 'awaiting-merge', 'awaiting-build', 'awaiting-sign', 'awaiting-publish',
|
|
15
20
|
'awaiting-registry-verify', 'awaiting-catalog-admission', 'release-complete', 'release-failed', 'publish-ambiguous']);
|
|
16
21
|
const SOURCE_PLAN_KEYS = ['schemaVersion', 'kind', 'id', 'gapId', 'gapSnapshot', 'status', 'revision', 'createdAt',
|
|
@@ -62,8 +67,15 @@ function positiveInteger(value, field) {
|
|
|
62
67
|
throw new ControlPlaneStoreError('invalid-input', `${field} must be a positive safe integer`);
|
|
63
68
|
return value;
|
|
64
69
|
}
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 'modify' plans never run scripts/create-plugin; their generator binding is a
|
|
72
|
+
* protocol constant rather than a scaffolding-tool digest.
|
|
73
|
+
*/
|
|
74
|
+
export const MODIFY_GENERATOR_DIGEST = createHash('sha256').update('dsh-source-modify-no-generator-v1').digest('hex');
|
|
75
|
+
function expectedSourceScope(name, mode) {
|
|
76
|
+
return mode === 'create'
|
|
77
|
+
? Object.freeze(['plugins/README.md', `plugins/${name}`].sort())
|
|
78
|
+
: Object.freeze([`plugins/${name}`]);
|
|
67
79
|
}
|
|
68
80
|
function objectRecord(value, label) {
|
|
69
81
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -111,7 +123,8 @@ function activationSnapshotFromStored(value) {
|
|
|
111
123
|
const gapSnapshot = objectRecord(item['gapSnapshot'], 'stored activation gap snapshot');
|
|
112
124
|
exactKeys(gapSnapshot, ['revision', 'inputDigest', 'roi', 'capability'], 'stored activation gap snapshot');
|
|
113
125
|
const dossier = objectRecord(item['dossier'], 'stored activation dossier');
|
|
114
|
-
exactKeys(dossier, ['catalogDigest', 'catalogProvenance', 'matchedCapabilities', 'authorities', 'packages'
|
|
126
|
+
exactKeys(dossier, ['catalogDigest', 'catalogProvenance', 'matchedCapabilities', 'authorities', 'packages',
|
|
127
|
+
...(Object.hasOwn(dossier, 'handoff') ? ['handoff'] : [])], 'stored activation dossier');
|
|
115
128
|
const ledger = objectRecord(item['ledger'], 'stored activation ledger');
|
|
116
129
|
exactKeys(ledger, ['id', 'path'], 'stored activation ledger');
|
|
117
130
|
const target = objectRecord(item['target'], 'stored activation target');
|
|
@@ -144,6 +157,14 @@ function activationSnapshotFromStored(value) {
|
|
|
144
157
|
|| typeof executor['sha256'] !== 'string' || !DIGEST.test(executor['sha256'])) {
|
|
145
158
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation plan snapshot is corrupt');
|
|
146
159
|
}
|
|
160
|
+
if (dossier['handoff'] !== undefined) {
|
|
161
|
+
try {
|
|
162
|
+
validateAdoptionHandoffTerms(dossier['handoff']);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored activation handoff terms are corrupt');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
147
168
|
const immutable = { schemaVersion: 4, kind: 'activation', id: item['id'], gapId: item['gapId'], gapSnapshot,
|
|
148
169
|
profile: item['profile'], candidate, dossier, installationId: item['installationId'], ledger, target, executor,
|
|
149
170
|
createdAt: item['createdAt'], expiresAt: item['expiresAt'] };
|
|
@@ -153,12 +174,18 @@ function activationSnapshotFromStored(value) {
|
|
|
153
174
|
let activation;
|
|
154
175
|
if (item['activation'] !== undefined) {
|
|
155
176
|
const stored = objectRecord(item['activation'], 'stored activation identity');
|
|
156
|
-
const activationOptional = ['targetOriginallyExisted', 'failureCode'].filter(key => Object.hasOwn(stored, key));
|
|
177
|
+
const activationOptional = ['targetOriginallyExisted', 'targetBaselineFiles', 'hostRecoveryRequired', 'rollbackProfileRestored', 'failureCode'].filter(key => Object.hasOwn(stored, key));
|
|
157
178
|
exactKeys(stored, ['id', 'fence', 'updatedAt', ...activationOptional], 'stored activation identity');
|
|
158
179
|
if (typeof stored['id'] !== 'string' || !KEY.test(stored['id']) || !Number.isSafeInteger(stored['fence'])
|
|
159
180
|
|| Number(stored['fence']) < 1 || !Number.isSafeInteger(stored['updatedAt'])
|
|
160
181
|
|| Number(stored['updatedAt']) < Number(item['createdAt'])
|
|
161
182
|
|| (stored['targetOriginallyExisted'] !== undefined && typeof stored['targetOriginallyExisted'] !== 'boolean')
|
|
183
|
+
|| (stored['targetBaselineFiles'] !== undefined && (!Array.isArray(stored['targetBaselineFiles']) || ![0, 3].includes(stored['targetBaselineFiles'].length)
|
|
184
|
+
|| stored['targetBaselineFiles'].some(file => typeof file !== 'object' || file === null || Array.isArray(file)
|
|
185
|
+
|| Object.keys(file).sort().join('\0') !== 'path\0sha256' || typeof file.path !== 'string'
|
|
186
|
+
|| !isAbsolute(file.path) || (file.sha256 !== null && !DIGEST.test(file.sha256)))))
|
|
187
|
+
|| (stored['hostRecoveryRequired'] !== undefined && stored['hostRecoveryRequired'] !== true)
|
|
188
|
+
|| (stored['rollbackProfileRestored'] !== undefined && stored['rollbackProfileRestored'] !== true)
|
|
162
189
|
|| (stored['failureCode'] !== undefined && typeof stored['failureCode'] !== 'string')) {
|
|
163
190
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation identity is corrupt');
|
|
164
191
|
}
|
|
@@ -184,6 +211,95 @@ function gapFromRow(row) {
|
|
|
184
211
|
createdAt: row.created_at, updatedAt: row.updated_at,
|
|
185
212
|
};
|
|
186
213
|
}
|
|
214
|
+
function watchFromRow(row) {
|
|
215
|
+
const exact = { package: row.package_name, version: row.package_version, integrity: row.package_integrity };
|
|
216
|
+
const watch = {
|
|
217
|
+
planId: row.plan_id, exact, activationId: row.activation_id, fence: row.fence,
|
|
218
|
+
state: row.state, revision: row.revision, startedAt: row.started_at, updatedAt: row.updated_at,
|
|
219
|
+
lastHostGeneration: row.last_host_generation, healthyObservations: row.healthy_observations,
|
|
220
|
+
};
|
|
221
|
+
if (row.close_disposition === null) {
|
|
222
|
+
if (row.state !== 'watching' || row.close_at !== null || row.close_evidence_id !== null || row.close_signature_digest !== null) {
|
|
223
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored post-activation watch closure is corrupt');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
if (row.close_at === null || row.close_evidence_id === null || row.close_signature_digest === null
|
|
228
|
+
|| !DIGEST.test(row.close_signature_digest)
|
|
229
|
+
|| (row.state !== 'closed-regressed' && row.state !== 'closed-retracted')) {
|
|
230
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored post-activation watch closure is corrupt');
|
|
231
|
+
}
|
|
232
|
+
watch.close = { disposition: row.close_disposition, at: row.close_at, evidenceId: row.close_evidence_id, signatureDigest: row.close_signature_digest };
|
|
233
|
+
}
|
|
234
|
+
return watch;
|
|
235
|
+
}
|
|
236
|
+
function watchEvidenceFromRow(row) {
|
|
237
|
+
if (!DIGEST.test(row.receipt_digest) || !DIGEST.test(row.signature_digest)) {
|
|
238
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored post-activation evidence is corrupt');
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
observationId: row.observation_id, planId: row.plan_id, disposition: row.disposition,
|
|
242
|
+
receiptDigest: row.receipt_digest, signatureDigest: row.signature_digest, hostGeneration: row.host_generation,
|
|
243
|
+
failures: row.failures, checks: row.checks, createdAt: row.created_at,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function watchFromStored(value) {
|
|
247
|
+
const item = objectRecord(value, 'stored post-activation watch');
|
|
248
|
+
const exactItem = objectRecord(item.exact, 'stored post-activation watch exact target');
|
|
249
|
+
const watch = {
|
|
250
|
+
planId: boundedString(item.planId, 'planId'),
|
|
251
|
+
exact: {
|
|
252
|
+
package: boundedString(exactItem.package, 'package'), version: boundedString(exactItem.version, 'version'),
|
|
253
|
+
integrity: boundedString(exactItem.integrity, 'integrity'),
|
|
254
|
+
},
|
|
255
|
+
activationId: boundedString(item.activationId, 'activationId'),
|
|
256
|
+
fence: storedInteger(item.fence, 'fence', 1),
|
|
257
|
+
state: item.state === 'watching' || item.state === 'closed-regressed' || item.state === 'closed-retracted'
|
|
258
|
+
? item.state : (() => { throw new ControlPlaneStoreError('invalid-state', 'stored post-activation watch state is corrupt'); })(),
|
|
259
|
+
revision: storedInteger(item.revision, 'revision', 1),
|
|
260
|
+
startedAt: storedInteger(item.startedAt, 'startedAt', 0),
|
|
261
|
+
updatedAt: storedInteger(item.updatedAt, 'updatedAt', 0),
|
|
262
|
+
lastHostGeneration: storedInteger(item.lastHostGeneration, 'lastHostGeneration', 0),
|
|
263
|
+
healthyObservations: storedInteger(item.healthyObservations, 'healthyObservations', 0),
|
|
264
|
+
};
|
|
265
|
+
if (item.close !== undefined) {
|
|
266
|
+
if (item.close === null)
|
|
267
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored post-activation watch closure is corrupt');
|
|
268
|
+
const close = objectRecord(item.close, 'stored post-activation watch closure');
|
|
269
|
+
if (close.disposition !== 'regressed' && close.disposition !== 'retracted')
|
|
270
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored post-activation watch closure is corrupt');
|
|
271
|
+
watch.close = {
|
|
272
|
+
disposition: close.disposition, at: storedInteger(close.at, 'close.at', 0),
|
|
273
|
+
evidenceId: boundedString(close.evidenceId, 'close.evidenceId'),
|
|
274
|
+
signatureDigest: digestField(close.signatureDigest, 'close.signatureDigest'),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return watch;
|
|
278
|
+
}
|
|
279
|
+
function boundedString(value, label) {
|
|
280
|
+
if (typeof value !== 'string' || value === '')
|
|
281
|
+
throw new ControlPlaneStoreError('invalid-state', `stored post-activation watch ${label} is corrupt`);
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
function storedInteger(value, label, minimum) {
|
|
285
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum)
|
|
286
|
+
throw new ControlPlaneStoreError('invalid-state', `stored post-activation watch ${label} is corrupt`);
|
|
287
|
+
return Number(value);
|
|
288
|
+
}
|
|
289
|
+
function digestField(value, label) {
|
|
290
|
+
if (typeof value !== 'string' || !DIGEST.test(value))
|
|
291
|
+
throw new ControlPlaneStoreError('invalid-state', `stored post-activation watch ${label} is corrupt`);
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
function activationCoreFiles(value, targetPath, label) {
|
|
295
|
+
const expectedPaths = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'].map(name => `${targetPath}/${name}`);
|
|
296
|
+
if (!Array.isArray(value) || value.length !== 3 || value.some((item, index) => typeof item !== 'object' || item === null || Array.isArray(item)
|
|
297
|
+
|| Object.keys(item).sort().join('\0') !== 'path\0sha256' || item.path !== expectedPaths[index]
|
|
298
|
+
|| (item.sha256 !== null && !DIGEST.test(item.sha256)))) {
|
|
299
|
+
throw new ControlPlaneStoreError('invalid-state', `${label} is corrupt`);
|
|
300
|
+
}
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
187
303
|
function activationFromRow(row) {
|
|
188
304
|
const candidate = parseCatalog({ schemaVersion: 1, entries: [JSON.parse(row.candidate_json)] }).entries[0];
|
|
189
305
|
const gapSnapshot = JSON.parse(row.gap_snapshot_json);
|
|
@@ -191,6 +307,14 @@ function activationFromRow(row) {
|
|
|
191
307
|
if (candidate === undefined || !DIGEST.test(row.plan_digest) || !UUID.test(row.installation_id)
|
|
192
308
|
|| !DIGEST.test(gapSnapshot.inputDigest) || !DIGEST.test(dossier.catalogDigest))
|
|
193
309
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation plan is corrupt');
|
|
310
|
+
if (dossier.handoff !== undefined) {
|
|
311
|
+
try {
|
|
312
|
+
validateAdoptionHandoffTerms(dossier.handoff);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored activation handoff terms are corrupt');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
194
318
|
const immutable = {
|
|
195
319
|
schemaVersion: 4, kind: 'activation', id: row.id, gapId: row.gap_id, gapSnapshot,
|
|
196
320
|
profile: row.profile, candidate, dossier, installationId: row.installation_id,
|
|
@@ -212,11 +336,26 @@ function activationFromRow(row) {
|
|
|
212
336
|
}
|
|
213
337
|
approval = verifiedApprovalFromStored(storedApproval, row.id, row.plan_digest, row.created_at, row.expires_at);
|
|
214
338
|
}
|
|
339
|
+
let baselineFiles;
|
|
340
|
+
if (row.activation_target_baseline_json !== null) {
|
|
341
|
+
let value;
|
|
342
|
+
try {
|
|
343
|
+
value = JSON.parse(row.activation_target_baseline_json);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored activation baseline is corrupt');
|
|
347
|
+
}
|
|
348
|
+
if (!Array.isArray(value) || ![0, 3].includes(value.length)) {
|
|
349
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored activation baseline is corrupt');
|
|
350
|
+
}
|
|
351
|
+
baselineFiles = value.length === 0 ? [] : activationCoreFiles(value, row.target_path, 'stored activation baseline');
|
|
352
|
+
}
|
|
215
353
|
const requiresApproval = row.status !== 'pending-approval';
|
|
216
354
|
const requiresActivation = !['pending-approval', 'approved'].includes(row.status);
|
|
217
355
|
if (requiresApproval !== (approval !== undefined) || requiresActivation !== (row.activation_id !== null)
|
|
218
356
|
|| (row.activation_id === null && (row.activation_fence !== 0 || row.activation_lease_until !== null
|
|
219
|
-
|| row.activation_target_existed !== null || row.
|
|
357
|
+
|| row.activation_target_existed !== null || row.activation_target_baseline_json !== null || row.host_recovery_required !== 0
|
|
358
|
+
|| row.rollback_profile_restored !== 0 || row.failure_code !== null))
|
|
220
359
|
|| (row.activation_id !== null && (!KEY.test(row.activation_id) || row.activation_fence < 1))
|
|
221
360
|
|| (['rolled-back', 'activated'].includes(row.status) && row.activation_lease_until !== null)) {
|
|
222
361
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation state is corrupt or incomplete');
|
|
@@ -226,19 +365,85 @@ function activationFromRow(row) {
|
|
|
226
365
|
...(approval === undefined ? {} : { approval }),
|
|
227
366
|
...(row.activation_id === null ? {} : { activation: { id: row.activation_id, fence: row.activation_fence,
|
|
228
367
|
...(row.activation_target_existed === null ? {} : { targetOriginallyExisted: row.activation_target_existed === 1 }),
|
|
368
|
+
...(baselineFiles === undefined ? {} : { targetBaselineFiles: baselineFiles }),
|
|
369
|
+
...(row.host_recovery_required === 0 ? {} : { hostRecoveryRequired: true }),
|
|
370
|
+
...(row.rollback_profile_restored === 0 ? {} : { rollbackProfileRestored: true }),
|
|
229
371
|
...(row.failure_code === null ? {} : { failureCode: row.failure_code }), updatedAt: row.updated_at } }),
|
|
230
372
|
};
|
|
231
373
|
}
|
|
374
|
+
function preparedEvidenceFromStored(value) {
|
|
375
|
+
const item = objectRecord(value, 'stored source prepared evidence');
|
|
376
|
+
exactKeys(item, ['schemaVersion', 'kind', 'environment', 'commands', 'pack', 'preparedAt'], 'stored source prepared evidence');
|
|
377
|
+
const environment = objectRecord(item['environment'], 'stored source prepared evidence environment');
|
|
378
|
+
exactKeys(environment, ['npmConfigIgnoreScripts', 'frozenLockfile', 'offline', 'nodeVersion', 'pnpmVersion'], 'stored source prepared evidence environment');
|
|
379
|
+
if (item['schemaVersion'] !== 1 || item['kind'] !== 'dsh-source-prepared-evidence'
|
|
380
|
+
|| environment['npmConfigIgnoreScripts'] !== true || environment['frozenLockfile'] !== true
|
|
381
|
+
|| typeof environment['offline'] !== 'boolean'
|
|
382
|
+
|| typeof environment['nodeVersion'] !== 'string' || environment['nodeVersion'] === '' || environment['nodeVersion'].length > 64
|
|
383
|
+
|| typeof environment['pnpmVersion'] !== 'string' || environment['pnpmVersion'] === '' || environment['pnpmVersion'].length > 64
|
|
384
|
+
|| !Array.isArray(item['commands']) || item['commands'].length === 0 || item['commands'].length > 16
|
|
385
|
+
|| !Number.isSafeInteger(item['preparedAt']) || Number(item['preparedAt']) < 0) {
|
|
386
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source prepared evidence is corrupt');
|
|
387
|
+
}
|
|
388
|
+
const commands = item['commands'].map((entry, index) => {
|
|
389
|
+
const command = objectRecord(entry, `stored source prepared evidence command ${index}`);
|
|
390
|
+
exactKeys(command, ['command', 'args', 'exitCode', 'durationMs', 'logDigest'], `stored source prepared evidence command ${index}`);
|
|
391
|
+
if (typeof command['command'] !== 'string' || command['command'] === '' || command['command'].length > 64
|
|
392
|
+
|| !Array.isArray(command['args']) || command['args'].length > 64
|
|
393
|
+
|| command['args'].some(arg => typeof arg !== 'string' || arg.length > 4096)
|
|
394
|
+
|| command['exitCode'] !== 0
|
|
395
|
+
|| !Number.isSafeInteger(command['durationMs']) || Number(command['durationMs']) < 0
|
|
396
|
+
|| !DIGEST.test(String(command['logDigest']))) {
|
|
397
|
+
throw new ControlPlaneStoreError('invalid-state', `stored source prepared evidence command ${index} is corrupt`);
|
|
398
|
+
}
|
|
399
|
+
return Object.freeze({ command: String(command['command']), args: Object.freeze([...command['args']]),
|
|
400
|
+
exitCode: 0, durationMs: Number(command['durationMs']), logDigest: String(command['logDigest']) });
|
|
401
|
+
});
|
|
402
|
+
const pack = objectRecord(item['pack'], 'stored source prepared evidence pack');
|
|
403
|
+
exactKeys(pack, ['name', 'version', 'sizeBytes', 'sha256'], 'stored source prepared evidence pack');
|
|
404
|
+
if (typeof pack['name'] !== 'string' || pack['name'] === '' || pack['name'].length > 214
|
|
405
|
+
|| typeof pack['version'] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._+=-]{0,63}$/u.test(pack['version'])
|
|
406
|
+
|| !Number.isSafeInteger(pack['sizeBytes']) || Number(pack['sizeBytes']) < 0
|
|
407
|
+
|| !DIGEST.test(String(pack['sha256']))) {
|
|
408
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source prepared evidence pack is corrupt');
|
|
409
|
+
}
|
|
410
|
+
return Object.freeze({ schemaVersion: 1, kind: 'dsh-source-prepared-evidence',
|
|
411
|
+
environment: Object.freeze({ npmConfigIgnoreScripts: true, frozenLockfile: true,
|
|
412
|
+
offline: environment['offline'] === true, nodeVersion: String(environment['nodeVersion']), pnpmVersion: String(environment['pnpmVersion']) }),
|
|
413
|
+
commands: Object.freeze(commands),
|
|
414
|
+
pack: Object.freeze({ name: String(pack['name']), version: String(pack['version']),
|
|
415
|
+
sizeBytes: Number(pack['sizeBytes']), sha256: String(pack['sha256']) }),
|
|
416
|
+
preparedAt: Number(item['preparedAt']) });
|
|
417
|
+
}
|
|
232
418
|
function sourceFromRow(row) {
|
|
419
|
+
if (row.mode !== 'create' && row.mode !== 'modify') {
|
|
420
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source plan has an unknown mode');
|
|
421
|
+
}
|
|
233
422
|
const gapSnapshot = JSON.parse(row.gap_snapshot_json);
|
|
234
423
|
const scope = JSON.parse(row.scope_json);
|
|
235
424
|
const immutable = { schemaVersion: 1, kind: 'source', id: row.id, gapId: row.gap_id, gapSnapshot,
|
|
236
425
|
repository: row.repository, worktree: row.worktree, baseCommit: row.base_commit, name: row.plugin_name,
|
|
237
426
|
generatorDigest: row.generator_digest, scope, createdAt: row.created_at, expiresAt: row.expires_at };
|
|
238
427
|
if (!COMMIT.test(row.base_commit) || !DIGEST.test(row.generator_digest) || !DIGEST.test(gapSnapshot.inputDigest)
|
|
239
|
-
|| controlPlaneDigest(scope) !== controlPlaneDigest(expectedSourceScope(row.plugin_name))
|
|
240
|
-
|| controlPlaneDigest(immutable) !== row.plan_digest)
|
|
428
|
+
|| controlPlaneDigest(scope) !== controlPlaneDigest(expectedSourceScope(row.plugin_name, row.mode)))
|
|
241
429
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan is corrupt or digest-mismatched');
|
|
430
|
+
let preparedEvidence;
|
|
431
|
+
if (row.prepared_evidence_json !== null) {
|
|
432
|
+
try {
|
|
433
|
+
preparedEvidence = preparedEvidenceFromStored(JSON.parse(row.prepared_evidence_json));
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (error instanceof ControlPlaneStoreError) {
|
|
437
|
+
throw new ControlPlaneStoreError('invalid-state', `stored source prepared evidence is corrupt: ${error.message}`);
|
|
438
|
+
}
|
|
439
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source prepared evidence is corrupt');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if ((row.mode === 'modify') !== (preparedEvidence !== undefined)
|
|
443
|
+
|| (row.mode === 'modify' && (row.checked_tree_digest === null || row.checked_patch_digest === null || row.checked_at === null))
|
|
444
|
+
|| (row.mode === 'modify' && ['running-local-checks', 'local-checks-failed'].includes(row.status))) {
|
|
445
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source plan mode does not match its evidence or status');
|
|
446
|
+
}
|
|
242
447
|
let approval;
|
|
243
448
|
if (row.approval_json !== null) {
|
|
244
449
|
let storedApproval;
|
|
@@ -258,6 +463,12 @@ function sourceFromRow(row) {
|
|
|
258
463
|
|| !Number.isSafeInteger(sourceCheck.checkedAt) || sourceCheck.checkedAt < 0))) {
|
|
259
464
|
throw new ControlPlaneStoreError('invalid-state', 'stored source check evidence is corrupt');
|
|
260
465
|
}
|
|
466
|
+
const digestBinding = row.mode === 'modify'
|
|
467
|
+
? { ...immutable, mode: row.mode, sourceCheck, preparedEvidence }
|
|
468
|
+
: immutable;
|
|
469
|
+
if (controlPlaneDigest(digestBinding) !== row.plan_digest) {
|
|
470
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source plan is corrupt or digest-mismatched');
|
|
471
|
+
}
|
|
261
472
|
let releaseAuthorization;
|
|
262
473
|
if (row.release_authorization_json !== null) {
|
|
263
474
|
try {
|
|
@@ -282,8 +493,10 @@ function sourceFromRow(row) {
|
|
|
282
493
|
&& (row.release_id === null || releaseAuthorization === undefined || sourceCheck === undefined))) {
|
|
283
494
|
throw new ControlPlaneStoreError('invalid-state', 'stored source release state is incomplete');
|
|
284
495
|
}
|
|
285
|
-
return { ...immutable, digest: row.plan_digest, status: row.status, revision: row.revision,
|
|
496
|
+
return { ...immutable, mode: row.mode, digest: row.plan_digest, status: row.status, revision: row.revision,
|
|
497
|
+
...(approval === undefined ? {} : { approval }),
|
|
286
498
|
...(sourceCheck === undefined ? {} : { sourceCheck }),
|
|
499
|
+
...(preparedEvidence === undefined ? {} : { preparedEvidence }),
|
|
287
500
|
...(releaseAuthorization === undefined ? {} : { releaseAuthorization }),
|
|
288
501
|
...(row.release_id === null ? {} : { release: { id: row.release_id, fence: row.release_fence,
|
|
289
502
|
...(row.release_failure_phase === null ? {} : { failurePhase: row.release_failure_phase }),
|
|
@@ -291,20 +504,31 @@ function sourceFromRow(row) {
|
|
|
291
504
|
}
|
|
292
505
|
function sourceSnapshotFromStored(value) {
|
|
293
506
|
const item = objectRecord(value, 'stored source plan snapshot');
|
|
294
|
-
|
|
507
|
+
// `mode` was introduced in schema v13; receipt rows written by older binaries
|
|
508
|
+
// omit it and are backfilled to 'create'. `preparedEvidence` exists only on
|
|
509
|
+
// 'modify' plans.
|
|
510
|
+
const optional = ['approval', 'sourceCheck', 'preparedEvidence', 'releaseAuthorization', 'release']
|
|
295
511
|
.filter(key => Object.hasOwn(item, key));
|
|
296
|
-
exactKeys(item, [...SOURCE_PLAN_KEYS, ...optional], 'stored source plan snapshot');
|
|
512
|
+
exactKeys(item, [...SOURCE_PLAN_KEYS, 'mode', ...optional], 'stored source plan snapshot');
|
|
513
|
+
const rawMode = item['mode'];
|
|
514
|
+
const mode = rawMode === undefined
|
|
515
|
+
? 'create'
|
|
516
|
+
: rawMode === 'create' || rawMode === 'modify' ? rawMode : 'create';
|
|
517
|
+
const modeValid = rawMode === undefined || rawMode === 'create' || rawMode === 'modify';
|
|
297
518
|
const gapSnapshot = objectRecord(item['gapSnapshot'], 'stored source gap snapshot');
|
|
298
519
|
exactKeys(gapSnapshot, ['revision', 'inputDigest', 'roi', 'capability'], 'stored source gap snapshot');
|
|
299
520
|
if (item['schemaVersion'] !== 1 || item['kind'] !== 'source' || typeof item['id'] !== 'string'
|
|
300
521
|
|| typeof item['gapId'] !== 'string' || typeof item['status'] !== 'string' || !SOURCE_STATUSES.has(item['status'])
|
|
522
|
+
|| !modeValid
|
|
301
523
|
|| !Number.isSafeInteger(item['revision']) || Number(item['revision']) < 1 || !Number.isSafeInteger(item['createdAt'])
|
|
302
524
|
|| !Number.isSafeInteger(item['expiresAt']) || Number(item['expiresAt']) <= Number(item['createdAt'])
|
|
303
525
|
|| typeof item['digest'] !== 'string' || !DIGEST.test(item['digest']) || typeof item['repository'] !== 'string'
|
|
304
526
|
|| typeof item['worktree'] !== 'string' || typeof item['baseCommit'] !== 'string' || !COMMIT.test(item['baseCommit'])
|
|
305
527
|
|| typeof item['name'] !== 'string' || !PLUGIN_NAME.test(item['name']) || typeof item['generatorDigest'] !== 'string'
|
|
306
528
|
|| !DIGEST.test(item['generatorDigest']) || !Array.isArray(item['scope'])
|
|
307
|
-
|| controlPlaneDigest(item['scope']) !== controlPlaneDigest(expectedSourceScope(item['name']))
|
|
529
|
+
|| controlPlaneDigest(item['scope']) !== controlPlaneDigest(expectedSourceScope(item['name'], mode))
|
|
530
|
+
|| (mode === 'modify' && item['preparedEvidence'] === undefined)
|
|
531
|
+
|| (mode === 'create' && item['preparedEvidence'] !== undefined)
|
|
308
532
|
|| !Number.isSafeInteger(gapSnapshot['revision']) || Number(gapSnapshot['revision']) < 1
|
|
309
533
|
|| typeof gapSnapshot['inputDigest'] !== 'string' || !DIGEST.test(gapSnapshot['inputDigest'])
|
|
310
534
|
|| typeof gapSnapshot['roi'] !== 'number' || !Number.isFinite(gapSnapshot['roi']) || typeof gapSnapshot['capability'] !== 'string') {
|
|
@@ -313,8 +537,7 @@ function sourceSnapshotFromStored(value) {
|
|
|
313
537
|
const immutable = { schemaVersion: 1, kind: 'source', id: item['id'], gapId: item['gapId'], gapSnapshot,
|
|
314
538
|
repository: item['repository'], worktree: item['worktree'], baseCommit: item['baseCommit'], name: item['name'],
|
|
315
539
|
generatorDigest: item['generatorDigest'], scope: item['scope'], createdAt: item['createdAt'], expiresAt: item['expiresAt'] };
|
|
316
|
-
|
|
317
|
-
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot digest is corrupt');
|
|
540
|
+
const preparedEvidence = item['preparedEvidence'] === undefined ? undefined : preparedEvidenceFromStored(item['preparedEvidence']);
|
|
318
541
|
const approval = item['approval'] === undefined ? undefined : verifiedApprovalFromStored(item['approval'], item['id'], item['digest'], Number(item['createdAt']), Number(item['expiresAt']));
|
|
319
542
|
let sourceCheck;
|
|
320
543
|
if (item['sourceCheck'] !== undefined) {
|
|
@@ -322,11 +545,14 @@ function sourceSnapshotFromStored(value) {
|
|
|
322
545
|
exactKeys(check, ['treeDigest', 'patchDigest', 'checkedAt'], 'stored source check snapshot');
|
|
323
546
|
if (typeof check['treeDigest'] !== 'string' || !DIGEST.test(check['treeDigest'])
|
|
324
547
|
|| typeof check['patchDigest'] !== 'string' || !DIGEST.test(check['patchDigest'])
|
|
325
|
-
|| !Number.isSafeInteger(check['checkedAt']) || Number(check['checkedAt']) <
|
|
548
|
+
|| !Number.isSafeInteger(check['checkedAt']) || Number(check['checkedAt']) < 0) {
|
|
326
549
|
throw new ControlPlaneStoreError('invalid-state', 'stored source check snapshot is corrupt');
|
|
327
550
|
}
|
|
328
551
|
sourceCheck = check;
|
|
329
552
|
}
|
|
553
|
+
const digestBinding = mode === 'modify' ? { ...immutable, mode, sourceCheck, preparedEvidence } : immutable;
|
|
554
|
+
if (controlPlaneDigest(digestBinding) !== item['digest'])
|
|
555
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot digest is corrupt');
|
|
330
556
|
let releaseAuthorization;
|
|
331
557
|
if (item['releaseAuthorization'] !== undefined) {
|
|
332
558
|
try {
|
|
@@ -360,16 +586,25 @@ function sourceSnapshotFromStored(value) {
|
|
|
360
586
|
}
|
|
361
587
|
const status = item['status'];
|
|
362
588
|
const requiresApproval = status !== 'pending-approval';
|
|
363
|
-
|
|
364
|
-
|
|
589
|
+
// 'create' plans acquire checked digests only after the post-approval scaffold
|
|
590
|
+
// run; 'modify' plans carry them from the pending state and never enter the
|
|
591
|
+
// running-local-checks / local-checks-failed states.
|
|
592
|
+
const postCheckStates = ['ready-for-human-review', 'release-complete',
|
|
593
|
+
'release-failed', 'publish-ambiguous'];
|
|
594
|
+
const requiresCheck = mode === 'modify'
|
|
595
|
+
|| expectedSourceRelease(status) !== undefined || postCheckStates.includes(status);
|
|
365
596
|
const requiresRelease = expectedSourceRelease(status) !== undefined || ['release-complete', 'release-failed', 'publish-ambiguous'].includes(status);
|
|
366
|
-
if (
|
|
597
|
+
if ((mode === 'modify' && (['running-local-checks', 'local-checks-failed'].includes(status) || sourceCheck === undefined))
|
|
598
|
+
|| (status !== 'expired' && requiresApproval !== (approval !== undefined))
|
|
599
|
+
|| (status === 'expired' && mode !== 'modify')
|
|
600
|
+
|| requiresCheck !== (sourceCheck !== undefined)
|
|
367
601
|
|| requiresRelease !== (releaseAuthorization !== undefined && release !== undefined)
|
|
368
602
|
|| (!requiresRelease && (releaseAuthorization !== undefined || release !== undefined))) {
|
|
369
603
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot fields do not match its status');
|
|
370
604
|
}
|
|
371
|
-
return { ...immutable, digest: item['digest'], status: item['status'], revision: Number(item['revision']),
|
|
605
|
+
return { ...immutable, mode, digest: item['digest'], status: item['status'], revision: Number(item['revision']),
|
|
372
606
|
...(approval === undefined ? {} : { approval }), ...(sourceCheck === undefined ? {} : { sourceCheck }),
|
|
607
|
+
...(preparedEvidence === undefined ? {} : { preparedEvidence }),
|
|
373
608
|
...(releaseAuthorization === undefined ? {} : { releaseAuthorization }),
|
|
374
609
|
...(release === undefined ? {} : { release }) };
|
|
375
610
|
}
|
|
@@ -381,6 +616,15 @@ const expectedAttestation = Object.freeze({
|
|
|
381
616
|
'awaiting-canary': { phase: 'canary', next: 'awaiting-soak' },
|
|
382
617
|
'awaiting-soak': { phase: 'soak', next: 'awaiting-health' },
|
|
383
618
|
'awaiting-health': { phase: 'health', next: 'commit-pending' },
|
|
619
|
+
'rollback-pending': { phase: 'rollback', next: 'rolled-back' },
|
|
620
|
+
});
|
|
621
|
+
const predecessorPhase = Object.freeze({
|
|
622
|
+
readiness: 'reload',
|
|
623
|
+
'effect-blocked-replay': 'readiness',
|
|
624
|
+
shadow: 'effect-blocked-replay',
|
|
625
|
+
canary: 'shadow',
|
|
626
|
+
soak: 'canary',
|
|
627
|
+
health: 'soak',
|
|
384
628
|
});
|
|
385
629
|
export function expectedHostAttestation(status) {
|
|
386
630
|
return expectedAttestation[status];
|
|
@@ -389,6 +633,21 @@ function requestBinding(request) {
|
|
|
389
633
|
const { operationId: _operationId, requestedAt: _requestedAt, ...binding } = request;
|
|
390
634
|
return binding;
|
|
391
635
|
}
|
|
636
|
+
const hostPhases = new Set(['reload', 'readiness', 'effect-blocked-replay', 'shadow', 'canary', 'soak', 'health', 'rollback']);
|
|
637
|
+
function isBoundHostRequest(request) {
|
|
638
|
+
if (request.schemaVersion !== 2 || !hostPhases.has(request.phase))
|
|
639
|
+
return false;
|
|
640
|
+
const predecessor = request.predecessor;
|
|
641
|
+
if (predecessor === null)
|
|
642
|
+
return request.phase === 'reload' || request.phase === 'rollback';
|
|
643
|
+
return typeof predecessor === 'object' && predecessor !== null
|
|
644
|
+
&& typeof predecessor.operationId === 'string' && KEY.test(predecessor.operationId)
|
|
645
|
+
&& typeof predecessor.receiptId === 'string' && KEY.test(predecessor.receiptId)
|
|
646
|
+
&& typeof predecessor.phase === 'string' && hostPhases.has(predecessor.phase)
|
|
647
|
+
&& typeof predecessor.receiptDigest === 'string' && DIGEST.test(predecessor.receiptDigest)
|
|
648
|
+
&& Number.isSafeInteger(predecessor.hostGeneration) && predecessor.hostGeneration >= 1
|
|
649
|
+
&& predecessor.operationId !== request.operationId && predecessor.phase !== request.phase;
|
|
650
|
+
}
|
|
392
651
|
function hostOperationFromRow(row) {
|
|
393
652
|
const request = JSON.parse(row.request_json);
|
|
394
653
|
if (request.operationId !== row.operation_id || request.plan.id !== row.plan_id || request.phase !== row.phase
|
|
@@ -521,10 +780,267 @@ function releaseArtifact(evidence) {
|
|
|
521
780
|
provenanceSha256: evidence.provenanceSha256, mergedCommit: evidence.mergedCommit, dshBaseline: evidence.dshBaseline,
|
|
522
781
|
capabilities: evidence.capabilities, authorities: evidence.authorities, requires: evidence.requires };
|
|
523
782
|
}
|
|
783
|
+
function sourceJobIntentFromStored(value) {
|
|
784
|
+
const intent = objectRecord(value, 'source job intent');
|
|
785
|
+
exactKeys(intent, ['authority', 'owner', 'ownerDigest', 'trustDigest', 'repository', 'name', 'gapId', 'gapRevision', 'gapDigest',
|
|
786
|
+
'baseCommit', 'files', 'ttlMs', 'build', 'worktree', 'containerName'], 'source job intent');
|
|
787
|
+
const authority = objectRecord(intent['authority'], 'source job authority');
|
|
788
|
+
exactKeys(authority, ['id', 'digest', 'expiresAt', 'maxSubmissions'], 'source job authority');
|
|
789
|
+
const owner = objectRecord(intent['owner'], 'source job owner');
|
|
790
|
+
exactKeys(owner, ['receiptVersion', 'authorityId', 'authorityHash', 'principalId', 'principalRecordId', 'principalVersion',
|
|
791
|
+
'workspace', 'agentPreset', 'bindingVersion', 'generation'], 'source job owner');
|
|
792
|
+
if (typeof authority['id'] !== 'string' || !KEY.test(authority['id']) || typeof authority['digest'] !== 'string' || !DIGEST.test(authority['digest'])
|
|
793
|
+
|| !Number.isSafeInteger(authority['expiresAt']) || Number(authority['expiresAt']) < 0
|
|
794
|
+
|| !Number.isSafeInteger(authority['maxSubmissions']) || Number(authority['maxSubmissions']) < 1 || Number(authority['maxSubmissions']) > 1_000
|
|
795
|
+
|| owner['receiptVersion'] !== 2 || typeof owner['authorityId'] !== 'string' || typeof owner['authorityHash'] !== 'string' || !DIGEST.test(owner['authorityHash'])
|
|
796
|
+
|| typeof owner['principalId'] !== 'string' || typeof owner['principalRecordId'] !== 'string' || !Number.isSafeInteger(owner['principalVersion'])
|
|
797
|
+
|| typeof owner['workspace'] !== 'string' || typeof owner['agentPreset'] !== 'string' || !Number.isSafeInteger(owner['bindingVersion'])
|
|
798
|
+
|| !Number.isSafeInteger(owner['generation']) || typeof intent['ownerDigest'] !== 'string' || !DIGEST.test(intent['ownerDigest'])
|
|
799
|
+
|| typeof intent['trustDigest'] !== 'string' || !DIGEST.test(intent['trustDigest'])
|
|
800
|
+
|| typeof intent['repository'] !== 'string' || !isAbsolute(intent['repository'])
|
|
801
|
+
|| typeof intent['worktree'] !== 'string' || !isAbsolute(intent['worktree'])
|
|
802
|
+
|| typeof intent['name'] !== 'string' || !PLUGIN_NAME.test(intent['name'])
|
|
803
|
+
|| typeof intent['gapId'] !== 'string' || !KEY.test(intent['gapId']) || !Number.isSafeInteger(intent['gapRevision']) || Number(intent['gapRevision']) < 1
|
|
804
|
+
|| typeof intent['gapDigest'] !== 'string' || !DIGEST.test(intent['gapDigest'])
|
|
805
|
+
|| typeof intent['baseCommit'] !== 'string' || !COMMIT.test(intent['baseCommit'])
|
|
806
|
+
|| !Array.isArray(intent['files']) || intent['files'].length === 0 || intent['files'].length > 64
|
|
807
|
+
|| !Number.isSafeInteger(intent['ttlMs']) || Number(intent['ttlMs']) < 60_000 || Number(intent['ttlMs']) > 86_400_000
|
|
808
|
+
|| typeof intent['containerName'] !== 'string' || !/^dsh-source-job-[a-f0-9]{64}$/u.test(intent['containerName'])) {
|
|
809
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job intent is invalid');
|
|
810
|
+
}
|
|
811
|
+
if (Number(owner['principalVersion']) < 1 || Number(owner['bindingVersion']) < 1 || Number(owner['generation']) < 1) {
|
|
812
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job owner version is invalid');
|
|
813
|
+
}
|
|
814
|
+
if (controlPlaneDigest(owner) !== intent['ownerDigest'])
|
|
815
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job owner digest is invalid');
|
|
816
|
+
const files = intent['files'].map((item, index) => {
|
|
817
|
+
const file = objectRecord(item, `source job file ${index}`);
|
|
818
|
+
exactKeys(file, ['path', 'content'], `source job file ${index}`);
|
|
819
|
+
if (typeof file['path'] !== 'string' || file['path'] === '' || file['path'].length > 512 || file['path'].startsWith('/')
|
|
820
|
+
|| file['path'].split('/').some(part => part === '' || part === '.' || part === '..')
|
|
821
|
+
|| typeof file['content'] !== 'string' || Buffer.byteLength(file['content']) > 65_536) {
|
|
822
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job file is invalid');
|
|
823
|
+
}
|
|
824
|
+
return Object.freeze({ path: file['path'], content: file['content'] });
|
|
825
|
+
});
|
|
826
|
+
if (new Set(files.map(file => file.path)).size !== files.length)
|
|
827
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job files are duplicated');
|
|
828
|
+
try {
|
|
829
|
+
validateScopedPluginFiles(files);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job files are invalid');
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
validateSourceBuildConfig(intent['build']);
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job build configuration is invalid');
|
|
839
|
+
}
|
|
840
|
+
return Object.freeze({ authority: Object.freeze({ id: authority['id'], digest: authority['digest'], expiresAt: Number(authority['expiresAt']), maxSubmissions: Number(authority['maxSubmissions']) }),
|
|
841
|
+
owner: Object.freeze({ receiptVersion: 2, authorityId: owner['authorityId'], authorityHash: owner['authorityHash'],
|
|
842
|
+
principalId: owner['principalId'], principalRecordId: owner['principalRecordId'], principalVersion: Number(owner['principalVersion']),
|
|
843
|
+
workspace: owner['workspace'], agentPreset: owner['agentPreset'], bindingVersion: Number(owner['bindingVersion']), generation: Number(owner['generation']) }),
|
|
844
|
+
ownerDigest: intent['ownerDigest'], trustDigest: intent['trustDigest'], repository: intent['repository'],
|
|
845
|
+
name: intent['name'], gapId: intent['gapId'], gapRevision: Number(intent['gapRevision']), gapDigest: intent['gapDigest'],
|
|
846
|
+
baseCommit: intent['baseCommit'], files: Object.freeze(files), ttlMs: Number(intent['ttlMs']), build: intent['build'],
|
|
847
|
+
worktree: intent['worktree'], containerName: intent['containerName'] });
|
|
848
|
+
}
|
|
849
|
+
function exactInputRecord(value, label) {
|
|
850
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
851
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} is invalid`);
|
|
852
|
+
}
|
|
853
|
+
return value;
|
|
854
|
+
}
|
|
855
|
+
function exactInputKeys(value, fields, label) {
|
|
856
|
+
if (Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) {
|
|
857
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} has unknown or missing fields`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function exactText(value, field, maximum = 512) {
|
|
861
|
+
if (typeof value !== 'string' || bounded(value, field, maximum) !== value) {
|
|
862
|
+
throw new ControlPlaneStoreError('invalid-input', `${field} is invalid`);
|
|
863
|
+
}
|
|
864
|
+
return value;
|
|
865
|
+
}
|
|
866
|
+
function ownerTaskFailureReferenceFromStored(value, errorCode = 'invalid-input') {
|
|
867
|
+
try {
|
|
868
|
+
const reference = errorCode === 'invalid-input' ? exactInputRecord(value, 'owner task failure reference') : objectRecord(value, 'owner task failure reference');
|
|
869
|
+
const keys = errorCode === 'invalid-input' ? exactInputKeys : exactKeys;
|
|
870
|
+
keys(reference, ['schemaVersion', 'owner', 'outcomeId', 'projection', 'sourceDigest'], 'owner task failure reference');
|
|
871
|
+
const owner = errorCode === 'invalid-input' ? exactInputRecord(reference['owner'], 'owner task failure owner') : objectRecord(reference['owner'], 'owner task failure owner');
|
|
872
|
+
keys(owner, ['receiptVersion', 'authorityId', 'authorityHash', 'principalId', 'principalRecordId', 'principalVersion',
|
|
873
|
+
'workspace', 'agentPreset', 'bindingVersion', 'generation'], 'owner task failure owner');
|
|
874
|
+
const projection = errorCode === 'invalid-input' ? exactInputRecord(reference['projection'], 'owner task failure projection') : objectRecord(reference['projection'], 'owner task failure projection');
|
|
875
|
+
const projectionFields = ['subjectKind', 'subjectRef', 'version', 'digest', 'disposition',
|
|
876
|
+
...(Object.hasOwn(projection, 'evidenceOutcomeId') ? ['evidenceOutcomeId'] : [])];
|
|
877
|
+
keys(projection, projectionFields, 'owner task failure projection');
|
|
878
|
+
const text = errorCode === 'invalid-input' ? exactText : (item, field, maximum) => {
|
|
879
|
+
if (typeof item !== 'string' || bounded(item, field, maximum) !== item)
|
|
880
|
+
throw new ControlPlaneStoreError('invalid-state', `${field} is invalid`);
|
|
881
|
+
return item;
|
|
882
|
+
};
|
|
883
|
+
if (reference['schemaVersion'] !== 1 || owner['receiptVersion'] !== 2
|
|
884
|
+
|| !KEY.test(text(owner['authorityId'], 'owner authorityId', 160)) || !DIGEST.test(text(owner['authorityHash'], 'owner authorityHash', 64))
|
|
885
|
+
|| !isAbsolute(text(owner['workspace'], 'owner workspace', 4_096)) || !KEY.test(text(owner['agentPreset'], 'owner agentPreset', 160))
|
|
886
|
+
|| !KEY.test(text(reference['outcomeId'], 'outcomeId', 160)) || projection['subjectKind'] !== 'foreground-turn'
|
|
887
|
+
|| !KEY.test(text(projection['subjectRef'], 'projection subjectRef', 160)) || projection['disposition'] !== 'upsert'
|
|
888
|
+
|| !DIGEST.test(text(projection['digest'], 'projection digest', 64)) || !DIGEST.test(text(reference['sourceDigest'], 'sourceDigest', 64))
|
|
889
|
+
|| !Number.isSafeInteger(owner['principalVersion']) || Number(owner['principalVersion']) < 1
|
|
890
|
+
|| !Number.isSafeInteger(owner['bindingVersion']) || Number(owner['bindingVersion']) < 1
|
|
891
|
+
|| !Number.isSafeInteger(owner['generation']) || Number(owner['generation']) < 1
|
|
892
|
+
|| !Number.isSafeInteger(projection['version']) || Number(projection['version']) < 1) {
|
|
893
|
+
throw new ControlPlaneStoreError(errorCode, 'owner task failure reference is invalid');
|
|
894
|
+
}
|
|
895
|
+
const principalId = text(owner['principalId'], 'owner principalId', 512);
|
|
896
|
+
const principalRecordId = text(owner['principalRecordId'], 'owner principalRecordId', 512);
|
|
897
|
+
if (projection['evidenceOutcomeId'] !== undefined && !KEY.test(text(projection['evidenceOutcomeId'], 'projection evidenceOutcomeId', 160))) {
|
|
898
|
+
throw new ControlPlaneStoreError(errorCode, 'owner task failure reference is invalid');
|
|
899
|
+
}
|
|
900
|
+
return Object.freeze({ schemaVersion: 1, owner: Object.freeze({ receiptVersion: 2,
|
|
901
|
+
authorityId: owner['authorityId'], authorityHash: owner['authorityHash'],
|
|
902
|
+
principalId, principalRecordId, principalVersion: Number(owner['principalVersion']), workspace: owner['workspace'],
|
|
903
|
+
agentPreset: owner['agentPreset'], bindingVersion: Number(owner['bindingVersion']), generation: Number(owner['generation']) }),
|
|
904
|
+
outcomeId: reference['outcomeId'], projection: Object.freeze({ subjectKind: 'foreground-turn',
|
|
905
|
+
subjectRef: projection['subjectRef'], version: Number(projection['version']), digest: projection['digest'],
|
|
906
|
+
disposition: 'upsert', ...(projection['evidenceOutcomeId'] === undefined ? {} : { evidenceOutcomeId: projection['evidenceOutcomeId'] }) }),
|
|
907
|
+
sourceDigest: reference['sourceDigest'] });
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
if (error instanceof ControlPlaneStoreError)
|
|
911
|
+
throw error;
|
|
912
|
+
throw new ControlPlaneStoreError(errorCode, 'owner task failure reference is invalid');
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function sourceJobFromRow(row) {
|
|
916
|
+
let intent;
|
|
917
|
+
try {
|
|
918
|
+
intent = sourceJobIntentFromStored(JSON.parse(row.intent_json));
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
if (error instanceof ControlPlaneStoreError)
|
|
922
|
+
throw error;
|
|
923
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source job intent is corrupt');
|
|
924
|
+
}
|
|
925
|
+
if (!DIGEST.test(row.intent_digest) || controlPlaneDigest(intent) !== row.intent_digest || row.authority_id !== intent.authority.id
|
|
926
|
+
|| row.expires_at !== intent.authority.expiresAt || !KEY.test(row.idempotency_key) || !Number.isSafeInteger(row.revision) || row.revision < 1
|
|
927
|
+
|| !Number.isSafeInteger(row.created_at) || !Number.isSafeInteger(row.updated_at) || row.updated_at < row.created_at
|
|
928
|
+
|| !['queued', 'running', 'prepared', 'failed', 'unknown'].includes(row.status)
|
|
929
|
+
|| (row.definition_hash !== null && !DIGEST.test(row.definition_hash)) || (row.occurrence_id !== null && !KEY.test(row.occurrence_id))
|
|
930
|
+
|| (row.plan_id !== null && !/^source-[a-f0-9-]{36}$/u.test(row.plan_id)) || (row.failure_code !== null && !KEY.test(row.failure_code))) {
|
|
931
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source job is corrupt');
|
|
932
|
+
}
|
|
933
|
+
const active = row.status === 'queued' || row.status === 'running' || row.status === 'unknown';
|
|
934
|
+
if ((row.status === 'queued' && (row.occurrence_id !== null || row.plan_id !== null || row.failure_code !== null))
|
|
935
|
+
|| (row.status === 'running' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id !== null || row.failure_code !== null))
|
|
936
|
+
|| (row.status === 'prepared' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id === null || row.failure_code !== null))
|
|
937
|
+
|| (row.status === 'unknown' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id !== null || row.failure_code === null))
|
|
938
|
+
|| (row.status === 'failed' && (row.plan_id !== null || row.failure_code === null))
|
|
939
|
+
|| (!active && row.status !== 'prepared' && row.plan_id !== null))
|
|
940
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source job state is corrupt');
|
|
941
|
+
return Object.freeze({ id: row.id, automationId: row.automation_id, idempotencyKey: row.idempotency_key, intent, intentDigest: row.intent_digest,
|
|
942
|
+
status: row.status, revision: row.revision, createdAt: row.created_at, expiresAt: row.expires_at, updatedAt: row.updated_at,
|
|
943
|
+
...(row.definition_hash === null ? {} : { definitionHash: row.definition_hash }), ...(row.occurrence_id === null ? {} : { occurrenceId: row.occurrence_id }),
|
|
944
|
+
...(row.plan_id === null ? {} : { planId: row.plan_id }), ...(row.failure_code === null ? {} : { failureCode: row.failure_code }) });
|
|
945
|
+
}
|
|
946
|
+
function readOwnerTaskFailureReference(database, gapId) {
|
|
947
|
+
if (typeof gapId !== 'string' || !KEY.test(gapId))
|
|
948
|
+
throw new ControlPlaneStoreError('invalid-input', 'gap id is invalid');
|
|
949
|
+
const row = database.prepare('SELECT reference_json, reference_digest FROM owner_task_failure_gaps WHERE gap_id = ?')
|
|
950
|
+
.get(gapId);
|
|
951
|
+
const gap = database.prepare('SELECT idempotency_key FROM capability_gaps WHERE id = ?').get(gapId);
|
|
952
|
+
if (row === undefined) {
|
|
953
|
+
if (gap?.idempotency_key.startsWith('owner-task-failure:'))
|
|
954
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap sidecar is missing');
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
if (gap?.idempotency_key !== `owner-task-failure:${row.reference_digest}`)
|
|
958
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap identity is corrupt');
|
|
959
|
+
if (!DIGEST.test(row.reference_digest))
|
|
960
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap digest is corrupt');
|
|
961
|
+
let reference;
|
|
962
|
+
try {
|
|
963
|
+
reference = ownerTaskFailureReferenceFromStored(JSON.parse(row.reference_json), 'invalid-state');
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
if (error instanceof ControlPlaneStoreError)
|
|
967
|
+
throw error;
|
|
968
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap reference is corrupt');
|
|
969
|
+
}
|
|
970
|
+
if (controlPlaneDigest(reference) !== row.reference_digest)
|
|
971
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap digest is corrupt');
|
|
972
|
+
return reference;
|
|
973
|
+
}
|
|
974
|
+
/** Read-only broker seam: parse existing authoritative rows without opening a migrating Store. */
|
|
975
|
+
export function readOwnerPreparedSourcePlan(database, planId) {
|
|
976
|
+
if (database.prepare('PRAGMA user_version').get()?.user_version !== controlPlaneSchemaVersion
|
|
977
|
+
|| typeof planId !== 'string' || !KEY.test(planId)) {
|
|
978
|
+
throw new ControlPlaneStoreError('invalid-state', 'source authorization database or plan identity is invalid');
|
|
979
|
+
}
|
|
980
|
+
const row = database.prepare('SELECT * FROM source_plans WHERE id = ?').get(planId);
|
|
981
|
+
if (!row)
|
|
982
|
+
throw new ControlPlaneStoreError('not-found', 'source authorization plan is absent');
|
|
983
|
+
const plan = sourceFromRow(row);
|
|
984
|
+
const source = readOwnerTaskFailureReference(database, plan.gapId);
|
|
985
|
+
if (!source || plan.mode !== 'modify' || !plan.sourceCheck || !plan.preparedEvidence) {
|
|
986
|
+
throw new ControlPlaneStoreError('invalid-state', 'source authorization requires an owner-bound prepared modification');
|
|
987
|
+
}
|
|
988
|
+
return { plan, source };
|
|
989
|
+
}
|
|
990
|
+
function readReleaseCandidate(database, plan) {
|
|
991
|
+
const row = database.prepare(`SELECT * FROM source_release_operations WHERE plan_id = ? AND phase = 'build'
|
|
992
|
+
AND status = 'applied' ORDER BY attempt DESC LIMIT 1`).get(plan.id);
|
|
993
|
+
if (!row)
|
|
994
|
+
throw new ControlPlaneStoreError('invalid-state', 'source release has no applied build artifact');
|
|
995
|
+
const operation = sourceReleaseOperationFromRow(row), evidence = operation.receipt?.evidence;
|
|
996
|
+
if (operation.receipt?.outcome !== 'passed' || evidence?.kind !== 'build' || !plan.releaseAuthorization)
|
|
997
|
+
throw new ControlPlaneStoreError('invalid-state', 'source release build evidence is not successful');
|
|
998
|
+
const artifact = releaseArtifact(evidence), policy = plan.releaseAuthorization.releasePolicy;
|
|
999
|
+
return parseCatalog({ schemaVersion: 1, entries: [{ id: artifact.candidateId, package: artifact.packageName,
|
|
1000
|
+
version: artifact.packageVersion, integrity: artifact.tarballIntegrity, dshBaseline: artifact.dshBaseline,
|
|
1001
|
+
registry: { id: policy.registryId, locator: policy.registryLocator, reference: policy.registryReference },
|
|
1002
|
+
capabilities: artifact.capabilities, authorities: artifact.authorities, requires: artifact.requires }] }).entries[0];
|
|
1003
|
+
}
|
|
1004
|
+
function adoptionBinding(sourcePlan, source, plan) {
|
|
1005
|
+
return { sourcePlanId: sourcePlan.id, sourcePlanDigest: sourcePlan.digest, releaseId: sourcePlan.release.id,
|
|
1006
|
+
releaseFence: sourcePlan.release.fence, activationPlanId: plan.id, activationPlanDigest: plan.digest,
|
|
1007
|
+
sourceReferenceDigest: controlPlaneDigest(source), candidateDigest: controlPlaneDigest(plan.candidate) };
|
|
1008
|
+
}
|
|
1009
|
+
/** Read-only signer seam; no migration or unbound caller candidate is admitted. */
|
|
1010
|
+
export function readOwnerSourceAdoptionPlan(database, activationPlanId) {
|
|
1011
|
+
if (database.prepare('PRAGMA user_version').get()?.user_version !== controlPlaneSchemaVersion)
|
|
1012
|
+
throw new ControlPlaneStoreError('invalid-state', 'source adoption database version is invalid');
|
|
1013
|
+
const link = database.prepare('SELECT * FROM source_adoptions WHERE activation_plan_id = ?').get(activationPlanId);
|
|
1014
|
+
const row = database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(activationPlanId);
|
|
1015
|
+
if (!link || !row)
|
|
1016
|
+
throw new ControlPlaneStoreError('not-found', 'owner source adoption binding absent');
|
|
1017
|
+
const plan = activationFromRow(row), { plan: sourcePlan, source } = readOwnerPreparedSourcePlan(database, link.source_plan_id);
|
|
1018
|
+
if (sourcePlan.status !== 'release-complete' || !sourcePlan.release || plan.gapId !== sourcePlan.gapId)
|
|
1019
|
+
throw new ControlPlaneStoreError('invalid-state', 'source adoption requires a completed owner release');
|
|
1020
|
+
const expected = adoptionBinding(sourcePlan, source, plan), released = readReleaseCandidate(database, sourcePlan);
|
|
1021
|
+
if (controlPlaneDigest(JSON.parse(link.binding_json)) !== link.binding_digest || controlPlaneDigest(expected) !== link.binding_digest
|
|
1022
|
+
|| controlPlaneDigest(plan.candidate) !== controlPlaneDigest(released))
|
|
1023
|
+
throw new ControlPlaneStoreError('invalid-state', 'source adoption binding changed');
|
|
1024
|
+
return { plan, sourcePlan, source, released };
|
|
1025
|
+
}
|
|
524
1026
|
export class ControlPlaneStore {
|
|
525
1027
|
#database;
|
|
526
1028
|
#now;
|
|
527
|
-
|
|
1029
|
+
#ownerTaskFailureGapAdmission;
|
|
1030
|
+
#foregroundDeploymentAdmission;
|
|
1031
|
+
#withOwnerActivationFence;
|
|
1032
|
+
#adoptionCoordinatorId;
|
|
1033
|
+
constructor(options) {
|
|
1034
|
+
if (options.adoptionCoordinatorId !== undefined && (!KEY.test(options.adoptionCoordinatorId) || options.adoptionCoordinatorId !== options.adoptionCoordinatorId.normalize('NFC').trim())) {
|
|
1035
|
+
throw new ControlPlaneStoreError('invalid-input', 'adoption coordinator id is invalid');
|
|
1036
|
+
}
|
|
1037
|
+
if (options.adoptionCoordinatorId && options.withOwnerActivationFence)
|
|
1038
|
+
throw new ControlPlaneStoreError('invalid-input', 'adoption coordinator cannot hold owner fence');
|
|
1039
|
+
this.#database = openControlPlaneDatabase(options.path);
|
|
1040
|
+
this.#now = options.now ?? Date.now;
|
|
1041
|
+
this.#withOwnerActivationFence = options.withOwnerActivationFence;
|
|
1042
|
+
this.#adoptionCoordinatorId = options.adoptionCoordinatorId;
|
|
1043
|
+
}
|
|
528
1044
|
close() { this.#database.close(); }
|
|
529
1045
|
recordGap(input) {
|
|
530
1046
|
const normalized = {
|
|
@@ -535,6 +1051,9 @@ export class ControlPlaneStore {
|
|
|
535
1051
|
};
|
|
536
1052
|
if (!KEY.test(normalized.idempotencyKey))
|
|
537
1053
|
throw new ControlPlaneStoreError('invalid-input', 'idempotencyKey has invalid syntax');
|
|
1054
|
+
if (normalized.idempotencyKey.startsWith('owner-task-failure:')) {
|
|
1055
|
+
throw new ControlPlaneStoreError('invalid-input', 'owner task failure gap keys are reserved');
|
|
1056
|
+
}
|
|
538
1057
|
const inputDigest = controlPlaneDigest(normalized);
|
|
539
1058
|
const prior = this.#database.prepare('SELECT * FROM capability_gaps WHERE idempotency_key = ?').get(normalized.idempotencyKey);
|
|
540
1059
|
if (prior !== undefined) {
|
|
@@ -567,9 +1086,142 @@ export class ControlPlaneStore {
|
|
|
567
1086
|
listGaps(limit = 20) {
|
|
568
1087
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
569
1088
|
throw new ControlPlaneStoreError('invalid-input', 'gap limit must be 1..100');
|
|
570
|
-
return this.#database.prepare(`SELECT
|
|
1089
|
+
return this.#database.prepare(`SELECT gap.* FROM capability_gaps gap
|
|
1090
|
+
WHERE gap.status = 'open' AND gap.idempotency_key NOT LIKE 'owner-task-failure:%'
|
|
1091
|
+
AND NOT EXISTS (SELECT 1 FROM owner_task_failure_gaps sidecar WHERE sidecar.gap_id = gap.id)
|
|
1092
|
+
ORDER BY gap.roi DESC, gap.created_at, gap.id LIMIT ?`).all(limit).map(gapFromRow);
|
|
1093
|
+
}
|
|
1094
|
+
recordOwnerTaskFailureGap(referenceInput) {
|
|
1095
|
+
const reference = ownerTaskFailureReferenceFromStored(referenceInput);
|
|
1096
|
+
const referenceDigest = controlPlaneDigest(reference);
|
|
1097
|
+
const idempotencyKey = `owner-task-failure:${referenceDigest}`;
|
|
1098
|
+
const normalized = { idempotencyKey, capability: 'foreground-task-repair',
|
|
1099
|
+
context: 'Owner-verified foreground task failure. Private source reference retained.',
|
|
1100
|
+
expectedValue: 0, frequency: 1, estimatedCost: 1, risk: 1 };
|
|
1101
|
+
const inputDigest = controlPlaneDigest(normalized);
|
|
1102
|
+
const now = this.#now();
|
|
1103
|
+
const id = `gap-${randomUUID()}`;
|
|
1104
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1105
|
+
try {
|
|
1106
|
+
const prior = this.#database.prepare('SELECT * FROM capability_gaps WHERE idempotency_key = ?').get(idempotencyKey);
|
|
1107
|
+
if (prior !== undefined) {
|
|
1108
|
+
if (prior.input_digest !== inputDigest)
|
|
1109
|
+
throw new ControlPlaneStoreError('conflict', 'owner task failure gap idempotency is corrupt');
|
|
1110
|
+
const stored = this.getOwnerTaskFailureReference(prior.id);
|
|
1111
|
+
if (stored === undefined || controlPlaneDigest(stored) !== referenceDigest) {
|
|
1112
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap sidecar is corrupt');
|
|
1113
|
+
}
|
|
1114
|
+
this.#database.exec('COMMIT');
|
|
1115
|
+
return gapFromRow(prior);
|
|
1116
|
+
}
|
|
1117
|
+
this.#database.prepare(`INSERT INTO capability_gaps (id, idempotency_key, input_digest, capability, context,
|
|
1118
|
+
expected_value, frequency, estimated_cost, risk, roi, status, candidate_id, revision, created_at, updated_at)
|
|
1119
|
+
VALUES (?, ?, ?, ?, ?, 0, 1, 1, 1, 0, 'open', NULL, 1, ?, ?)`).run(id, idempotencyKey, inputDigest, normalized.capability, normalized.context, now, now);
|
|
1120
|
+
this.#database.prepare(`INSERT INTO owner_task_failure_gaps (gap_id, reference_json, reference_digest)
|
|
1121
|
+
VALUES (?, ?, ?)`).run(id, JSON.stringify(reference), referenceDigest);
|
|
1122
|
+
const gap = this.getGap(id);
|
|
1123
|
+
this.#database.exec('COMMIT');
|
|
1124
|
+
return gap;
|
|
1125
|
+
}
|
|
1126
|
+
catch (error) {
|
|
1127
|
+
this.#database.exec('ROLLBACK');
|
|
1128
|
+
throw error;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
getOwnerTaskFailureReference(gapId) {
|
|
1132
|
+
return readOwnerTaskFailureReference(this.#database, gapId);
|
|
1133
|
+
}
|
|
1134
|
+
withOwnerTaskFailureGapAdmission(gapId, callback) {
|
|
1135
|
+
if (this.#adoptionCoordinatorId !== undefined)
|
|
1136
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot create or approve owner work');
|
|
1137
|
+
if (typeof gapId !== 'string' || !KEY.test(gapId) || typeof callback !== 'function') {
|
|
1138
|
+
throw new ControlPlaneStoreError('invalid-input', 'owner task failure gap admission is invalid');
|
|
1139
|
+
}
|
|
1140
|
+
if (callback.constructor.name === 'AsyncFunction')
|
|
1141
|
+
throw new ControlPlaneStoreError('invalid-input', 'owner task failure gap admission must be synchronous');
|
|
1142
|
+
if (this.#ownerTaskFailureGapAdmission !== undefined)
|
|
1143
|
+
throw new ControlPlaneStoreError('conflict', 'owner task failure gap admission is already active');
|
|
1144
|
+
if (this.getOwnerTaskFailureReference(gapId) === undefined)
|
|
1145
|
+
throw new ControlPlaneStoreError('not-found', 'owner task failure gap is absent');
|
|
1146
|
+
this.#ownerTaskFailureGapAdmission = gapId;
|
|
1147
|
+
try {
|
|
1148
|
+
const result = callback();
|
|
1149
|
+
if (typeof result?.then === 'function') {
|
|
1150
|
+
throw new ControlPlaneStoreError('invalid-input', 'owner task failure gap admission must be synchronous');
|
|
1151
|
+
}
|
|
1152
|
+
return result;
|
|
1153
|
+
}
|
|
1154
|
+
finally {
|
|
1155
|
+
this.#ownerTaskFailureGapAdmission = undefined;
|
|
1156
|
+
}
|
|
571
1157
|
}
|
|
1158
|
+
#assertOwnerTaskFailureGapAdmission(gapId) {
|
|
1159
|
+
if (this.getOwnerTaskFailureReference(gapId) !== undefined && this.#ownerTaskFailureGapAdmission !== gapId) {
|
|
1160
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner task failure gap requires Host admission');
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
#withActivationSource(planId, callback, recovery = false) {
|
|
1164
|
+
const plan = this.getPlan(planId);
|
|
1165
|
+
const ownerReference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
1166
|
+
if (this.#adoptionCoordinatorId !== undefined && !ownerReference) {
|
|
1167
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator requires an owner-bound handoff');
|
|
1168
|
+
}
|
|
1169
|
+
if (!ownerReference)
|
|
1170
|
+
return callback();
|
|
1171
|
+
// Every owner-bound coordinator action also re-reads the durable binding.
|
|
1172
|
+
// A handoff delegates one exact released repair, never an arbitrary gap.
|
|
1173
|
+
if (plan.dossier.handoff !== undefined || this.#adoptionCoordinatorId !== undefined)
|
|
1174
|
+
readOwnerSourceAdoptionPlan(this.#database, planId);
|
|
1175
|
+
if (this.#adoptionCoordinatorId !== undefined) {
|
|
1176
|
+
if (recovery || plan.status === 'rollback-pending' || plan.status === 'rolled-back') {
|
|
1177
|
+
this.#assertAdoptionHandoffBinding(planId, this.#adoptionCoordinatorId);
|
|
1178
|
+
return callback();
|
|
1179
|
+
}
|
|
1180
|
+
if (!['approved', 'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay',
|
|
1181
|
+
'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health'].includes(plan.status)) {
|
|
1182
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot advance this activation state');
|
|
1183
|
+
}
|
|
1184
|
+
this.#assertAdoptionHandoff(planId, this.#adoptionCoordinatorId);
|
|
1185
|
+
return callback();
|
|
1186
|
+
}
|
|
1187
|
+
if (recovery || plan.status === 'rollback-pending' || plan.status === 'rolled-back')
|
|
1188
|
+
return callback();
|
|
1189
|
+
const commit = () => {
|
|
1190
|
+
const current = this.getPlan(planId);
|
|
1191
|
+
if (current.status === 'commit-pending' && current.dossier.handoff !== undefined)
|
|
1192
|
+
this.#assertAdoptionHandoff(planId);
|
|
1193
|
+
return callback();
|
|
1194
|
+
};
|
|
1195
|
+
if (this.#ownerTaskFailureGapAdmission === plan.gapId)
|
|
1196
|
+
return commit();
|
|
1197
|
+
if (!this.#withOwnerActivationFence)
|
|
1198
|
+
throw new ControlPlaneStoreError('invalid-state', 'owner activation requires current Host source admission');
|
|
1199
|
+
return this.#withOwnerActivationFence(plan.gapId, () => this.withOwnerTaskFailureGapAdmission(plan.gapId, commit));
|
|
1200
|
+
}
|
|
1201
|
+
/** Recheck after acquiring the writer lock; another Host can revoke a handoff while it is acquired. */
|
|
1202
|
+
#withActivationWrite(planId, callback, recovery = false) {
|
|
1203
|
+
return this.#withActivationSource(planId, () => {
|
|
1204
|
+
const own = !this.#database.isTransaction;
|
|
1205
|
+
if (own)
|
|
1206
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1207
|
+
try {
|
|
1208
|
+
const result = this.#withActivationSource(planId, callback, recovery);
|
|
1209
|
+
if (own)
|
|
1210
|
+
this.#database.exec('COMMIT');
|
|
1211
|
+
return result;
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
if (own)
|
|
1215
|
+
this.#database.exec('ROLLBACK');
|
|
1216
|
+
throw error;
|
|
1217
|
+
}
|
|
1218
|
+
}, recovery);
|
|
1219
|
+
}
|
|
1220
|
+
/** Only the Host worker connection can advance a task-bound deployment. */
|
|
1221
|
+
assertOwnerActivationSource(planId) { this.#withActivationSource(planId, () => { }); }
|
|
572
1222
|
createPlan(input) {
|
|
1223
|
+
if (this.#adoptionCoordinatorId !== undefined)
|
|
1224
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot create or approve owner work');
|
|
573
1225
|
const profile = bounded(input.profile, 'profile', 64);
|
|
574
1226
|
const idempotencyKey = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
575
1227
|
if (!PROFILE.test(profile) || !KEY.test(idempotencyKey) || !UUID.test(input.installationId) || !UUID.test(input.ledger.id)
|
|
@@ -587,24 +1239,40 @@ export class ControlPlaneStore {
|
|
|
587
1239
|
...(candidate.registry === undefined ? {} : { registry: candidate.registry }) }, ...candidate.requires]);
|
|
588
1240
|
const requestBinding = { operation: 'create-activation-plan', gapId: input.gapId, candidate, catalog: input.catalog,
|
|
589
1241
|
matchedCapabilities, profile, target: input.target, installationId: input.installationId,
|
|
590
|
-
ledger: input.ledger, executor: input.executor, ttlMs: input.ttlMs
|
|
1242
|
+
ledger: input.ledger, executor: input.executor, ttlMs: input.ttlMs,
|
|
1243
|
+
...(input.sourcePlanId === undefined ? {} : { sourcePlanId: input.sourcePlanId }), ...(input.handoff === undefined ? {} : { handoff: input.handoff }) };
|
|
1244
|
+
this.#assertOwnerTaskFailureGapAdmission(input.gapId);
|
|
1245
|
+
const source = input.sourcePlanId === undefined ? undefined : readOwnerPreparedSourcePlan(this.#database, input.sourcePlanId);
|
|
1246
|
+
if (input.handoff !== undefined) {
|
|
1247
|
+
validateAdoptionHandoffTerms(input.handoff);
|
|
1248
|
+
if (!source)
|
|
1249
|
+
throw new ControlPlaneStoreError('invalid-input', 'adoption handoff requires an exact owner release');
|
|
1250
|
+
}
|
|
1251
|
+
if (source && (source.plan.status !== 'release-complete' || source.plan.gapId !== input.gapId
|
|
1252
|
+
|| controlPlaneDigest(readReleaseCandidate(this.#database, source.plan)) !== controlPlaneDigest(candidate))) {
|
|
1253
|
+
throw new ControlPlaneStoreError('conflict', 'activation does not match the exact owner release');
|
|
1254
|
+
}
|
|
591
1255
|
const inputDigest = controlPlaneDigest(requestBinding);
|
|
592
1256
|
const prior = this.#activationPlanReceiptByKey(idempotencyKey, 'create-activation-plan', inputDigest);
|
|
593
|
-
if (prior !== undefined)
|
|
1257
|
+
if (prior !== undefined) {
|
|
1258
|
+
if (source)
|
|
1259
|
+
readOwnerSourceAdoptionPlan(this.#database, prior.result.id);
|
|
594
1260
|
return prior;
|
|
1261
|
+
}
|
|
595
1262
|
const gap = this.getGap(input.gapId);
|
|
596
1263
|
if (gap.status !== 'open')
|
|
597
1264
|
throw new ControlPlaneStoreError('invalid-state', 'only an open gap can create an activation plan');
|
|
598
1265
|
if (gap.candidateId !== undefined && gap.candidateId !== candidate.id) {
|
|
599
1266
|
throw new ControlPlaneStoreError('conflict', 'released capability gap is reserved for its exact admitted candidate');
|
|
600
1267
|
}
|
|
601
|
-
if (!discover({ schemaVersion: 1, entries: [candidate] }, gap.capability).some(item => item.id === candidate.id)
|
|
1268
|
+
if ((!source && !discover({ schemaVersion: 1, entries: [candidate] }, gap.capability).some(item => item.id === candidate.id))
|
|
602
1269
|
|| matchedCapabilities.some(value => !candidate.capabilities.includes(value))) {
|
|
603
1270
|
throw new ControlPlaneStoreError('invalid-input', 'candidate dossier does not match the exact gap capability');
|
|
604
1271
|
}
|
|
605
1272
|
const gapSnapshot = Object.freeze({ revision: gap.revision, inputDigest: gap.inputDigest, roi: gap.roi, capability: gap.capability });
|
|
606
1273
|
const dossier = Object.freeze({ catalogDigest: input.catalog.digest, catalogProvenance: input.catalog.provenance,
|
|
607
|
-
matchedCapabilities: Object.freeze(matchedCapabilities), authorities: Object.freeze([...candidate.authorities]), packages
|
|
1274
|
+
matchedCapabilities: Object.freeze(matchedCapabilities), authorities: Object.freeze([...candidate.authorities]), packages,
|
|
1275
|
+
...(input.handoff === undefined ? {} : { handoff: Object.freeze({ ...input.handoff }) }) });
|
|
608
1276
|
const now = this.#now();
|
|
609
1277
|
const id = `plugin-${randomUUID()}`;
|
|
610
1278
|
const expiresAt = now + input.ttlMs;
|
|
@@ -633,6 +1301,11 @@ export class ControlPlaneStore {
|
|
|
633
1301
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending-approval', 1, ?, ?, NULL, NULL, 0, NULL, NULL, NULL, ?)`).run(id, planDigest, gap.id, JSON.stringify(gapSnapshot), profile, JSON.stringify(candidate), JSON.stringify(dossier), input.installationId, input.ledger.id, input.ledger.path, input.target.dshHome, input.target.profilePath, input.executor.id, input.executor.version, input.executor.path, input.executor.sha256, now, expiresAt, now);
|
|
634
1302
|
this.#database.prepare(`UPDATE capability_gaps SET status = 'matched', candidate_id = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?`).run(candidate.id, now, gap.id, gap.revision);
|
|
635
1303
|
const plan = this.getPlan(id);
|
|
1304
|
+
if (source) {
|
|
1305
|
+
const binding = adoptionBinding(source.plan, source.source, plan);
|
|
1306
|
+
this.#database.prepare('INSERT INTO source_adoptions (source_plan_id, activation_plan_id, binding_json, binding_digest, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
1307
|
+
.run(source.plan.id, plan.id, JSON.stringify(binding), controlPlaneDigest(binding), now);
|
|
1308
|
+
}
|
|
636
1309
|
const receipt = { idempotencyKey, operation: 'create-activation-plan', inputDigest, result: plan, createdAt: now };
|
|
637
1310
|
this.#insertReceipt(receipt);
|
|
638
1311
|
this.#database.exec('COMMIT');
|
|
@@ -649,22 +1322,268 @@ export class ControlPlaneStore {
|
|
|
649
1322
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
650
1323
|
return activationFromRow(row);
|
|
651
1324
|
}
|
|
1325
|
+
findSourceAdoption(sourcePlanId) {
|
|
1326
|
+
const row = this.#database.prepare('SELECT activation_plan_id FROM source_adoptions WHERE source_plan_id = ?').get(sourcePlanId);
|
|
1327
|
+
return row === undefined ? undefined : readOwnerSourceAdoptionPlan(this.#database, row.activation_plan_id).plan;
|
|
1328
|
+
}
|
|
1329
|
+
getAdoptionHandoff(planId) {
|
|
1330
|
+
const row = this.#database.prepare('SELECT * FROM adoption_handoffs WHERE plan_id=?').get(planId);
|
|
1331
|
+
return row && { planId: row.plan_id, planDigest: row.plan_digest, coordinatorId: row.coordinator_id, createdAt: row.created_at, expiresAt: row.expires_at, ...(row.revoked_at === null ? {} : { revokedAt: row.revoked_at }) };
|
|
1332
|
+
}
|
|
1333
|
+
listAdoptionHandoffs(coordinatorId, limit = 100) {
|
|
1334
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u.test(coordinatorId) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
1335
|
+
throw new ControlPlaneStoreError('invalid-input', 'adoption handoff query is invalid');
|
|
1336
|
+
return this.#database.prepare(`SELECT handoff.* FROM adoption_handoffs handoff JOIN activation_plans plan ON plan.id=handoff.plan_id
|
|
1337
|
+
WHERE handoff.coordinator_id=? AND plan.status NOT IN ('activated','rolled-back')
|
|
1338
|
+
AND NOT (plan.status = 'approved' AND (handoff.revoked_at IS NOT NULL OR handoff.expires_at <= ?))
|
|
1339
|
+
ORDER BY handoff.created_at LIMIT ?`).all(coordinatorId, this.#now(), limit).map(row => this.getAdoptionHandoff(row.plan_id));
|
|
1340
|
+
}
|
|
1341
|
+
prepareAdoptionHandoff(input) {
|
|
1342
|
+
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 1)
|
|
1343
|
+
throw new ControlPlaneStoreError('invalid-input', 'adoption handoff revision is invalid');
|
|
1344
|
+
if (this.#adoptionCoordinatorId !== undefined)
|
|
1345
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot prepare a target handoff');
|
|
1346
|
+
return this.#withActivationSource(input.planId, () => {
|
|
1347
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1348
|
+
try {
|
|
1349
|
+
this.assertOwnerActivationSource(input.planId);
|
|
1350
|
+
const plan = this.getPlan(input.planId), terms = plan.dossier.handoff;
|
|
1351
|
+
// Preparation is target-host work while the caller's synchronous source
|
|
1352
|
+
// fence remains held. Re-read the exact owner repair in this transaction.
|
|
1353
|
+
readOwnerSourceAdoptionPlan(this.#database, plan.id);
|
|
1354
|
+
if (!terms || plan.revision !== input.expectedRevision || plan.status !== 'approved' || !plan.approval) {
|
|
1355
|
+
throw new ControlPlaneStoreError('conflict', 'adoption handoff is not approved');
|
|
1356
|
+
}
|
|
1357
|
+
try {
|
|
1358
|
+
validateAdoptionHandoffTerms(terms);
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption handoff terms are corrupt');
|
|
1362
|
+
}
|
|
1363
|
+
const prior = this.getAdoptionHandoff(plan.id);
|
|
1364
|
+
if (prior !== undefined) {
|
|
1365
|
+
if (prior.planDigest !== plan.digest || prior.coordinatorId !== terms.coordinatorId) {
|
|
1366
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored adoption handoff is corrupt');
|
|
1367
|
+
}
|
|
1368
|
+
this.#database.exec('COMMIT');
|
|
1369
|
+
return prior;
|
|
1370
|
+
}
|
|
1371
|
+
const now = this.#now(), expiresAt = Math.min(now + terms.maximumWindowMs, plan.expiresAt, plan.approval.expiresAt);
|
|
1372
|
+
if (expiresAt <= now)
|
|
1373
|
+
throw new ControlPlaneStoreError('expired', 'adoption handoff expired before preparation');
|
|
1374
|
+
this.#database.prepare('INSERT INTO adoption_handoffs (plan_id,plan_digest,coordinator_id,created_at,expires_at,revoked_at) VALUES (?,?,?,?,?,NULL)')
|
|
1375
|
+
.run(plan.id, plan.digest, terms.coordinatorId, now, expiresAt);
|
|
1376
|
+
const handoff = this.getAdoptionHandoff(plan.id);
|
|
1377
|
+
this.#database.exec('COMMIT');
|
|
1378
|
+
return handoff;
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
this.#database.exec('ROLLBACK');
|
|
1382
|
+
throw error;
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
revokeAdoptionHandoff(planId) {
|
|
1387
|
+
if (typeof planId !== 'string' || !KEY.test(planId))
|
|
1388
|
+
throw new ControlPlaneStoreError('invalid-input', 'adoption handoff plan id is invalid');
|
|
1389
|
+
if (this.#adoptionCoordinatorId !== undefined)
|
|
1390
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot revoke a target handoff');
|
|
1391
|
+
// Withdrawal is deliberately local: it must remain possible after source
|
|
1392
|
+
// feedback disappears and must not require the delegated coordinator.
|
|
1393
|
+
if (!this.getAdoptionHandoff(planId))
|
|
1394
|
+
return;
|
|
1395
|
+
this.#assertAdoptionHandoffBinding(planId);
|
|
1396
|
+
this.#database.prepare('UPDATE adoption_handoffs SET revoked_at=? WHERE plan_id=? AND revoked_at IS NULL').run(this.#now(), planId);
|
|
1397
|
+
}
|
|
1398
|
+
#assertAdoptionHandoffBinding(planId, expectedCoordinatorId) {
|
|
1399
|
+
const row = this.getAdoptionHandoff(planId), plan = this.getPlan(planId), terms = plan.dossier.handoff;
|
|
1400
|
+
if (!row || !terms || row.planDigest !== plan.digest || row.coordinatorId !== terms.coordinatorId
|
|
1401
|
+
|| (expectedCoordinatorId !== undefined && row.coordinatorId !== expectedCoordinatorId))
|
|
1402
|
+
throw new ControlPlaneStoreError('conflict', 'adoption handoff binding is inactive');
|
|
1403
|
+
try {
|
|
1404
|
+
validateAdoptionHandoffTerms(terms);
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption handoff terms are corrupt');
|
|
1408
|
+
}
|
|
1409
|
+
if (!plan.approval || plan.approval.decision !== 'approved' || plan.approval.planId !== plan.id || plan.approval.planDigest !== plan.digest
|
|
1410
|
+
|| row.createdAt < plan.createdAt || row.expiresAt !== Math.min(row.createdAt + terms.maximumWindowMs, plan.expiresAt, plan.approval.expiresAt)) {
|
|
1411
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption handoff terms do not match its approved dossier');
|
|
1412
|
+
}
|
|
1413
|
+
readOwnerSourceAdoptionPlan(this.#database, planId);
|
|
1414
|
+
return { row, plan, terms };
|
|
1415
|
+
}
|
|
1416
|
+
#assertAdoptionHandoff(planId, expectedCoordinatorId) {
|
|
1417
|
+
const { row, plan } = this.#assertAdoptionHandoffBinding(planId, expectedCoordinatorId);
|
|
1418
|
+
if (row.revokedAt !== undefined || row.expiresAt <= this.#now() || plan.expiresAt <= this.#now()
|
|
1419
|
+
|| !plan.approval || plan.approval.expiresAt <= this.#now())
|
|
1420
|
+
throw new ControlPlaneStoreError('conflict', 'adoption handoff is inactive');
|
|
1421
|
+
}
|
|
1422
|
+
assertAdoptionHandoff(planId) { this.#assertAdoptionHandoff(planId, this.#adoptionCoordinatorId); }
|
|
1423
|
+
enqueueSourceJob(input) {
|
|
1424
|
+
if (!/^source-job-[a-f0-9]{64}$/u.test(input.id) || input.automationId !== input.id) {
|
|
1425
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job identity is invalid');
|
|
1426
|
+
}
|
|
1427
|
+
const idempotencyKey = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
1428
|
+
if (!KEY.test(idempotencyKey))
|
|
1429
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job idempotencyKey has invalid syntax');
|
|
1430
|
+
const intent = sourceJobIntentFromStored(input.intent);
|
|
1431
|
+
const intentDigest = controlPlaneDigest(intent);
|
|
1432
|
+
const now = this.#now();
|
|
1433
|
+
this.#assertOwnerTaskFailureGapAdmission(intent.gapId);
|
|
1434
|
+
if (intent.containerName !== `dsh-${input.id}` || basename(intent.worktree) !== `worktree-job-${input.id.slice('source-job-'.length)}`) {
|
|
1435
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job resource identity is invalid');
|
|
1436
|
+
}
|
|
1437
|
+
if (now >= intent.authority.expiresAt)
|
|
1438
|
+
throw new ControlPlaneStoreError('expired', 'source job authority is expired');
|
|
1439
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1440
|
+
try {
|
|
1441
|
+
const prior = this.#database.prepare('SELECT * FROM source_jobs WHERE authority_id = ? AND idempotency_key = ?')
|
|
1442
|
+
.get(intent.authority.id, idempotencyKey);
|
|
1443
|
+
if (prior !== undefined) {
|
|
1444
|
+
const record = sourceJobFromRow(prior);
|
|
1445
|
+
if (record.intentDigest !== intentDigest || record.id !== input.id || record.automationId !== input.automationId) {
|
|
1446
|
+
throw new ControlPlaneStoreError('conflict', 'source job idempotency key was reused with different input');
|
|
1447
|
+
}
|
|
1448
|
+
this.#database.exec('COMMIT');
|
|
1449
|
+
return record;
|
|
1450
|
+
}
|
|
1451
|
+
const authority = this.#database.prepare('SELECT * FROM source_job_authorities WHERE authority_id = ?').get(intent.authority.id);
|
|
1452
|
+
if (authority !== undefined && (authority.authority_digest !== intent.authority.digest || authority.expires_at !== intent.authority.expiresAt
|
|
1453
|
+
|| authority.max_submissions !== intent.authority.maxSubmissions)) {
|
|
1454
|
+
throw new ControlPlaneStoreError('conflict', 'source job authority configuration is immutable');
|
|
1455
|
+
}
|
|
1456
|
+
if (authority !== undefined && authority.submissions >= authority.max_submissions)
|
|
1457
|
+
throw new ControlPlaneStoreError('invalid-state', 'source job authority submission quota is exhausted');
|
|
1458
|
+
if (authority === undefined) {
|
|
1459
|
+
this.#database.prepare(`INSERT INTO source_job_authorities (authority_id, authority_digest, expires_at, max_submissions, submissions)
|
|
1460
|
+
VALUES (?, ?, ?, ?, 0)`).run(intent.authority.id, intent.authority.digest, intent.authority.expiresAt, intent.authority.maxSubmissions);
|
|
1461
|
+
}
|
|
1462
|
+
this.#database.prepare('UPDATE source_job_authorities SET submissions = submissions + 1 WHERE authority_id = ? AND submissions < max_submissions')
|
|
1463
|
+
.run(intent.authority.id);
|
|
1464
|
+
this.#database.prepare(`INSERT INTO source_jobs (id, automation_id, authority_id, idempotency_key, intent_json, intent_digest,
|
|
1465
|
+
status, revision, created_at, expires_at, definition_hash, occurrence_id, plan_id, failure_code, updated_at)
|
|
1466
|
+
VALUES (?, ?, ?, ?, ?, ?, 'queued', 1, ?, ?, NULL, NULL, NULL, NULL, ?)`).run(input.id, input.automationId, intent.authority.id, idempotencyKey, JSON.stringify(intent), intentDigest, now, intent.authority.expiresAt, now);
|
|
1467
|
+
const record = this.getSourceJob(input.id);
|
|
1468
|
+
if (record === undefined)
|
|
1469
|
+
throw new ControlPlaneStoreError('invalid-state', 'inserted source job could not be read');
|
|
1470
|
+
this.#database.exec('COMMIT');
|
|
1471
|
+
return record;
|
|
1472
|
+
}
|
|
1473
|
+
catch (error) {
|
|
1474
|
+
this.#database.exec('ROLLBACK');
|
|
1475
|
+
throw error;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
getSourceJob(id) {
|
|
1479
|
+
const row = this.#database.prepare('SELECT * FROM source_jobs WHERE id = ?').get(id);
|
|
1480
|
+
return row === undefined ? undefined : sourceJobFromRow(row);
|
|
1481
|
+
}
|
|
1482
|
+
getSourceJobByAutomation(automationId) {
|
|
1483
|
+
const row = this.#database.prepare('SELECT * FROM source_jobs WHERE automation_id = ?').get(automationId);
|
|
1484
|
+
return row === undefined ? undefined : sourceJobFromRow(row);
|
|
1485
|
+
}
|
|
1486
|
+
/** Outstanding jobs sort first so restart reconciliation cannot be hidden by history. */
|
|
1487
|
+
listSourceJobs(limit = 100) {
|
|
1488
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1489
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job limit must be 1..100');
|
|
1490
|
+
return this.#database.prepare(`SELECT * FROM source_jobs ORDER BY CASE WHEN status IN ('queued', 'running', 'unknown') THEN 0 ELSE 1 END,
|
|
1491
|
+
created_at DESC, id DESC LIMIT ?`).all(limit).map(sourceJobFromRow);
|
|
1492
|
+
}
|
|
1493
|
+
/** Prepared owner continuations have a bounded recovery query, independent of job history. */
|
|
1494
|
+
listPreparedSourceApprovalJobs(includeRelease = false, includeExecution = false, includeAdoption = false) {
|
|
1495
|
+
return this.#database.prepare(`SELECT j.* FROM source_jobs j JOIN source_plans p ON p.id = j.plan_id
|
|
1496
|
+
JOIN owner_task_failure_gaps g ON g.gap_id = p.gap_id
|
|
1497
|
+
LEFT JOIN source_adoptions a ON a.source_plan_id = p.id
|
|
1498
|
+
LEFT JOIN activation_plans ap ON ap.id = a.activation_plan_id
|
|
1499
|
+
WHERE j.status = 'prepared' AND (p.status = 'pending-approval' OR (? = 1 AND p.status IN ('approved', 'ready-for-human-review'))
|
|
1500
|
+
OR (? = 1 AND p.status IN ('awaiting-pr', 'awaiting-review', 'awaiting-merge', 'awaiting-build', 'awaiting-sign', 'awaiting-publish',
|
|
1501
|
+
'awaiting-registry-verify', 'awaiting-catalog-admission'))
|
|
1502
|
+
OR (? = 1 AND p.status = 'release-complete' AND (ap.id IS NULL OR ap.status NOT IN ('activated', 'rolled-back', 'rejected')))) AND (p.expires_at > ? OR ap.status IN ('staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending'))
|
|
1503
|
+
ORDER BY j.created_at, j.id LIMIT 1000`).all(includeRelease ? 1 : 0, includeExecution ? 1 : 0, includeAdoption ? 1 : 0, this.#now()).map(sourceJobFromRow);
|
|
1504
|
+
}
|
|
1505
|
+
bindSourceJobDefinition(input) {
|
|
1506
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.definitionHash))
|
|
1507
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job definition binding is invalid');
|
|
1508
|
+
const now = this.#now();
|
|
1509
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET definition_hash = ?, revision = revision + 1, updated_at = ?
|
|
1510
|
+
WHERE id = ? AND revision = ? AND status = 'queued' AND definition_hash IS NULL AND expires_at > ?`).run(input.definitionHash, now, input.id, input.revision, now);
|
|
1511
|
+
if (Number(result.changes) !== 1)
|
|
1512
|
+
throw new ControlPlaneStoreError('conflict', 'source job definition binding lost its queued CAS');
|
|
1513
|
+
return this.getSourceJob(input.id);
|
|
1514
|
+
}
|
|
1515
|
+
claimSourceJob(input) {
|
|
1516
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.definitionHash) || !KEY.test(input.occurrenceId)) {
|
|
1517
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job claim is invalid');
|
|
1518
|
+
}
|
|
1519
|
+
const now = this.#now();
|
|
1520
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = 'running', occurrence_id = ?, revision = revision + 1, updated_at = ?
|
|
1521
|
+
WHERE id = ? AND revision = ? AND status = 'queued' AND definition_hash = ? AND occurrence_id IS NULL AND expires_at > ?`).run(input.occurrenceId, now, input.id, input.revision, input.definitionHash, now);
|
|
1522
|
+
if (Number(result.changes) !== 1)
|
|
1523
|
+
throw new ControlPlaneStoreError('conflict', 'source job claim lost its queued CAS');
|
|
1524
|
+
return this.getSourceJob(input.id);
|
|
1525
|
+
}
|
|
1526
|
+
settleSourceJob(input) {
|
|
1527
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || (input.status !== 'failed' && input.status !== 'unknown') || !KEY.test(input.failureCode))
|
|
1528
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job settlement is invalid');
|
|
1529
|
+
const permitted = input.status === 'unknown' ? "status = 'running'" : "status IN ('queued', 'running', 'unknown')";
|
|
1530
|
+
const now = this.#now();
|
|
1531
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = ?, failure_code = ?, revision = revision + 1, updated_at = ?
|
|
1532
|
+
WHERE id = ? AND revision = ? AND ${permitted}`).run(input.status, input.failureCode, now, input.id, input.revision);
|
|
1533
|
+
if (Number(result.changes) !== 1)
|
|
1534
|
+
throw new ControlPlaneStoreError('conflict', 'source job settlement lost its state CAS');
|
|
1535
|
+
return this.getSourceJob(input.id);
|
|
1536
|
+
}
|
|
1537
|
+
interruptSourceJobs() {
|
|
1538
|
+
const now = this.#now();
|
|
1539
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = 'unknown', failure_code = 'interrupted',
|
|
1540
|
+
revision = revision + 1, updated_at = ? WHERE status = 'running'`).run(now);
|
|
1541
|
+
return Number(result.changes);
|
|
1542
|
+
}
|
|
652
1543
|
createSourcePlan(input) {
|
|
1544
|
+
const mode = input.mode ?? 'create';
|
|
1545
|
+
if (input.sourceJob !== undefined && mode !== 'modify')
|
|
1546
|
+
throw new ControlPlaneStoreError('invalid-input', 'source jobs only complete checked modify plans');
|
|
653
1547
|
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
654
1548
|
const name = bounded(input.name, 'name', 64);
|
|
655
1549
|
if (!KEY.test(key) || !PLUGIN_NAME.test(name) || !COMMIT.test(input.baseCommit) || !DIGEST.test(input.generatorDigest)
|
|
656
1550
|
|| input.scope.length === 0 || input.scope.length > 32)
|
|
657
1551
|
throw new ControlPlaneStoreError('invalid-input', 'source plan binding is invalid');
|
|
1552
|
+
if (mode === 'modify' && input.generatorDigest !== MODIFY_GENERATOR_DIGEST) {
|
|
1553
|
+
throw new ControlPlaneStoreError('invalid-input', 'modify source plans must bind the modify generator constant');
|
|
1554
|
+
}
|
|
1555
|
+
let preparedEvidence;
|
|
1556
|
+
if (mode === 'modify') {
|
|
1557
|
+
if (input.prepared === undefined) {
|
|
1558
|
+
throw new ControlPlaneStoreError('invalid-input', 'modify source plan requires prepared check evidence');
|
|
1559
|
+
}
|
|
1560
|
+
if (!DIGEST.test(input.prepared.treeDigest) || !DIGEST.test(input.prepared.patchDigest)
|
|
1561
|
+
|| !Number.isSafeInteger(input.prepared.checkedAt) || input.prepared.checkedAt < 0) {
|
|
1562
|
+
throw new ControlPlaneStoreError('invalid-input', 'modify source plan prepared digests are invalid');
|
|
1563
|
+
}
|
|
1564
|
+
preparedEvidence = preparedEvidenceFromStored(input.prepared.evidence);
|
|
1565
|
+
}
|
|
1566
|
+
else if (input.prepared !== undefined) {
|
|
1567
|
+
throw new ControlPlaneStoreError('invalid-input', 'create source plans cannot carry prepared evidence');
|
|
1568
|
+
}
|
|
658
1569
|
positiveInteger(input.ttlMs, 'ttlMs');
|
|
659
1570
|
if (input.ttlMs < 60_000 || input.ttlMs > 86_400_000)
|
|
660
1571
|
throw new ControlPlaneStoreError('invalid-input', 'ttlMs is invalid');
|
|
661
1572
|
const scope = Object.freeze([...new Set(input.scope.map(value => bounded(value, 'scope', 500)))].sort());
|
|
662
|
-
if (controlPlaneDigest(scope) !== controlPlaneDigest(expectedSourceScope(name))) {
|
|
663
|
-
throw new ControlPlaneStoreError('invalid-input', 'source plan scope must bind the
|
|
1573
|
+
if (controlPlaneDigest(scope) !== controlPlaneDigest(expectedSourceScope(name, mode))) {
|
|
1574
|
+
throw new ControlPlaneStoreError('invalid-input', 'source plan scope must bind exactly the paths allowed for its mode');
|
|
1575
|
+
}
|
|
1576
|
+
if (input.sourceJob !== undefined && (!Number.isSafeInteger(input.sourceJob.jobRevision) || input.sourceJob.jobRevision < 1
|
|
1577
|
+
|| !KEY.test(input.sourceJob.jobId) || !KEY.test(input.sourceJob.occurrenceId))) {
|
|
1578
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job completion is invalid');
|
|
664
1579
|
}
|
|
665
1580
|
const requestBinding = { operation: 'create-source-plan', gapId: input.gapId, repository: input.repository,
|
|
666
1581
|
worktree: input.worktree, baseCommit: input.baseCommit, name, generatorDigest: input.generatorDigest, scope, ttlMs: input.ttlMs };
|
|
667
|
-
|
|
1582
|
+
this.#assertOwnerTaskFailureGapAdmission(input.gapId);
|
|
1583
|
+
const sourceJobBinding = input.sourceJob === undefined ? undefined : { jobId: input.sourceJob.jobId, jobRevision: input.sourceJob.jobRevision,
|
|
1584
|
+
occurrenceId: input.sourceJob.occurrenceId };
|
|
1585
|
+
const inputDigest = controlPlaneDigest({ ...(mode === 'modify' ? { ...requestBinding, mode, prepared: input.prepared } : requestBinding),
|
|
1586
|
+
...(sourceJobBinding === undefined ? {} : { sourceJob: sourceJobBinding }) });
|
|
668
1587
|
const prior = this.#sourcePlanReceiptByKey(key, 'create-source-plan', inputDigest);
|
|
669
1588
|
if (prior !== undefined)
|
|
670
1589
|
return prior;
|
|
@@ -679,14 +1598,49 @@ export class ControlPlaneStore {
|
|
|
679
1598
|
const immutable = { schemaVersion: 1, kind: 'source', id, gapId: gap.id, gapSnapshot,
|
|
680
1599
|
repository: input.repository, worktree: input.worktree, baseCommit: input.baseCommit, name,
|
|
681
1600
|
generatorDigest: input.generatorDigest, scope, createdAt: now, expiresAt };
|
|
682
|
-
const digest = controlPlaneDigest(
|
|
1601
|
+
const digest = controlPlaneDigest(mode === 'modify'
|
|
1602
|
+
? { ...immutable, mode, sourceCheck: { treeDigest: input.prepared.treeDigest, patchDigest: input.prepared.patchDigest,
|
|
1603
|
+
checkedAt: input.prepared.checkedAt }, preparedEvidence }
|
|
1604
|
+
: immutable);
|
|
683
1605
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
684
1606
|
try {
|
|
1607
|
+
const currentGap = this.getGap(gap.id);
|
|
1608
|
+
if (currentGap.revision !== gap.revision || currentGap.inputDigest !== gap.inputDigest || currentGap.status !== 'open'
|
|
1609
|
+
|| currentGap.candidateId !== undefined)
|
|
1610
|
+
throw new ControlPlaneStoreError('conflict', 'capability gap changed before source plan creation');
|
|
1611
|
+
let sourceJob;
|
|
1612
|
+
if (sourceJobBinding !== undefined) {
|
|
1613
|
+
sourceJob = this.getSourceJob(sourceJobBinding.jobId);
|
|
1614
|
+
if (sourceJob === undefined || sourceJob.status !== 'running' || sourceJob.revision !== sourceJobBinding.jobRevision
|
|
1615
|
+
|| sourceJob.occurrenceId !== sourceJobBinding.occurrenceId || sourceJob.intent.gapId !== gap.id
|
|
1616
|
+
|| sourceJob.intent.gapRevision !== currentGap.revision || sourceJob.intent.gapDigest !== controlPlaneDigest(currentGap)
|
|
1617
|
+
|| sourceJob.intent.baseCommit !== input.baseCommit || sourceJob.intent.name !== name
|
|
1618
|
+
|| sourceJob.intent.repository !== input.repository || sourceJob.intent.worktree !== input.worktree || sourceJob.intent.ttlMs !== input.ttlMs) {
|
|
1619
|
+
throw new ControlPlaneStoreError('conflict', 'source job completion lost its immutable running binding');
|
|
1620
|
+
}
|
|
1621
|
+
if (now >= sourceJob.expiresAt)
|
|
1622
|
+
throw new ControlPlaneStoreError('expired', 'source job authority is expired');
|
|
1623
|
+
}
|
|
685
1624
|
this.#database.prepare('INSERT INTO gap_plan_claims (gap_id, plan_id, plan_kind, claimed_at) VALUES (?, ?, ?, ?)').run(gap.id, id, 'source', now);
|
|
1625
|
+
// 'modify' rows carry their checked digests and frozen-build evidence from
|
|
1626
|
+
// creation; 'create' rows leave all four columns NULL until the
|
|
1627
|
+
// post-approval scaffold run finishes.
|
|
686
1628
|
this.#database.prepare(`INSERT INTO source_plans (id, plan_digest, gap_id, gap_snapshot_json, repository, worktree,
|
|
687
|
-
base_commit, plugin_name, generator_digest, scope_json, status, revision, created_at, expires_at,
|
|
688
|
-
|
|
689
|
-
|
|
1629
|
+
base_commit, plugin_name, generator_digest, scope_json, mode, status, revision, created_at, expires_at,
|
|
1630
|
+
approval_json, checked_tree_digest, checked_patch_digest, checked_at, prepared_evidence_json, updated_at)
|
|
1631
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending-approval', 1, ?, ?, NULL, ?, ?, ?, ?, ?)`).run(id, digest, gap.id, JSON.stringify(gapSnapshot), input.repository, input.worktree, input.baseCommit, name, input.generatorDigest, JSON.stringify(scope), mode, now, expiresAt, input.prepared?.treeDigest ?? null, input.prepared?.patchDigest ?? null, input.prepared?.checkedAt ?? null, preparedEvidence === undefined ? null : JSON.stringify(preparedEvidence), now);
|
|
1632
|
+
const matched = this.#database.prepare(`UPDATE capability_gaps SET status = 'matched', revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?`).run(now, gap.id, gap.revision);
|
|
1633
|
+
if (Number(matched.changes) !== 1)
|
|
1634
|
+
throw new ControlPlaneStoreError('conflict', 'capability gap changed while source plan was created');
|
|
1635
|
+
if (sourceJob !== undefined) {
|
|
1636
|
+
const occurrenceId = sourceJob.occurrenceId;
|
|
1637
|
+
if (occurrenceId === undefined)
|
|
1638
|
+
throw new ControlPlaneStoreError('invalid-state', 'running source job has no occurrence');
|
|
1639
|
+
const prepared = this.#database.prepare(`UPDATE source_jobs SET status = 'prepared', plan_id = ?, revision = revision + 1, updated_at = ?
|
|
1640
|
+
WHERE id = ? AND status = 'running' AND revision = ? AND occurrence_id = ?`).run(id, now, sourceJob.id, sourceJob.revision, occurrenceId);
|
|
1641
|
+
if (Number(prepared.changes) !== 1)
|
|
1642
|
+
throw new ControlPlaneStoreError('conflict', 'source job completion lost its prepared CAS');
|
|
1643
|
+
}
|
|
690
1644
|
const plan = this.getSourcePlan(id);
|
|
691
1645
|
const receipt = { idempotencyKey: key, operation: 'create-source-plan', inputDigest, result: plan, createdAt: now };
|
|
692
1646
|
this.#insertReceipt(receipt);
|
|
@@ -704,6 +1658,58 @@ export class ControlPlaneStore {
|
|
|
704
1658
|
throw new ControlPlaneStoreError('not-found', 'source plan not found');
|
|
705
1659
|
return sourceFromRow(row);
|
|
706
1660
|
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Enumerate 'modify' plans for isolated-worktree garbage collection. Only
|
|
1663
|
+
* modify plans own control-plane-created worktrees under the state root;
|
|
1664
|
+
* create plans bind externally prepared worktrees and must never be removed
|
|
1665
|
+
* by the control plane.
|
|
1666
|
+
*/
|
|
1667
|
+
listModifySourcePlans(filter) {
|
|
1668
|
+
const rows = filter?.expiredBefore === undefined
|
|
1669
|
+
? this.#database.prepare(`SELECT * FROM source_plans WHERE mode = 'modify' ORDER BY created_at, id`).all()
|
|
1670
|
+
: this.#database.prepare(`SELECT * FROM source_plans WHERE mode = 'modify' AND expires_at < ? ORDER BY created_at, id`)
|
|
1671
|
+
.all(filter.expiredBefore);
|
|
1672
|
+
return rows.map(sourceFromRow);
|
|
1673
|
+
}
|
|
1674
|
+
/** Retire an expired prepared plan before removing its worktree. The CAS
|
|
1675
|
+
* fences owner approval/review, and releases only this plan's gap claim. */
|
|
1676
|
+
expirePreparedSourcePlan(input) {
|
|
1677
|
+
if (!Number.isSafeInteger(input.now) || input.now < 0)
|
|
1678
|
+
throw new ControlPlaneStoreError('invalid-input', 'expiry time is invalid');
|
|
1679
|
+
const plan = this.getSourcePlan(input.planId);
|
|
1680
|
+
if (plan.mode !== 'modify')
|
|
1681
|
+
throw new ControlPlaneStoreError('invalid-state', 'only prepared modify plans may expire here');
|
|
1682
|
+
const expiryDigest = controlPlaneDigest({ planId: plan.id, planDigest: plan.digest, expiresAt: plan.expiresAt });
|
|
1683
|
+
if (plan.status === 'expired') {
|
|
1684
|
+
const receipt = this.#sourcePlanReceipt(`source-expired:${plan.id}`, 'source-expired', expiryDigest, plan.id);
|
|
1685
|
+
if (receipt?.result.status !== 'expired')
|
|
1686
|
+
throw new ControlPlaneStoreError('invalid-state', 'expired source receipt is missing');
|
|
1687
|
+
return receipt.result;
|
|
1688
|
+
}
|
|
1689
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1690
|
+
try {
|
|
1691
|
+
const retired = this.#database.prepare(`UPDATE source_plans SET status = 'expired', revision = revision + 1, updated_at = ?
|
|
1692
|
+
WHERE id = ? AND revision = ? AND mode = 'modify' AND status IN ('pending-approval', 'approved') AND expires_at < ?`)
|
|
1693
|
+
.run(input.now, plan.id, input.expectedRevision, input.now);
|
|
1694
|
+
if (Number(retired.changes) !== 1)
|
|
1695
|
+
throw new ControlPlaneStoreError('conflict', 'prepared plan changed or is not expired');
|
|
1696
|
+
const released = this.#database.prepare(`DELETE FROM gap_plan_claims WHERE gap_id = ? AND plan_id = ? AND plan_kind = 'source'`)
|
|
1697
|
+
.run(plan.gapId, plan.id);
|
|
1698
|
+
if (Number(released.changes) === 1) {
|
|
1699
|
+
this.#database.prepare(`UPDATE capability_gaps SET status = 'open', candidate_id = NULL, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1700
|
+
.run(input.now, plan.gapId);
|
|
1701
|
+
}
|
|
1702
|
+
const result = this.getSourcePlan(plan.id);
|
|
1703
|
+
this.#insertReceipt({ idempotencyKey: `source-expired:${plan.id}`, operation: 'source-expired',
|
|
1704
|
+
inputDigest: expiryDigest, result, createdAt: input.now });
|
|
1705
|
+
this.#database.exec('COMMIT');
|
|
1706
|
+
return result;
|
|
1707
|
+
}
|
|
1708
|
+
catch (error) {
|
|
1709
|
+
this.#database.exec('ROLLBACK');
|
|
1710
|
+
throw error;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
707
1713
|
async approve(input) {
|
|
708
1714
|
return this.#approvePlan('activation', input);
|
|
709
1715
|
}
|
|
@@ -711,16 +1717,27 @@ export class ControlPlaneStore {
|
|
|
711
1717
|
return this.#approvePlan('source', input);
|
|
712
1718
|
}
|
|
713
1719
|
async #approvePlan(kind, input) {
|
|
1720
|
+
if (this.#adoptionCoordinatorId !== undefined)
|
|
1721
|
+
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot create or approve owner work');
|
|
714
1722
|
const inputDigest = controlPlaneDigest({ operation: 'approve-plan', kind, planId: input.planId, expectedRevision: input.expectedRevision, receipt: input.receipt });
|
|
1723
|
+
const withCurrentSource = (gapId, callback) => {
|
|
1724
|
+
const commit = () => { this.#assertOwnerTaskFailureGapAdmission(gapId); return callback(); };
|
|
1725
|
+
return input.withSourceFence ? input.withSourceFence(commit)
|
|
1726
|
+
: kind === 'activation' ? this.#withActivationSource(input.planId, callback) : commit();
|
|
1727
|
+
};
|
|
715
1728
|
if (kind === 'source') {
|
|
716
1729
|
const prior = this.#sourcePlanReceipt(input.idempotencyKey, 'approve-plan', inputDigest, input.planId);
|
|
717
1730
|
if (prior !== undefined) {
|
|
1731
|
+
const expectedPrepared = prior.result.mode === 'modify';
|
|
718
1732
|
if (prior.result.status !== 'approved' || prior.result.revision !== input.expectedRevision + 1
|
|
719
1733
|
|| controlPlaneDigest(prior.result.approval) !== controlPlaneDigest(projectedApproval(input.receipt))
|
|
720
|
-
||
|
|
1734
|
+
|| (expectedPrepared
|
|
1735
|
+
? (prior.result.sourceCheck === undefined || prior.result.preparedEvidence === undefined)
|
|
1736
|
+
: (prior.result.sourceCheck !== undefined || prior.result.preparedEvidence !== undefined))
|
|
1737
|
+
|| prior.result.releaseAuthorization !== undefined || prior.result.release !== undefined) {
|
|
721
1738
|
throw new ControlPlaneStoreError('invalid-state', 'stored source approval receipt is corrupt');
|
|
722
1739
|
}
|
|
723
|
-
return prior;
|
|
1740
|
+
return withCurrentSource(prior.result.gapId, () => prior);
|
|
724
1741
|
}
|
|
725
1742
|
}
|
|
726
1743
|
else {
|
|
@@ -730,45 +1747,48 @@ export class ControlPlaneStore {
|
|
|
730
1747
|
|| controlPlaneDigest(prior.result.approval) !== controlPlaneDigest(projectedApproval(input.receipt))
|
|
731
1748
|
|| prior.result.activation !== undefined)
|
|
732
1749
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation approval receipt is corrupt');
|
|
733
|
-
return prior;
|
|
1750
|
+
return withCurrentSource(prior.result.gapId, () => prior);
|
|
734
1751
|
}
|
|
735
1752
|
}
|
|
736
1753
|
const plan = kind === 'activation' ? this.getPlan(input.planId) : this.getSourcePlan(input.planId);
|
|
737
1754
|
const verified = await input.resolveAuthority(input.receipt).verify(input.receipt, plan);
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1755
|
+
return withCurrentSource(plan.gapId, () => {
|
|
1756
|
+
if (plan.revision !== input.expectedRevision)
|
|
1757
|
+
throw new ControlPlaneStoreError('conflict', 'plan revision conflict');
|
|
1758
|
+
if (plan.status !== 'pending-approval')
|
|
1759
|
+
throw new ControlPlaneStoreError('invalid-state', 'plan is not pending approval');
|
|
1760
|
+
if (this.#now() > plan.expiresAt || this.#now() > verified.expiresAt || verified.decision !== 'approved')
|
|
1761
|
+
throw new ControlPlaneStoreError(verified.decision === 'approved' ? 'expired' : 'invalid-state', 'plan approval is not currently applicable');
|
|
1762
|
+
const table = kind === 'activation' ? 'activation_plans' : 'source_plans';
|
|
1763
|
+
const now = this.#now();
|
|
1764
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1765
|
+
try {
|
|
1766
|
+
const statement = this.#database.prepare(`UPDATE ${table} SET status = 'approved', revision = revision + 1, approval_json = ?,
|
|
1767
|
+
${kind === 'activation' ? 'approval_receipt_json = ?,' : ''} updated_at = ?
|
|
1768
|
+
WHERE id = ? AND status = 'pending-approval' AND revision = ? AND plan_digest = ?`);
|
|
1769
|
+
const result = kind === 'activation'
|
|
1770
|
+
? statement.run(JSON.stringify(verified), JSON.stringify(input.receipt), now, plan.id, input.expectedRevision, plan.digest)
|
|
1771
|
+
: statement.run(JSON.stringify(verified), now, plan.id, input.expectedRevision, plan.digest);
|
|
1772
|
+
if (Number(result.changes) !== 1)
|
|
1773
|
+
throw new ControlPlaneStoreError('conflict', 'plan changed while approval was being applied');
|
|
1774
|
+
const output = kind === 'activation' ? this.getPlan(plan.id) : this.getSourcePlan(plan.id);
|
|
1775
|
+
const receipt = { idempotencyKey: input.idempotencyKey, operation: 'approve-plan', inputDigest, result: output, createdAt: now };
|
|
1776
|
+
this.#insertReceipt(receipt);
|
|
1777
|
+
this.#database.exec('COMMIT');
|
|
1778
|
+
return receipt;
|
|
1779
|
+
}
|
|
1780
|
+
catch (error) {
|
|
1781
|
+
this.#database.exec('ROLLBACK');
|
|
1782
|
+
throw error;
|
|
1783
|
+
}
|
|
1784
|
+
});
|
|
766
1785
|
}
|
|
767
1786
|
async claimActivation(input) {
|
|
768
1787
|
const now = this.#now();
|
|
769
1788
|
positiveInteger(input.leaseMs, 'leaseMs');
|
|
770
1789
|
if (input.leaseMs < 5_000 || input.leaseMs > 300_000)
|
|
771
1790
|
throw new ControlPlaneStoreError('invalid-input', 'leaseMs is invalid');
|
|
1791
|
+
this.assertOwnerActivationSource(input.planId);
|
|
772
1792
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
773
1793
|
try {
|
|
774
1794
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
@@ -791,27 +1811,32 @@ export class ControlPlaneStore {
|
|
|
791
1811
|
if (controlPlaneDigest(verifiedApproval) !== controlPlaneDigest(plan.approval)) {
|
|
792
1812
|
throw new ControlPlaneStoreError('conflict', 'activation approval changed during claim verification');
|
|
793
1813
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if (
|
|
797
|
-
|
|
798
|
-
|
|
1814
|
+
return this.#withActivationSource(input.planId, () => {
|
|
1815
|
+
const recoverable = plan.status === 'staging' || plan.status === 'rollback-pending' || plan.status === 'commit-pending';
|
|
1816
|
+
if (plan.status === 'approved') {
|
|
1817
|
+
if (now > plan.expiresAt)
|
|
1818
|
+
throw new ControlPlaneStoreError('expired', 'activation plan expired before its first claim');
|
|
1819
|
+
const targetOwner = this.#database.prepare(`SELECT id FROM activation_plans WHERE target_path = ? AND id <> ? AND status IN (
|
|
799
1820
|
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
800
1821
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`).get(plan.target.profilePath, plan.id);
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1822
|
+
if (targetOwner !== undefined)
|
|
1823
|
+
throw new ControlPlaneStoreError('conflict', 'target profile already has an active activation');
|
|
1824
|
+
}
|
|
1825
|
+
else if (plan.status === 'rollback-pending' && plan.activation?.rollbackProfileRestored) {
|
|
1826
|
+
throw new ControlPlaneStoreError('invalid-state', 'physical Host rollback recovery is pending');
|
|
1827
|
+
}
|
|
1828
|
+
else if (!(recoverable && Number(row.activation_lease_until ?? 0) < now))
|
|
1829
|
+
throw new ControlPlaneStoreError('invalid-state', 'activation plan cannot be claimed');
|
|
1830
|
+
const activationId = row.activation_id ?? `activation-${randomUUID()}`;
|
|
1831
|
+
const status = plan.status === 'approved' ? 'staging' : plan.status;
|
|
1832
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET status = ?, revision = revision + 1, activation_id = ?,
|
|
809
1833
|
activation_fence = activation_fence + 1, activation_lease_until = ?, updated_at = ? WHERE id = ? AND revision = ?`).run(status, activationId, now + input.leaseMs, now, plan.id, input.expectedRevision);
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1834
|
+
if (Number(result.changes) !== 1)
|
|
1835
|
+
throw new ControlPlaneStoreError('conflict', 'activation plan changed while being claimed');
|
|
1836
|
+
const claimed = this.getPlan(plan.id);
|
|
1837
|
+
this.#database.exec('COMMIT');
|
|
1838
|
+
return claimed;
|
|
1839
|
+
});
|
|
815
1840
|
}
|
|
816
1841
|
catch (error) {
|
|
817
1842
|
this.#database.exec('ROLLBACK');
|
|
@@ -819,41 +1844,225 @@ export class ControlPlaneStore {
|
|
|
819
1844
|
}
|
|
820
1845
|
}
|
|
821
1846
|
heartbeatActivation(input) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1847
|
+
return this.#withActivationWrite(input.planId, () => {
|
|
1848
|
+
const now = this.#now();
|
|
1849
|
+
positiveInteger(input.leaseMs, 'leaseMs');
|
|
1850
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET activation_lease_until = ?, updated_at = ?
|
|
1851
|
+
WHERE id = ? AND revision = ? AND activation_fence = ? AND status IN ('staging', 'rollback-pending', 'commit-pending') AND activation_lease_until >= ?`).run(now + input.leaseMs, now, input.planId, input.expectedRevision, input.fence, now);
|
|
1852
|
+
if (Number(result.changes) !== 1)
|
|
1853
|
+
throw new ControlPlaneStoreError('conflict', 'activation heartbeat lost its revision/fence/lease');
|
|
1854
|
+
return this.getPlan(input.planId);
|
|
1855
|
+
});
|
|
829
1856
|
}
|
|
830
1857
|
recordActivationTargetBaseline(input) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1858
|
+
return this.#withActivationSource(input.planId, () => {
|
|
1859
|
+
const now = this.#now();
|
|
1860
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1861
|
+
try {
|
|
1862
|
+
this.assertOwnerActivationSource(input.planId);
|
|
1863
|
+
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1864
|
+
if (row === undefined)
|
|
1865
|
+
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1866
|
+
const plan = activationFromRow(row);
|
|
1867
|
+
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== 'staging'
|
|
1868
|
+
|| Number(row.activation_lease_until ?? 0) < now)
|
|
1869
|
+
throw new ControlPlaneStoreError('conflict', 'activation lost its claim before recording the target baseline');
|
|
1870
|
+
const expectedPaths = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'].map(name => `${plan.target.profilePath}/${name}`);
|
|
1871
|
+
if (input.baselineFiles.length !== (input.existed ? 3 : 0) || input.baselineFiles.some((file, index) => file.path !== expectedPaths[index]
|
|
1872
|
+
|| (file.sha256 !== null && !DIGEST.test(file.sha256))))
|
|
1873
|
+
throw new ControlPlaneStoreError('invalid-input', 'activation target baseline is invalid');
|
|
1874
|
+
const baseline = JSON.stringify(input.baselineFiles);
|
|
1875
|
+
if (row.activation_target_existed !== null && ((row.activation_target_existed === 1) !== input.existed
|
|
1876
|
+
|| row.activation_target_baseline_json !== baseline)) {
|
|
1877
|
+
throw new ControlPlaneStoreError('conflict', 'activation target baseline is immutable');
|
|
1878
|
+
}
|
|
1879
|
+
if (row.activation_target_existed === null) {
|
|
1880
|
+
this.#database.prepare(`UPDATE activation_plans SET activation_target_existed = ?, activation_target_baseline_json = ?, updated_at = ?
|
|
1881
|
+
WHERE id = ? AND revision = ? AND activation_fence = ? AND activation_target_existed IS NULL AND activation_target_baseline_json IS NULL`).run(input.existed ? 1 : 0, baseline, now, plan.id, plan.revision, input.fence);
|
|
1882
|
+
}
|
|
1883
|
+
const result = this.getPlan(plan.id);
|
|
1884
|
+
this.#database.exec('COMMIT');
|
|
1885
|
+
return result;
|
|
1886
|
+
}
|
|
1887
|
+
catch (error) {
|
|
1888
|
+
this.#database.exec('ROLLBACK');
|
|
1889
|
+
throw error;
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Freeze the exact candidate profile after all normal Host gates passed but
|
|
1895
|
+
* before terminal promotion. This is a mutable deployment checkpoint, not a
|
|
1896
|
+
* field in the signed immutable plan: it is the compare-and-restore fence for
|
|
1897
|
+
* a later quality-triggered rollback.
|
|
1898
|
+
*/
|
|
1899
|
+
recordActivationInstalledBaseline(input) {
|
|
1900
|
+
return this.#withActivationSource(input.planId, () => {
|
|
1901
|
+
const now = this.#now();
|
|
1902
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1903
|
+
try {
|
|
1904
|
+
this.assertOwnerActivationSource(input.planId);
|
|
1905
|
+
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1906
|
+
if (row === undefined)
|
|
1907
|
+
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1908
|
+
const plan = activationFromRow(row);
|
|
1909
|
+
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== 'commit-pending'
|
|
1910
|
+
|| Number(row.activation_lease_until ?? 0) < now) {
|
|
1911
|
+
throw new ControlPlaneStoreError('conflict', 'activation lost its claim before recording the installed baseline');
|
|
1912
|
+
}
|
|
1913
|
+
let files;
|
|
1914
|
+
try {
|
|
1915
|
+
files = activationCoreFiles(input.baselineFiles, plan.target.profilePath, 'activation installed baseline');
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
if (error instanceof ControlPlaneStoreError)
|
|
1919
|
+
throw new ControlPlaneStoreError('invalid-input', 'activation installed baseline is invalid');
|
|
1920
|
+
throw error;
|
|
1921
|
+
}
|
|
1922
|
+
const baseline = JSON.stringify(files);
|
|
1923
|
+
const existing = this.#database.prepare('SELECT * FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
1924
|
+
.get(plan.id);
|
|
1925
|
+
if (existing !== undefined) {
|
|
1926
|
+
if (existing.baseline_json !== baseline)
|
|
1927
|
+
throw new ControlPlaneStoreError('conflict', 'activation installed baseline is immutable');
|
|
1928
|
+
}
|
|
1929
|
+
else {
|
|
1930
|
+
const sequence = this.#database.prepare('SELECT next_exposure_order FROM activation_deployment_sequence WHERE singleton = 1')
|
|
1931
|
+
.get();
|
|
1932
|
+
if (sequence === undefined || !Number.isSafeInteger(sequence.next_exposure_order) || sequence.next_exposure_order < 1) {
|
|
1933
|
+
throw new ControlPlaneStoreError('invalid-state', 'activation deployment sequence is corrupt');
|
|
1934
|
+
}
|
|
1935
|
+
const advanced = this.#database.prepare(`UPDATE activation_deployment_sequence SET next_exposure_order = next_exposure_order + 1
|
|
1936
|
+
WHERE singleton = 1 AND next_exposure_order = ?`).run(sequence.next_exposure_order);
|
|
1937
|
+
if (Number(advanced.changes) !== 1)
|
|
1938
|
+
throw new ControlPlaneStoreError('conflict', 'activation deployment sequence changed');
|
|
1939
|
+
this.#database.prepare(`INSERT INTO activation_deployment_checkpoints (plan_id, baseline_json, exposure_order,
|
|
1940
|
+
successful_order, recorded_at, succeeded_at) VALUES (?, ?, ?, NULL, ?, NULL)`).run(plan.id, baseline, sequence.next_exposure_order, now);
|
|
1941
|
+
}
|
|
1942
|
+
const output = this.getPlan(plan.id);
|
|
1943
|
+
this.#database.exec('COMMIT');
|
|
1944
|
+
return output;
|
|
1945
|
+
}
|
|
1946
|
+
catch (error) {
|
|
1947
|
+
this.#database.exec('ROLLBACK');
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
getActivationInstalledBaseline(planId) {
|
|
1953
|
+
const plan = this.getPlan(planId);
|
|
1954
|
+
const row = this.#database.prepare('SELECT * FROM activation_deployment_checkpoints WHERE plan_id = ?').get(plan.id);
|
|
1955
|
+
if (row === undefined)
|
|
1956
|
+
return undefined;
|
|
1957
|
+
let value;
|
|
1958
|
+
try {
|
|
1959
|
+
value = JSON.parse(row.baseline_json);
|
|
1960
|
+
}
|
|
1961
|
+
catch {
|
|
1962
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored installed activation baseline is corrupt');
|
|
1963
|
+
}
|
|
1964
|
+
return activationCoreFiles(value, plan.target.profilePath, 'stored installed activation baseline');
|
|
1965
|
+
}
|
|
1966
|
+
/** Move a signed closed deployment watch into the existing physical rollback lifecycle. */
|
|
1967
|
+
beginPostActivationRollback(input) {
|
|
1968
|
+
const now = this.#now();
|
|
1969
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1970
|
+
try {
|
|
1971
|
+
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
835
1972
|
if (row === undefined)
|
|
836
1973
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
837
1974
|
const plan = activationFromRow(row);
|
|
838
|
-
if (plan.revision !== input.expectedRevision
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1975
|
+
if (plan.revision !== input.expectedRevision)
|
|
1976
|
+
throw new ControlPlaneStoreError('conflict', 'activation plan revision conflict');
|
|
1977
|
+
// A restart or retry must retain the original rollback identity and never
|
|
1978
|
+
// manufacture another rollback from a now-closed watch.
|
|
1979
|
+
if ((plan.status === 'rollback-pending' || plan.status === 'rolled-back') && this.#hasPostActivationRollbackProvenance(plan)) {
|
|
1980
|
+
this.#database.exec('COMMIT');
|
|
1981
|
+
return plan;
|
|
1982
|
+
}
|
|
1983
|
+
if (plan.status !== 'activated' || plan.activation === undefined) {
|
|
1984
|
+
throw new ControlPlaneStoreError('invalid-state', 'only an activated deployment can enter post-activation rollback');
|
|
843
1985
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1986
|
+
const watch = this.getActivationWatch(plan.id);
|
|
1987
|
+
if ((watch.state !== 'closed-regressed' && watch.state !== 'closed-retracted')
|
|
1988
|
+
|| watch.activationId !== plan.activation.id || watch.fence !== plan.activation.fence || watch.close === undefined) {
|
|
1989
|
+
throw new ControlPlaneStoreError('invalid-state', 'post-activation rollback requires an exact signed closed watch');
|
|
847
1990
|
}
|
|
848
|
-
|
|
1991
|
+
if (!plan.activation.hostRecoveryRequired || plan.activation.targetBaselineFiles === undefined) {
|
|
1992
|
+
throw new ControlPlaneStoreError('invalid-state', 'post-activation rollback lacks its original recovery baseline');
|
|
1993
|
+
}
|
|
1994
|
+
const checkpoint = this.#database.prepare('SELECT * FROM activation_deployment_checkpoints WHERE plan_id = ?').get(plan.id);
|
|
1995
|
+
if (checkpoint === undefined || checkpoint.successful_order !== checkpoint.exposure_order) {
|
|
1996
|
+
throw new ControlPlaneStoreError('invalid-state', 'post-activation rollback lacks a successful installed baseline checkpoint');
|
|
1997
|
+
}
|
|
1998
|
+
this.getActivationInstalledBaseline(plan.id);
|
|
1999
|
+
const active = this.#database.prepare(`SELECT id FROM activation_plans WHERE target_path = ? AND id <> ? AND status IN (
|
|
2000
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2001
|
+
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`)
|
|
2002
|
+
.get(plan.target.profilePath, plan.id);
|
|
2003
|
+
if (active !== undefined)
|
|
2004
|
+
throw new ControlPlaneStoreError('conflict', 'a newer activation currently owns the target profile');
|
|
2005
|
+
const superseded = this.#database.prepare(`SELECT checkpoint.plan_id FROM activation_deployment_checkpoints AS checkpoint
|
|
2006
|
+
JOIN activation_plans AS candidate ON candidate.id = checkpoint.plan_id
|
|
2007
|
+
WHERE candidate.target_path = ? AND checkpoint.successful_order IS NOT NULL
|
|
2008
|
+
AND checkpoint.exposure_order > ? LIMIT 1`).get(plan.target.profilePath, checkpoint.exposure_order);
|
|
2009
|
+
if (superseded !== undefined)
|
|
2010
|
+
throw new ControlPlaneStoreError('conflict', 'a newer successful deployment permanently superseded this rollback target');
|
|
2011
|
+
const failureCode = watch.state === 'closed-regressed' ? 'post-activation-regressed' : 'post-activation-retracted';
|
|
2012
|
+
const updated = this.#database.prepare(`UPDATE activation_plans SET status = 'rollback-pending', revision = revision + 1,
|
|
2013
|
+
activation_lease_until = NULL, rollback_profile_restored = 0, failure_code = ?, updated_at = ?
|
|
2014
|
+
WHERE id = ? AND status = 'activated' AND revision = ? AND activation_id = ? AND activation_fence = ?`).run(failureCode, now, plan.id, plan.revision, plan.activation.id, plan.activation.fence);
|
|
2015
|
+
if (Number(updated.changes) !== 1)
|
|
2016
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation rollback lost its activation CAS');
|
|
2017
|
+
const output = this.getPlan(plan.id);
|
|
849
2018
|
this.#database.exec('COMMIT');
|
|
850
|
-
return
|
|
2019
|
+
return output;
|
|
851
2020
|
}
|
|
852
2021
|
catch (error) {
|
|
853
2022
|
this.#database.exec('ROLLBACK');
|
|
854
2023
|
throw error;
|
|
855
2024
|
}
|
|
856
2025
|
}
|
|
2026
|
+
/** Backups safe to delete only after `currentPlanId` is terminally promoted. */
|
|
2027
|
+
listRetiredActivationBackups(currentPlanId) {
|
|
2028
|
+
const current = this.getPlan(currentPlanId);
|
|
2029
|
+
if (current.status !== 'activated')
|
|
2030
|
+
throw new ControlPlaneStoreError('invalid-state', 'only a successful deployment may retire older backups');
|
|
2031
|
+
const checkpoint = this.#database.prepare('SELECT * FROM activation_deployment_checkpoints WHERE plan_id = ?').get(current.id);
|
|
2032
|
+
if (checkpoint === undefined || checkpoint.successful_order !== checkpoint.exposure_order)
|
|
2033
|
+
return [];
|
|
2034
|
+
const rows = this.#database.prepare(`SELECT activation.* FROM activation_deployment_checkpoints AS older
|
|
2035
|
+
JOIN activation_plans AS activation ON activation.id = older.plan_id
|
|
2036
|
+
WHERE activation.target_path = ? AND activation.id <> ? AND activation.status = 'activated'
|
|
2037
|
+
AND older.successful_order IS NOT NULL AND older.exposure_order < ?
|
|
2038
|
+
ORDER BY older.exposure_order`).all(current.target.profilePath, current.id, checkpoint.exposure_order);
|
|
2039
|
+
return rows.map(activationFromRow);
|
|
2040
|
+
}
|
|
2041
|
+
/** Freeze recovery obligation before the profile can become Host-visible. */
|
|
2042
|
+
markActivationHostExposure(input) {
|
|
2043
|
+
return this.#withActivationWrite(input.planId, () => {
|
|
2044
|
+
const now = this.#now();
|
|
2045
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET host_recovery_required = 1, updated_at = ?
|
|
2046
|
+
WHERE id = ? AND revision = ? AND activation_fence = ? AND status = 'staging'
|
|
2047
|
+
AND activation_target_baseline_json IS NOT NULL AND activation_target_existed IS NOT NULL
|
|
2048
|
+
AND activation_lease_until >= ?`).run(now, input.planId, input.expectedRevision, input.fence, now);
|
|
2049
|
+
if (Number(result.changes) !== 1)
|
|
2050
|
+
throw new ControlPlaneStoreError('conflict', 'Host exposure lost its claim or original baseline');
|
|
2051
|
+
return this.getPlan(input.planId);
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
markRollbackProfileRestored(input) {
|
|
2055
|
+
return this.#withActivationWrite(input.planId, () => {
|
|
2056
|
+
const now = this.#now();
|
|
2057
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET rollback_profile_restored = 1, activation_lease_until = NULL, updated_at = ?
|
|
2058
|
+
WHERE id = ? AND revision = ? AND activation_fence = ? AND status = 'rollback-pending'
|
|
2059
|
+
AND host_recovery_required = 1 AND rollback_profile_restored = 0 AND activation_target_baseline_json IS NOT NULL
|
|
2060
|
+
AND activation_lease_until >= ?`).run(now, input.planId, input.expectedRevision, input.fence, now);
|
|
2061
|
+
if (Number(result.changes) !== 1)
|
|
2062
|
+
throw new ControlPlaneStoreError('conflict', 'rollback restoration marker lost its fence or baseline');
|
|
2063
|
+
return this.getPlan(input.planId);
|
|
2064
|
+
}, true);
|
|
2065
|
+
}
|
|
857
2066
|
/**
|
|
858
2067
|
* Hold SQLite's cross-process write lock for the complete command or
|
|
859
2068
|
* destructive filesystem operation. A lease timeout is therefore never a
|
|
@@ -879,15 +2088,18 @@ export class ControlPlaneStore {
|
|
|
879
2088
|
throw new ControlPlaneStoreError('conflict', 'activation no longer owns the filesystem fence');
|
|
880
2089
|
return row;
|
|
881
2090
|
};
|
|
2091
|
+
this.assertOwnerActivationSource(input.planId);
|
|
882
2092
|
assertOwner(true);
|
|
883
2093
|
const started = this.#now();
|
|
884
2094
|
this.#database.prepare('UPDATE activation_plans SET activation_lease_until = ?, updated_at = ? WHERE id = ?').run(started + input.leaseMs, started, input.planId);
|
|
885
2095
|
const result = await action();
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
2096
|
+
return this.#withActivationSource(input.planId, () => {
|
|
2097
|
+
assertOwner(false);
|
|
2098
|
+
const finished = this.#now();
|
|
2099
|
+
this.#database.prepare('UPDATE activation_plans SET activation_lease_until = ?, updated_at = ? WHERE id = ?').run(finished + input.leaseMs, finished, input.planId);
|
|
2100
|
+
this.#database.exec('COMMIT');
|
|
2101
|
+
return result;
|
|
2102
|
+
});
|
|
891
2103
|
}
|
|
892
2104
|
catch (error) {
|
|
893
2105
|
this.#database.exec('ROLLBACK');
|
|
@@ -913,61 +2125,82 @@ export class ControlPlaneStore {
|
|
|
913
2125
|
}
|
|
914
2126
|
}
|
|
915
2127
|
assertActivationFence(input) {
|
|
2128
|
+
this.assertOwnerActivationSource(input.planId);
|
|
916
2129
|
const plan = this.getPlan(input.planId);
|
|
917
2130
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || !input.statuses.includes(plan.status))
|
|
918
2131
|
throw new ControlPlaneStoreError('conflict', 'activation no longer owns the exact revision/fence/status');
|
|
919
2132
|
return plan;
|
|
920
2133
|
}
|
|
921
2134
|
prepareHostAttestationOperation(input) {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
throw new ControlPlaneStoreError('invalid-input', 'Host attestation requirements do not match the awaited phase');
|
|
934
|
-
if (!Number.isSafeInteger(input.receiptTtlMs) || input.receiptTtlMs < 1_000 || input.receiptTtlMs > 300_000) {
|
|
935
|
-
throw new ControlPlaneStoreError('invalid-input', 'Host attestation receipt TTL is invalid');
|
|
936
|
-
}
|
|
937
|
-
if (expected.phase === 'reload') {
|
|
938
|
-
const previous = this.#database.prepare(`SELECT max(attestation.host_generation) AS generation
|
|
939
|
-
FROM host_attestations AS attestation JOIN activation_plans AS activation ON activation.id = attestation.plan_id
|
|
940
|
-
WHERE activation.installation_id = ?`).get(plan.installationId);
|
|
941
|
-
if (input.requirements.kind !== 'reload' || input.requirements.previousHostGeneration !== (previous.generation ?? 0)) {
|
|
942
|
-
throw new ControlPlaneStoreError('conflict', 'reload operation does not bind the durable prior Host generation');
|
|
2135
|
+
return this.#withActivationSource(input.planId, () => {
|
|
2136
|
+
const now = this.#now();
|
|
2137
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2138
|
+
try {
|
|
2139
|
+
this.assertOwnerActivationSource(input.planId);
|
|
2140
|
+
const plan = this.getPlan(input.planId);
|
|
2141
|
+
const expected = expectedAttestation[plan.status];
|
|
2142
|
+
if (expected === undefined)
|
|
2143
|
+
throw new ControlPlaneStoreError('invalid-state', 'activation is not awaiting a Host attestation operation');
|
|
2144
|
+
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.expectedFence) {
|
|
2145
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation operation targets a stale activation revision/fence');
|
|
943
2146
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
if (
|
|
956
|
-
|
|
2147
|
+
if (input.requirements.kind !== expected.phase)
|
|
2148
|
+
throw new ControlPlaneStoreError('invalid-input', 'Host attestation requirements do not match the awaited phase');
|
|
2149
|
+
if (!Number.isSafeInteger(input.receiptTtlMs) || input.receiptTtlMs < 1_000 || input.receiptTtlMs > 300_000) {
|
|
2150
|
+
throw new ControlPlaneStoreError('invalid-input', 'Host attestation receipt TTL is invalid');
|
|
2151
|
+
}
|
|
2152
|
+
if (expected.phase === 'reload') {
|
|
2153
|
+
const previous = this.latestHostGeneration(plan.installationId);
|
|
2154
|
+
if (input.requirements.kind !== 'reload' || input.requirements.previousHostGeneration !== previous) {
|
|
2155
|
+
throw new ControlPlaneStoreError('conflict', 'reload operation does not bind the durable prior Host generation');
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
if (expected.phase === 'rollback') {
|
|
2159
|
+
if (!plan.activation?.hostRecoveryRequired || !plan.activation.rollbackProfileRestored || plan.activation.targetBaselineFiles === undefined) {
|
|
2160
|
+
throw new ControlPlaneStoreError('invalid-state', 'rollback physical recovery has not been durably restored and bound');
|
|
2161
|
+
}
|
|
2162
|
+
const previous = this.latestHostGeneration(plan.installationId);
|
|
2163
|
+
if (input.requirements.kind !== 'rollback' || input.requirements.previousHostGeneration !== previous
|
|
2164
|
+
|| input.requirements.baselineFiles.length !== plan.activation.targetBaselineFiles.length
|
|
2165
|
+
|| controlPlaneDigest(input.requirements.baselineFiles) !== controlPlaneDigest(plan.activation.targetBaselineFiles)
|
|
2166
|
+
|| input.requirements.action !== (plan.activation.targetOriginallyExisted ? 'restore' : 'stop')) {
|
|
2167
|
+
throw new ControlPlaneStoreError('conflict', 'rollback operation does not bind the durable recovery baseline');
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
const predecessor = this.#appliedHostPredecessor(plan, expected.phase);
|
|
2171
|
+
if (expected.phase !== 'rollback' && expected.phase !== 'reload' && predecessor === null) {
|
|
2172
|
+
throw new ControlPlaneStoreError('conflict', 'normal Host attestation phase has no applied predecessor');
|
|
2173
|
+
}
|
|
2174
|
+
const operationId = `host-operation-${randomUUID()}`;
|
|
2175
|
+
const request = { schemaVersion: 2, kind: 'dsh-host-attestation-request', operationId,
|
|
2176
|
+
requestedAt: now, receiptTtlMs: input.receiptTtlMs, installationId: plan.installationId, ledger: plan.ledger, plan: { id: plan.id, digest: plan.digest },
|
|
2177
|
+
activation: { id: plan.activation.id, fence: plan.activation.fence }, profile: { name: plan.profile, path: plan.target.profilePath },
|
|
2178
|
+
issuer: input.issuer, phase: expected.phase, requirements: input.requirements, predecessor };
|
|
2179
|
+
const bindingDigest = controlPlaneDigest(requestBinding(request));
|
|
2180
|
+
const priorRow = this.#database.prepare('SELECT * FROM host_attestation_operations WHERE plan_id = ? AND phase = ?')
|
|
2181
|
+
.get(plan.id, expected.phase);
|
|
2182
|
+
if (priorRow !== undefined) {
|
|
2183
|
+
const prior = hostOperationFromRow(priorRow);
|
|
2184
|
+
if (!isBoundHostRequest(prior.request)) {
|
|
2185
|
+
throw new ControlPlaneStoreError('invalid-state', 'legacy Host attestation operation requires reconciliation before upgrade');
|
|
2186
|
+
}
|
|
2187
|
+
if (prior.bindingDigest !== bindingDigest)
|
|
2188
|
+
throw new ControlPlaneStoreError('conflict', 'durable Host operation payload changed for the same phase');
|
|
2189
|
+
this.#database.exec('COMMIT');
|
|
2190
|
+
return prior;
|
|
2191
|
+
}
|
|
2192
|
+
this.#database.prepare(`INSERT INTO host_attestation_operations (plan_id, phase, operation_id, binding_digest,
|
|
2193
|
+
request_digest, request_json, status, receipt_digest, receipt_json, created_at, completed_at, applied_at)
|
|
2194
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, NULL, NULL)`).run(plan.id, expected.phase, operationId, bindingDigest, controlPlaneDigest(request), JSON.stringify(request), now);
|
|
2195
|
+
const operation = this.getHostAttestationOperation(operationId);
|
|
957
2196
|
this.#database.exec('COMMIT');
|
|
958
|
-
return
|
|
2197
|
+
return operation;
|
|
959
2198
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
return operation;
|
|
966
|
-
}
|
|
967
|
-
catch (error) {
|
|
968
|
-
this.#database.exec('ROLLBACK');
|
|
969
|
-
throw error;
|
|
970
|
-
}
|
|
2199
|
+
catch (error) {
|
|
2200
|
+
this.#database.exec('ROLLBACK');
|
|
2201
|
+
throw error;
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
971
2204
|
}
|
|
972
2205
|
getHostAttestationOperation(operationId) {
|
|
973
2206
|
const row = this.#database.prepare('SELECT * FROM host_attestation_operations WHERE operation_id = ?').get(operationId);
|
|
@@ -975,84 +2208,467 @@ export class ControlPlaneStore {
|
|
|
975
2208
|
throw new ControlPlaneStoreError('not-found', 'Host attestation operation not found');
|
|
976
2209
|
return hostOperationFromRow(row);
|
|
977
2210
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
2211
|
+
getHostAttestationDispatchStatus(operationId) {
|
|
2212
|
+
const row = this.#database.prepare('SELECT * FROM host_attestation_dispatches WHERE operation_id = ?').get(operationId);
|
|
2213
|
+
if (!row)
|
|
2214
|
+
return undefined;
|
|
2215
|
+
if (!['claimed', 'completed'].includes(row.status) || !Number.isSafeInteger(row.claimed_at) || row.claimed_at < 0
|
|
2216
|
+
|| (row.status === 'claimed') !== (row.completed_at === null))
|
|
2217
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored Host attestation dispatch is corrupt');
|
|
2218
|
+
return row.status;
|
|
2219
|
+
}
|
|
2220
|
+
assertNoClaimedHostAttestation(planId) {
|
|
2221
|
+
const claimed = this.#database.prepare(`SELECT 1 FROM host_attestation_operations operation JOIN host_attestation_dispatches dispatch
|
|
2222
|
+
ON dispatch.operation_id = operation.operation_id WHERE operation.plan_id = ? AND dispatch.status = 'claimed' LIMIT 1`).get(planId);
|
|
2223
|
+
if (claimed !== undefined)
|
|
2224
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation dispatch outcome is unknown');
|
|
2225
|
+
}
|
|
2226
|
+
/** Retain an exact signed Host receipt without dispatching or advancing the plan. */
|
|
2227
|
+
async acceptHostAttestationReceipt(input) {
|
|
2228
|
+
const operation = this.getHostAttestationOperation(input.operationId), plan = this.getPlan(operation.planId);
|
|
2229
|
+
const expected = expectedAttestation[plan.status];
|
|
2230
|
+
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.expectedFence || expected?.phase !== operation.phase
|
|
2231
|
+
|| operation.requestDigest !== controlPlaneDigest(operation.request) || input.receipt.operationId !== operation.operationId) {
|
|
2232
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation receipt lost its exact operation binding');
|
|
2233
|
+
}
|
|
2234
|
+
if (operation.receipt !== undefined) {
|
|
2235
|
+
if (controlPlaneDigest(operation.receipt) !== controlPlaneDigest(input.receipt))
|
|
2236
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation receipt changed after completion');
|
|
2237
|
+
return operation.receipt;
|
|
2238
|
+
}
|
|
2239
|
+
const request = this.#assertHostAttestationChain(operation, plan);
|
|
2240
|
+
await input.resolveAuthority(input.receipt).verify(input.receipt, plan, request);
|
|
2241
|
+
if (operation.phase !== 'reload' && operation.phase !== 'rollback' && input.receipt.hostGeneration !== request.predecessor.hostGeneration) {
|
|
2242
|
+
throw new ControlPlaneStoreError('conflict', 'non-transition Host receipt changed generation from its predecessor');
|
|
2243
|
+
}
|
|
2244
|
+
if (operation.phase === 'rollback' && input.receipt.outcome !== 'passed')
|
|
2245
|
+
throw new ControlPlaneStoreError('conflict', 'failed physical rollback receipt cannot settle recovery');
|
|
2246
|
+
const commit = () => {
|
|
993
2247
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
2248
|
+
try {
|
|
2249
|
+
if (input.requireCurrentSource)
|
|
2250
|
+
this.assertOwnerActivationSource(plan.id);
|
|
2251
|
+
const current = this.getPlan(plan.id), currentOperation = this.getHostAttestationOperation(operation.operationId);
|
|
2252
|
+
if (current.revision !== input.expectedRevision || current.activation?.fence !== input.expectedFence
|
|
2253
|
+
|| expectedAttestation[current.status]?.phase !== currentOperation.phase || currentOperation.requestDigest !== operation.requestDigest) {
|
|
2254
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation receipt became stale');
|
|
2255
|
+
}
|
|
2256
|
+
this.#assertHostAttestationChain(currentOperation, current);
|
|
2257
|
+
if (currentOperation.receipt !== undefined) {
|
|
2258
|
+
if (controlPlaneDigest(currentOperation.receipt) !== controlPlaneDigest(input.receipt)) {
|
|
2259
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation receipt changed after completion');
|
|
2260
|
+
}
|
|
2261
|
+
this.#database.exec('COMMIT');
|
|
2262
|
+
return currentOperation.receipt;
|
|
2263
|
+
}
|
|
2264
|
+
const dispatch = this.getHostAttestationDispatchStatus(operation.operationId);
|
|
2265
|
+
if (dispatch === 'completed')
|
|
2266
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation dispatch completed without its receipt');
|
|
2267
|
+
const now = this.#now(), updated = this.#database.prepare(`UPDATE host_attestation_operations SET status='completed',receipt_digest=?,receipt_json=?,completed_at=?
|
|
2268
|
+
WHERE operation_id=? AND status='pending' AND request_digest=?`).run(controlPlaneDigest(input.receipt), JSON.stringify(input.receipt), now, operation.operationId, operation.requestDigest);
|
|
2269
|
+
if (Number(updated.changes) !== 1)
|
|
2270
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation receipt completion lost its claim');
|
|
2271
|
+
if (dispatch === 'claimed') {
|
|
2272
|
+
const completed = this.#database.prepare("UPDATE host_attestation_dispatches SET status='completed',completed_at=? WHERE operation_id=? AND status='claimed'").run(now, operation.operationId);
|
|
2273
|
+
if (Number(completed.changes) !== 1)
|
|
2274
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation dispatch completion lost its claim');
|
|
2275
|
+
}
|
|
2276
|
+
this.#database.exec('COMMIT');
|
|
2277
|
+
return input.receipt;
|
|
2278
|
+
}
|
|
2279
|
+
catch (error) {
|
|
2280
|
+
this.#database.exec('ROLLBACK');
|
|
2281
|
+
throw error;
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
return input.requireCurrentSource ? this.#withActivationSource(plan.id, commit) : commit();
|
|
2285
|
+
}
|
|
2286
|
+
/** Same synchronous transaction owns current-profile selection and capture. */
|
|
2287
|
+
withForegroundDeployment(task, profilePath, callback) {
|
|
2288
|
+
assertForegroundTask(task);
|
|
2289
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
998
2290
|
try {
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (
|
|
1003
|
-
|
|
1004
|
-
|
|
2291
|
+
const active = this.#database.prepare(`SELECT id FROM activation_plans WHERE target_path = ? AND status IN (
|
|
2292
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2293
|
+
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`).get(profilePath);
|
|
2294
|
+
if (active)
|
|
2295
|
+
throw new ControlPlaneStoreError('conflict', 'profile has an unsettled activation');
|
|
2296
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id, checkpoint.exposure_order, checkpoint.successful_order
|
|
2297
|
+
FROM activation_deployment_checkpoints checkpoint JOIN activation_plans plan ON plan.id = checkpoint.plan_id
|
|
2298
|
+
WHERE plan.target_path = ? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(profilePath);
|
|
2299
|
+
if (!latest || latest.exposure_order !== latest.successful_order)
|
|
2300
|
+
throw new ControlPlaneStoreError('invalid-state', 'profile has no current successful deployment');
|
|
2301
|
+
const plan = this.getPlan(latest.plan_id), watch = this.getActivationWatch(plan.id);
|
|
2302
|
+
if (plan.status !== 'activated' || !plan.activation || watch.state !== 'watching'
|
|
2303
|
+
|| watch.activationId !== plan.activation.id || watch.fence !== plan.activation.fence || watch.startedAt > task.dispatchedAt) {
|
|
2304
|
+
throw new ControlPlaneStoreError('invalid-state', 'task did not begin under a watched deployment');
|
|
1005
2305
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
2306
|
+
const source = readOwnerSourceAdoptionPlan(this.#database, plan.id).source;
|
|
2307
|
+
const owner = source.owner;
|
|
2308
|
+
if (owner.principalRecordId !== task.owner.principalRecordId || owner.principalVersion !== task.owner.principalVersion
|
|
2309
|
+
|| owner.workspace !== task.scope.workspace || owner.agentPreset !== task.scope.preset) {
|
|
2310
|
+
throw new ControlPlaneStoreError('conflict', 'foreground task owner differs from adopted source owner');
|
|
1009
2311
|
}
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
2312
|
+
const row = this.#database.prepare(`SELECT operation.* FROM host_attestation_operations operation
|
|
2313
|
+
JOIN host_attestations attestation ON attestation.plan_id = operation.plan_id AND attestation.phase = operation.phase
|
|
2314
|
+
AND attestation.receipt_digest = operation.receipt_digest
|
|
2315
|
+
WHERE operation.plan_id = ? AND operation.phase = 'readiness' AND operation.status = 'applied'`).get(plan.id);
|
|
2316
|
+
if (!row)
|
|
2317
|
+
throw new ControlPlaneStoreError('invalid-state', 'deployment has no applied readiness receipt');
|
|
2318
|
+
this.#foregroundDeploymentAdmission = plan.id;
|
|
2319
|
+
const result = callback(plan, hostOperationFromRow(row));
|
|
2320
|
+
if (result && (typeof result === 'object' || typeof result === 'function') && 'then' in result)
|
|
2321
|
+
throw new ControlPlaneStoreError('invalid-input', 'foreground capture must be synchronous');
|
|
1017
2322
|
this.#database.exec('COMMIT');
|
|
1018
|
-
return
|
|
2323
|
+
return result;
|
|
1019
2324
|
}
|
|
1020
2325
|
catch (error) {
|
|
1021
2326
|
this.#database.exec('ROLLBACK');
|
|
1022
2327
|
throw error;
|
|
1023
2328
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
2329
|
+
finally {
|
|
2330
|
+
this.#foregroundDeploymentAdmission = undefined;
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
beginForegroundDeployment(record) {
|
|
2334
|
+
assertForegroundDeployment(record);
|
|
2335
|
+
if (this.#foregroundDeploymentAdmission !== record.readiness.planId)
|
|
2336
|
+
throw new ControlPlaneStoreError('conflict', 'foreground deployment lacks current admission');
|
|
2337
|
+
if (record.state !== 'pending')
|
|
2338
|
+
throw new ControlPlaneStoreError('invalid-input', 'foreground deployment must begin pending');
|
|
2339
|
+
const encoded = JSON.stringify(record);
|
|
2340
|
+
if (Buffer.byteLength(encoded) > 262_144)
|
|
2341
|
+
throw new ControlPlaneStoreError('invalid-input', 'foreground deployment exceeds storage bound');
|
|
2342
|
+
this.#database.prepare(`INSERT INTO foreground_deployments (inbox_id, plan_id, record_json, record_digest)
|
|
2343
|
+
VALUES (?, ?, ?, ?)`).run(record.task.inboxId, record.readiness.planId, encoded, controlPlaneDigest(record));
|
|
2344
|
+
}
|
|
2345
|
+
getForegroundDeployment(inboxId) {
|
|
2346
|
+
const row = this.#database.prepare('SELECT record_json, record_digest, plan_id FROM foreground_deployments WHERE inbox_id = ?').get(inboxId);
|
|
2347
|
+
if (!row)
|
|
2348
|
+
return undefined;
|
|
2349
|
+
if (Buffer.byteLength(row.record_json) > 262_144)
|
|
2350
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored foreground deployment exceeds storage bound');
|
|
2351
|
+
const record = JSON.parse(row.record_json);
|
|
2352
|
+
if (controlPlaneDigest(record) !== row.record_digest || record.task.inboxId !== inboxId || record.readiness.planId !== row.plan_id) {
|
|
2353
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored foreground deployment changed');
|
|
2354
|
+
}
|
|
2355
|
+
assertForegroundDeployment(record);
|
|
2356
|
+
return record;
|
|
2357
|
+
}
|
|
2358
|
+
finishForegroundDeployment(record) {
|
|
2359
|
+
assertForegroundDeployment(record);
|
|
2360
|
+
if (record.state === 'observed' && this.#foregroundDeploymentAdmission !== record.readiness.planId)
|
|
2361
|
+
throw new ControlPlaneStoreError('conflict', 'foreground deployment lacks current admission');
|
|
2362
|
+
const prior = this.getForegroundDeployment(record.task.inboxId);
|
|
2363
|
+
if (!prior || prior.state !== 'pending' || record.state === 'pending'
|
|
2364
|
+
|| controlPlaneDigest(prior.task) !== controlPlaneDigest(record.task)
|
|
2365
|
+
|| controlPlaneDigest(prior.readiness) !== controlPlaneDigest(record.readiness)
|
|
2366
|
+
|| controlPlaneDigest(prior.begin) !== controlPlaneDigest(record.begin))
|
|
2367
|
+
throw new ControlPlaneStoreError('conflict', 'foreground deployment completion changed');
|
|
2368
|
+
const encoded = JSON.stringify(record);
|
|
2369
|
+
if (Buffer.byteLength(encoded) > 262_144)
|
|
2370
|
+
throw new ControlPlaneStoreError('invalid-input', 'foreground deployment exceeds storage bound');
|
|
2371
|
+
const result = this.#database.prepare(`UPDATE foreground_deployments SET record_json = ?, record_digest = ?
|
|
2372
|
+
WHERE inbox_id = ? AND record_digest = ?`).run(encoded, controlPlaneDigest(record), record.task.inboxId, controlPlaneDigest(prior));
|
|
2373
|
+
if (result.changes !== 1)
|
|
2374
|
+
throw new ControlPlaneStoreError('conflict', 'foreground deployment completion lost its fence');
|
|
2375
|
+
}
|
|
2376
|
+
listObservedForegroundDeployments(profilePath, limit = 1000) {
|
|
2377
|
+
if (!isAbsolute(profilePath) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
2378
|
+
throw new ControlPlaneStoreError('invalid-input', 'foreground deployment query is invalid');
|
|
2379
|
+
const rows = this.#database.prepare(`SELECT deployment.record_json, deployment.record_digest, deployment.plan_id FROM foreground_deployments deployment
|
|
2380
|
+
JOIN activation_plans plan ON plan.id = deployment.plan_id WHERE plan.target_path = ?
|
|
2381
|
+
AND json_extract(deployment.record_json, '$.state') = 'observed'
|
|
2382
|
+
ORDER BY json_extract(deployment.record_json, '$.execution.completedAt') DESC LIMIT ?`).all(profilePath, limit);
|
|
2383
|
+
return rows.map(row => {
|
|
2384
|
+
const record = JSON.parse(row.record_json);
|
|
2385
|
+
assertForegroundDeployment(record);
|
|
2386
|
+
if (record.state !== 'observed' || controlPlaneDigest(record) !== row.record_digest || record.readiness.planId !== row.plan_id)
|
|
2387
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored foreground deployment changed');
|
|
2388
|
+
return record;
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
putTaskObservation(batch) {
|
|
2392
|
+
assertTaskObservationBatch(batch);
|
|
1031
2393
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
1032
2394
|
try {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const plan = this.getPlan(input.planId);
|
|
1042
|
-
if (input.to === 'rolled-back' || input.to === 'activated') {
|
|
1043
|
-
if (input.to === 'activated')
|
|
1044
|
-
this.#database.prepare(`UPDATE capability_gaps SET status = 'closed', revision = revision + 1, updated_at = ? WHERE id = ?`).run(now, plan.gapId);
|
|
1045
|
-
this.#finishActivation(plan, input.fence, now);
|
|
2395
|
+
if (batch.createdAt > this.#now() || batch.expiresAt <= this.#now())
|
|
2396
|
+
throw new ControlPlaneStoreError('expired', 'task observation is outside its validity interval');
|
|
2397
|
+
const prior = this.getTaskObservation(batch.id);
|
|
2398
|
+
if (prior) {
|
|
2399
|
+
if (prior.batch.digest !== batch.digest)
|
|
2400
|
+
throw new ControlPlaneStoreError('conflict', 'task observation identity reused');
|
|
2401
|
+
this.#database.exec('COMMIT');
|
|
2402
|
+
return prior;
|
|
1046
2403
|
}
|
|
2404
|
+
const encoded = JSON.stringify(batch);
|
|
2405
|
+
this.#database.prepare('INSERT INTO task_observation_batches (id,lane,plan_id,batch_json,batch_digest,state,created_at) VALUES (?,?,?,?,?,?,?)').run(batch.id, batch.lane, batch.planId, encoded, controlPlaneDigest(batch), 'pending', batch.createdAt);
|
|
2406
|
+
readTaskObservationContext(this.#database, batch.id);
|
|
1047
2407
|
this.#database.exec('COMMIT');
|
|
1048
|
-
return
|
|
2408
|
+
return Object.freeze({ batch, state: 'pending' });
|
|
1049
2409
|
}
|
|
1050
2410
|
catch (error) {
|
|
1051
2411
|
this.#database.exec('ROLLBACK');
|
|
1052
2412
|
throw error;
|
|
1053
2413
|
}
|
|
1054
2414
|
}
|
|
2415
|
+
getTaskObservation(id) { return getTaskObservationRecord(this.#database, id); }
|
|
2416
|
+
assertCurrentTaskObservation(id) { readTaskObservationContext(this.#database, id); }
|
|
2417
|
+
listTaskObservations(lane, limit = 100) {
|
|
2418
|
+
if (!KEY.test(lane) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
2419
|
+
throw new ControlPlaneStoreError('invalid-input', 'task observation list is invalid');
|
|
2420
|
+
const rows = this.#database.prepare(`SELECT batch_json,batch_digest,state,receipt_json FROM task_observation_batches WHERE lane=?
|
|
2421
|
+
ORDER BY CASE WHEN state='pending' THEN 0 WHEN state='signed' THEN 1
|
|
2422
|
+
WHEN state='applied' AND json_extract(receipt_json,'$.disposition')='regressed' THEN 2 WHEN state='applied' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`).all(lane, limit);
|
|
2423
|
+
return rows.map(row => getTaskObservationRecord(this.#database, JSON.parse(row.batch_json).id));
|
|
2424
|
+
}
|
|
2425
|
+
hasAppliedTaskObservationVote(lane, inboxId, projectionDigest) {
|
|
2426
|
+
return this.#database.prepare(`SELECT 1 FROM task_observation_batches batch, json_each(batch.batch_json, '$.votes') vote
|
|
2427
|
+
WHERE batch.lane=? AND batch.state='applied' AND json_extract(vote.value,'$.inboxId')=?
|
|
2428
|
+
AND json_extract(vote.value,'$.projection.digest')=? LIMIT 1`).get(lane, inboxId, projectionDigest) !== undefined;
|
|
2429
|
+
}
|
|
2430
|
+
signTaskObservation(id, receipt) {
|
|
2431
|
+
const record = this.getTaskObservation(id);
|
|
2432
|
+
if (!record || record.state === 'stale' || record.batch.expiresAt <= this.#now())
|
|
2433
|
+
throw new ControlPlaneStoreError('conflict', 'task observation cannot be signed');
|
|
2434
|
+
const plan = this.getPlan(record.batch.planId), failures = record.batch.votes.filter(vote => vote.status === 'not-achieved').length;
|
|
2435
|
+
if (receipt.observationId !== record.batch.id || receipt.evidence.probeDigest !== record.batch.digest
|
|
2436
|
+
|| receipt.installationId !== record.batch.installationId || receipt.planId !== plan.id || receipt.planDigest !== plan.digest
|
|
2437
|
+
|| receipt.activationId !== plan.activation?.id || receipt.fence !== plan.activation?.fence
|
|
2438
|
+
|| receipt.package !== plan.candidate.package || receipt.version !== plan.candidate.version || receipt.integrity !== plan.candidate.integrity
|
|
2439
|
+
|| receipt.hostGeneration !== record.batch.hostGeneration || receipt.evidence.checks !== record.batch.votes.length
|
|
2440
|
+
|| receipt.evidence.failures !== failures || (receipt.disposition === 'regressed') !== (failures > 0)
|
|
2441
|
+
|| receipt.expiresAt <= this.#now())
|
|
2442
|
+
throw new ControlPlaneStoreError('conflict', 'task observation receipt does not bind its exact batch');
|
|
2443
|
+
if (record.receipt && controlPlaneDigest(record.receipt) !== controlPlaneDigest(receipt))
|
|
2444
|
+
throw new ControlPlaneStoreError('conflict', 'task observation signature changed');
|
|
2445
|
+
const changed = this.#database.prepare("UPDATE task_observation_batches SET state='signed',receipt_json=?,receipt_digest=? WHERE id=? AND state='pending'").run(JSON.stringify(receipt), controlPlaneDigest(receipt), id);
|
|
2446
|
+
if (changed.changes !== 1 && record.state !== 'signed')
|
|
2447
|
+
throw new ControlPlaneStoreError('conflict', 'task observation signing lost its fence');
|
|
2448
|
+
return this.getTaskObservation(id);
|
|
2449
|
+
}
|
|
2450
|
+
staleTaskObservation(id) {
|
|
2451
|
+
this.#database.prepare("UPDATE task_observation_batches SET state='stale' WHERE id=? AND state IN ('pending','signed')").run(id);
|
|
2452
|
+
const result = this.getTaskObservation(id);
|
|
2453
|
+
if (!result)
|
|
2454
|
+
throw new ControlPlaneStoreError('not-found', 'task observation absent');
|
|
2455
|
+
return result;
|
|
2456
|
+
}
|
|
2457
|
+
latestHostGeneration(installationId) {
|
|
2458
|
+
if (!UUID.test(installationId))
|
|
2459
|
+
throw new ControlPlaneStoreError('invalid-input', 'installation id is invalid');
|
|
2460
|
+
const row = this.#database.prepare(`SELECT max(generation) AS generation FROM (
|
|
2461
|
+
SELECT max(attestation.host_generation) AS generation
|
|
2462
|
+
FROM host_attestations AS attestation JOIN activation_plans AS activation ON activation.id = attestation.plan_id
|
|
2463
|
+
WHERE activation.installation_id = ?
|
|
2464
|
+
UNION ALL
|
|
2465
|
+
SELECT max(watch.last_host_generation) AS generation
|
|
2466
|
+
FROM activation_watch AS watch JOIN activation_plans AS activation ON activation.id = watch.plan_id
|
|
2467
|
+
WHERE activation.installation_id = ?
|
|
2468
|
+
)`).get(installationId, installationId);
|
|
2469
|
+
return row.generation ?? 0;
|
|
2470
|
+
}
|
|
2471
|
+
#appliedHostPredecessor(plan, phase) {
|
|
2472
|
+
if (phase === 'reload')
|
|
2473
|
+
return null;
|
|
2474
|
+
const priorPhase = phase === 'rollback' ? undefined : predecessorPhase[phase];
|
|
2475
|
+
const row = this.#database.prepare(`SELECT operation.* FROM host_attestation_operations AS operation
|
|
2476
|
+
JOIN host_attestations AS attestation ON attestation.plan_id = operation.plan_id
|
|
2477
|
+
AND attestation.phase = operation.phase AND attestation.receipt_digest = operation.receipt_digest
|
|
2478
|
+
WHERE operation.plan_id = ? AND operation.status = 'applied'
|
|
2479
|
+
${priorPhase === undefined ? '' : 'AND operation.phase = ?'}
|
|
2480
|
+
ORDER BY operation.applied_at DESC,
|
|
2481
|
+
CASE operation.phase WHEN 'reload' THEN 1 WHEN 'readiness' THEN 2 WHEN 'effect-blocked-replay' THEN 3
|
|
2482
|
+
WHEN 'shadow' THEN 4 WHEN 'canary' THEN 5 WHEN 'soak' THEN 6 WHEN 'health' THEN 7 WHEN 'rollback' THEN 8 END DESC`).all(...(priorPhase === undefined ? [plan.id] : [plan.id, priorPhase]));
|
|
2483
|
+
for (const candidate of row) {
|
|
2484
|
+
const operation = hostOperationFromRow(candidate);
|
|
2485
|
+
const receipt = operation.receipt;
|
|
2486
|
+
if (receipt === undefined || candidate.receipt_digest === null)
|
|
2487
|
+
throw new ControlPlaneStoreError('invalid-state', 'applied Host predecessor lacks its receipt');
|
|
2488
|
+
const postActivationRollback = phase === 'rollback' && this.#isPostActivationRollback(plan);
|
|
2489
|
+
if (operation.request.activation.id !== plan.activation?.id || receipt.activationId !== plan.activation.id
|
|
2490
|
+
|| (!postActivationRollback && (operation.request.activation.fence !== plan.activation.fence || receipt.fence !== plan.activation.fence))
|
|
2491
|
+
|| (postActivationRollback && (operation.request.activation.fence >= plan.activation.fence || receipt.fence >= plan.activation.fence)))
|
|
2492
|
+
continue;
|
|
2493
|
+
return { operationId: operation.operationId, receiptId: receipt.receiptId, phase: operation.phase,
|
|
2494
|
+
receiptDigest: candidate.receipt_digest, hostGeneration: receipt.hostGeneration };
|
|
2495
|
+
}
|
|
2496
|
+
return null;
|
|
2497
|
+
}
|
|
2498
|
+
#isPostActivationRollback(plan) {
|
|
2499
|
+
if (plan.status !== 'rollback-pending' || plan.activation === undefined || plan.activation.failureCode === undefined
|
|
2500
|
+
|| !['post-activation-regressed', 'post-activation-retracted'].includes(plan.activation.failureCode))
|
|
2501
|
+
return false;
|
|
2502
|
+
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2503
|
+
.get(plan.id);
|
|
2504
|
+
return checkpoint !== undefined && checkpoint.successful_order === checkpoint.exposure_order;
|
|
2505
|
+
}
|
|
2506
|
+
#hasPostActivationRollbackProvenance(plan) {
|
|
2507
|
+
if (plan.activation === undefined || !['post-activation-regressed', 'post-activation-retracted'].includes(plan.activation.failureCode ?? ''))
|
|
2508
|
+
return false;
|
|
2509
|
+
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2510
|
+
.get(plan.id);
|
|
2511
|
+
if (checkpoint === undefined || checkpoint.successful_order !== checkpoint.exposure_order)
|
|
2512
|
+
return false;
|
|
2513
|
+
const watch = this.#database.prepare('SELECT activation_id, fence, state, close_signature_digest FROM activation_watch WHERE plan_id = ?').get(plan.id);
|
|
2514
|
+
return watch !== undefined && (watch.state === 'closed-regressed' || watch.state === 'closed-retracted')
|
|
2515
|
+
&& watch.close_signature_digest !== null && watch.activation_id === plan.activation.id && watch.fence <= plan.activation.fence;
|
|
2516
|
+
}
|
|
2517
|
+
#assertHostAttestationChain(operation, plan) {
|
|
2518
|
+
const request = operation.request;
|
|
2519
|
+
if (!isBoundHostRequest(request))
|
|
2520
|
+
throw new ControlPlaneStoreError('invalid-state', 'legacy Host attestation request requires reconciliation before dispatch or apply');
|
|
2521
|
+
if (request.phase !== operation.phase || request.activation.id !== plan.activation?.id || request.activation.fence !== plan.activation.fence) {
|
|
2522
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation request lost its activation binding');
|
|
2523
|
+
}
|
|
2524
|
+
const latest = this.latestHostGeneration(plan.installationId);
|
|
2525
|
+
if (operation.phase === 'reload') {
|
|
2526
|
+
if (request.predecessor !== null || request.requirements.kind !== 'reload'
|
|
2527
|
+
|| request.requirements.previousHostGeneration !== latest) {
|
|
2528
|
+
throw new ControlPlaneStoreError('conflict', 'reload Host generation advanced after request reservation');
|
|
2529
|
+
}
|
|
2530
|
+
return request;
|
|
2531
|
+
}
|
|
2532
|
+
const rollbackRequirements = request.requirements.kind === 'rollback' ? request.requirements : undefined;
|
|
2533
|
+
if (operation.phase === 'rollback' && rollbackRequirements === undefined) {
|
|
2534
|
+
throw new ControlPlaneStoreError('conflict', 'rollback Host request requirements changed');
|
|
2535
|
+
}
|
|
2536
|
+
if (operation.phase === 'rollback' && rollbackRequirements !== undefined && rollbackRequirements.previousHostGeneration !== latest) {
|
|
2537
|
+
throw new ControlPlaneStoreError('conflict', 'rollback Host generation advanced after request reservation');
|
|
2538
|
+
}
|
|
2539
|
+
const predecessor = request.predecessor;
|
|
2540
|
+
const actual = this.#appliedHostPredecessor(plan, operation.phase);
|
|
2541
|
+
if (predecessor === null) {
|
|
2542
|
+
if (operation.phase !== 'rollback' || actual !== null) {
|
|
2543
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation predecessor is missing or was added after reservation');
|
|
2544
|
+
}
|
|
2545
|
+
return request;
|
|
2546
|
+
}
|
|
2547
|
+
if (actual === null || actual.operationId !== predecessor.operationId || actual.receiptId !== predecessor.receiptId
|
|
2548
|
+
|| actual.phase !== predecessor.phase || actual.receiptDigest !== predecessor.receiptDigest
|
|
2549
|
+
|| actual.hostGeneration !== predecessor.hostGeneration) {
|
|
2550
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation predecessor does not match the applied receipt ledger');
|
|
2551
|
+
}
|
|
2552
|
+
if (operation.phase !== 'rollback') {
|
|
2553
|
+
if (predecessor.phase !== predecessorPhase[operation.phase] || latest !== predecessor.hostGeneration) {
|
|
2554
|
+
throw new ControlPlaneStoreError('conflict', 'Host generation changed after predecessor binding');
|
|
2555
|
+
}
|
|
2556
|
+
const row = this.#database.prepare('SELECT receipt_json FROM host_attestation_operations WHERE operation_id = ?').get(predecessor.operationId);
|
|
2557
|
+
const receipt = row === undefined ? undefined : JSON.parse(row.receipt_json);
|
|
2558
|
+
if (receipt?.outcome !== 'passed')
|
|
2559
|
+
throw new ControlPlaneStoreError('conflict', 'normal Host phase predecessor was not passed');
|
|
2560
|
+
}
|
|
2561
|
+
return request;
|
|
2562
|
+
}
|
|
2563
|
+
/** Reserve once before external I/O; unknown results require signed receipt reconciliation. */
|
|
2564
|
+
async runHostAttestationOperation(input) {
|
|
2565
|
+
const claim = () => {
|
|
2566
|
+
const candidate = this.getHostAttestationOperation(input.operationId);
|
|
2567
|
+
return this.#withActivationSource(candidate.planId, () => {
|
|
2568
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2569
|
+
try {
|
|
2570
|
+
this.assertOwnerActivationSource(candidate.planId);
|
|
2571
|
+
const operation = this.getHostAttestationOperation(input.operationId), plan = this.getPlan(operation.planId), expected = expectedAttestation[plan.status];
|
|
2572
|
+
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.expectedFence || expected?.phase !== operation.phase || operation.requestDigest !== controlPlaneDigest(operation.request))
|
|
2573
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation operation lost its plan revision/fence/phase');
|
|
2574
|
+
const request = this.#assertHostAttestationChain(operation, plan);
|
|
2575
|
+
if (operation.receipt) {
|
|
2576
|
+
this.#database.exec('COMMIT');
|
|
2577
|
+
return { operation, plan, request };
|
|
2578
|
+
}
|
|
2579
|
+
if (this.getHostAttestationDispatchStatus(operation.operationId) !== undefined)
|
|
2580
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation outcome is unknown; receipt reconciliation is required');
|
|
2581
|
+
this.#database.prepare("INSERT INTO host_attestation_dispatches (operation_id,status,claimed_at,completed_at) VALUES (?,'claimed',?,NULL)").run(operation.operationId, this.#now());
|
|
2582
|
+
this.#database.exec('COMMIT');
|
|
2583
|
+
return { operation, plan, request };
|
|
2584
|
+
}
|
|
2585
|
+
catch (error) {
|
|
2586
|
+
this.#database.exec('ROLLBACK');
|
|
2587
|
+
throw error;
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
};
|
|
2591
|
+
const { operation, plan, request } = claim();
|
|
2592
|
+
if (operation.receipt)
|
|
2593
|
+
return operation.receipt;
|
|
2594
|
+
const receipt = await input.execute(request);
|
|
2595
|
+
await input.resolveAuthority(receipt).verify(receipt, plan, request);
|
|
2596
|
+
if (operation.phase !== 'reload' && operation.phase !== 'rollback' && receipt.hostGeneration !== request.predecessor.hostGeneration)
|
|
2597
|
+
throw new ControlPlaneStoreError('conflict', 'non-transition Host receipt changed generation from its predecessor');
|
|
2598
|
+
if (operation.phase === 'rollback' && receipt.outcome !== 'passed')
|
|
2599
|
+
throw new ControlPlaneStoreError('conflict', 'failed physical rollback receipt cannot consume the durable recovery operation');
|
|
2600
|
+
// Normal dispatches must still be current when their external result is
|
|
2601
|
+
// settled. The public accept path is deliberately narrower: it records
|
|
2602
|
+
// an exact signed fact for an already-unknown dispatch so recovery can
|
|
2603
|
+
// later choose rollback without replaying the external action.
|
|
2604
|
+
return this.acceptHostAttestationReceipt({ operationId: operation.operationId, expectedRevision: input.expectedRevision, expectedFence: input.expectedFence, receipt, resolveAuthority: input.resolveAuthority, requireCurrentSource: true });
|
|
2605
|
+
}
|
|
2606
|
+
/** Stop an exposed deployment between phases, or after its previous installer lease expired. */
|
|
2607
|
+
requestActivationRollback(input) {
|
|
2608
|
+
return this.#withActivationWrite(input.planId, () => {
|
|
2609
|
+
const code = bounded(input.failureCode, 'failureCode', 160);
|
|
2610
|
+
if (!KEY.test(code))
|
|
2611
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid rollback failure code');
|
|
2612
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET status = 'rollback-pending', revision = revision + 1,
|
|
2613
|
+
failure_code = ?, activation_lease_until = NULL, updated_at = ? WHERE id = ? AND revision = ? AND activation_fence = ?
|
|
2614
|
+
AND NOT EXISTS (SELECT 1 FROM host_attestation_operations operation JOIN host_attestation_dispatches dispatch
|
|
2615
|
+
ON dispatch.operation_id = operation.operation_id WHERE operation.plan_id = activation_plans.id
|
|
2616
|
+
AND dispatch.status = 'claimed')
|
|
2617
|
+
AND (status IN ('awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2618
|
+
'awaiting-canary', 'awaiting-soak', 'awaiting-health')
|
|
2619
|
+
OR (status IN ('staging', 'commit-pending') AND COALESCE(activation_lease_until, 0) < ?))`).run(code, this.#now(), input.planId, input.expectedRevision, input.fence, this.#now());
|
|
2620
|
+
if (Number(result.changes) !== 1)
|
|
2621
|
+
throw new ControlPlaneStoreError('conflict', 'activation recovery request lost its waiting-phase fence');
|
|
2622
|
+
return this.getPlan(input.planId);
|
|
2623
|
+
}, true);
|
|
2624
|
+
}
|
|
2625
|
+
advanceActivation(input) {
|
|
2626
|
+
return this.#withActivationSource(input.planId, () => {
|
|
2627
|
+
const allowed = { staging: ['awaiting-reload', 'rollback-pending'],
|
|
2628
|
+
'rollback-pending': ['rolled-back'], 'commit-pending': ['activated', 'rollback-pending'] };
|
|
2629
|
+
if (!allowed[input.from]?.includes(input.to))
|
|
2630
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid activation transition');
|
|
2631
|
+
// The delegated coordinator may reach commit-pending, but only the
|
|
2632
|
+
// target Host can finish under its still-live owner source fence. Keep
|
|
2633
|
+
// this check in this synchronous callback so an earlier runner check
|
|
2634
|
+
// cannot survive an await or a withdrawal.
|
|
2635
|
+
if (input.to === 'activated') {
|
|
2636
|
+
const current = this.getPlan(input.planId);
|
|
2637
|
+
if (current.dossier.handoff !== undefined)
|
|
2638
|
+
this.#assertAdoptionHandoff(input.planId);
|
|
2639
|
+
}
|
|
2640
|
+
const now = this.#now();
|
|
2641
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2642
|
+
try {
|
|
2643
|
+
this.#withActivationSource(input.planId, () => { }, input.to === 'rollback-pending' || input.to === 'rolled-back');
|
|
2644
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET status = ?, revision = revision + 1,
|
|
2645
|
+
activation_lease_until = CASE
|
|
2646
|
+
WHEN ? IN ('rolled-back', 'activated', 'awaiting-reload') THEN NULL
|
|
2647
|
+
ELSE activation_lease_until END,
|
|
2648
|
+
host_recovery_required = CASE WHEN ? = 'awaiting-reload' THEN 1 ELSE host_recovery_required END,
|
|
2649
|
+
failure_code = COALESCE(?, failure_code), updated_at = ?
|
|
2650
|
+
WHERE id = ? AND revision = ? AND activation_fence = ? AND status = ? AND activation_lease_until >= ?
|
|
2651
|
+
AND NOT (? = 'rolled-back' AND host_recovery_required = 1)`).run(input.to, input.to, input.to, input.failureCode ?? null, now, input.planId, input.expectedRevision, input.fence, input.from, now, input.to);
|
|
2652
|
+
if (Number(result.changes) !== 1)
|
|
2653
|
+
throw new ControlPlaneStoreError('conflict', 'activation transition lost its CAS/fencing claim');
|
|
2654
|
+
const plan = this.getPlan(input.planId);
|
|
2655
|
+
if (input.to === 'rolled-back' || input.to === 'activated') {
|
|
2656
|
+
if (input.to === 'activated')
|
|
2657
|
+
this.#database.prepare(`UPDATE capability_gaps SET status = 'closed', revision = revision + 1, updated_at = ? WHERE id = ?`).run(now, plan.gapId);
|
|
2658
|
+
this.#finishActivation(plan, input.fence, now);
|
|
2659
|
+
}
|
|
2660
|
+
this.#database.exec('COMMIT');
|
|
2661
|
+
return plan;
|
|
2662
|
+
}
|
|
2663
|
+
catch (error) {
|
|
2664
|
+
this.#database.exec('ROLLBACK');
|
|
2665
|
+
throw error;
|
|
2666
|
+
}
|
|
2667
|
+
}, input.to === 'rollback-pending' || input.to === 'rolled-back');
|
|
2668
|
+
}
|
|
1055
2669
|
async applyHostAttestation(input) {
|
|
2670
|
+
const recovery = input.receipt.phase === 'rollback' || input.receipt.outcome !== 'passed';
|
|
2671
|
+
this.#withActivationSource(input.planId, () => { }, recovery);
|
|
1056
2672
|
const inputDigest = controlPlaneDigest({ operation: 'host-attestation', planId: input.planId,
|
|
1057
2673
|
expectedRevision: input.expectedRevision, expectedFence: input.expectedFence, receipt: input.receipt });
|
|
1058
2674
|
const replay = this.#activationPlanReceipt(input.idempotencyKey, 'host-attestation', inputDigest, input.planId);
|
|
@@ -1063,7 +2679,7 @@ export class ControlPlaneStore {
|
|
|
1063
2679
|
const expectedFailure = input.receipt.outcome === 'passed' ? undefined : 'host-attestation-failed';
|
|
1064
2680
|
if (replay.result.status !== expectedStatus || replay.result.revision !== input.expectedRevision + 1
|
|
1065
2681
|
|| replay.result.activation?.id !== input.receipt.activationId || replay.result.activation.fence !== input.expectedFence
|
|
1066
|
-
|| replay.result.activation.failureCode !== expectedFailure || replay.result.activation.updatedAt !== replay.createdAt) {
|
|
2682
|
+
|| (input.receipt.phase !== 'rollback' && replay.result.activation.failureCode !== expectedFailure) || replay.result.activation.updatedAt !== replay.createdAt) {
|
|
1067
2683
|
throw new ControlPlaneStoreError('invalid-state', 'stored Host attestation operation receipt is corrupt');
|
|
1068
2684
|
}
|
|
1069
2685
|
return replay;
|
|
@@ -1079,30 +2695,193 @@ export class ControlPlaneStore {
|
|
|
1079
2695
|
|| operation.receipt === undefined || controlPlaneDigest(operation.receipt) !== controlPlaneDigest(input.receipt)) {
|
|
1080
2696
|
throw new ControlPlaneStoreError('conflict', 'Host attestation was not completed by the durable phase operation');
|
|
1081
2697
|
}
|
|
1082
|
-
const
|
|
2698
|
+
const request = this.#assertHostAttestationChain(operation, plan);
|
|
2699
|
+
const verified = await input.resolveAuthority(input.receipt).verify(input.receipt, plan, request);
|
|
2700
|
+
if (operation.phase !== 'reload' && operation.phase !== 'rollback'
|
|
2701
|
+
&& verified.hostGeneration !== request.predecessor.hostGeneration) {
|
|
2702
|
+
throw new ControlPlaneStoreError('conflict', 'non-transition Host receipt changed generation from its predecessor');
|
|
2703
|
+
}
|
|
1083
2704
|
const now = this.#now();
|
|
1084
2705
|
const nextStatus = verified.outcome === 'passed' ? expected.next : 'rollback-pending';
|
|
1085
|
-
this.#
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
2706
|
+
return this.#withActivationSource(input.planId, () => {
|
|
2707
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2708
|
+
try {
|
|
2709
|
+
this.#withActivationSource(input.planId, () => { }, recovery);
|
|
2710
|
+
const lockedOperation = this.getHostAttestationOperation(input.receipt.operationId);
|
|
2711
|
+
const lockedPlan = this.getPlan(input.planId);
|
|
2712
|
+
if (lockedOperation.status !== 'completed' || lockedPlan.revision !== input.expectedRevision
|
|
2713
|
+
|| lockedPlan.activation?.fence !== input.expectedFence || lockedPlan.status !== plan.status) {
|
|
2714
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation changed before apply');
|
|
2715
|
+
}
|
|
2716
|
+
this.#assertHostAttestationChain(lockedOperation, lockedPlan);
|
|
2717
|
+
const previousGeneration = this.latestHostGeneration(plan.installationId);
|
|
2718
|
+
if (verified.hostGeneration < previousGeneration)
|
|
2719
|
+
throw new ControlPlaneStoreError('conflict', 'host generation regressed');
|
|
2720
|
+
this.#database.prepare(`INSERT INTO host_attestations (plan_id, phase, receipt_id, receipt_digest, receipt_json, host_generation, created_at)
|
|
1093
2721
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(plan.id, verified.phase, verified.receiptId, controlPlaneDigest(input.receipt), JSON.stringify(verified), verified.hostGeneration, now);
|
|
1094
|
-
|
|
2722
|
+
const result = this.#database.prepare(`UPDATE activation_plans SET status = ?, revision = revision + 1,
|
|
1095
2723
|
activation_lease_until = NULL,
|
|
1096
|
-
failure_code = CASE WHEN ? = 'rollback-pending'
|
|
2724
|
+
failure_code = CASE WHEN ? = 'rollback-pending' AND (failure_code IS NULL OR failure_code NOT IN ('post-activation-regressed', 'post-activation-retracted'))
|
|
2725
|
+
THEN 'host-attestation-failed' ELSE failure_code END, updated_at = ?
|
|
1097
2726
|
WHERE id = ? AND status = ? AND revision = ? AND activation_fence = ?`).run(nextStatus, nextStatus, now, plan.id, plan.status, plan.revision, input.expectedFence);
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
2727
|
+
if (Number(result.changes) !== 1)
|
|
2728
|
+
throw new ControlPlaneStoreError('conflict', 'host attestation lost its plan CAS');
|
|
2729
|
+
const applied = this.#database.prepare(`UPDATE host_attestation_operations SET status = 'applied', applied_at = ?
|
|
1101
2730
|
WHERE operation_id = ? AND status = 'completed' AND receipt_digest = ?`).run(now, operation.operationId, controlPlaneDigest(input.receipt));
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
2731
|
+
if (Number(applied.changes) !== 1)
|
|
2732
|
+
throw new ControlPlaneStoreError('conflict', 'Host attestation operation lost its apply CAS');
|
|
2733
|
+
const output = this.getPlan(plan.id);
|
|
2734
|
+
if (nextStatus === 'rolled-back')
|
|
2735
|
+
this.#finishActivation(output, input.expectedFence, now);
|
|
2736
|
+
const operationReceipt = { idempotencyKey: input.idempotencyKey, operation: 'host-attestation', inputDigest, result: output, createdAt: now };
|
|
2737
|
+
this.#insertReceipt(operationReceipt);
|
|
2738
|
+
this.#database.exec('COMMIT');
|
|
2739
|
+
return operationReceipt;
|
|
2740
|
+
}
|
|
2741
|
+
catch (error) {
|
|
2742
|
+
this.#database.exec('ROLLBACK');
|
|
2743
|
+
throw error;
|
|
2744
|
+
}
|
|
2745
|
+
}, recovery);
|
|
2746
|
+
}
|
|
2747
|
+
// -------------------------------------------------------------------------
|
|
2748
|
+
// Post-activation quality watch (deployment cohort monitoring). This is an
|
|
2749
|
+
// evidence lifecycle before a rollback worker claims physical recovery:
|
|
2750
|
+
// Host-signed observations append evidence and a `regressed` probe closes the
|
|
2751
|
+
// exact pinned version, while an owner-signed retraction closes the watch.
|
|
2752
|
+
// `beginPostActivationRollback()` later turns that signed closure into the
|
|
2753
|
+
// existing fenced recovery lifecycle. Healthy evidence never closes a watch.
|
|
2754
|
+
// -------------------------------------------------------------------------
|
|
2755
|
+
getActivationWatch(planId) {
|
|
2756
|
+
const row = this.#database.prepare('SELECT * FROM activation_watch WHERE plan_id = ?').get(planId);
|
|
2757
|
+
if (row === undefined)
|
|
2758
|
+
throw new ControlPlaneStoreError('not-found', 'post-activation watch not found');
|
|
2759
|
+
return watchFromRow(row);
|
|
2760
|
+
}
|
|
2761
|
+
listActivationWatches(limit = 50) {
|
|
2762
|
+
const rows = this.#database.prepare('SELECT * FROM activation_watch ORDER BY started_at, plan_id LIMIT ?').all(limit);
|
|
2763
|
+
return rows.map(watchFromRow);
|
|
2764
|
+
}
|
|
2765
|
+
listActivationWatchEvidence(planId, limit = 100) {
|
|
2766
|
+
const rows = this.#database.prepare(`SELECT * FROM activation_watch_evidence WHERE plan_id = ?
|
|
2767
|
+
ORDER BY created_at, observation_id LIMIT ?`).all(planId, limit);
|
|
2768
|
+
return rows.map(watchEvidenceFromRow);
|
|
2769
|
+
}
|
|
2770
|
+
async recordPostActivationObservation(input) {
|
|
2771
|
+
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
2772
|
+
if (!KEY.test(key))
|
|
2773
|
+
throw new ControlPlaneStoreError('invalid-input', 'idempotencyKey has invalid syntax');
|
|
2774
|
+
const inputDigest = controlPlaneDigest({ operation: 'post-activation-observation', planId: input.receipt.planId,
|
|
2775
|
+
observationId: input.receipt.observationId, receipt: input.receipt });
|
|
2776
|
+
const replay = this.#watchReceipt(key, 'post-activation-observation', inputDigest);
|
|
2777
|
+
if (replay !== undefined)
|
|
2778
|
+
return replay;
|
|
2779
|
+
const plan = this.getPlan(input.receipt.planId);
|
|
2780
|
+
const watch = this.getActivationWatch(plan.id);
|
|
2781
|
+
if (watch.state !== 'watching')
|
|
2782
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation watch is already closed');
|
|
2783
|
+
if (input.expectedRevision !== undefined && watch.revision !== input.expectedRevision) {
|
|
2784
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation observation targets a stale watch revision');
|
|
2785
|
+
}
|
|
2786
|
+
const verified = await input.resolveAuthority(input.receipt).verify(input.receipt, plan, watch.exact);
|
|
2787
|
+
const now = this.#now();
|
|
2788
|
+
if (input.taskObservationId !== undefined) {
|
|
2789
|
+
const task = this.getTaskObservation(input.taskObservationId);
|
|
2790
|
+
if (!task || task.state !== 'signed' || !task.receipt || task.batch.expiresAt <= now
|
|
2791
|
+
|| task.batch.planId !== plan.id || task.batch.planDigest !== plan.digest
|
|
2792
|
+
|| task.receipt.observationId !== verified.observationId || controlPlaneDigest(task.receipt) !== controlPlaneDigest(input.receipt)
|
|
2793
|
+
|| task.batch.hostGeneration !== verified.hostGeneration || task.batch.votes.length !== verified.evidence.checks
|
|
2794
|
+
|| task.batch.votes.filter(vote => vote.status === 'not-achieved').length !== verified.evidence.failures
|
|
2795
|
+
|| (verified.disposition === 'regressed') !== (verified.evidence.failures > 0)) {
|
|
2796
|
+
throw new ControlPlaneStoreError('conflict', 'task observation receipt differs from its signed batch');
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
const commit = () => {
|
|
2800
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2801
|
+
try {
|
|
2802
|
+
if (input.taskObservationId !== undefined) {
|
|
2803
|
+
const context = readTaskObservationContext(this.#database, input.taskObservationId);
|
|
2804
|
+
if (context.record.state !== 'signed' || context.record.batch.expiresAt <= this.#now()
|
|
2805
|
+
|| context.record.batch.id !== verified.observationId || context.record.batch.digest !== verified.evidence.probeDigest
|
|
2806
|
+
|| verified.expiresAt <= this.#now())
|
|
2807
|
+
throw new ControlPlaneStoreError('expired', 'task observation is no longer current');
|
|
2808
|
+
}
|
|
2809
|
+
const current = this.#database.prepare('SELECT * FROM activation_watch WHERE plan_id = ?').get(plan.id);
|
|
2810
|
+
if (current.state !== 'watching')
|
|
2811
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation watch closed while evidence was verified');
|
|
2812
|
+
if (verified.hostGeneration < current.last_host_generation)
|
|
2813
|
+
throw new ControlPlaneStoreError('conflict', 'host generation regressed');
|
|
2814
|
+
this.#insertWatchEvidence(verified.observationId, plan.id, verified.disposition, controlPlaneDigest(input.receipt), verified.signatureDigest, verified, verified.hostGeneration, verified.evidence.failures, verified.evidence.checks, now);
|
|
2815
|
+
if (verified.disposition === 'regressed') {
|
|
2816
|
+
const closed = this.#database.prepare(`UPDATE activation_watch SET state = 'closed-regressed', revision = revision + 1,
|
|
2817
|
+
last_host_generation = ?, updated_at = ?, close_disposition = 'regressed', close_at = ?,
|
|
2818
|
+
close_evidence_id = ?, close_signature_digest = ?
|
|
2819
|
+
WHERE plan_id = ? AND state = 'watching' AND revision = ?`).run(verified.hostGeneration, now, now, verified.observationId, verified.signatureDigest, plan.id, current.revision);
|
|
2820
|
+
if (Number(closed.changes) !== 1)
|
|
2821
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation regression lost its watch CAS');
|
|
2822
|
+
}
|
|
2823
|
+
else {
|
|
2824
|
+
const acknowledged = this.#database.prepare(`UPDATE activation_watch SET revision = revision + 1, last_host_generation = ?,
|
|
2825
|
+
healthy_observations = healthy_observations + 1, updated_at = ? WHERE plan_id = ? AND state = 'watching' AND revision = ?`).run(verified.hostGeneration, now, plan.id, current.revision);
|
|
2826
|
+
if (Number(acknowledged.changes) !== 1)
|
|
2827
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation observation lost its watch CAS');
|
|
2828
|
+
}
|
|
2829
|
+
const output = this.getActivationWatch(plan.id);
|
|
2830
|
+
if (input.taskObservationId !== undefined) {
|
|
2831
|
+
const changed = this.#database.prepare("UPDATE task_observation_batches SET state='applied' WHERE id=? AND state='signed' AND receipt_digest=?")
|
|
2832
|
+
.run(input.taskObservationId, controlPlaneDigest(input.receipt));
|
|
2833
|
+
if (changed.changes !== 1)
|
|
2834
|
+
throw new ControlPlaneStoreError('conflict', 'task observation apply lost its fence');
|
|
2835
|
+
}
|
|
2836
|
+
const operationReceipt = { idempotencyKey: key, operation: 'post-activation-observation', inputDigest, result: output, createdAt: now };
|
|
2837
|
+
this.#insertReceipt(operationReceipt);
|
|
2838
|
+
this.#database.exec('COMMIT');
|
|
2839
|
+
return operationReceipt;
|
|
2840
|
+
}
|
|
2841
|
+
catch (error) {
|
|
2842
|
+
this.#database.exec('ROLLBACK');
|
|
2843
|
+
throw error;
|
|
2844
|
+
}
|
|
2845
|
+
};
|
|
2846
|
+
if (input.taskObservationId !== undefined && input.withSourceFence === undefined) {
|
|
2847
|
+
throw new ControlPlaneStoreError('conflict', 'task observation apply requires the current source fence');
|
|
2848
|
+
}
|
|
2849
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
2850
|
+
}
|
|
2851
|
+
async retractActivation(input) {
|
|
2852
|
+
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
2853
|
+
if (!KEY.test(key))
|
|
2854
|
+
throw new ControlPlaneStoreError('invalid-input', 'idempotencyKey has invalid syntax');
|
|
2855
|
+
const inputDigest = controlPlaneDigest({ operation: 'activation-retraction', planId: input.receipt.planId,
|
|
2856
|
+
retractionId: input.receipt.retractionId, receipt: input.receipt });
|
|
2857
|
+
const replay = this.#watchReceipt(key, 'activation-retraction', inputDigest);
|
|
2858
|
+
if (replay !== undefined)
|
|
2859
|
+
return replay;
|
|
2860
|
+
const plan = this.getPlan(input.receipt.planId);
|
|
2861
|
+
const watch = this.getActivationWatch(plan.id);
|
|
2862
|
+
if (watch.state === 'closed-retracted')
|
|
2863
|
+
throw new ControlPlaneStoreError('conflict', 'activation is already retracted');
|
|
2864
|
+
if (input.expectedRevision !== undefined && watch.revision !== input.expectedRevision) {
|
|
2865
|
+
throw new ControlPlaneStoreError('conflict', 'activation retraction targets a stale watch revision');
|
|
2866
|
+
}
|
|
2867
|
+
const verified = await input.resolveAuthority(input.receipt).verify(input.receipt, plan, watch.exact);
|
|
2868
|
+
const now = this.#now();
|
|
2869
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2870
|
+
try {
|
|
2871
|
+
const current = this.#database.prepare('SELECT * FROM activation_watch WHERE plan_id = ?').get(plan.id);
|
|
2872
|
+
if (current.state === 'closed-retracted')
|
|
2873
|
+
throw new ControlPlaneStoreError('conflict', 'activation retracted while the decision was verified');
|
|
2874
|
+
this.#insertWatchEvidence(verified.retractionId, plan.id, 'retracted', controlPlaneDigest(input.receipt), verified.signatureDigest, verified, current.last_host_generation, 0, 0, now);
|
|
2875
|
+
const closed = this.#database.prepare(`UPDATE activation_watch SET state = 'closed-retracted', revision = revision + 1,
|
|
2876
|
+
updated_at = ?, close_disposition = 'retracted', close_at = ?, close_evidence_id = ?, close_signature_digest = ?
|
|
2877
|
+
WHERE plan_id = ? AND state IN ('watching', 'closed-regressed') AND revision = ?`).run(now, now, verified.retractionId, verified.signatureDigest, plan.id, current.revision);
|
|
2878
|
+
if (Number(closed.changes) !== 1)
|
|
2879
|
+
throw new ControlPlaneStoreError('conflict', 'activation retraction lost its watch CAS');
|
|
2880
|
+
// Owner withdrawal may race a fresh plan created after an earlier
|
|
2881
|
+
// retraction. Only reopen when this historic plan remains the sole claim.
|
|
2882
|
+
this.#reopenGapAfterActivation(plan, now);
|
|
2883
|
+
const output = this.getActivationWatch(plan.id);
|
|
2884
|
+
const operationReceipt = { idempotencyKey: key, operation: 'activation-retraction', inputDigest, result: output, createdAt: now };
|
|
1106
2885
|
this.#insertReceipt(operationReceipt);
|
|
1107
2886
|
this.#database.exec('COMMIT');
|
|
1108
2887
|
return operationReceipt;
|
|
@@ -1112,26 +2891,78 @@ export class ControlPlaneStore {
|
|
|
1112
2891
|
throw error;
|
|
1113
2892
|
}
|
|
1114
2893
|
}
|
|
2894
|
+
#insertWatchEvidence(observationId, planId, disposition, receiptDigest, signatureDigest, verified, hostGeneration, failures, checks, now) {
|
|
2895
|
+
this.#database.prepare(`INSERT INTO activation_watch_evidence (observation_id, plan_id, disposition, receipt_digest,
|
|
2896
|
+
signature_digest, receipt_json, host_generation, failures, checks, created_at)
|
|
2897
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(observationId, planId, disposition, receiptDigest, signatureDigest, JSON.stringify(verified), hostGeneration, failures, checks, now);
|
|
2898
|
+
}
|
|
2899
|
+
#watchReceipt(idempotencyKey, operation, inputDigest) {
|
|
2900
|
+
const receipt = this.#receipt(idempotencyKey, operation, inputDigest);
|
|
2901
|
+
if (receipt === undefined)
|
|
2902
|
+
return undefined;
|
|
2903
|
+
const snapshot = watchFromStored(receipt.result);
|
|
2904
|
+
const authoritative = this.getActivationWatch(snapshot.planId);
|
|
2905
|
+
// The watch may have advanced (or closed) after this operation was applied; a
|
|
2906
|
+
// replay returns the snapshot recorded at the time, so only the immutable
|
|
2907
|
+
// exact binding and the monotone revision are cross-checked, not the state.
|
|
2908
|
+
if (controlPlaneDigest(snapshot.exact) !== controlPlaneDigest(authoritative.exact)
|
|
2909
|
+
|| snapshot.activationId !== authoritative.activationId || snapshot.fence !== authoritative.fence
|
|
2910
|
+
|| snapshot.revision > authoritative.revision || snapshot.updatedAt !== receipt.createdAt) {
|
|
2911
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored watch operation receipt is not bound to authoritative state');
|
|
2912
|
+
}
|
|
2913
|
+
return { ...receipt, result: snapshot };
|
|
2914
|
+
}
|
|
1115
2915
|
#finishActivation(plan, fence, now) {
|
|
1116
2916
|
const activationId = plan.activation?.id;
|
|
1117
2917
|
if (activationId === undefined)
|
|
1118
2918
|
throw new ControlPlaneStoreError('invalid-state', 'terminal activation has no identity');
|
|
1119
2919
|
const inputDigest = controlPlaneDigest({ planId: plan.id, planDigest: plan.digest, activationId, fence, status: plan.status, failureCode: plan.activation?.failureCode });
|
|
1120
|
-
this.#
|
|
2920
|
+
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2921
|
+
.get(plan.id);
|
|
2922
|
+
const postActivationRollback = plan.status === 'rolled-back' && checkpoint !== undefined && checkpoint.successful_order === checkpoint.exposure_order;
|
|
2923
|
+
this.#insertReceipt({ idempotencyKey: postActivationRollback ? `post-activation-rollback:${activationId}:${fence}` : `activation:${activationId}`,
|
|
2924
|
+
operation: postActivationRollback ? 'post-activation-rollback' : 'activate-plan', inputDigest, result: plan, createdAt: now });
|
|
1121
2925
|
if (plan.status === 'rolled-back') {
|
|
1122
|
-
this.#
|
|
1123
|
-
|
|
2926
|
+
this.#reopenGapAfterActivation(plan, now);
|
|
2927
|
+
}
|
|
2928
|
+
else if (plan.status === 'activated') {
|
|
2929
|
+
if (checkpoint !== undefined) {
|
|
2930
|
+
const marked = this.#database.prepare(`UPDATE activation_deployment_checkpoints SET successful_order = exposure_order,
|
|
2931
|
+
succeeded_at = ? WHERE plan_id = ? AND successful_order IS NULL`).run(now, plan.id);
|
|
2932
|
+
if (Number(marked.changes) !== 1)
|
|
2933
|
+
throw new ControlPlaneStoreError('invalid-state', 'activation installed baseline success marker is corrupt');
|
|
2934
|
+
}
|
|
2935
|
+
const result = this.#database.prepare(`INSERT INTO activation_watch (plan_id, package_name, package_version,
|
|
2936
|
+
package_integrity, activation_id, fence, state, revision, last_host_generation, healthy_observations,
|
|
2937
|
+
started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'watching', 1, 0, 0, ?, ?)
|
|
2938
|
+
ON CONFLICT(plan_id) DO NOTHING`).run(plan.id, plan.candidate.package, plan.candidate.version, plan.candidate.integrity, activationId, fence, now, now);
|
|
2939
|
+
if (Number(result.changes) !== 1)
|
|
2940
|
+
throw new ControlPlaneStoreError('invalid-state', 'activated plan has no post-activation watch');
|
|
1124
2941
|
}
|
|
1125
2942
|
}
|
|
2943
|
+
#reopenGapAfterActivation(plan, now) {
|
|
2944
|
+
this.#database.prepare('DELETE FROM gap_plan_claims WHERE gap_id = ? AND plan_id = ?').run(plan.gapId, plan.id);
|
|
2945
|
+
// A later owner may already have claimed the gap while a historic closed
|
|
2946
|
+
// watch was being reconciled. Preserve that newer owner and its candidate.
|
|
2947
|
+
this.#database.prepare(`UPDATE capability_gaps SET status = 'open', candidate_id = NULL, revision = revision + 1, updated_at = ?
|
|
2948
|
+
WHERE id = ? AND NOT EXISTS (SELECT 1 FROM gap_plan_claims WHERE gap_id = ?)`)
|
|
2949
|
+
.run(now, plan.gapId, plan.gapId);
|
|
2950
|
+
}
|
|
1126
2951
|
beginSourceChecks(input) {
|
|
2952
|
+
const existing = this.getSourcePlan(input.planId);
|
|
2953
|
+
if (existing.mode !== 'create')
|
|
2954
|
+
throw new ControlPlaneStoreError('invalid-state', 'modify source plans are verified, not locally scaffolded');
|
|
1127
2955
|
const now = this.#now();
|
|
1128
2956
|
const result = this.#database.prepare(`UPDATE source_plans SET status = 'running-local-checks', revision = revision + 1, updated_at = ?
|
|
1129
|
-
WHERE id = ? AND revision = ? AND status = 'approved'`).run(now, input.planId, input.expectedRevision);
|
|
2957
|
+
WHERE id = ? AND revision = ? AND status = 'approved' AND mode = 'create'`).run(now, input.planId, input.expectedRevision);
|
|
1130
2958
|
if (Number(result.changes) !== 1)
|
|
1131
2959
|
throw new ControlPlaneStoreError('conflict', 'source plan changed before local checks');
|
|
1132
2960
|
return this.getSourcePlan(input.planId);
|
|
1133
2961
|
}
|
|
1134
2962
|
finishSourceChecks(input) {
|
|
2963
|
+
const existing = this.getSourcePlan(input.planId);
|
|
2964
|
+
if (existing.mode !== 'create')
|
|
2965
|
+
throw new ControlPlaneStoreError('invalid-state', 'modify source plans are verified, not locally scaffolded');
|
|
1135
2966
|
const now = this.#now();
|
|
1136
2967
|
const status = input.succeeded ? 'ready-for-human-review' : 'local-checks-failed';
|
|
1137
2968
|
if (input.succeeded && (!DIGEST.test(input.checkedTreeDigest ?? '') || !DIGEST.test(input.checkedPatchDigest ?? ''))) {
|
|
@@ -1162,7 +2993,63 @@ export class ControlPlaneStore {
|
|
|
1162
2993
|
throw error;
|
|
1163
2994
|
}
|
|
1164
2995
|
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Owner-side verification of a 'modify' plan: the owner recomputes the tree
|
|
2998
|
+
* and patch digests on the unchanged isolated worktree and they must match the
|
|
2999
|
+
* pending evidence *exactly*. On match the plan advances approved ->
|
|
3000
|
+
* ready-for-human-review without ever entering running-local-checks. No
|
|
3001
|
+
* approval, signature or release authority is reachable from this method.
|
|
3002
|
+
*/
|
|
3003
|
+
verifyPreparedSourcePlan(input) {
|
|
3004
|
+
if (!DIGEST.test(input.recheckedTreeDigest) || !DIGEST.test(input.recheckedPatchDigest)) {
|
|
3005
|
+
throw new ControlPlaneStoreError('invalid-input', 'rechecked source digests must be 64-char hex');
|
|
3006
|
+
}
|
|
3007
|
+
const plan = this.getSourcePlan(input.planId);
|
|
3008
|
+
if (plan.mode !== 'modify')
|
|
3009
|
+
throw new ControlPlaneStoreError('invalid-state', 'only modify source plans are verified from prepared evidence');
|
|
3010
|
+
if (plan.revision !== input.expectedRevision)
|
|
3011
|
+
throw new ControlPlaneStoreError('conflict', 'source plan revision conflict');
|
|
3012
|
+
if (plan.status !== 'approved')
|
|
3013
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source plan must be approved before verification');
|
|
3014
|
+
if (plan.sourceCheck === undefined || plan.preparedEvidence === undefined) {
|
|
3015
|
+
throw new ControlPlaneStoreError('invalid-state', 'approved modify plan is missing its prepared evidence');
|
|
3016
|
+
}
|
|
3017
|
+
const now = this.#now();
|
|
3018
|
+
if (now > plan.expiresAt)
|
|
3019
|
+
throw new ControlPlaneStoreError('expired', 'source plan verification is no longer applicable');
|
|
3020
|
+
if (plan.sourceCheck.treeDigest !== input.recheckedTreeDigest || plan.sourceCheck.patchDigest !== input.recheckedPatchDigest) {
|
|
3021
|
+
throw new ControlPlaneStoreError('conflict', 'rechecked source tree or patch drifted from the prepared evidence');
|
|
3022
|
+
}
|
|
3023
|
+
const commit = () => {
|
|
3024
|
+
this.#assertOwnerTaskFailureGapAdmission(plan.gapId);
|
|
3025
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3026
|
+
try {
|
|
3027
|
+
const result = this.#database.prepare(`UPDATE source_plans SET status = 'ready-for-human-review', revision = revision + 1, updated_at = ?
|
|
3028
|
+
WHERE id = ? AND revision = ? AND status = 'approved' AND mode = 'modify'
|
|
3029
|
+
AND checked_tree_digest = ? AND checked_patch_digest = ? AND plan_digest = ?`).run(now, plan.id, input.expectedRevision, input.recheckedTreeDigest, input.recheckedPatchDigest, plan.digest);
|
|
3030
|
+
if (Number(result.changes) !== 1)
|
|
3031
|
+
throw new ControlPlaneStoreError('conflict', 'source plan changed while prepared verification was applied');
|
|
3032
|
+
const output = this.getSourcePlan(plan.id);
|
|
3033
|
+
const inputDigest = controlPlaneDigest({ operation: 'source-verify-prepared', planId: output.id,
|
|
3034
|
+
planDigest: output.digest, recheckedTreeDigest: input.recheckedTreeDigest, recheckedPatchDigest: input.recheckedPatchDigest });
|
|
3035
|
+
const receipt = { idempotencyKey: `source-verify-prepared:${output.id}:${output.revision}`,
|
|
3036
|
+
operation: 'source-verify-prepared', inputDigest, result: output, createdAt: now };
|
|
3037
|
+
this.#insertReceipt(receipt);
|
|
3038
|
+
this.#database.exec('COMMIT');
|
|
3039
|
+
return receipt;
|
|
3040
|
+
}
|
|
3041
|
+
catch (error) {
|
|
3042
|
+
this.#database.exec('ROLLBACK');
|
|
3043
|
+
throw error;
|
|
3044
|
+
}
|
|
3045
|
+
};
|
|
3046
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
3047
|
+
}
|
|
1165
3048
|
async startSourceRelease(input) {
|
|
3049
|
+
const withCurrentSource = (gapId, callback) => {
|
|
3050
|
+
const commit = () => { this.#assertOwnerTaskFailureGapAdmission(gapId); return callback(); };
|
|
3051
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
3052
|
+
};
|
|
1166
3053
|
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
1167
3054
|
if (!KEY.test(key))
|
|
1168
3055
|
throw new ControlPlaneStoreError('invalid-input', 'idempotencyKey has invalid syntax');
|
|
@@ -1185,83 +3072,99 @@ export class ControlPlaneStore {
|
|
|
1185
3072
|
|| controlPlaneDigest(replay.result.releaseAuthorization) !== controlPlaneDigest(verifiedAuthorization)) {
|
|
1186
3073
|
throw new ControlPlaneStoreError('invalid-state', 'stored source release start receipt is corrupt');
|
|
1187
3074
|
}
|
|
1188
|
-
return replay;
|
|
3075
|
+
return withCurrentSource(replay.result.gapId, () => replay);
|
|
1189
3076
|
}
|
|
1190
3077
|
const plan = this.getSourcePlan(input.planId);
|
|
1191
3078
|
if (plan.revision !== input.expectedRevision)
|
|
1192
3079
|
throw new ControlPlaneStoreError('conflict', 'source release authorization targets a stale plan revision');
|
|
1193
3080
|
if (plan.status !== 'ready-for-human-review' || plan.sourceCheck === undefined) {
|
|
1194
|
-
throw new ControlPlaneStoreError('invalid-state', 'source release requires
|
|
3081
|
+
throw new ControlPlaneStoreError('invalid-state', 'source release requires verified source and a release authorization');
|
|
1195
3082
|
}
|
|
3083
|
+
withCurrentSource(plan.gapId, () => { });
|
|
1196
3084
|
const verified = await input.resolveAuthority(input.authorization).verify(input.authorization, plan);
|
|
1197
3085
|
const now = this.#now();
|
|
1198
3086
|
if (now > plan.expiresAt || now > verified.expiresAt)
|
|
1199
3087
|
throw new ControlPlaneStoreError('expired', 'source release authorization is no longer applicable');
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
catch (error) {
|
|
1217
|
-
this.#database.exec('ROLLBACK');
|
|
1218
|
-
throw error;
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
async prepareSourceReleaseOperation(input) {
|
|
1222
|
-
const now = this.#now();
|
|
1223
|
-
this.#database.exec('BEGIN IMMEDIATE');
|
|
1224
|
-
try {
|
|
1225
|
-
const plan = this.getSourcePlan(input.planId);
|
|
1226
|
-
const expected = expectedSourceRelease(plan.status);
|
|
1227
|
-
if (expected === undefined)
|
|
1228
|
-
throw new ControlPlaneStoreError('invalid-state', 'source plan is not awaiting a release operation');
|
|
1229
|
-
if (plan.revision !== input.expectedRevision || plan.release?.fence !== input.expectedFence) {
|
|
1230
|
-
throw new ControlPlaneStoreError('conflict', 'source release operation targets a stale revision/fence');
|
|
3088
|
+
return withCurrentSource(plan.gapId, () => {
|
|
3089
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3090
|
+
try {
|
|
3091
|
+
const releaseId = `release-${randomUUID()}`;
|
|
3092
|
+
const result = this.#database.prepare(`UPDATE source_plans SET status = 'awaiting-pr', revision = revision + 1,
|
|
3093
|
+
release_authorization_json = ?, release_authorization_digest = ?, release_id = ?, release_fence = release_fence + 1,
|
|
3094
|
+
release_failure_phase = NULL, release_failure_code = NULL, updated_at = ?
|
|
3095
|
+
WHERE id = ? AND status = 'ready-for-human-review' AND revision = ? AND plan_digest = ?
|
|
3096
|
+
AND checked_tree_digest = ? AND checked_patch_digest = ? AND release_id IS NULL`).run(JSON.stringify(verified), controlPlaneDigest(verified), releaseId, now, plan.id, input.expectedRevision, plan.digest, plan.sourceCheck.treeDigest, plan.sourceCheck.patchDigest);
|
|
3097
|
+
if (Number(result.changes) !== 1)
|
|
3098
|
+
throw new ControlPlaneStoreError('conflict', 'source plan changed while release authorization was applied');
|
|
3099
|
+
const output = this.getSourcePlan(plan.id);
|
|
3100
|
+
const operationReceipt = { idempotencyKey: key, operation: 'start-source-release', inputDigest, result: output, createdAt: now };
|
|
3101
|
+
this.#insertReceipt(operationReceipt);
|
|
3102
|
+
this.#database.exec('COMMIT');
|
|
3103
|
+
return operationReceipt;
|
|
1231
3104
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
3105
|
+
catch (error) {
|
|
3106
|
+
this.#database.exec('ROLLBACK');
|
|
3107
|
+
throw error;
|
|
1234
3108
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
async prepareSourceReleaseOperation(input) {
|
|
3112
|
+
const withCurrentSource = (gapId, callback) => {
|
|
3113
|
+
const commit = () => { this.#assertOwnerTaskFailureGapAdmission(gapId); return callback(); };
|
|
3114
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
3115
|
+
};
|
|
3116
|
+
const plan = this.getSourcePlan(input.planId);
|
|
3117
|
+
const expected = expectedSourceRelease(plan.status);
|
|
3118
|
+
if (expected === undefined)
|
|
3119
|
+
throw new ControlPlaneStoreError('invalid-state', 'source plan is not awaiting a release operation');
|
|
3120
|
+
if (plan.revision !== input.expectedRevision || plan.release?.fence !== input.expectedFence) {
|
|
3121
|
+
throw new ControlPlaneStoreError('conflict', 'source release operation targets a stale revision/fence');
|
|
3122
|
+
}
|
|
3123
|
+
if (plan.releaseAuthorization === undefined || plan.sourceCheck === undefined) {
|
|
3124
|
+
throw new ControlPlaneStoreError('invalid-state', 'source release operation has no durable post-check authorization');
|
|
3125
|
+
}
|
|
3126
|
+
withCurrentSource(plan.gapId, () => { });
|
|
3127
|
+
await reverifySourceReleaseAuthorization(plan.releaseAuthorization, plan, input.resolveAuthorizationAuthority);
|
|
3128
|
+
return withCurrentSource(plan.gapId, () => {
|
|
3129
|
+
const now = this.#now();
|
|
3130
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3131
|
+
try {
|
|
3132
|
+
const current = this.getSourcePlan(plan.id);
|
|
3133
|
+
const currentExpected = expectedSourceRelease(current.status);
|
|
3134
|
+
if (current.revision !== input.expectedRevision || current.release?.fence !== input.expectedFence
|
|
3135
|
+
|| current.status !== plan.status || current.digest !== plan.digest || currentExpected?.phase !== expected.phase) {
|
|
3136
|
+
throw new ControlPlaneStoreError('conflict', 'source release operation changed during authorization verification');
|
|
1247
3137
|
}
|
|
3138
|
+
this.#assertReleaseEnvironment(input, expected.phase);
|
|
3139
|
+
const previous = this.#previousReleaseEvidence(current, expected.phase);
|
|
3140
|
+
const priorRow = this.#database.prepare(`SELECT * FROM source_release_operations WHERE plan_id = ? AND phase = ?
|
|
3141
|
+
ORDER BY attempt DESC LIMIT 1`).get(current.id, expected.phase);
|
|
3142
|
+
const attempt = priorRow !== undefined && priorRow.release_fence === current.release.fence
|
|
3143
|
+
? priorRow.attempt : (priorRow?.attempt ?? 0) + 1;
|
|
3144
|
+
if (priorRow !== undefined && priorRow.release_fence === current.release.fence) {
|
|
3145
|
+
const prior = sourceReleaseOperationFromRow(priorRow);
|
|
3146
|
+
const expectedRequest = this.#sourceReleaseRequest(current, expected.phase, prior.operationId, prior.attempt, prior.request.requestedAt, input, previous);
|
|
3147
|
+
if (prior.bindingDigest !== controlPlaneDigest(releaseRequestBinding(expectedRequest))) {
|
|
3148
|
+
throw new ControlPlaneStoreError('conflict', 'durable release operation payload changed for the same phase/fence');
|
|
3149
|
+
}
|
|
3150
|
+
this.#database.exec('COMMIT');
|
|
3151
|
+
return prior;
|
|
3152
|
+
}
|
|
3153
|
+
const operationId = `release-operation-${randomUUID()}`;
|
|
3154
|
+
const request = this.#sourceReleaseRequest(current, expected.phase, operationId, attempt, now, input, previous);
|
|
3155
|
+
const bindingDigest = controlPlaneDigest(releaseRequestBinding(request));
|
|
3156
|
+
this.#database.prepare(`INSERT INTO source_release_operations (plan_id, phase, release_id, release_fence, attempt,
|
|
3157
|
+
operation_id, binding_digest, request_digest, request_json, status, receipt_digest, receipt_json, created_at, completed_at, applied_at)
|
|
3158
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, NULL, NULL)`).run(current.id, expected.phase, current.release.id, current.release.fence, attempt, operationId, bindingDigest, controlPlaneDigest(request), JSON.stringify(request), now);
|
|
3159
|
+
const operation = this.getSourceReleaseOperation(operationId);
|
|
1248
3160
|
this.#database.exec('COMMIT');
|
|
1249
|
-
return
|
|
3161
|
+
return operation;
|
|
1250
3162
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, NULL, NULL)`).run(plan.id, expected.phase, plan.release.id, plan.release.fence, attempt, operationId, bindingDigest, controlPlaneDigest(request), JSON.stringify(request), now);
|
|
1257
|
-
const operation = this.getSourceReleaseOperation(operationId);
|
|
1258
|
-
this.#database.exec('COMMIT');
|
|
1259
|
-
return operation;
|
|
1260
|
-
}
|
|
1261
|
-
catch (error) {
|
|
1262
|
-
this.#database.exec('ROLLBACK');
|
|
1263
|
-
throw error;
|
|
1264
|
-
}
|
|
3163
|
+
catch (error) {
|
|
3164
|
+
this.#database.exec('ROLLBACK');
|
|
3165
|
+
throw error;
|
|
3166
|
+
}
|
|
3167
|
+
});
|
|
1265
3168
|
}
|
|
1266
3169
|
getSourceReleaseOperation(operationId) {
|
|
1267
3170
|
const row = this.#database.prepare('SELECT * FROM source_release_operations WHERE operation_id = ?').get(operationId);
|
|
@@ -1269,68 +3172,183 @@ export class ControlPlaneStore {
|
|
|
1269
3172
|
throw new ControlPlaneStoreError('not-found', 'source release operation not found');
|
|
1270
3173
|
return sourceReleaseOperationFromRow(row);
|
|
1271
3174
|
}
|
|
3175
|
+
findSourceReleaseOperation(planId, phase, fence) {
|
|
3176
|
+
const row = this.#database.prepare(`SELECT * FROM source_release_operations WHERE plan_id = ? AND phase = ?
|
|
3177
|
+
AND release_fence = ? ORDER BY attempt DESC LIMIT 1`).get(planId, phase, fence);
|
|
3178
|
+
return row === undefined ? undefined : sourceReleaseOperationFromRow(row);
|
|
3179
|
+
}
|
|
3180
|
+
getSourceReleaseDispatchStatus(operationId) {
|
|
3181
|
+
const row = this.#database.prepare('SELECT * FROM source_release_dispatches WHERE operation_id = ?').get(operationId);
|
|
3182
|
+
if (row === undefined)
|
|
3183
|
+
return undefined;
|
|
3184
|
+
if ((row.status !== 'claimed' && row.status !== 'completed') || !Number.isSafeInteger(row.claimed_at) || row.claimed_at < 0
|
|
3185
|
+
|| (row.status === 'claimed') !== (row.completed_at === null)
|
|
3186
|
+
|| (row.completed_at !== null && (!Number.isSafeInteger(row.completed_at) || row.completed_at < row.claimed_at))) {
|
|
3187
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source release dispatch is corrupt');
|
|
3188
|
+
}
|
|
3189
|
+
return row.status;
|
|
3190
|
+
}
|
|
1272
3191
|
sourceReleaseCandidate(planId) {
|
|
1273
3192
|
const plan = this.getSourcePlan(planId);
|
|
1274
3193
|
if (plan.status !== 'release-complete') {
|
|
1275
3194
|
throw new ControlPlaneStoreError('invalid-state', 'source candidate is not admitted until release completes');
|
|
1276
3195
|
}
|
|
1277
|
-
|
|
1278
|
-
AND status = 'applied' ORDER BY attempt DESC LIMIT 1`).get(plan.id);
|
|
1279
|
-
if (row === undefined)
|
|
1280
|
-
throw new ControlPlaneStoreError('invalid-state', 'source release has no applied build artifact');
|
|
1281
|
-
const operation = sourceReleaseOperationFromRow(row);
|
|
1282
|
-
const evidence = operation.receipt?.evidence;
|
|
1283
|
-
if (operation.receipt?.outcome !== 'passed' || evidence?.kind !== 'build') {
|
|
1284
|
-
throw new ControlPlaneStoreError('invalid-state', 'source release build evidence is not successful');
|
|
1285
|
-
}
|
|
1286
|
-
const authorization = plan.releaseAuthorization;
|
|
1287
|
-
if (authorization === undefined)
|
|
1288
|
-
throw new ControlPlaneStoreError('invalid-state', 'source release has no verified authorization');
|
|
1289
|
-
const artifact = releaseArtifact(evidence);
|
|
1290
|
-
return parseCatalog({ schemaVersion: 1, entries: [{ id: artifact.candidateId, package: artifact.packageName,
|
|
1291
|
-
version: artifact.packageVersion, integrity: artifact.tarballIntegrity, dshBaseline: artifact.dshBaseline,
|
|
1292
|
-
registry: { id: authorization.releasePolicy.registryId, locator: authorization.releasePolicy.registryLocator,
|
|
1293
|
-
reference: authorization.releasePolicy.registryReference },
|
|
1294
|
-
capabilities: artifact.capabilities, authorities: artifact.authorities, requires: artifact.requires }] }).entries[0];
|
|
3196
|
+
return this.#releaseCandidate(plan);
|
|
1295
3197
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
catch {
|
|
1301
|
-
throw new ControlPlaneStoreError('conflict', 'source release single-flight is held');
|
|
3198
|
+
previewSourceReleaseCandidate(planId) {
|
|
3199
|
+
const plan = this.getSourcePlan(planId);
|
|
3200
|
+
if (plan.status !== 'awaiting-catalog-admission') {
|
|
3201
|
+
throw new ControlPlaneStoreError('invalid-state', 'source candidate is not ready for catalog admission');
|
|
1302
3202
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
3203
|
+
return this.#releaseCandidate(plan);
|
|
3204
|
+
}
|
|
3205
|
+
#releaseCandidate(plan) {
|
|
3206
|
+
return readReleaseCandidate(this.#database, plan);
|
|
3207
|
+
}
|
|
3208
|
+
async runSourceReleaseOperation(input) {
|
|
3209
|
+
const withCurrentSource = (gapId, callback) => {
|
|
3210
|
+
const commit = () => { this.#assertOwnerTaskFailureGapAdmission(gapId); return callback(); };
|
|
3211
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
3212
|
+
};
|
|
3213
|
+
const operation = this.getSourceReleaseOperation(input.operationId);
|
|
3214
|
+
const plan = this.getSourcePlan(operation.planId);
|
|
3215
|
+
const assertCurrent = (current, currentOperation) => {
|
|
3216
|
+
const currentExpected = expectedSourceRelease(current.status);
|
|
3217
|
+
if (current.revision !== input.expectedRevision || current.release?.fence !== input.expectedFence
|
|
3218
|
+
|| currentOperation.fence !== input.expectedFence || currentExpected?.phase !== currentOperation.phase
|
|
3219
|
+
|| currentOperation.requestDigest !== controlPlaneDigest(currentOperation.request)) {
|
|
1310
3220
|
throw new ControlPlaneStoreError('conflict', 'source release operation lost its plan revision/fence/phase');
|
|
1311
3221
|
}
|
|
1312
|
-
|
|
3222
|
+
};
|
|
3223
|
+
assertCurrent(plan, operation);
|
|
3224
|
+
if (operation.receipt !== undefined)
|
|
3225
|
+
return withCurrentSource(plan.gapId, () => operation.receipt);
|
|
3226
|
+
withCurrentSource(plan.gapId, () => { });
|
|
3227
|
+
await reverifySourceReleaseAuthorization(operation.request.authorization, plan, input.resolveAuthorizationAuthority);
|
|
3228
|
+
const claimed = withCurrentSource(plan.gapId, () => {
|
|
3229
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3230
|
+
try {
|
|
3231
|
+
const current = this.getSourcePlan(plan.id);
|
|
3232
|
+
const currentOperation = this.getSourceReleaseOperation(operation.operationId);
|
|
3233
|
+
assertCurrent(current, currentOperation);
|
|
3234
|
+
if (currentOperation.receipt !== undefined) {
|
|
3235
|
+
this.#database.exec('COMMIT');
|
|
3236
|
+
return false;
|
|
3237
|
+
}
|
|
3238
|
+
const dispatch = this.#database.prepare('SELECT * FROM source_release_dispatches WHERE operation_id = ?').get(operation.operationId);
|
|
3239
|
+
if (dispatch !== undefined) {
|
|
3240
|
+
this.getSourceReleaseDispatchStatus(operation.operationId);
|
|
3241
|
+
throw new ControlPlaneStoreError('conflict', 'source release operation outcome is unknown; receipt reconciliation is required');
|
|
3242
|
+
}
|
|
3243
|
+
this.#database.prepare(`INSERT INTO source_release_dispatches (operation_id, status, claimed_at, completed_at)
|
|
3244
|
+
VALUES (?, 'claimed', ?, NULL)`).run(operation.operationId, this.#now());
|
|
1313
3245
|
this.#database.exec('COMMIT');
|
|
1314
|
-
return
|
|
3246
|
+
return true;
|
|
1315
3247
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
3248
|
+
catch (error) {
|
|
3249
|
+
this.#database.exec('ROLLBACK');
|
|
3250
|
+
throw error;
|
|
3251
|
+
}
|
|
3252
|
+
});
|
|
3253
|
+
if (!claimed)
|
|
3254
|
+
return this.getSourceReleaseOperation(operation.operationId).receipt;
|
|
3255
|
+
const receipt = await input.execute(operation.request);
|
|
3256
|
+
await input.resolveAuthority(receipt).verify(receipt, plan, operation.request);
|
|
3257
|
+
return withCurrentSource(plan.gapId, () => {
|
|
3258
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3259
|
+
try {
|
|
3260
|
+
const current = this.getSourcePlan(plan.id);
|
|
3261
|
+
const currentOperation = this.getSourceReleaseOperation(operation.operationId);
|
|
3262
|
+
assertCurrent(current, currentOperation);
|
|
3263
|
+
const dispatch = this.#database.prepare('SELECT * FROM source_release_dispatches WHERE operation_id = ?').get(operation.operationId);
|
|
3264
|
+
if (dispatch === undefined || dispatch.status !== 'claimed')
|
|
3265
|
+
throw new ControlPlaneStoreError('conflict', 'source release completion lost its dispatch claim');
|
|
3266
|
+
const now = this.#now();
|
|
3267
|
+
const result = this.#database.prepare(`UPDATE source_release_operations SET status = 'completed', receipt_digest = ?,
|
|
3268
|
+
receipt_json = ?, completed_at = ? WHERE operation_id = ? AND status = 'pending' AND request_digest = ?
|
|
3269
|
+
AND release_fence = ?`).run(controlPlaneDigest(receipt), JSON.stringify(receipt), now, operation.operationId, operation.requestDigest, input.expectedFence);
|
|
3270
|
+
if (Number(result.changes) !== 1)
|
|
3271
|
+
throw new ControlPlaneStoreError('conflict', 'source release completion lost its single-flight');
|
|
3272
|
+
const completed = this.#database.prepare(`UPDATE source_release_dispatches SET status = 'completed', completed_at = ?
|
|
3273
|
+
WHERE operation_id = ? AND status = 'claimed'`).run(now, operation.operationId);
|
|
3274
|
+
if (Number(completed.changes) !== 1)
|
|
3275
|
+
throw new ControlPlaneStoreError('conflict', 'source release completion lost its dispatch claim');
|
|
3276
|
+
this.#database.exec('COMMIT');
|
|
3277
|
+
return receipt;
|
|
3278
|
+
}
|
|
3279
|
+
catch (error) {
|
|
3280
|
+
this.#database.exec('ROLLBACK');
|
|
3281
|
+
throw error;
|
|
3282
|
+
}
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Reconciles a signed receipt for a durably claimed dispatch without
|
|
3287
|
+
* invoking an adapter again. This is the only recovery path for a lost
|
|
3288
|
+
* adapter response.
|
|
3289
|
+
*/
|
|
3290
|
+
async acceptSourceReleaseReceipt(input) {
|
|
3291
|
+
const withCurrentSource = (gapId, callback) => {
|
|
3292
|
+
const commit = () => { this.#assertOwnerTaskFailureGapAdmission(gapId); return callback(); };
|
|
3293
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
3294
|
+
};
|
|
3295
|
+
const operation = this.getSourceReleaseOperation(input.operationId), plan = this.getSourcePlan(operation.planId);
|
|
3296
|
+
const assertCurrent = (current, currentOperation) => {
|
|
3297
|
+
const expected = expectedSourceRelease(current.status);
|
|
3298
|
+
if (current.revision !== input.expectedRevision || current.release?.fence !== input.expectedFence
|
|
3299
|
+
|| currentOperation.fence !== input.expectedFence || expected?.phase !== currentOperation.phase
|
|
3300
|
+
|| currentOperation.requestDigest !== controlPlaneDigest(currentOperation.request)) {
|
|
3301
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt lost its plan revision/fence/phase');
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
assertCurrent(plan, operation);
|
|
3305
|
+
if (operation.receipt !== undefined) {
|
|
3306
|
+
if (controlPlaneDigest(operation.receipt) !== controlPlaneDigest(input.receipt))
|
|
3307
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt differs from completed operation');
|
|
3308
|
+
return withCurrentSource(plan.gapId, () => operation.receipt);
|
|
3309
|
+
}
|
|
3310
|
+
withCurrentSource(plan.gapId, () => { });
|
|
3311
|
+
await reverifySourceReleaseAuthorization(operation.request.authorization, plan, input.resolveAuthorizationAuthority);
|
|
3312
|
+
await input.resolveAuthority(input.receipt).verify(input.receipt, plan, operation.request);
|
|
3313
|
+
return withCurrentSource(plan.gapId, () => {
|
|
3314
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3315
|
+
try {
|
|
3316
|
+
const current = this.getSourcePlan(plan.id), currentOperation = this.getSourceReleaseOperation(operation.operationId);
|
|
3317
|
+
assertCurrent(current, currentOperation);
|
|
3318
|
+
if (currentOperation.receipt !== undefined) {
|
|
3319
|
+
if (controlPlaneDigest(currentOperation.receipt) !== controlPlaneDigest(input.receipt))
|
|
3320
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt differs from completed operation');
|
|
3321
|
+
this.#database.exec('COMMIT');
|
|
3322
|
+
return currentOperation.receipt;
|
|
3323
|
+
}
|
|
3324
|
+
const dispatch = this.#database.prepare('SELECT * FROM source_release_dispatches WHERE operation_id = ?').get(operation.operationId);
|
|
3325
|
+
if (dispatch !== undefined && dispatch.status !== 'claimed')
|
|
3326
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt has no unresolved dispatch claim');
|
|
3327
|
+
const now = this.#now();
|
|
3328
|
+
const result = this.#database.prepare(`UPDATE source_release_operations SET status = 'completed', receipt_digest = ?, receipt_json = ?, completed_at = ?
|
|
3329
|
+
WHERE operation_id = ? AND status = 'pending' AND request_digest = ? AND release_fence = ?`).run(controlPlaneDigest(input.receipt), JSON.stringify(input.receipt), now, operation.operationId, operation.requestDigest, input.expectedFence);
|
|
3330
|
+
if (Number(result.changes) !== 1)
|
|
3331
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt lost its completion CAS');
|
|
3332
|
+
if (dispatch !== undefined) {
|
|
3333
|
+
const completed = this.#database.prepare(`UPDATE source_release_dispatches SET status = 'completed', completed_at = ?
|
|
3334
|
+
WHERE operation_id = ? AND status = 'claimed'`).run(now, operation.operationId);
|
|
3335
|
+
if (Number(completed.changes) !== 1)
|
|
3336
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt lost its dispatch claim');
|
|
3337
|
+
}
|
|
3338
|
+
this.#database.exec('COMMIT');
|
|
3339
|
+
return input.receipt;
|
|
3340
|
+
}
|
|
3341
|
+
catch (error) {
|
|
3342
|
+
this.#database.exec('ROLLBACK');
|
|
3343
|
+
throw error;
|
|
3344
|
+
}
|
|
3345
|
+
});
|
|
1332
3346
|
}
|
|
1333
3347
|
async applySourceRelease(input) {
|
|
3348
|
+
const withCurrentSource = (gapId, callback) => {
|
|
3349
|
+
const commit = () => { this.#assertOwnerTaskFailureGapAdmission(gapId); return callback(); };
|
|
3350
|
+
return input.withSourceFence ? input.withSourceFence(commit) : commit();
|
|
3351
|
+
};
|
|
1334
3352
|
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
1335
3353
|
if (!KEY.test(key))
|
|
1336
3354
|
throw new ControlPlaneStoreError('invalid-input', 'idempotencyKey has invalid syntax');
|
|
@@ -1350,7 +3368,7 @@ export class ControlPlaneStore {
|
|
|
1350
3368
|
|| replay.result.release.updatedAt !== replay.createdAt) {
|
|
1351
3369
|
throw new ControlPlaneStoreError('invalid-state', 'stored source release apply receipt is corrupt');
|
|
1352
3370
|
}
|
|
1353
|
-
return replay;
|
|
3371
|
+
return withCurrentSource(replay.result.gapId, () => replay);
|
|
1354
3372
|
}
|
|
1355
3373
|
const plan = this.getSourcePlan(input.planId);
|
|
1356
3374
|
const expected = expectedSourceRelease(plan.status);
|
|
@@ -1365,6 +3383,7 @@ export class ControlPlaneStore {
|
|
|
1365
3383
|
|| controlPlaneDigest(operation.receipt) !== controlPlaneDigest(input.receipt)) {
|
|
1366
3384
|
throw new ControlPlaneStoreError('conflict', 'release receipt was not completed by the durable fenced phase operation');
|
|
1367
3385
|
}
|
|
3386
|
+
withCurrentSource(plan.gapId, () => { });
|
|
1368
3387
|
const verified = await input.resolveAuthority(input.receipt).verify(input.receipt, plan, operation.request);
|
|
1369
3388
|
let nextStatus;
|
|
1370
3389
|
let failurePhase = null;
|
|
@@ -1385,41 +3404,55 @@ export class ControlPlaneStore {
|
|
|
1385
3404
|
? `${verified.evidence.code}${verified.evidence.remoteState === 'unchanged' ? '' : `:${verified.evidence.remoteState}`}` : 'release-failed';
|
|
1386
3405
|
}
|
|
1387
3406
|
const now = this.#now();
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
3407
|
+
return withCurrentSource(plan.gapId, () => {
|
|
3408
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3409
|
+
try {
|
|
3410
|
+
const current = this.getSourcePlan(plan.id);
|
|
3411
|
+
const currentExpected = expectedSourceRelease(current.status);
|
|
3412
|
+
if (current.revision !== input.expectedRevision || current.release?.fence !== input.expectedFence
|
|
3413
|
+
|| current.status !== plan.status || current.digest !== plan.digest || currentExpected?.phase !== expected.phase) {
|
|
3414
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt changed during verification');
|
|
3415
|
+
}
|
|
3416
|
+
const currentOperation = this.getSourceReleaseOperation(input.receipt.operationId);
|
|
3417
|
+
if (currentOperation.planId !== current.id || currentOperation.phase !== expected.phase || currentOperation.fence !== input.expectedFence
|
|
3418
|
+
|| currentOperation.status !== 'completed' || currentOperation.receipt === undefined
|
|
3419
|
+
|| controlPlaneDigest(currentOperation.receipt) !== controlPlaneDigest(input.receipt)) {
|
|
3420
|
+
throw new ControlPlaneStoreError('conflict', 'release receipt changed during verification');
|
|
3421
|
+
}
|
|
3422
|
+
const result = this.#database.prepare(`UPDATE source_plans SET status = ?, revision = revision + 1,
|
|
1391
3423
|
release_failure_phase = ?, release_failure_code = ?, updated_at = ?
|
|
1392
|
-
WHERE id = ? AND status = ? AND revision = ? AND release_id = ? AND release_fence = ?`).run(nextStatus, failurePhase, failureCode, now,
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
WHERE operation_id = ? AND status = 'completed' AND receipt_digest = ? AND release_fence = ?`).run(now,
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
3424
|
+
WHERE id = ? AND status = ? AND revision = ? AND release_id = ? AND release_fence = ?`).run(nextStatus, failurePhase, failureCode, now, current.id, current.status, current.revision, current.release.id, input.expectedFence);
|
|
3425
|
+
if (Number(result.changes) !== 1)
|
|
3426
|
+
throw new ControlPlaneStoreError('conflict', 'source release receipt lost its plan CAS');
|
|
3427
|
+
const applied = this.#database.prepare(`UPDATE source_release_operations SET status = 'applied', applied_at = ?
|
|
3428
|
+
WHERE operation_id = ? AND status = 'completed' AND receipt_digest = ? AND release_fence = ?`).run(now, currentOperation.operationId, controlPlaneDigest(input.receipt), input.expectedFence);
|
|
3429
|
+
if (Number(applied.changes) !== 1)
|
|
3430
|
+
throw new ControlPlaneStoreError('conflict', 'source release operation lost its apply CAS');
|
|
3431
|
+
const output = this.getSourcePlan(current.id);
|
|
3432
|
+
if (nextStatus === 'release-complete') {
|
|
3433
|
+
const candidateId = verified.evidence.kind === 'catalog-admission' ? verified.evidence.candidate.id : undefined;
|
|
3434
|
+
if (candidateId === undefined)
|
|
3435
|
+
throw new ControlPlaneStoreError('invalid-state', 'release completion has no admitted candidate');
|
|
3436
|
+
this.#database.prepare(`UPDATE capability_gaps SET status = 'open', candidate_id = ?, revision = revision + 1, updated_at = ?
|
|
1405
3437
|
WHERE id = ? AND status = 'matched'`).run(candidateId, now, output.gapId);
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
3438
|
+
this.#database.prepare('DELETE FROM gap_plan_claims WHERE gap_id = ? AND plan_id = ?').run(output.gapId, output.id);
|
|
3439
|
+
}
|
|
3440
|
+
else if (nextStatus === 'release-failed' && verified.evidence.kind === 'failure'
|
|
3441
|
+
&& verified.evidence.remoteState === 'unchanged') {
|
|
3442
|
+
this.#database.prepare('DELETE FROM gap_plan_claims WHERE gap_id = ? AND plan_id = ?').run(output.gapId, output.id);
|
|
3443
|
+
this.#database.prepare(`UPDATE capability_gaps SET status = 'open', candidate_id = NULL, revision = revision + 1, updated_at = ?
|
|
1412
3444
|
WHERE id = ? AND status = 'matched'`).run(now, output.gapId);
|
|
3445
|
+
}
|
|
3446
|
+
const operationReceipt = { idempotencyKey: key, operation: 'source-release', inputDigest, result: output, createdAt: now };
|
|
3447
|
+
this.#insertReceipt(operationReceipt);
|
|
3448
|
+
this.#database.exec('COMMIT');
|
|
3449
|
+
return operationReceipt;
|
|
1413
3450
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
}
|
|
1419
|
-
catch (error) {
|
|
1420
|
-
this.#database.exec('ROLLBACK');
|
|
1421
|
-
throw error;
|
|
1422
|
-
}
|
|
3451
|
+
catch (error) {
|
|
3452
|
+
this.#database.exec('ROLLBACK');
|
|
3453
|
+
throw error;
|
|
3454
|
+
}
|
|
3455
|
+
});
|
|
1423
3456
|
}
|
|
1424
3457
|
async prepareSourcePublishReconciliation(input) {
|
|
1425
3458
|
const now = this.#now();
|
|
@@ -1756,8 +3789,13 @@ export class ControlPlaneStore {
|
|
|
1756
3789
|
'awaiting-effect-blocked-replay', 'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending')) AS active_activations,
|
|
1757
3790
|
(SELECT count(*) FROM activation_plans WHERE status = 'rolled-back') +
|
|
1758
3791
|
(SELECT count(*) FROM source_plans WHERE status IN ('local-checks-failed', 'release-failed')) AS failed,
|
|
1759
|
-
(SELECT count(*) FROM activation_plans WHERE status = 'rollback-pending') AS rollback_pending
|
|
1760
|
-
|
|
3792
|
+
(SELECT count(*) FROM activation_plans WHERE status = 'rollback-pending') AS rollback_pending,
|
|
3793
|
+
(SELECT count(*) FROM activation_watch WHERE state = 'watching') AS watching_activations,
|
|
3794
|
+
(SELECT count(*) FROM activation_watch WHERE state = 'closed-regressed') AS closed_regressed,
|
|
3795
|
+
(SELECT count(*) FROM activation_watch WHERE state = 'closed-retracted') AS closed_retracted`).get();
|
|
3796
|
+
return { gaps: row.gaps, readyPlans: row.ready_plans, activeActivations: row.active_activations, failed: row.failed,
|
|
3797
|
+
rollbackPending: row.rollback_pending, watchingActivations: row.watching_activations,
|
|
3798
|
+
closedRegressed: row.closed_regressed, closedRetracted: row.closed_retracted };
|
|
1761
3799
|
}
|
|
1762
3800
|
#sourcePlanReceipt(idempotencyKey, operation, inputDigest, planId) {
|
|
1763
3801
|
const receipt = this.#receipt(idempotencyKey, operation, inputDigest);
|
|
@@ -1769,6 +3807,8 @@ export class ControlPlaneStore {
|
|
|
1769
3807
|
|| result.revision > authoritative.revision
|
|
1770
3808
|
|| (result.approval !== undefined && controlPlaneDigest(result.approval) !== controlPlaneDigest(authoritative.approval))
|
|
1771
3809
|
|| (result.sourceCheck !== undefined && controlPlaneDigest(result.sourceCheck) !== controlPlaneDigest(authoritative.sourceCheck))
|
|
3810
|
+
|| (result.preparedEvidence !== undefined
|
|
3811
|
+
&& controlPlaneDigest(result.preparedEvidence) !== controlPlaneDigest(authoritative.preparedEvidence))
|
|
1772
3812
|
|| (result.releaseAuthorization !== undefined
|
|
1773
3813
|
&& controlPlaneDigest(result.releaseAuthorization) !== controlPlaneDigest(authoritative.releaseAuthorization))
|
|
1774
3814
|
|| (result.release !== undefined && (result.release.id !== authoritative.release?.id
|
|
@@ -1788,8 +3828,13 @@ export class ControlPlaneStore {
|
|
|
1788
3828
|
scope: snapshot.scope, ttlMs: snapshot.expiresAt - snapshot.createdAt };
|
|
1789
3829
|
if (snapshot.digest !== authoritative.digest || snapshot.gapId !== authoritative.gapId
|
|
1790
3830
|
|| snapshot.revision !== 1 || snapshot.status !== 'pending-approval' || snapshot.createdAt !== receipt.createdAt
|
|
1791
|
-
|| snapshot.approval !== undefined || snapshot.
|
|
1792
|
-
|| snapshot.release !== undefined || controlPlaneDigest(
|
|
3831
|
+
|| snapshot.approval !== undefined || snapshot.releaseAuthorization !== undefined
|
|
3832
|
+
|| snapshot.release !== undefined || controlPlaneDigest(snapshot.mode === 'modify' ? { ...replayBinding, mode: snapshot.mode,
|
|
3833
|
+
prepared: { treeDigest: snapshot.sourceCheck.treeDigest, patchDigest: snapshot.sourceCheck.patchDigest,
|
|
3834
|
+
checkedAt: snapshot.sourceCheck.checkedAt, evidence: snapshot.preparedEvidence } } : replayBinding) !== inputDigest
|
|
3835
|
+
|| (snapshot.mode === 'create' && (snapshot.sourceCheck !== undefined || snapshot.preparedEvidence !== undefined))
|
|
3836
|
+
|| (snapshot.mode === 'modify' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
3837
|
+
|| controlPlaneDigest(snapshot.sourceCheck) !== controlPlaneDigest(authoritative.sourceCheck)))) {
|
|
1793
3838
|
throw new ControlPlaneStoreError('invalid-state', 'stored source operation receipt is not bound to authoritative state');
|
|
1794
3839
|
}
|
|
1795
3840
|
return { ...receipt, result: snapshot };
|
|
@@ -1815,11 +3860,14 @@ export class ControlPlaneStore {
|
|
|
1815
3860
|
return undefined;
|
|
1816
3861
|
const snapshot = activationSnapshotFromStored(receipt.result);
|
|
1817
3862
|
const authoritative = this.getPlan(snapshot.id);
|
|
3863
|
+
const adoption = this.#database.prepare('SELECT source_plan_id FROM source_adoptions WHERE activation_plan_id = ?').get(snapshot.id);
|
|
3864
|
+
if (adoption)
|
|
3865
|
+
readOwnerSourceAdoptionPlan(this.#database, snapshot.id);
|
|
1818
3866
|
const replayBinding = { operation: 'create-activation-plan', gapId: snapshot.gapId, candidate: snapshot.candidate,
|
|
1819
3867
|
catalog: { digest: snapshot.dossier.catalogDigest, provenance: snapshot.dossier.catalogProvenance },
|
|
1820
3868
|
matchedCapabilities: snapshot.dossier.matchedCapabilities, profile: snapshot.profile, target: snapshot.target,
|
|
1821
3869
|
installationId: snapshot.installationId, ledger: snapshot.ledger, executor: snapshot.executor,
|
|
1822
|
-
ttlMs: snapshot.expiresAt - snapshot.createdAt };
|
|
3870
|
+
ttlMs: snapshot.expiresAt - snapshot.createdAt, ...(adoption ? { sourcePlanId: adoption.source_plan_id } : {}), ...(snapshot.dossier.handoff === undefined ? {} : { handoff: snapshot.dossier.handoff }) };
|
|
1823
3871
|
if (snapshot.digest !== authoritative.digest || snapshot.gapId !== authoritative.gapId || snapshot.revision !== 1
|
|
1824
3872
|
|| snapshot.status !== 'pending-approval' || snapshot.createdAt !== receipt.createdAt
|
|
1825
3873
|
|| snapshot.approval !== undefined || snapshot.activation !== undefined || controlPlaneDigest(replayBinding) !== inputDigest) {
|