@dsh-enhanced/plugin-control-plane 0.1.47 → 0.1.49
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 +183 -15
- package/bin/dsh-live-qualification-authority.js +15 -0
- package/bin/dsh-local-release-adapter.js +328 -8
- package/bin/dsh-systemd-host-attestor.js +359 -9
- package/bin/dsh-systemd-host-authority.js +12 -0
- package/lib/adoption-coordinator.d.ts +3 -1
- package/lib/adoption-coordinator.d.ts.map +1 -1
- package/lib/adoption-coordinator.js +24 -9
- package/lib/adoption-coordinator.js.map +1 -1
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +33 -14
- package/lib/cli.js.map +1 -1
- package/lib/creation-capability-feedback.d.ts +23 -0
- package/lib/creation-capability-feedback.d.ts.map +1 -0
- package/lib/creation-capability-feedback.js +132 -0
- package/lib/creation-capability-feedback.js.map +1 -0
- package/lib/creation-capability-journal.d.ts +66 -0
- package/lib/creation-capability-journal.d.ts.map +1 -0
- package/lib/creation-capability-journal.js +751 -0
- package/lib/creation-capability-journal.js.map +1 -0
- package/lib/creation-capability-runtime.d.ts +63 -0
- package/lib/creation-capability-runtime.d.ts.map +1 -0
- package/lib/creation-capability-runtime.js +564 -0
- package/lib/creation-capability-runtime.js.map +1 -0
- package/lib/creation-capability-source.d.ts +37 -0
- package/lib/creation-capability-source.d.ts.map +1 -0
- package/lib/creation-capability-source.js +328 -0
- package/lib/creation-capability-source.js.map +1 -0
- package/lib/creation-capability-types.d.ts +252 -0
- package/lib/creation-capability-types.d.ts.map +1 -0
- package/lib/creation-capability-types.js +2 -0
- package/lib/creation-capability-types.js.map +1 -0
- package/lib/deployment-readiness.d.ts +16 -0
- package/lib/deployment-readiness.d.ts.map +1 -1
- package/lib/deployment-readiness.js +48 -1
- package/lib/deployment-readiness.js.map +1 -1
- package/lib/effect-blocked-replay.js +2 -2
- package/lib/effect-blocked-replay.js.map +1 -1
- package/lib/foreground-deployment-runtime.d.ts +3 -0
- package/lib/foreground-deployment-runtime.d.ts.map +1 -1
- package/lib/foreground-deployment-runtime.js +55 -13
- package/lib/foreground-deployment-runtime.js.map +1 -1
- package/lib/foreground-deployment.d.ts.map +1 -1
- package/lib/foreground-deployment.js +5 -0
- package/lib/foreground-deployment.js.map +1 -1
- package/lib/host-attestor.d.ts +2 -0
- package/lib/host-attestor.d.ts.map +1 -1
- package/lib/host-attestor.js +8 -1
- package/lib/host-attestor.js.map +1 -1
- package/lib/host-inputs.d.ts +13 -0
- package/lib/host-inputs.d.ts.map +1 -0
- package/lib/host-inputs.js +162 -0
- package/lib/host-inputs.js.map +1 -0
- package/lib/host-maintenance.d.ts +61 -0
- package/lib/host-maintenance.d.ts.map +1 -0
- package/lib/host-maintenance.js +158 -0
- package/lib/host-maintenance.js.map +1 -0
- package/lib/host-readiness.d.ts +5 -0
- package/lib/host-readiness.d.ts.map +1 -0
- package/lib/host-readiness.js +22 -0
- package/lib/host-readiness.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -0
- package/lib/index.js.map +1 -1
- package/lib/live-qualification-authority.d.ts +32 -0
- package/lib/live-qualification-authority.d.ts.map +1 -0
- package/lib/live-qualification-authority.js +280 -0
- package/lib/live-qualification-authority.js.map +1 -0
- package/lib/live-qualification-client.d.ts +4 -0
- package/lib/live-qualification-client.d.ts.map +1 -0
- package/lib/live-qualification-client.js +22 -0
- package/lib/live-qualification-client.js.map +1 -0
- package/lib/live-qualification-runtime.d.ts +78 -0
- package/lib/live-qualification-runtime.d.ts.map +1 -0
- package/lib/live-qualification-runtime.js +367 -0
- package/lib/live-qualification-runtime.js.map +1 -0
- package/lib/live-qualification.d.ts +73 -0
- package/lib/live-qualification.d.ts.map +1 -0
- package/lib/live-qualification.js +164 -0
- package/lib/live-qualification.js.map +1 -0
- package/lib/owner-task-gaps.d.ts +2 -0
- package/lib/owner-task-gaps.d.ts.map +1 -1
- package/lib/owner-task-gaps.js +9 -1
- package/lib/owner-task-gaps.js.map +1 -1
- package/lib/runtime-epoch.d.ts +69 -0
- package/lib/runtime-epoch.d.ts.map +1 -0
- package/lib/runtime-epoch.js +139 -0
- package/lib/runtime-epoch.js.map +1 -0
- package/lib/runtime-observer.d.ts.map +1 -1
- package/lib/runtime-observer.js +42 -6
- package/lib/runtime-observer.js.map +1 -1
- package/lib/service.d.ts +247 -4
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1332 -27
- package/lib/service.js.map +1 -1
- package/lib/source-adoption-authority.d.ts +3 -0
- package/lib/source-adoption-authority.d.ts.map +1 -1
- package/lib/source-adoption-authority.js +13 -3
- package/lib/source-adoption-authority.js.map +1 -1
- package/lib/source-adoption-runner.d.ts +3 -0
- package/lib/source-adoption-runner.d.ts.map +1 -1
- package/lib/source-adoption-runner.js +16 -4
- package/lib/source-adoption-runner.js.map +1 -1
- package/lib/source-baseline.d.ts +37 -0
- package/lib/source-baseline.d.ts.map +1 -0
- package/lib/source-baseline.js +250 -0
- package/lib/source-baseline.js.map +1 -0
- package/lib/source-build.d.ts +17 -1
- package/lib/source-build.d.ts.map +1 -1
- package/lib/source-build.js +501 -7
- package/lib/source-build.js.map +1 -1
- package/lib/source-context.d.ts +18 -0
- package/lib/source-context.d.ts.map +1 -1
- package/lib/source-context.js +85 -11
- package/lib/source-context.js.map +1 -1
- package/lib/source-creation.d.ts +106 -0
- package/lib/source-creation.d.ts.map +1 -0
- package/lib/source-creation.js +664 -0
- package/lib/source-creation.js.map +1 -0
- package/lib/source-job-types.d.ts +19 -0
- package/lib/source-job-types.d.ts.map +1 -1
- package/lib/source-jobs.d.ts +30 -3
- package/lib/source-jobs.d.ts.map +1 -1
- package/lib/source-jobs.js +382 -40
- package/lib/source-jobs.js.map +1 -1
- package/lib/source-maintenance.d.ts +66 -0
- package/lib/source-maintenance.d.ts.map +1 -0
- package/lib/source-maintenance.js +177 -0
- package/lib/source-maintenance.js.map +1 -0
- package/lib/source-revision.d.ts +57 -0
- package/lib/source-revision.d.ts.map +1 -0
- package/lib/source-revision.js +175 -0
- package/lib/source-revision.js.map +1 -0
- package/lib/source-workspace.d.ts +19 -14
- package/lib/source-workspace.d.ts.map +1 -1
- package/lib/source-workspace.js +90 -17
- package/lib/source-workspace.js.map +1 -1
- package/lib/sqlite.d.ts +13 -3
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +362 -1
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +258 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2211 -95
- package/lib/store.js.map +1 -1
- package/lib/systemd-host-authority.d.ts +68 -0
- package/lib/systemd-host-authority.d.ts.map +1 -0
- package/lib/systemd-host-authority.js +424 -0
- package/lib/systemd-host-authority.js.map +1 -0
- package/lib/task-observation-evidence.d.ts +32 -0
- package/lib/task-observation-evidence.d.ts.map +1 -0
- package/lib/task-observation-evidence.js +62 -0
- package/lib/task-observation-evidence.js.map +1 -0
- package/lib/task-observation-runtime.d.ts +2 -3
- package/lib/task-observation-runtime.d.ts.map +1 -1
- package/lib/task-observation-runtime.js +7 -39
- package/lib/task-observation-runtime.js.map +1 -1
- package/lib/task-observation-store.js +1 -1
- 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 +43 -6
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +21 -14
package/lib/store.js
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
|
+
import { validateLiveQualificationTerms, assertLiveQualificationBatch, parseLiveQualificationReceipt, verifyLiveQualificationReceipt } from './live-qualification.js';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { basename, isAbsolute, posix, resolve, win32 } from 'node:path';
|
|
3
7
|
import { discover, parseCatalog } from './catalog.js';
|
|
4
8
|
import { parseApprovalReceipt } from './approval.js';
|
|
5
9
|
import { parseSourcePublishReconciliationReceipt, parseSourcePublishReconciliationRequest, parseSourceReleaseAuthorization, parseSourceReleaseReceipt, parseSourceReleaseRequest, parseVerifiedSourceReleaseAuthorization } from './release.js';
|
|
6
|
-
import { controlPlaneOperationReceiptDigest, controlPlaneSchemaVersion, openControlPlaneDatabase } from './sqlite.js';
|
|
10
|
+
import { controlPlaneOperationReceiptDigest, controlPlaneSchemaVersion, openControlPlaneDatabase, openControlPlaneDatabaseReadOnly } from './sqlite.js';
|
|
7
11
|
import { validateSourceBuildConfig } from './source-build.js';
|
|
12
|
+
import { validateSourceBaselineConfig, verifySourceBaselineHistory } from './source-baseline.js';
|
|
8
13
|
import { validateScopedPluginFiles } from './source-workspace.js';
|
|
14
|
+
import { validateCreationCapabilitySource } from './creation-capability-source.js';
|
|
15
|
+
import { validateSourceRevisionBinding } from './source-revision.js';
|
|
16
|
+
import { validateSourceCreationFiles, validateSourceCreationGrant } from './source-creation.js';
|
|
17
|
+
import { sourceGrowthRunDigest, validatePluginRevisionRegressionCertificate, validatePluginRevisionVerificationCertificate, validatePluginCreationVerificationCertificate, validateSourceGrowthRunBinding } from '@dsh-enhanced/assistant-growth-contract';
|
|
9
18
|
import { validateAdoptionHandoffTerms } from './adoption-handoff.js';
|
|
10
19
|
import { assertForegroundDeployment, assertForegroundTask } from './foreground-deployment.js';
|
|
11
20
|
import { assertTaskObservationBatch, getTaskObservationRecord, readTaskObservationContext } from './task-observation-store.js';
|
|
21
|
+
import { parseRuntimeEpochRequest, parseRuntimeEpochReceipt, verifyRuntimeEpochReceipt } from './runtime-epoch.js';
|
|
22
|
+
import { runtimeIdentityDigest } from './foreground-deployment.js';
|
|
23
|
+
import { readHostMaintenanceRecords, verifyHostMaintenanceChain, hostMaintenanceCanonical, hostMaintenanceDigest } from './host-maintenance.js';
|
|
24
|
+
import { parseSourceMaintenanceRecord, sameSourceMaintenancePublicKey, sourceMaintenanceDigest, sourceBaselineChain, verifySourceMaintenanceRecords } from './source-maintenance.js';
|
|
25
|
+
import { resolveTrustKey } from './trust.js';
|
|
12
26
|
const KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
|
|
13
27
|
const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
14
28
|
const PLUGIN_NAME = /^(?=.{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
15
29
|
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
16
30
|
const COMMIT = /^[a-f0-9]{40}$/u;
|
|
17
31
|
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;
|
|
32
|
+
const MAX_PREPARED_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
33
|
+
const MAX_STORED_ARTIFACT_BYTES = 128 * 1024 * 1024;
|
|
34
|
+
const MAX_STORED_ARTIFACTS = 256;
|
|
18
35
|
const SOURCE_STATUSES = new Set(['expired', 'pending-approval', 'approved', 'running-local-checks', 'ready-for-human-review',
|
|
19
36
|
'local-checks-failed', 'awaiting-pr', 'awaiting-review', 'awaiting-merge', 'awaiting-build', 'awaiting-sign', 'awaiting-publish',
|
|
20
37
|
'awaiting-registry-verify', 'awaiting-catalog-admission', 'release-complete', 'release-failed', 'publish-ambiguous']);
|
|
@@ -22,9 +39,26 @@ const SOURCE_PLAN_KEYS = ['schemaVersion', 'kind', 'id', 'gapId', 'gapSnapshot',
|
|
|
22
39
|
'expiresAt', 'digest', 'repository', 'worktree', 'baseCommit', 'name', 'generatorDigest', 'scope'];
|
|
23
40
|
const ACTIVATION_PLAN_KEYS = ['schemaVersion', 'kind', 'id', 'gapId', 'gapSnapshot', 'status', 'revision', 'createdAt',
|
|
24
41
|
'expiresAt', 'profile', 'candidate', 'dossier', 'installationId', 'ledger', 'target', 'executor', 'digest'];
|
|
25
|
-
const ACTIVATION_STATUSES = new Set(['pending-approval', 'approved', 'staging', 'awaiting-reload', 'awaiting-readiness',
|
|
42
|
+
const ACTIVATION_STATUSES = new Set(['pending-approval', 'approved', 'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks',
|
|
26
43
|
'awaiting-effect-blocked-replay', 'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending',
|
|
27
44
|
'rollback-pending', 'activated', 'rolled-back']);
|
|
45
|
+
export function validateHostDeploymentInputs(value) {
|
|
46
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 128) {
|
|
47
|
+
throw new ControlPlaneStoreError('invalid-input', 'Host deployment inputs require 1..128 relative paths');
|
|
48
|
+
}
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const input of value) {
|
|
51
|
+
if (typeof input !== 'string' || input.length === 0 || Buffer.byteLength(input) > 4096
|
|
52
|
+
|| input !== input.normalize('NFC') || input !== input.trim() || input.includes('\\')
|
|
53
|
+
|| [...input].some(character => { const point = character.codePointAt(0); return point < 32 || point === 127; })
|
|
54
|
+
|| posix.isAbsolute(input) || win32.parse(input).root !== ''
|
|
55
|
+
|| input.split('/').some(part => part === '' || part === '.' || part === '..')
|
|
56
|
+
|| posix.normalize(input) !== input || seen.has(input)) {
|
|
57
|
+
throw new ControlPlaneStoreError('invalid-input', 'Host deployment input is not a unique normalized relative path');
|
|
58
|
+
}
|
|
59
|
+
seen.add(input);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
28
62
|
export class ControlPlaneStoreError extends Error {
|
|
29
63
|
code;
|
|
30
64
|
constructor(code, message) {
|
|
@@ -33,6 +67,50 @@ export class ControlPlaneStoreError extends Error {
|
|
|
33
67
|
this.name = 'ControlPlaneStoreError';
|
|
34
68
|
}
|
|
35
69
|
}
|
|
70
|
+
function sourceMaintenanceGit(cwd, args) {
|
|
71
|
+
const result = spawnSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'protocol.allow=never',
|
|
72
|
+
'-c', 'gc.auto=0', ...args], { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 16384,
|
|
73
|
+
env: { LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
|
74
|
+
GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_COUNT: '0', GIT_ALLOW_PROTOCOL: 'file',
|
|
75
|
+
GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1' } });
|
|
76
|
+
if (result.error || result.status !== 0 || result.signal || typeof result.stdout !== 'string') {
|
|
77
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance Git evidence failed');
|
|
78
|
+
}
|
|
79
|
+
return result.stdout.trimEnd();
|
|
80
|
+
}
|
|
81
|
+
function sourceMaintenanceDirectRef(cwd, ref) {
|
|
82
|
+
const fields = sourceMaintenanceGit(cwd, ['for-each-ref', '--format=%(refname)%00%(objectname)%00%(symref)', ref]).split('\0');
|
|
83
|
+
if (fields.length !== 3 || fields[0] !== ref || !COMMIT.test(fields[1] ?? '') || fields[2] !== '') {
|
|
84
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance Git ref is missing or symbolic');
|
|
85
|
+
}
|
|
86
|
+
return fields[1];
|
|
87
|
+
}
|
|
88
|
+
function assertSourceMaintenanceGit(record, physicalHome, logicalHome, requireCurrentRefs = true) {
|
|
89
|
+
if (realpathSync(physicalHome) !== physicalHome)
|
|
90
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance physical Home is not canonical');
|
|
91
|
+
const project = (path) => {
|
|
92
|
+
if (!path.startsWith(`${logicalHome}/`))
|
|
93
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance Git path is outside Home');
|
|
94
|
+
return `${physicalHome}${path.slice(logicalHome.length)}`;
|
|
95
|
+
};
|
|
96
|
+
const repository = project(record.repository), remote = project(record.baseline.remote);
|
|
97
|
+
if (realpathSync(repository) !== repository || realpathSync(remote) !== remote
|
|
98
|
+
|| sourceMaintenanceGit(repository, ['rev-parse', '--show-toplevel']) !== repository
|
|
99
|
+
|| sourceMaintenanceGit(remote, ['rev-parse', '--is-bare-repository']) !== 'true') {
|
|
100
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance Git repository changed');
|
|
101
|
+
}
|
|
102
|
+
const branch = requireCurrentRefs ? sourceMaintenanceDirectRef(remote, `refs/heads/${record.baseline.targetBranch}`) : undefined;
|
|
103
|
+
const managed = requireCurrentRefs ? sourceMaintenanceDirectRef(repository, record.baseline.ref) : undefined;
|
|
104
|
+
if (requireCurrentRefs && (branch !== record.candidateTip || managed !== record.candidateTip)
|
|
105
|
+
|| sourceMaintenanceGit(repository, ['rev-parse', `${record.candidateTip}^{tree}`]) !== record.sourceTree) {
|
|
106
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance candidate Git tip or tree changed');
|
|
107
|
+
}
|
|
108
|
+
for (const [earlier, later] of [[record.previousTip, record.candidateTip], [record.upstreamCommit, record.candidateTip],
|
|
109
|
+
[record.before.sourceCommit, record.previousTip], [record.after.sourceCommit, record.candidateTip],
|
|
110
|
+
[record.before.sourceCommit, record.after.sourceCommit]]) {
|
|
111
|
+
sourceMaintenanceGit(repository, ['merge-base', '--is-ancestor', earlier, later]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
36
114
|
function canonical(value) {
|
|
37
115
|
if (Array.isArray(value))
|
|
38
116
|
return `[${value.map(canonical).join(',')}]`;
|
|
@@ -73,9 +151,45 @@ function positiveInteger(value, field) {
|
|
|
73
151
|
*/
|
|
74
152
|
export const MODIFY_GENERATOR_DIGEST = createHash('sha256').update('dsh-source-modify-no-generator-v1').digest('hex');
|
|
75
153
|
function expectedSourceScope(name, mode) {
|
|
76
|
-
return mode === 'create'
|
|
77
|
-
? Object.freeze(['plugins/README.md', `plugins/${name}
|
|
78
|
-
:
|
|
154
|
+
return mode === 'prepared-create' || mode === 'prepared-revise'
|
|
155
|
+
? Object.freeze(['plugins/README.md', `plugins/${name}`, 'pnpm-lock.yaml'].sort())
|
|
156
|
+
: mode === 'create'
|
|
157
|
+
? Object.freeze(['plugins/README.md', `plugins/${name}`].sort())
|
|
158
|
+
: Object.freeze([`plugins/${name}`]);
|
|
159
|
+
}
|
|
160
|
+
function creationBindingFromStored(value) {
|
|
161
|
+
const binding = objectRecord(value, 'source creation binding');
|
|
162
|
+
exactKeys(binding, ['grant', 'generatorDigest', ...(Object.hasOwn(binding, 'growthRun') ? ['growthRun'] : [])], 'source creation binding');
|
|
163
|
+
try {
|
|
164
|
+
validateSourceCreationGrant(binding['grant']);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new ControlPlaneStoreError('invalid-input', 'source creation grant is invalid');
|
|
168
|
+
}
|
|
169
|
+
if (typeof binding['generatorDigest'] !== 'string' || !DIGEST.test(binding['generatorDigest'])) {
|
|
170
|
+
throw new ControlPlaneStoreError('invalid-input', 'source creation generator digest is invalid');
|
|
171
|
+
}
|
|
172
|
+
let growthRun;
|
|
173
|
+
if (Object.hasOwn(binding, 'growthRun')) {
|
|
174
|
+
try {
|
|
175
|
+
validateSourceGrowthRunBinding(binding['growthRun']);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new ControlPlaneStoreError('invalid-input', 'source creation growth run is invalid');
|
|
179
|
+
}
|
|
180
|
+
growthRun = structuredClone(binding['growthRun']);
|
|
181
|
+
}
|
|
182
|
+
return Object.freeze({ grant: Object.freeze(structuredClone(binding['grant'])), generatorDigest: binding['generatorDigest'],
|
|
183
|
+
...(growthRun === undefined ? {} : { growthRun: Object.freeze(growthRun) }) });
|
|
184
|
+
}
|
|
185
|
+
function revisionBindingFromStored(value) {
|
|
186
|
+
try {
|
|
187
|
+
validateSourceRevisionBinding(value);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new ControlPlaneStoreError('invalid-input', 'source revision binding is invalid');
|
|
191
|
+
}
|
|
192
|
+
return Object.freeze(structuredClone(value));
|
|
79
193
|
}
|
|
80
194
|
function objectRecord(value, label) {
|
|
81
195
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -124,7 +238,8 @@ function activationSnapshotFromStored(value) {
|
|
|
124
238
|
exactKeys(gapSnapshot, ['revision', 'inputDigest', 'roi', 'capability'], 'stored activation gap snapshot');
|
|
125
239
|
const dossier = objectRecord(item['dossier'], 'stored activation dossier');
|
|
126
240
|
exactKeys(dossier, ['catalogDigest', 'catalogProvenance', 'matchedCapabilities', 'authorities', 'packages',
|
|
127
|
-
...(Object.hasOwn(dossier, 'handoff') ? ['handoff'] : [])
|
|
241
|
+
...(Object.hasOwn(dossier, 'handoff') ? ['handoff'] : []), ...(Object.hasOwn(dossier, 'liveQualification') ? ['liveQualification'] : []),
|
|
242
|
+
...(Object.hasOwn(dossier, 'hostDeploymentInputs') ? ['hostDeploymentInputs'] : [])], 'stored activation dossier');
|
|
128
243
|
const ledger = objectRecord(item['ledger'], 'stored activation ledger');
|
|
129
244
|
exactKeys(ledger, ['id', 'path'], 'stored activation ledger');
|
|
130
245
|
const target = objectRecord(item['target'], 'stored activation target');
|
|
@@ -157,6 +272,16 @@ function activationSnapshotFromStored(value) {
|
|
|
157
272
|
|| typeof executor['sha256'] !== 'string' || !DIGEST.test(executor['sha256'])) {
|
|
158
273
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation plan snapshot is corrupt');
|
|
159
274
|
}
|
|
275
|
+
if (dossier['liveQualification'] !== undefined)
|
|
276
|
+
validateLiveQualificationTerms(dossier['liveQualification']);
|
|
277
|
+
if (dossier['hostDeploymentInputs'] !== undefined) {
|
|
278
|
+
try {
|
|
279
|
+
validateHostDeploymentInputs(dossier['hostDeploymentInputs']);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored Host deployment inputs are corrupt');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
160
285
|
if (dossier['handoff'] !== undefined) {
|
|
161
286
|
try {
|
|
162
287
|
validateAdoptionHandoffTerms(dossier['handoff']);
|
|
@@ -300,6 +425,141 @@ function activationCoreFiles(value, targetPath, label) {
|
|
|
300
425
|
}
|
|
301
426
|
return value;
|
|
302
427
|
}
|
|
428
|
+
function hostInputPins(value, inputs, targetPath, label) {
|
|
429
|
+
if (!Array.isArray(value) || value.length !== inputs.length)
|
|
430
|
+
throw new ControlPlaneStoreError('invalid-state', `${label} does not cover signed inputs`);
|
|
431
|
+
const paths = new Set();
|
|
432
|
+
for (let index = 0; index < value.length; index++) {
|
|
433
|
+
const item = value[index];
|
|
434
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)
|
|
435
|
+
|| Object.keys(item).sort().join('\0') !== 'input\0path\0sha256' || item.input !== inputs[index]
|
|
436
|
+
|| typeof item.path !== 'string' || !isAbsolute(item.path) || resolve(item.path) !== item.path
|
|
437
|
+
|| !item.path.startsWith(`${targetPath}/`) || paths.has(item.path)
|
|
438
|
+
|| typeof item.sha256 !== 'string' || !DIGEST.test(item.sha256)) {
|
|
439
|
+
throw new ControlPlaneStoreError('invalid-state', `${label} contains an unbound or unsafe file pin`);
|
|
440
|
+
}
|
|
441
|
+
paths.add(item.path);
|
|
442
|
+
}
|
|
443
|
+
return value;
|
|
444
|
+
}
|
|
445
|
+
function hostInputWitnessFromRow(database, plan, project = true) {
|
|
446
|
+
const row = database.prepare('SELECT witness_json,witness_digest FROM activation_host_input_witnesses WHERE plan_id=?')
|
|
447
|
+
.get(plan.id);
|
|
448
|
+
if (!row)
|
|
449
|
+
return undefined;
|
|
450
|
+
if (Buffer.byteLength(row.witness_json) > 262_144)
|
|
451
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host input witness exceeds storage bound');
|
|
452
|
+
let witness;
|
|
453
|
+
try {
|
|
454
|
+
witness = JSON.parse(row.witness_json);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host input witness JSON is corrupt');
|
|
458
|
+
}
|
|
459
|
+
if (!witness || typeof witness !== 'object' || Array.isArray(witness)
|
|
460
|
+
|| Object.keys(witness).sort().join('\0') !== ['schemaVersion', 'kind', 'planId', 'planDigest', 'activationId', 'fence', 'createdAt',
|
|
461
|
+
'inputs', 'profileFiles', 'deploymentFiles', 'baselineDeploymentFiles', 'digest'].sort().join('\0')
|
|
462
|
+
|| witness.schemaVersion !== 1 || witness.kind !== 'dsh-host-input-witness'
|
|
463
|
+
|| !plan.dossier.hostDeploymentInputs || witness.planId !== plan.id || witness.planDigest !== plan.digest
|
|
464
|
+
|| !plan.activation || witness.activationId !== plan.activation.id || !Number.isSafeInteger(witness.fence) || witness.fence < 1
|
|
465
|
+
|| (witness.fence !== plan.activation.fence && !(['rollback-pending', 'rolled-back'].includes(plan.status) && witness.fence < plan.activation.fence))
|
|
466
|
+
|| !Number.isSafeInteger(witness.createdAt) || witness.createdAt < plan.createdAt
|
|
467
|
+
|| controlPlaneDigest(witness) !== row.witness_digest)
|
|
468
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host input witness identity changed');
|
|
469
|
+
const { digest, ...unsigned } = witness;
|
|
470
|
+
if (digest !== controlPlaneDigest(unsigned) || !Array.isArray(witness.inputs)
|
|
471
|
+
|| controlPlaneDigest(witness.inputs) !== controlPlaneDigest(plan.dossier.hostDeploymentInputs)) {
|
|
472
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host input witness digest or signed input set changed');
|
|
473
|
+
}
|
|
474
|
+
const core = activationCoreFiles(witness.profileFiles, plan.target.profilePath, 'Host input witness core files');
|
|
475
|
+
if (core.some(file => file.sha256 === null))
|
|
476
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host input witness has an absent core file');
|
|
477
|
+
hostInputPins(witness.deploymentFiles, witness.inputs, plan.target.profilePath, 'Host input witness deployment files');
|
|
478
|
+
if (plan.activation.targetOriginallyExisted) {
|
|
479
|
+
hostInputPins(witness.baselineDeploymentFiles, witness.inputs, plan.target.profilePath, 'Host input witness baseline files');
|
|
480
|
+
}
|
|
481
|
+
else if (!Array.isArray(witness.baselineDeploymentFiles) || witness.baselineDeploymentFiles.length !== 0) {
|
|
482
|
+
throw new ControlPlaneStoreError('invalid-state', 'absent original target has Host input baseline files');
|
|
483
|
+
}
|
|
484
|
+
if (!project)
|
|
485
|
+
return witness;
|
|
486
|
+
const latest = readHostMaintenanceContext(database, plan.id).records.at(-1);
|
|
487
|
+
if (!latest)
|
|
488
|
+
return witness;
|
|
489
|
+
const { digest: _digest, ...original } = witness;
|
|
490
|
+
const effective = { ...original, profileFiles: latest.after.profileFiles,
|
|
491
|
+
deploymentFiles: latest.after.deploymentFiles, baselineDeploymentFiles: latest.after.baselineDeploymentFiles };
|
|
492
|
+
return { ...effective, digest: controlPlaneDigest(effective) };
|
|
493
|
+
}
|
|
494
|
+
/** Offline update seam. Reads original history before verifying its maintenance chain. */
|
|
495
|
+
export function readHostMaintenanceContext(database, planId) {
|
|
496
|
+
const row = database.prepare('SELECT * FROM activation_plans WHERE id=?').get(planId);
|
|
497
|
+
if (!row)
|
|
498
|
+
throw new ControlPlaneStoreError('not-found', 'maintenance plan absent');
|
|
499
|
+
const plan = activationFromRow(row), records = readHostMaintenanceRecords(database, planId);
|
|
500
|
+
const witness = hostInputWitnessFromRow(database, plan, false);
|
|
501
|
+
const readinessRow = database.prepare(`SELECT operation.* FROM host_attestation_operations operation
|
|
502
|
+
JOIN host_attestations attestation ON attestation.plan_id=operation.plan_id AND attestation.phase=operation.phase
|
|
503
|
+
AND attestation.receipt_digest=operation.receipt_digest
|
|
504
|
+
WHERE operation.plan_id=? AND operation.phase='readiness' AND operation.status='applied'`).get(planId);
|
|
505
|
+
const readiness = readinessRow ? hostOperationFromRow(readinessRow).receipt : undefined;
|
|
506
|
+
if (records.length) {
|
|
507
|
+
if (!witness || !readiness || readiness.outcome !== 'passed')
|
|
508
|
+
throw new ControlPlaneStoreError('invalid-state', 'maintenance lost original readiness');
|
|
509
|
+
verifyHostMaintenanceChain(records, plan, readiness, witness);
|
|
510
|
+
const checkpoint = database.prepare('SELECT baseline_json FROM activation_deployment_checkpoints WHERE plan_id=?').get(plan.id);
|
|
511
|
+
if (!checkpoint || controlPlaneDigest(JSON.parse(String(checkpoint.baseline_json))) !== controlPlaneDigest(records[0].before.profileFiles)) {
|
|
512
|
+
throw new ControlPlaneStoreError('invalid-state', 'maintenance original deployment checkpoint changed');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { plan, witness, readiness, records };
|
|
516
|
+
}
|
|
517
|
+
/** Caller owns the stopped Home transaction; this append never rewrites historical evidence. */
|
|
518
|
+
export function appendHostMaintenance(database, record) {
|
|
519
|
+
database.exec('SAVEPOINT append_host_maintenance');
|
|
520
|
+
try {
|
|
521
|
+
// Acquire the SQLite writer reservation before reading the current lineage.
|
|
522
|
+
// A savepoint composes with the installer's enclosing offline transaction.
|
|
523
|
+
database.exec('UPDATE deployment_host_maintenance SET sequence=sequence WHERE 0');
|
|
524
|
+
appendHostMaintenanceLocked(database, record);
|
|
525
|
+
database.exec('RELEASE append_host_maintenance');
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
database.exec('ROLLBACK TO append_host_maintenance; RELEASE append_host_maintenance');
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function appendHostMaintenanceLocked(database, record) {
|
|
533
|
+
const context = readHostMaintenanceContext(database, record.plan.id), { plan, witness, readiness, records } = context;
|
|
534
|
+
const prior = records.find(item => item.transactionId === record.transactionId);
|
|
535
|
+
if (prior) {
|
|
536
|
+
if (hostMaintenanceDigest(prior) !== hostMaintenanceDigest(record))
|
|
537
|
+
throw new ControlPlaneStoreError('conflict', 'maintenance transaction changed');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const current = readCurrentRuntimeEpochDeployment(database, plan.target.profilePath);
|
|
541
|
+
if (current.plan.id !== plan.id)
|
|
542
|
+
throw new ControlPlaneStoreError('conflict', 'maintenance deployment was superseded');
|
|
543
|
+
const watch = database.prepare('SELECT state,activation_id,fence FROM activation_watch WHERE plan_id=?').get(plan.id);
|
|
544
|
+
if (plan.status !== 'activated' || !witness || !readiness || readiness.outcome !== 'passed'
|
|
545
|
+
|| watch?.state !== 'watching' || watch.activation_id !== plan.activation?.id || watch.fence !== plan.activation?.fence) {
|
|
546
|
+
throw new ControlPlaneStoreError('invalid-state', 'maintenance requires a watched successful deployment');
|
|
547
|
+
}
|
|
548
|
+
verifyHostMaintenanceChain([...records, record], plan, readiness, witness);
|
|
549
|
+
const checkpoint = database.prepare('SELECT baseline_json FROM activation_deployment_checkpoints WHERE plan_id=?').get(plan.id);
|
|
550
|
+
if (!checkpoint || controlPlaneDigest(JSON.parse(String(checkpoint.baseline_json))) !== controlPlaneDigest(witness.profileFiles)) {
|
|
551
|
+
throw new ControlPlaneStoreError('invalid-state', 'maintenance original deployment checkpoint changed');
|
|
552
|
+
}
|
|
553
|
+
database.prepare('INSERT INTO deployment_host_maintenance VALUES (?,?,?,?,?)')
|
|
554
|
+
.run(plan.id, record.sequence, record.transactionId, JSON.stringify(record), hostMaintenanceDigest(record));
|
|
555
|
+
}
|
|
556
|
+
function effectiveActivationFromRow(database, row) {
|
|
557
|
+
const plan = activationFromRow(row);
|
|
558
|
+
if (!database.prepare('SELECT 1 FROM deployment_host_maintenance WHERE plan_id=? LIMIT 1').get(plan.id))
|
|
559
|
+
return plan;
|
|
560
|
+
const latest = readHostMaintenanceContext(database, plan.id).records.at(-1);
|
|
561
|
+
return latest && plan.activation ? { ...plan, activation: { ...plan.activation, targetBaselineFiles: latest.after.baselineFiles } } : plan;
|
|
562
|
+
}
|
|
303
563
|
function activationFromRow(row) {
|
|
304
564
|
const candidate = parseCatalog({ schemaVersion: 1, entries: [JSON.parse(row.candidate_json)] }).entries[0];
|
|
305
565
|
const gapSnapshot = JSON.parse(row.gap_snapshot_json);
|
|
@@ -307,6 +567,16 @@ function activationFromRow(row) {
|
|
|
307
567
|
if (candidate === undefined || !DIGEST.test(row.plan_digest) || !UUID.test(row.installation_id)
|
|
308
568
|
|| !DIGEST.test(gapSnapshot.inputDigest) || !DIGEST.test(dossier.catalogDigest))
|
|
309
569
|
throw new ControlPlaneStoreError('invalid-state', 'stored activation plan is corrupt');
|
|
570
|
+
if (dossier.liveQualification !== undefined)
|
|
571
|
+
validateLiveQualificationTerms(dossier.liveQualification);
|
|
572
|
+
if (dossier.hostDeploymentInputs !== undefined) {
|
|
573
|
+
try {
|
|
574
|
+
validateHostDeploymentInputs(dossier.hostDeploymentInputs);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored Host deployment inputs are corrupt');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
310
580
|
if (dossier.handoff !== undefined) {
|
|
311
581
|
try {
|
|
312
582
|
validateAdoptionHandoffTerms(dossier.handoff);
|
|
@@ -388,15 +658,18 @@ function preparedEvidenceFromStored(value) {
|
|
|
388
658
|
const commands = item['commands'].map((entry, index) => {
|
|
389
659
|
const command = objectRecord(entry, `stored source prepared evidence command ${index}`);
|
|
390
660
|
exactKeys(command, ['command', 'args', 'exitCode', 'durationMs', 'logDigest'], `stored source prepared evidence command ${index}`);
|
|
661
|
+
const args = command['args'];
|
|
662
|
+
const scriptIndex = command['command'] === 'docker' && Array.isArray(args) && args.at(-2) === '-ceu'
|
|
663
|
+
? args.length - 1 : -1;
|
|
391
664
|
if (typeof command['command'] !== 'string' || command['command'] === '' || command['command'].length > 64
|
|
392
|
-
|| !Array.isArray(
|
|
393
|
-
||
|
|
665
|
+
|| !Array.isArray(args) || args.length > 96
|
|
666
|
+
|| args.some((arg, argIndex) => typeof arg !== 'string' || arg.length > (argIndex === scriptIndex ? 32_768 : 4096))
|
|
394
667
|
|| command['exitCode'] !== 0
|
|
395
668
|
|| !Number.isSafeInteger(command['durationMs']) || Number(command['durationMs']) < 0
|
|
396
669
|
|| !DIGEST.test(String(command['logDigest']))) {
|
|
397
670
|
throw new ControlPlaneStoreError('invalid-state', `stored source prepared evidence command ${index} is corrupt`);
|
|
398
671
|
}
|
|
399
|
-
return Object.freeze({ command: String(command['command']), args: Object.freeze([...
|
|
672
|
+
return Object.freeze({ command: String(command['command']), args: Object.freeze([...args]),
|
|
400
673
|
exitCode: 0, durationMs: Number(command['durationMs']), logDigest: String(command['logDigest']) });
|
|
401
674
|
});
|
|
402
675
|
const pack = objectRecord(item['pack'], 'stored source prepared evidence pack');
|
|
@@ -415,8 +688,21 @@ function preparedEvidenceFromStored(value) {
|
|
|
415
688
|
sizeBytes: Number(pack['sizeBytes']), sha256: String(pack['sha256']) }),
|
|
416
689
|
preparedAt: Number(item['preparedAt']) });
|
|
417
690
|
}
|
|
691
|
+
function preparedArtifactBytesFromRow(row, pack) {
|
|
692
|
+
if (row.pack_sha256 !== pack.sha256 || !Number.isSafeInteger(row.size_bytes)
|
|
693
|
+
|| row.size_bytes < 1 || row.size_bytes > MAX_PREPARED_ARTIFACT_BYTES
|
|
694
|
+
|| row.size_bytes !== pack.sizeBytes || !(row.bytes instanceof Uint8Array)
|
|
695
|
+
|| !Number.isSafeInteger(row.created_at) || row.created_at < 0) {
|
|
696
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored prepared source artifact metadata is corrupt');
|
|
697
|
+
}
|
|
698
|
+
const bytes = Buffer.from(row.bytes);
|
|
699
|
+
if (bytes.length !== row.size_bytes || createHash('sha256').update(bytes).digest('hex') !== pack.sha256) {
|
|
700
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored prepared source artifact bytes are corrupt');
|
|
701
|
+
}
|
|
702
|
+
return bytes;
|
|
703
|
+
}
|
|
418
704
|
function sourceFromRow(row) {
|
|
419
|
-
if (row.mode !== 'create' && row.mode !== 'modify') {
|
|
705
|
+
if (row.mode !== 'create' && row.mode !== 'modify' && row.mode !== 'prepared-create' && row.mode !== 'prepared-revise') {
|
|
420
706
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan has an unknown mode');
|
|
421
707
|
}
|
|
422
708
|
const gapSnapshot = JSON.parse(row.gap_snapshot_json);
|
|
@@ -439,9 +725,22 @@ function sourceFromRow(row) {
|
|
|
439
725
|
throw new ControlPlaneStoreError('invalid-state', 'stored source prepared evidence is corrupt');
|
|
440
726
|
}
|
|
441
727
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
728
|
+
let creation;
|
|
729
|
+
if (row.creation_json !== null) {
|
|
730
|
+
try {
|
|
731
|
+
creation = creationBindingFromStored(JSON.parse(row.creation_json));
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source creation binding is corrupt');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const revision = row.revision_json === null ? undefined : revisionBindingFromStored(JSON.parse(row.revision_json));
|
|
738
|
+
const prepared = row.mode === 'modify' || row.mode === 'prepared-create' || row.mode === 'prepared-revise';
|
|
739
|
+
if (prepared !== (preparedEvidence !== undefined) || (row.mode === 'prepared-create') !== (creation !== undefined) || (row.mode === 'prepared-revise') !== (revision !== undefined)
|
|
740
|
+
|| ((row.mode === 'prepared-create' || row.mode === 'prepared-revise') && (!['pending-approval', 'expired'].includes(row.status) || (creation ?? revision)?.generatorDigest !== row.generator_digest
|
|
741
|
+
|| row.approval_json !== null || row.release_authorization_json !== null || row.release_id !== null))
|
|
742
|
+
|| (prepared && (row.checked_tree_digest === null || row.checked_patch_digest === null || row.checked_at === null))
|
|
743
|
+
|| (prepared && ['running-local-checks', 'local-checks-failed'].includes(row.status))) {
|
|
445
744
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan mode does not match its evidence or status');
|
|
446
745
|
}
|
|
447
746
|
let approval;
|
|
@@ -463,8 +762,8 @@ function sourceFromRow(row) {
|
|
|
463
762
|
|| !Number.isSafeInteger(sourceCheck.checkedAt) || sourceCheck.checkedAt < 0))) {
|
|
464
763
|
throw new ControlPlaneStoreError('invalid-state', 'stored source check evidence is corrupt');
|
|
465
764
|
}
|
|
466
|
-
const digestBinding =
|
|
467
|
-
? { ...immutable, mode: row.mode, sourceCheck, preparedEvidence }
|
|
765
|
+
const digestBinding = prepared
|
|
766
|
+
? { ...immutable, mode: row.mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), sourceCheck, preparedEvidence }
|
|
468
767
|
: immutable;
|
|
469
768
|
if (controlPlaneDigest(digestBinding) !== row.plan_digest) {
|
|
470
769
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan is corrupt or digest-mismatched');
|
|
@@ -493,7 +792,7 @@ function sourceFromRow(row) {
|
|
|
493
792
|
&& (row.release_id === null || releaseAuthorization === undefined || sourceCheck === undefined))) {
|
|
494
793
|
throw new ControlPlaneStoreError('invalid-state', 'stored source release state is incomplete');
|
|
495
794
|
}
|
|
496
|
-
return { ...immutable, mode: row.mode, digest: row.plan_digest, status: row.status, revision: row.revision,
|
|
795
|
+
return { ...immutable, mode: row.mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), digest: row.plan_digest, status: row.status, revision: row.revision,
|
|
497
796
|
...(approval === undefined ? {} : { approval }),
|
|
498
797
|
...(sourceCheck === undefined ? {} : { sourceCheck }),
|
|
499
798
|
...(preparedEvidence === undefined ? {} : { preparedEvidence }),
|
|
@@ -507,14 +806,14 @@ function sourceSnapshotFromStored(value) {
|
|
|
507
806
|
// `mode` was introduced in schema v13; receipt rows written by older binaries
|
|
508
807
|
// omit it and are backfilled to 'create'. `preparedEvidence` exists only on
|
|
509
808
|
// 'modify' plans.
|
|
510
|
-
const optional = ['approval', 'sourceCheck', 'preparedEvidence', 'releaseAuthorization', 'release']
|
|
809
|
+
const optional = ['approval', 'sourceCheck', 'preparedEvidence', 'creation', 'sourceRevision', 'releaseAuthorization', 'release']
|
|
511
810
|
.filter(key => Object.hasOwn(item, key));
|
|
512
811
|
exactKeys(item, [...SOURCE_PLAN_KEYS, 'mode', ...optional], 'stored source plan snapshot');
|
|
513
812
|
const rawMode = item['mode'];
|
|
514
813
|
const mode = rawMode === undefined
|
|
515
814
|
? 'create'
|
|
516
|
-
: rawMode === 'create' || rawMode === 'modify' ? rawMode : 'create';
|
|
517
|
-
const modeValid = rawMode === undefined || rawMode === 'create' || rawMode === 'modify';
|
|
815
|
+
: rawMode === 'create' || rawMode === 'modify' || rawMode === 'prepared-create' || rawMode === 'prepared-revise' ? rawMode : 'create';
|
|
816
|
+
const modeValid = rawMode === undefined || rawMode === 'create' || rawMode === 'modify' || rawMode === 'prepared-create' || rawMode === 'prepared-revise';
|
|
518
817
|
const gapSnapshot = objectRecord(item['gapSnapshot'], 'stored source gap snapshot');
|
|
519
818
|
exactKeys(gapSnapshot, ['revision', 'inputDigest', 'roi', 'capability'], 'stored source gap snapshot');
|
|
520
819
|
if (item['schemaVersion'] !== 1 || item['kind'] !== 'source' || typeof item['id'] !== 'string'
|
|
@@ -527,8 +826,9 @@ function sourceSnapshotFromStored(value) {
|
|
|
527
826
|
|| typeof item['name'] !== 'string' || !PLUGIN_NAME.test(item['name']) || typeof item['generatorDigest'] !== 'string'
|
|
528
827
|
|| !DIGEST.test(item['generatorDigest']) || !Array.isArray(item['scope'])
|
|
529
828
|
|| controlPlaneDigest(item['scope']) !== controlPlaneDigest(expectedSourceScope(item['name'], mode))
|
|
530
|
-
|| (mode === 'modify'
|
|
531
|
-
|| (mode === 'create'
|
|
829
|
+
|| ((mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise') !== (item['preparedEvidence'] !== undefined))
|
|
830
|
+
|| ((mode === 'prepared-create') !== (item['creation'] !== undefined))
|
|
831
|
+
|| ((mode === 'prepared-revise') !== (item['sourceRevision'] !== undefined))
|
|
532
832
|
|| !Number.isSafeInteger(gapSnapshot['revision']) || Number(gapSnapshot['revision']) < 1
|
|
533
833
|
|| typeof gapSnapshot['inputDigest'] !== 'string' || !DIGEST.test(gapSnapshot['inputDigest'])
|
|
534
834
|
|| typeof gapSnapshot['roi'] !== 'number' || !Number.isFinite(gapSnapshot['roi']) || typeof gapSnapshot['capability'] !== 'string') {
|
|
@@ -538,6 +838,10 @@ function sourceSnapshotFromStored(value) {
|
|
|
538
838
|
repository: item['repository'], worktree: item['worktree'], baseCommit: item['baseCommit'], name: item['name'],
|
|
539
839
|
generatorDigest: item['generatorDigest'], scope: item['scope'], createdAt: item['createdAt'], expiresAt: item['expiresAt'] };
|
|
540
840
|
const preparedEvidence = item['preparedEvidence'] === undefined ? undefined : preparedEvidenceFromStored(item['preparedEvidence']);
|
|
841
|
+
const creation = item['creation'] === undefined ? undefined : creationBindingFromStored(item['creation']);
|
|
842
|
+
const revision = item['sourceRevision'] === undefined ? undefined : revisionBindingFromStored(item['sourceRevision']);
|
|
843
|
+
if ((mode === 'prepared-create' || mode === 'prepared-revise') && (creation ?? revision)?.generatorDigest !== item['generatorDigest'])
|
|
844
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source creation generator changed');
|
|
541
845
|
const approval = item['approval'] === undefined ? undefined : verifiedApprovalFromStored(item['approval'], item['id'], item['digest'], Number(item['createdAt']), Number(item['expiresAt']));
|
|
542
846
|
let sourceCheck;
|
|
543
847
|
if (item['sourceCheck'] !== undefined) {
|
|
@@ -550,7 +854,8 @@ function sourceSnapshotFromStored(value) {
|
|
|
550
854
|
}
|
|
551
855
|
sourceCheck = check;
|
|
552
856
|
}
|
|
553
|
-
const digestBinding = mode === 'modify'
|
|
857
|
+
const digestBinding = mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise'
|
|
858
|
+
? { ...immutable, mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), sourceCheck, preparedEvidence } : immutable;
|
|
554
859
|
if (controlPlaneDigest(digestBinding) !== item['digest'])
|
|
555
860
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot digest is corrupt');
|
|
556
861
|
let releaseAuthorization;
|
|
@@ -591,18 +896,19 @@ function sourceSnapshotFromStored(value) {
|
|
|
591
896
|
// running-local-checks / local-checks-failed states.
|
|
592
897
|
const postCheckStates = ['ready-for-human-review', 'release-complete',
|
|
593
898
|
'release-failed', 'publish-ambiguous'];
|
|
594
|
-
const requiresCheck = mode === 'modify'
|
|
899
|
+
const requiresCheck = mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise'
|
|
595
900
|
|| expectedSourceRelease(status) !== undefined || postCheckStates.includes(status);
|
|
596
901
|
const requiresRelease = expectedSourceRelease(status) !== undefined || ['release-complete', 'release-failed', 'publish-ambiguous'].includes(status);
|
|
597
|
-
if ((mode === 'modify' && (['running-local-checks', 'local-checks-failed'].includes(status) || sourceCheck === undefined))
|
|
902
|
+
if (((mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise') && (['running-local-checks', 'local-checks-failed'].includes(status) || sourceCheck === undefined))
|
|
903
|
+
|| ((mode === 'prepared-create' || mode === 'prepared-revise') && (!['pending-approval', 'expired'].includes(status) || approval !== undefined || releaseAuthorization !== undefined || release !== undefined))
|
|
598
904
|
|| (status !== 'expired' && requiresApproval !== (approval !== undefined))
|
|
599
|
-
|| (status === 'expired' && mode !== 'modify')
|
|
905
|
+
|| (status === 'expired' && mode !== 'modify' && mode !== 'prepared-create' && mode !== 'prepared-revise')
|
|
600
906
|
|| requiresCheck !== (sourceCheck !== undefined)
|
|
601
907
|
|| requiresRelease !== (releaseAuthorization !== undefined && release !== undefined)
|
|
602
908
|
|| (!requiresRelease && (releaseAuthorization !== undefined || release !== undefined))) {
|
|
603
909
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot fields do not match its status');
|
|
604
910
|
}
|
|
605
|
-
return { ...immutable, mode, digest: item['digest'], status: item['status'], revision: Number(item['revision']),
|
|
911
|
+
return { ...immutable, mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), digest: item['digest'], status: item['status'], revision: Number(item['revision']),
|
|
606
912
|
...(approval === undefined ? {} : { approval }), ...(sourceCheck === undefined ? {} : { sourceCheck }),
|
|
607
913
|
...(preparedEvidence === undefined ? {} : { preparedEvidence }),
|
|
608
914
|
...(releaseAuthorization === undefined ? {} : { releaseAuthorization }),
|
|
@@ -783,7 +1089,8 @@ function releaseArtifact(evidence) {
|
|
|
783
1089
|
function sourceJobIntentFromStored(value) {
|
|
784
1090
|
const intent = objectRecord(value, 'source job intent');
|
|
785
1091
|
exactKeys(intent, ['authority', 'owner', 'ownerDigest', 'trustDigest', 'repository', 'name', 'gapId', 'gapRevision', 'gapDigest',
|
|
786
|
-
'baseCommit', 'files', 'ttlMs', 'build', 'worktree', 'containerName'
|
|
1092
|
+
'baseCommit', 'files', 'ttlMs', 'build', 'worktree', 'containerName', ...(Object.hasOwn(intent, 'baseline') ? ['baseline'] : []),
|
|
1093
|
+
...(Object.hasOwn(intent, 'mode') ? ['mode'] : []), ...(Object.hasOwn(intent, 'creation') ? ['creation'] : []), ...(Object.hasOwn(intent, 'revision') ? ['revision'] : [])], 'source job intent');
|
|
787
1094
|
const authority = objectRecord(intent['authority'], 'source job authority');
|
|
788
1095
|
exactKeys(authority, ['id', 'digest', 'expiresAt', 'maxSubmissions'], 'source job authority');
|
|
789
1096
|
const owner = objectRecord(intent['owner'], 'source job owner');
|
|
@@ -805,7 +1112,10 @@ function sourceJobIntentFromStored(value) {
|
|
|
805
1112
|
|| typeof intent['baseCommit'] !== 'string' || !COMMIT.test(intent['baseCommit'])
|
|
806
1113
|
|| !Array.isArray(intent['files']) || intent['files'].length === 0 || intent['files'].length > 64
|
|
807
1114
|
|| !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'])
|
|
1115
|
+
|| typeof intent['containerName'] !== 'string' || !/^dsh-source-job-[a-f0-9]{64}$/u.test(intent['containerName'])
|
|
1116
|
+
|| (intent['mode'] !== undefined && intent['mode'] !== 'create' && intent['mode'] !== 'revise-created')
|
|
1117
|
+
|| (intent['mode'] === 'create') !== Object.hasOwn(intent, 'creation')
|
|
1118
|
+
|| (intent['mode'] === 'revise-created') !== Object.hasOwn(intent, 'revision')) {
|
|
809
1119
|
throw new ControlPlaneStoreError('invalid-input', 'source job intent is invalid');
|
|
810
1120
|
}
|
|
811
1121
|
if (Number(owner['principalVersion']) < 1 || Number(owner['bindingVersion']) < 1 || Number(owner['generation']) < 1) {
|
|
@@ -826,17 +1136,35 @@ function sourceJobIntentFromStored(value) {
|
|
|
826
1136
|
if (new Set(files.map(file => file.path)).size !== files.length)
|
|
827
1137
|
throw new ControlPlaneStoreError('invalid-input', 'source job files are duplicated');
|
|
828
1138
|
try {
|
|
829
|
-
|
|
1139
|
+
if (intent['mode'] === 'create' || intent['mode'] === 'revise-created')
|
|
1140
|
+
validateSourceCreationFiles(files);
|
|
1141
|
+
else
|
|
1142
|
+
validateScopedPluginFiles(files);
|
|
830
1143
|
}
|
|
831
1144
|
catch {
|
|
832
1145
|
throw new ControlPlaneStoreError('invalid-input', 'source job files are invalid');
|
|
833
1146
|
}
|
|
1147
|
+
const creation = intent['mode'] === 'create' ? creationBindingFromStored(intent['creation']) : undefined;
|
|
1148
|
+
const revision = intent['mode'] === 'revise-created' ? revisionBindingFromStored(intent['revision']) : undefined;
|
|
1149
|
+
if (revision !== undefined && !intent['name'].startsWith(revision.grant.namePrefix))
|
|
1150
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job plugin name is outside its revision grant');
|
|
1151
|
+
if (creation !== undefined && !intent['name'].startsWith(creation.grant.namePrefix)) {
|
|
1152
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job plugin name is outside its creation grant');
|
|
1153
|
+
}
|
|
834
1154
|
try {
|
|
835
1155
|
validateSourceBuildConfig(intent['build']);
|
|
836
1156
|
}
|
|
837
1157
|
catch {
|
|
838
1158
|
throw new ControlPlaneStoreError('invalid-input', 'source job build configuration is invalid');
|
|
839
1159
|
}
|
|
1160
|
+
if (Object.hasOwn(intent, 'baseline')) {
|
|
1161
|
+
try {
|
|
1162
|
+
validateSourceBaselineConfig(intent['baseline']);
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job baseline configuration is invalid');
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
840
1168
|
return Object.freeze({ authority: Object.freeze({ id: authority['id'], digest: authority['digest'], expiresAt: Number(authority['expiresAt']), maxSubmissions: Number(authority['maxSubmissions']) }),
|
|
841
1169
|
owner: Object.freeze({ receiptVersion: 2, authorityId: owner['authorityId'], authorityHash: owner['authorityHash'],
|
|
842
1170
|
principalId: owner['principalId'], principalRecordId: owner['principalRecordId'], principalVersion: Number(owner['principalVersion']),
|
|
@@ -844,7 +1172,10 @@ function sourceJobIntentFromStored(value) {
|
|
|
844
1172
|
ownerDigest: intent['ownerDigest'], trustDigest: intent['trustDigest'], repository: intent['repository'],
|
|
845
1173
|
name: intent['name'], gapId: intent['gapId'], gapRevision: Number(intent['gapRevision']), gapDigest: intent['gapDigest'],
|
|
846
1174
|
baseCommit: intent['baseCommit'], files: Object.freeze(files), ttlMs: Number(intent['ttlMs']), build: intent['build'],
|
|
847
|
-
worktree: intent['worktree'], containerName: intent['containerName']
|
|
1175
|
+
worktree: intent['worktree'], containerName: intent['containerName'],
|
|
1176
|
+
...(creation === undefined ? {} : { mode: 'create', creation }),
|
|
1177
|
+
...(revision === undefined ? {} : { mode: 'revise-created', revision }),
|
|
1178
|
+
...(Object.hasOwn(intent, 'baseline') ? { baseline: Object.freeze(intent['baseline']) } : {}) });
|
|
848
1179
|
}
|
|
849
1180
|
function exactInputRecord(value, label) {
|
|
850
1181
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -927,6 +1258,9 @@ function sourceJobFromRow(row) {
|
|
|
927
1258
|
|| !Number.isSafeInteger(row.created_at) || !Number.isSafeInteger(row.updated_at) || row.updated_at < row.created_at
|
|
928
1259
|
|| !['queued', 'running', 'prepared', 'failed', 'unknown'].includes(row.status)
|
|
929
1260
|
|| (row.definition_hash !== null && !DIGEST.test(row.definition_hash)) || (row.occurrence_id !== null && !KEY.test(row.occurrence_id))
|
|
1261
|
+
|| (row.dispatch_at !== null && (!Number.isSafeInteger(row.dispatch_at) || row.dispatch_at < row.created_at))
|
|
1262
|
+
|| (row.previous_definition_hash !== null && !DIGEST.test(row.previous_definition_hash))
|
|
1263
|
+
|| (row.previous_definition_version !== null && (!Number.isSafeInteger(row.previous_definition_version) || row.previous_definition_version < 1))
|
|
930
1264
|
|| (row.plan_id !== null && !/^source-[a-f0-9-]{36}$/u.test(row.plan_id)) || (row.failure_code !== null && !KEY.test(row.failure_code))) {
|
|
931
1265
|
throw new ControlPlaneStoreError('invalid-state', 'stored source job is corrupt');
|
|
932
1266
|
}
|
|
@@ -936,11 +1270,17 @@ function sourceJobFromRow(row) {
|
|
|
936
1270
|
|| (row.status === 'prepared' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id === null || row.failure_code !== null))
|
|
937
1271
|
|| (row.status === 'unknown' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id !== null || row.failure_code === null))
|
|
938
1272
|
|| (row.status === 'failed' && (row.plan_id !== null || row.failure_code === null))
|
|
939
|
-
|| (!active && row.status !== 'prepared' && row.plan_id !== null)
|
|
1273
|
+
|| (!active && row.status !== 'prepared' && row.plan_id !== null)
|
|
1274
|
+
|| (row.previous_definition_hash === null) !== (row.previous_definition_version === null)
|
|
1275
|
+
|| (row.previous_definition_hash !== null && (row.status !== 'queued' || row.definition_hash !== null || row.dispatch_at === null)))
|
|
940
1276
|
throw new ControlPlaneStoreError('invalid-state', 'stored source job state is corrupt');
|
|
941
1277
|
return Object.freeze({ id: row.id, automationId: row.automation_id, idempotencyKey: row.idempotency_key, intent, intentDigest: row.intent_digest,
|
|
942
1278
|
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 }),
|
|
1279
|
+
...(row.definition_hash === null ? {} : { definitionHash: row.definition_hash }),
|
|
1280
|
+
...(row.dispatch_at === null ? {} : { dispatchAt: row.dispatch_at }),
|
|
1281
|
+
...(row.previous_definition_hash === null ? {} : { previousDefinitionHash: row.previous_definition_hash }),
|
|
1282
|
+
...(row.previous_definition_version === null ? {} : { previousDefinitionVersion: row.previous_definition_version }),
|
|
1283
|
+
...(row.occurrence_id === null ? {} : { occurrenceId: row.occurrence_id }),
|
|
944
1284
|
...(row.plan_id === null ? {} : { planId: row.plan_id }), ...(row.failure_code === null ? {} : { failureCode: row.failure_code }) });
|
|
945
1285
|
}
|
|
946
1286
|
function readOwnerTaskFailureReference(database, gapId) {
|
|
@@ -1014,7 +1354,7 @@ export function readOwnerSourceAdoptionPlan(database, activationPlanId) {
|
|
|
1014
1354
|
const row = database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(activationPlanId);
|
|
1015
1355
|
if (!link || !row)
|
|
1016
1356
|
throw new ControlPlaneStoreError('not-found', 'owner source adoption binding absent');
|
|
1017
|
-
const plan =
|
|
1357
|
+
const plan = effectiveActivationFromRow(database, row), { plan: sourcePlan, source } = readOwnerPreparedSourcePlan(database, link.source_plan_id);
|
|
1018
1358
|
if (sourcePlan.status !== 'release-complete' || !sourcePlan.release || plan.gapId !== sourcePlan.gapId)
|
|
1019
1359
|
throw new ControlPlaneStoreError('invalid-state', 'source adoption requires a completed owner release');
|
|
1020
1360
|
const expected = adoptionBinding(sourcePlan, source, plan), released = readReleaseCandidate(database, sourcePlan);
|
|
@@ -1023,11 +1363,340 @@ export function readOwnerSourceAdoptionPlan(database, activationPlanId) {
|
|
|
1023
1363
|
throw new ControlPlaneStoreError('invalid-state', 'source adoption binding changed');
|
|
1024
1364
|
return { plan, sourcePlan, source, released };
|
|
1025
1365
|
}
|
|
1366
|
+
function runtimeEpochFromRow(row) {
|
|
1367
|
+
const request = parseRuntimeEpochRequest(JSON.parse(row.request_json));
|
|
1368
|
+
if (request.operationId !== row.operation_id || request.plan.id !== row.plan_id || request.sequence !== row.sequence
|
|
1369
|
+
|| request.runtimeIdentityDigest !== row.runtime_digest || controlPlaneDigest(request) !== row.request_digest
|
|
1370
|
+
|| request.requestedAt !== row.created_at || (row.status === 'applied') !== (row.receipt_json !== null)) {
|
|
1371
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch journal binding changed');
|
|
1372
|
+
}
|
|
1373
|
+
return { request, status: row.status, ...(row.receipt_json === null ? {} : { receipt: parseRuntimeEpochReceipt(JSON.parse(row.receipt_json)) }) };
|
|
1374
|
+
}
|
|
1375
|
+
/** Historical adoption remains immutable; only a currently watched deployment can acquire another runtime proof. */
|
|
1376
|
+
export function readCurrentRuntimeEpochDeployment(database, profilePath) {
|
|
1377
|
+
const unsettled = database.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND status IN (
|
|
1378
|
+
'staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow',
|
|
1379
|
+
'awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending') LIMIT 1`).get(profilePath);
|
|
1380
|
+
const latest = database.prepare(`SELECT checkpoint.plan_id,checkpoint.exposure_order,checkpoint.successful_order
|
|
1381
|
+
FROM activation_deployment_checkpoints checkpoint JOIN activation_plans plan ON plan.id=checkpoint.plan_id
|
|
1382
|
+
WHERE plan.target_path=? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(profilePath);
|
|
1383
|
+
if (unsettled || !latest || latest.exposure_order !== latest.successful_order) {
|
|
1384
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch requires the current successful deployment');
|
|
1385
|
+
}
|
|
1386
|
+
const adopted = readOwnerSourceAdoptionPlan(database, latest.plan_id), { plan } = adopted;
|
|
1387
|
+
const watch = database.prepare('SELECT state,activation_id,fence,last_host_generation FROM activation_watch WHERE plan_id=?').get(plan.id);
|
|
1388
|
+
if (plan.status !== 'activated' || !plan.activation || watch?.state !== 'watching'
|
|
1389
|
+
|| watch.activation_id !== plan.activation.id || watch.fence !== plan.activation.fence) {
|
|
1390
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch deployment is no longer watched');
|
|
1391
|
+
}
|
|
1392
|
+
const terms = plan.dossier.handoff;
|
|
1393
|
+
const handoffRow = database.prepare('SELECT * FROM adoption_handoffs WHERE plan_id=?').get(plan.id);
|
|
1394
|
+
if (!terms || !handoffRow || handoffRow.revoked_at !== null || handoffRow.plan_digest !== plan.digest
|
|
1395
|
+
|| handoffRow.coordinator_id !== terms.coordinatorId)
|
|
1396
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch handoff was revoked or changed');
|
|
1397
|
+
const handoff = { planId: plan.id, planDigest: plan.digest, coordinatorId: handoffRow.coordinator_id,
|
|
1398
|
+
createdAt: handoffRow.created_at, expiresAt: handoffRow.expires_at };
|
|
1399
|
+
const approvalRow = database.prepare('SELECT approval_receipt_json FROM activation_plans WHERE id=?').get(plan.id);
|
|
1400
|
+
const approvalReceipt = parseApprovalReceipt(JSON.parse(approvalRow.approval_receipt_json));
|
|
1401
|
+
if (!plan.approval || controlPlaneDigest(projectedApproval(approvalReceipt)) !== controlPlaneDigest(plan.approval)) {
|
|
1402
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch historical approval changed');
|
|
1403
|
+
}
|
|
1404
|
+
const witness = hostInputWitnessFromRow(database, plan);
|
|
1405
|
+
if (!witness || witness.activationId !== plan.activation.id || witness.fence !== plan.activation.fence) {
|
|
1406
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch deployment witness changed');
|
|
1407
|
+
}
|
|
1408
|
+
const readinessRow = database.prepare(`SELECT operation.* FROM host_attestation_operations operation
|
|
1409
|
+
JOIN host_attestations attestation ON attestation.plan_id=operation.plan_id AND attestation.phase=operation.phase
|
|
1410
|
+
AND attestation.receipt_digest=operation.receipt_digest
|
|
1411
|
+
WHERE operation.plan_id=? AND operation.phase='readiness' AND operation.status='applied'`).get(plan.id);
|
|
1412
|
+
if (!readinessRow)
|
|
1413
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch lacks original applied readiness');
|
|
1414
|
+
const readiness = hostOperationFromRow(readinessRow);
|
|
1415
|
+
if (!readiness.receipt || readiness.receipt.outcome !== 'passed' || readiness.receipt.activationId !== plan.activation.id
|
|
1416
|
+
|| readiness.receipt.fence !== plan.activation.fence || readiness.receipt.hostGeneration !== watch.last_host_generation) {
|
|
1417
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch original readiness changed');
|
|
1418
|
+
}
|
|
1419
|
+
return { ...adopted, handoff, approvalReceipt, witness, readiness,
|
|
1420
|
+
maintenance: readHostMaintenanceContext(database, plan.id).records };
|
|
1421
|
+
}
|
|
1422
|
+
/** Read-only authority seam; current request admission never extends the historical activation authorization. */
|
|
1423
|
+
export function readOwnerRuntimeEpochContext(database, operationId) {
|
|
1424
|
+
const row = database.prepare('SELECT * FROM deployment_runtime_epochs WHERE operation_id=?').get(operationId);
|
|
1425
|
+
if (!row)
|
|
1426
|
+
throw new ControlPlaneStoreError('not-found', 'runtime epoch operation absent');
|
|
1427
|
+
const record = runtimeEpochFromRow(row), request = record.request;
|
|
1428
|
+
if (record.status !== 'claimed')
|
|
1429
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch operation is not claimed');
|
|
1430
|
+
const context = readCurrentRuntimeEpochDeployment(database, request.profile.path), { plan, readiness } = context;
|
|
1431
|
+
const latest = database.prepare('SELECT max(sequence) AS sequence FROM deployment_runtime_epochs WHERE plan_id=?').get(plan.id);
|
|
1432
|
+
if (latest?.sequence !== request.sequence || request.plan.id !== plan.id || request.plan.digest !== plan.digest
|
|
1433
|
+
|| request.installationId !== plan.installationId || controlPlaneDigest(request.ledger) !== controlPlaneDigest(plan.ledger)
|
|
1434
|
+
|| request.profile.name !== plan.profile || request.activation.id !== plan.activation.id || request.activation.fence !== plan.activation.fence
|
|
1435
|
+
|| controlPlaneDigest(request.predecessor) !== controlPlaneDigest({ operationId: readiness.operationId,
|
|
1436
|
+
receiptDigest: controlPlaneDigest(readiness.receipt), hostGeneration: readiness.receipt.hostGeneration })) {
|
|
1437
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch request lost current deployment admission');
|
|
1438
|
+
}
|
|
1439
|
+
return { ...context, request, status: 'claimed', createdAt: row.created_at };
|
|
1440
|
+
}
|
|
1441
|
+
export function readOwnerHostAttestationContext(database, operationId) {
|
|
1442
|
+
if (database.prepare('PRAGMA user_version').get()?.user_version !== controlPlaneSchemaVersion
|
|
1443
|
+
|| typeof operationId !== 'string' || !KEY.test(operationId)) {
|
|
1444
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority database or operation identity is invalid');
|
|
1445
|
+
}
|
|
1446
|
+
const operationRow = database.prepare('SELECT * FROM host_attestation_operations WHERE operation_id=?')
|
|
1447
|
+
.get(operationId);
|
|
1448
|
+
if (!operationRow)
|
|
1449
|
+
throw new ControlPlaneStoreError('not-found', 'Host authority operation is absent');
|
|
1450
|
+
const parsed = hostOperationFromRow(operationRow);
|
|
1451
|
+
if (!isBoundHostRequest(parsed.request) || parsed.status !== 'pending' || parsed.receipt !== undefined
|
|
1452
|
+
|| operationRow.completed_at !== null || operationRow.applied_at !== null) {
|
|
1453
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority requires an uncompleted schema-2 operation');
|
|
1454
|
+
}
|
|
1455
|
+
const operation = parsed;
|
|
1456
|
+
const { plan, sourcePlan, source, released } = readOwnerSourceAdoptionPlan(database, operation.planId);
|
|
1457
|
+
const request = operation.request, activation = plan.activation, expected = expectedAttestation[plan.status];
|
|
1458
|
+
if (!activation || expected?.phase !== operation.phase || request.phase !== operation.phase
|
|
1459
|
+
|| request.operationId !== operation.operationId || request.plan.id !== plan.id || request.plan.digest !== plan.digest
|
|
1460
|
+
|| request.installationId !== plan.installationId || controlPlaneDigest(request.ledger) !== controlPlaneDigest(plan.ledger)
|
|
1461
|
+
|| request.profile.name !== plan.profile || request.profile.path !== plan.target.profilePath
|
|
1462
|
+
|| request.activation.id !== activation.id || request.activation.fence !== activation.fence
|
|
1463
|
+
|| request.requestedAt !== operation.createdAt || request.receiptTtlMs < 1_000 || request.receiptTtlMs > 300_000) {
|
|
1464
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority operation lost its current plan binding');
|
|
1465
|
+
}
|
|
1466
|
+
const dispatchRow = database.prepare('SELECT * FROM host_attestation_dispatches WHERE operation_id=?')
|
|
1467
|
+
.get(operationId);
|
|
1468
|
+
if (!dispatchRow || dispatchRow.status !== 'claimed' || dispatchRow.completed_at !== null
|
|
1469
|
+
|| !Number.isSafeInteger(dispatchRow.claimed_at) || dispatchRow.claimed_at < operation.createdAt) {
|
|
1470
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority operation was not durably claimed');
|
|
1471
|
+
}
|
|
1472
|
+
const handoffRow = database.prepare('SELECT * FROM adoption_handoffs WHERE plan_id=?').get(plan.id);
|
|
1473
|
+
const terms = plan.dossier.handoff;
|
|
1474
|
+
if (!handoffRow || !terms || handoffRow.plan_digest !== plan.digest || handoffRow.coordinator_id !== terms.coordinatorId
|
|
1475
|
+
|| !Number.isSafeInteger(handoffRow.created_at) || handoffRow.created_at < plan.createdAt
|
|
1476
|
+
|| handoffRow.expires_at !== Math.min(handoffRow.created_at + terms.maximumWindowMs, plan.expiresAt, plan.approval?.expiresAt ?? 0)) {
|
|
1477
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority handoff is not bound to the owner plan');
|
|
1478
|
+
}
|
|
1479
|
+
validateAdoptionHandoffTerms(terms);
|
|
1480
|
+
const handoff = { planId: plan.id, planDigest: plan.digest, coordinatorId: terms.coordinatorId,
|
|
1481
|
+
createdAt: handoffRow.created_at, expiresAt: handoffRow.expires_at,
|
|
1482
|
+
...(handoffRow.revoked_at === null ? {} : { revokedAt: handoffRow.revoked_at }) };
|
|
1483
|
+
const planRow = database.prepare('SELECT approval_receipt_json FROM activation_plans WHERE id=?').get(plan.id);
|
|
1484
|
+
let approvalReceipt;
|
|
1485
|
+
try {
|
|
1486
|
+
approvalReceipt = parseApprovalReceipt(JSON.parse(planRow.approval_receipt_json ?? 'null'));
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority approval receipt is corrupt');
|
|
1490
|
+
}
|
|
1491
|
+
if (!plan.approval || controlPlaneDigest(projectedApproval(approvalReceipt)) !== controlPlaneDigest(plan.approval)) {
|
|
1492
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority approval projection changed');
|
|
1493
|
+
}
|
|
1494
|
+
const rollback = operation.phase === 'rollback';
|
|
1495
|
+
const now = Date.now();
|
|
1496
|
+
if (!rollback && (now >= plan.expiresAt || now >= plan.approval.expiresAt || now >= handoff.expiresAt
|
|
1497
|
+
|| handoff.revokedAt !== undefined))
|
|
1498
|
+
throw new ControlPlaneStoreError('expired', 'Host authority forward authorization expired');
|
|
1499
|
+
const witness = hostInputWitnessFromRow(database, plan);
|
|
1500
|
+
if (!witness || witness.activationId !== activation.id || (!rollback && witness.fence !== activation.fence)) {
|
|
1501
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority lacks exact deployment witness');
|
|
1502
|
+
}
|
|
1503
|
+
if (rollback) {
|
|
1504
|
+
if (!activation.hostRecoveryRequired || !activation.rollbackProfileRestored || activation.targetBaselineFiles === undefined
|
|
1505
|
+
|| request.requirements.kind !== 'rollback'
|
|
1506
|
+
|| controlPlaneDigest(request.requirements.baselineFiles) !== controlPlaneDigest(activation.targetBaselineFiles)
|
|
1507
|
+
|| request.requirements.action !== (activation.targetOriginallyExisted ? 'restore' : 'stop')) {
|
|
1508
|
+
throw new ControlPlaneStoreError('conflict', 'Host rollback lacks a durable physical recovery obligation');
|
|
1509
|
+
}
|
|
1510
|
+
const checkpoint = database.prepare('SELECT exposure_order,successful_order FROM activation_deployment_checkpoints WHERE plan_id=?')
|
|
1511
|
+
.get(plan.id);
|
|
1512
|
+
const postActivated = ['post-activation-regressed', 'post-activation-retracted', 'live-qualification-invalidated']
|
|
1513
|
+
.includes(activation.failureCode ?? '');
|
|
1514
|
+
if (postActivated && (!checkpoint || checkpoint.exposure_order !== checkpoint.successful_order)) {
|
|
1515
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation rollback lacks a successful deployment checkpoint');
|
|
1516
|
+
}
|
|
1517
|
+
if (postActivated) {
|
|
1518
|
+
const readinessRow = database.prepare(`SELECT o.* FROM host_attestation_operations o
|
|
1519
|
+
JOIN host_attestations a ON a.plan_id=o.plan_id AND a.phase=o.phase AND a.receipt_digest=o.receipt_digest
|
|
1520
|
+
WHERE o.plan_id=? AND o.phase='readiness' AND o.status='applied'`).get(plan.id);
|
|
1521
|
+
const readiness = readinessRow && hostOperationFromRow(readinessRow);
|
|
1522
|
+
if (!readiness?.receipt || readiness.receipt.outcome !== 'passed'
|
|
1523
|
+
|| readiness.request.activation.id !== activation.id || readiness.request.activation.fence !== witness.fence
|
|
1524
|
+
|| readiness.receipt.activationId !== activation.id || readiness.receipt.fence !== witness.fence) {
|
|
1525
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation rollback lacks original applied readiness');
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const active = database.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND id<>? AND status IN
|
|
1529
|
+
('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow',
|
|
1530
|
+
'awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending') LIMIT 1`)
|
|
1531
|
+
.get(plan.target.profilePath, plan.id);
|
|
1532
|
+
const superseded = checkpoint && database.prepare(`SELECT 1 FROM activation_deployment_checkpoints AS c
|
|
1533
|
+
JOIN activation_plans AS p ON p.id=c.plan_id WHERE p.target_path=? AND c.successful_order IS NOT NULL
|
|
1534
|
+
AND c.exposure_order>? LIMIT 1`).get(plan.target.profilePath, checkpoint.exposure_order);
|
|
1535
|
+
if (active || superseded)
|
|
1536
|
+
throw new ControlPlaneStoreError('conflict', 'Host rollback target has a newer owner');
|
|
1537
|
+
}
|
|
1538
|
+
const latestRow = database.prepare(`SELECT max(generation) AS generation FROM (
|
|
1539
|
+
SELECT max(a.host_generation) AS generation FROM host_attestations a JOIN activation_plans p ON p.id=a.plan_id WHERE p.installation_id=?
|
|
1540
|
+
UNION ALL SELECT max(w.last_host_generation) AS generation FROM activation_watch w JOIN activation_plans p ON p.id=w.plan_id WHERE p.installation_id=?)`)
|
|
1541
|
+
.get(plan.installationId, plan.installationId);
|
|
1542
|
+
const latest = latestRow.generation ?? 0;
|
|
1543
|
+
if ((operation.phase === 'reload' && (request.predecessor !== null || request.requirements.kind !== 'reload'
|
|
1544
|
+
|| request.requirements.previousHostGeneration !== latest))
|
|
1545
|
+
|| (rollback && (request.requirements.kind !== 'rollback' || request.requirements.previousHostGeneration !== latest))) {
|
|
1546
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority generation changed after operation reservation');
|
|
1547
|
+
}
|
|
1548
|
+
if (operation.phase !== 'reload') {
|
|
1549
|
+
const predecessor = request.predecessor;
|
|
1550
|
+
const priorPhase = rollback ? undefined : predecessorPhase[operation.phase];
|
|
1551
|
+
const priorRows = database.prepare(`SELECT o.* FROM host_attestation_operations AS o
|
|
1552
|
+
JOIN host_attestations AS a ON a.plan_id=o.plan_id AND a.phase=o.phase AND a.receipt_digest=o.receipt_digest
|
|
1553
|
+
WHERE o.plan_id=? AND o.status='applied' ${priorPhase === undefined ? '' : 'AND o.phase=?'}
|
|
1554
|
+
ORDER BY o.applied_at DESC,
|
|
1555
|
+
CASE o.phase WHEN 'reload' THEN 1 WHEN 'readiness' THEN 2 WHEN 'effect-blocked-replay' THEN 3
|
|
1556
|
+
WHEN 'shadow' THEN 4 WHEN 'canary' THEN 5 WHEN 'soak' THEN 6 WHEN 'health' THEN 7 WHEN 'rollback' THEN 8 END DESC`)
|
|
1557
|
+
.all(...(priorPhase === undefined ? [plan.id] : [plan.id, priorPhase]));
|
|
1558
|
+
let actual = null;
|
|
1559
|
+
for (const priorRow of priorRows) {
|
|
1560
|
+
const prior = hostOperationFromRow(priorRow), receipt = prior.receipt;
|
|
1561
|
+
if (!receipt || priorRow.receipt_digest === null || prior.request.activation.id !== activation.id
|
|
1562
|
+
|| receipt.activationId !== activation.id
|
|
1563
|
+
|| (!rollback && (prior.request.activation.fence !== activation.fence || receipt.fence !== activation.fence))
|
|
1564
|
+
|| (rollback && (prior.request.activation.fence >= activation.fence || receipt.fence >= activation.fence)))
|
|
1565
|
+
continue;
|
|
1566
|
+
actual = { operationId: prior.operationId, receiptId: receipt.receiptId, phase: prior.phase,
|
|
1567
|
+
receiptDigest: priorRow.receipt_digest, hostGeneration: receipt.hostGeneration };
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
if (controlPlaneDigest(actual) !== controlPlaneDigest(predecessor)) {
|
|
1571
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority predecessor is no longer current');
|
|
1572
|
+
}
|
|
1573
|
+
if (predecessor !== null) {
|
|
1574
|
+
const prior = database.prepare(`SELECT o.*, a.host_generation AS applied_generation FROM host_attestation_operations o
|
|
1575
|
+
JOIN host_attestations a ON a.plan_id=o.plan_id AND a.phase=o.phase AND a.receipt_digest=o.receipt_digest
|
|
1576
|
+
WHERE o.operation_id=? AND o.plan_id=? AND o.status='applied'`).get(predecessor.operationId, plan.id);
|
|
1577
|
+
if (!prior)
|
|
1578
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority predecessor is not applied');
|
|
1579
|
+
const predecessorOperation = hostOperationFromRow(prior);
|
|
1580
|
+
if (!predecessorOperation.receipt || predecessorOperation.receipt.receiptId !== predecessor.receiptId
|
|
1581
|
+
|| predecessorOperation.phase !== predecessor.phase || prior.receipt_digest !== predecessor.receiptDigest
|
|
1582
|
+
|| prior.applied_generation !== predecessor.hostGeneration
|
|
1583
|
+
|| predecessorOperation.request.activation.id !== activation.id
|
|
1584
|
+
|| (!rollback && (predecessor.phase !== predecessorPhase[operation.phase] || predecessor.hostGeneration !== latest
|
|
1585
|
+
|| predecessorOperation.receipt.outcome !== 'passed'))
|
|
1586
|
+
|| (rollback && predecessorOperation.request.activation.fence >= activation.fence && witness.fence < activation.fence)) {
|
|
1587
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority predecessor binding changed');
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
else if (!rollback)
|
|
1591
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority normal phase lacks predecessor');
|
|
1592
|
+
}
|
|
1593
|
+
return { plan, sourcePlan, source, released, handoff, approvalReceipt, operation,
|
|
1594
|
+
dispatch: { status: 'claimed', claimedAt: dispatchRow.claimed_at }, witness };
|
|
1595
|
+
}
|
|
1596
|
+
function liveQualificationRecord(db, id) {
|
|
1597
|
+
const row = db.prepare('SELECT * FROM live_qualification_batches WHERE id=?').get(id);
|
|
1598
|
+
if (!row)
|
|
1599
|
+
return undefined;
|
|
1600
|
+
if (Buffer.byteLength(row.batch_json) > 262_144)
|
|
1601
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification batch too large');
|
|
1602
|
+
const batch = JSON.parse(row.batch_json);
|
|
1603
|
+
assertLiveQualificationBatch(batch);
|
|
1604
|
+
const receipt = row.receipt_json === null ? undefined : parseLiveQualificationReceipt(JSON.parse(row.receipt_json));
|
|
1605
|
+
if (row.id !== batch.id || row.plan_id !== batch.planId || row.batch_digest !== controlPlaneDigest(batch)
|
|
1606
|
+
|| (receipt && (row.receipt_digest !== controlPlaneDigest(receipt) || receipt.batchId !== batch.id || receipt.batchDigest !== batch.digest))
|
|
1607
|
+
|| (row.state === 'applied' && !receipt))
|
|
1608
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification journal changed');
|
|
1609
|
+
return { batch, state: row.state, ...(receipt ? { receipt } : {}) };
|
|
1610
|
+
}
|
|
1611
|
+
function liveQualificationReadiness(db, plan) {
|
|
1612
|
+
const row = db.prepare(`SELECT operation.* FROM host_attestation_operations operation
|
|
1613
|
+
JOIN host_attestations attestation ON attestation.plan_id=operation.plan_id AND attestation.phase=operation.phase
|
|
1614
|
+
AND attestation.receipt_digest=operation.receipt_digest
|
|
1615
|
+
WHERE operation.plan_id=? AND operation.phase='readiness' AND operation.status='applied'`).get(plan.id);
|
|
1616
|
+
if (!row)
|
|
1617
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification lacks applied readiness');
|
|
1618
|
+
const operation = hostOperationFromRow(row), receipt = operation.receipt;
|
|
1619
|
+
const generation = db.prepare('SELECT MAX(host_generation) AS generation FROM host_attestations WHERE plan_id IN (SELECT id FROM activation_plans WHERE installation_id=?)').get(plan.installationId);
|
|
1620
|
+
if (!receipt || receipt.outcome !== 'passed' || operation.request.schemaVersion !== 2
|
|
1621
|
+
|| receipt.planDigest !== plan.digest || receipt.activationId !== plan.activation?.id || receipt.fence !== plan.activation?.fence
|
|
1622
|
+
|| receipt.hostGeneration !== generation.generation || operation.request.activation.fence !== receipt.fence) {
|
|
1623
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification readiness generation changed');
|
|
1624
|
+
}
|
|
1625
|
+
return operation;
|
|
1626
|
+
}
|
|
1627
|
+
function liveQualificationWindow(db, plan) {
|
|
1628
|
+
const row = db.prepare('SELECT * FROM live_qualification_windows WHERE plan_id=?').get(plan.id);
|
|
1629
|
+
const terms = plan.dossier.liveQualification;
|
|
1630
|
+
if (!terms || !row || row.activation_id !== plan.activation?.id || row.fence !== plan.activation?.fence
|
|
1631
|
+
|| row.deadline_at <= row.started_at || row.deadline_at > row.started_at + terms.maximumWindowMs
|
|
1632
|
+
|| row.deadline_at > plan.expiresAt || row.deadline_at > (plan.approval?.expiresAt ?? 0)) {
|
|
1633
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification window changed');
|
|
1634
|
+
}
|
|
1635
|
+
const readiness = liveQualificationReadiness(db, plan);
|
|
1636
|
+
return { startedAt: row.started_at, deadlineAt: row.deadline_at,
|
|
1637
|
+
readinessDigest: controlPlaneDigest(readiness.receipt), hostGeneration: readiness.receipt.hostGeneration };
|
|
1638
|
+
}
|
|
1639
|
+
/** Independent signers use this read-only seam, never a migrating Store. */
|
|
1640
|
+
export function readLiveQualificationContext(db, id) {
|
|
1641
|
+
if (Number(db.prepare('PRAGMA user_version').get()?.user_version) !== controlPlaneSchemaVersion)
|
|
1642
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification schema differs');
|
|
1643
|
+
const record = liveQualificationRecord(db, id);
|
|
1644
|
+
if (!record)
|
|
1645
|
+
throw new ControlPlaneStoreError('not-found', 'live qualification batch absent');
|
|
1646
|
+
const { batch } = record;
|
|
1647
|
+
const { plan, source } = readOwnerSourceAdoptionPlan(db, batch.planId);
|
|
1648
|
+
const window = liveQualificationWindow(db, plan);
|
|
1649
|
+
if (!['awaiting-live-tasks', 'commit-pending', 'activated'].includes(plan.status)
|
|
1650
|
+
|| !plan.dossier.liveQualification || batch.planDigest !== plan.digest || batch.installationId !== plan.installationId
|
|
1651
|
+
|| batch.profilePath !== plan.target.profilePath || batch.activationId !== plan.activation?.id || batch.fence !== plan.activation?.fence
|
|
1652
|
+
|| controlPlaneDigest(batch.terms) !== controlPlaneDigest(plan.dossier.liveQualification)
|
|
1653
|
+
|| ['authorityId', 'authorityHash', 'principalId', 'principalRecordId', 'principalVersion', 'workspace', 'agentPreset'].some(key => batch.owner[key] !== source.owner[key])
|
|
1654
|
+
|| batch.startedAt !== window.startedAt || batch.deadlineAt !== window.deadlineAt
|
|
1655
|
+
|| batch.readinessDigest !== window.readinessDigest || batch.hostGeneration !== window.hostGeneration
|
|
1656
|
+
|| db.prepare('SELECT 1 FROM live_qualification_invalidations WHERE plan_id=?').get(plan.id)) {
|
|
1657
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification deployment or owner changed');
|
|
1658
|
+
}
|
|
1659
|
+
const competing = db.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND id<>? AND status IN
|
|
1660
|
+
('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow',
|
|
1661
|
+
'awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending')`).get(plan.target.profilePath, plan.id);
|
|
1662
|
+
if (competing)
|
|
1663
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification target superseded');
|
|
1664
|
+
if (plan.status === 'activated') {
|
|
1665
|
+
const latest = db.prepare(`SELECT checkpoint.plan_id FROM activation_deployment_checkpoints checkpoint
|
|
1666
|
+
JOIN activation_plans candidate ON candidate.id=checkpoint.plan_id WHERE candidate.target_path=? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(plan.target.profilePath);
|
|
1667
|
+
if (latest?.plan_id !== plan.id)
|
|
1668
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification deployment superseded');
|
|
1669
|
+
}
|
|
1670
|
+
const deployments = batch.votes.map(vote => {
|
|
1671
|
+
const row = db.prepare('SELECT record_json,record_digest,plan_id FROM foreground_deployments WHERE inbox_id=?').get(vote.inboxId);
|
|
1672
|
+
if (!row || row.plan_id !== plan.id || Buffer.byteLength(row.record_json) > 262_144)
|
|
1673
|
+
throw new ControlPlaneStoreError('invalid-state', 'live task witness absent');
|
|
1674
|
+
const deployment = JSON.parse(row.record_json);
|
|
1675
|
+
assertForegroundDeployment(deployment);
|
|
1676
|
+
if (controlPlaneDigest(deployment) !== row.record_digest || row.record_digest !== vote.deploymentDigest
|
|
1677
|
+
|| deployment.state !== 'observed' || !deployment.execution || !deployment.execution.quiescent
|
|
1678
|
+
|| deployment.execution.status !== 'succeeded' || deployment.execution.modelSelectionState !== 'frozen' || !deployment.execution.modelSelection
|
|
1679
|
+
|| deployment.task.inboxId !== vote.inboxId || deployment.execution.completedAt !== vote.completedAt
|
|
1680
|
+
|| deployment.task.dispatchedAt < window.startedAt || deployment.task.dispatchedAt >= window.deadlineAt
|
|
1681
|
+
|| vote.completedAt >= window.deadlineAt || deployment.task.owner.principalRecordId !== batch.owner.principalRecordId
|
|
1682
|
+
|| deployment.task.owner.principalVersion !== batch.owner.principalVersion || deployment.task.scope.workspace !== batch.owner.workspace
|
|
1683
|
+
|| deployment.task.scope.preset !== batch.owner.agentPreset || deployment.readiness.planDigest !== plan.digest
|
|
1684
|
+
|| deployment.readiness.planId !== plan.id || deployment.readiness.fence !== batch.fence
|
|
1685
|
+
|| deployment.readiness.hostGeneration !== batch.hostGeneration || deployment.readiness.receiptDigest !== batch.readinessDigest
|
|
1686
|
+
|| controlPlaneDigest(deployment.readiness.exact) !== controlPlaneDigest({ package: plan.candidate.package, version: plan.candidate.version, integrity: plan.candidate.integrity })) {
|
|
1687
|
+
throw new ControlPlaneStoreError('conflict', 'live task witness differs');
|
|
1688
|
+
}
|
|
1689
|
+
return deployment;
|
|
1690
|
+
});
|
|
1691
|
+
return { record, plan, source, deployments };
|
|
1692
|
+
}
|
|
1026
1693
|
export class ControlPlaneStore {
|
|
1027
1694
|
#database;
|
|
1028
1695
|
#now;
|
|
1029
1696
|
#ownerTaskFailureGapAdmission;
|
|
1030
1697
|
#foregroundDeploymentAdmission;
|
|
1698
|
+
#liveQualificationAdmission;
|
|
1699
|
+
#withLiveQualificationFence;
|
|
1031
1700
|
#withOwnerActivationFence;
|
|
1032
1701
|
#adoptionCoordinatorId;
|
|
1033
1702
|
constructor(options) {
|
|
@@ -1036,8 +1705,9 @@ export class ControlPlaneStore {
|
|
|
1036
1705
|
}
|
|
1037
1706
|
if (options.adoptionCoordinatorId && options.withOwnerActivationFence)
|
|
1038
1707
|
throw new ControlPlaneStoreError('invalid-input', 'adoption coordinator cannot hold owner fence');
|
|
1039
|
-
this.#database = openControlPlaneDatabase(options.path);
|
|
1708
|
+
this.#database = options.readOnly === true ? openControlPlaneDatabaseReadOnly(options.path) : openControlPlaneDatabase(options.path);
|
|
1040
1709
|
this.#now = options.now ?? Date.now;
|
|
1710
|
+
this.#withLiveQualificationFence = options.withLiveQualificationFence;
|
|
1041
1711
|
this.#withOwnerActivationFence = options.withOwnerActivationFence;
|
|
1042
1712
|
this.#adoptionCoordinatorId = options.adoptionCoordinatorId;
|
|
1043
1713
|
}
|
|
@@ -1177,7 +1847,7 @@ export class ControlPlaneStore {
|
|
|
1177
1847
|
this.#assertAdoptionHandoffBinding(planId, this.#adoptionCoordinatorId);
|
|
1178
1848
|
return callback();
|
|
1179
1849
|
}
|
|
1180
|
-
if (!['approved', 'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay',
|
|
1850
|
+
if (!['approved', 'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay',
|
|
1181
1851
|
'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health'].includes(plan.status)) {
|
|
1182
1852
|
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot advance this activation state');
|
|
1183
1853
|
}
|
|
@@ -1190,6 +1860,10 @@ export class ControlPlaneStore {
|
|
|
1190
1860
|
const current = this.getPlan(planId);
|
|
1191
1861
|
if (current.status === 'commit-pending' && current.dossier.handoff !== undefined)
|
|
1192
1862
|
this.#assertAdoptionHandoff(planId);
|
|
1863
|
+
if (current.status === 'commit-pending' && current.dossier.liveQualification) {
|
|
1864
|
+
this.#assertLiveQualificationCommit(planId);
|
|
1865
|
+
return callback();
|
|
1866
|
+
}
|
|
1193
1867
|
return callback();
|
|
1194
1868
|
};
|
|
1195
1869
|
if (this.#ownerTaskFailureGapAdmission === plan.gapId)
|
|
@@ -1240,9 +1914,17 @@ export class ControlPlaneStore {
|
|
|
1240
1914
|
const requestBinding = { operation: 'create-activation-plan', gapId: input.gapId, candidate, catalog: input.catalog,
|
|
1241
1915
|
matchedCapabilities, profile, target: input.target, installationId: input.installationId,
|
|
1242
1916
|
ledger: input.ledger, executor: input.executor, ttlMs: input.ttlMs,
|
|
1243
|
-
...(input.sourcePlanId === undefined ? {} : { sourcePlanId: input.sourcePlanId }), ...(input.handoff === undefined ? {} : { handoff: input.handoff }) }
|
|
1917
|
+
...(input.sourcePlanId === undefined ? {} : { sourcePlanId: input.sourcePlanId }), ...(input.handoff === undefined ? {} : { handoff: input.handoff }), ...(input.liveQualification === undefined ? {} : { liveQualification: input.liveQualification }),
|
|
1918
|
+
...(input.hostDeploymentInputs === undefined ? {} : { hostDeploymentInputs: input.hostDeploymentInputs }) };
|
|
1244
1919
|
this.#assertOwnerTaskFailureGapAdmission(input.gapId);
|
|
1245
1920
|
const source = input.sourcePlanId === undefined ? undefined : readOwnerPreparedSourcePlan(this.#database, input.sourcePlanId);
|
|
1921
|
+
if (input.liveQualification !== undefined) {
|
|
1922
|
+
validateLiveQualificationTerms(input.liveQualification);
|
|
1923
|
+
if (!source || !input.handoff)
|
|
1924
|
+
throw new ControlPlaneStoreError('invalid-input', 'bounded-live adoption requires an exact owner release and external coordinator');
|
|
1925
|
+
}
|
|
1926
|
+
if (input.hostDeploymentInputs !== undefined)
|
|
1927
|
+
validateHostDeploymentInputs(input.hostDeploymentInputs);
|
|
1246
1928
|
if (input.handoff !== undefined) {
|
|
1247
1929
|
validateAdoptionHandoffTerms(input.handoff);
|
|
1248
1930
|
if (!source)
|
|
@@ -1272,7 +1954,9 @@ export class ControlPlaneStore {
|
|
|
1272
1954
|
const gapSnapshot = Object.freeze({ revision: gap.revision, inputDigest: gap.inputDigest, roi: gap.roi, capability: gap.capability });
|
|
1273
1955
|
const dossier = Object.freeze({ catalogDigest: input.catalog.digest, catalogProvenance: input.catalog.provenance,
|
|
1274
1956
|
matchedCapabilities: Object.freeze(matchedCapabilities), authorities: Object.freeze([...candidate.authorities]), packages,
|
|
1275
|
-
...(input.handoff === undefined ? {} : { handoff: Object.freeze({ ...input.handoff }) })
|
|
1957
|
+
...(input.handoff === undefined ? {} : { handoff: Object.freeze({ ...input.handoff }) }),
|
|
1958
|
+
...(input.liveQualification === undefined ? {} : { liveQualification: Object.freeze({ ...input.liveQualification }) }),
|
|
1959
|
+
...(input.hostDeploymentInputs === undefined ? {} : { hostDeploymentInputs: Object.freeze([...input.hostDeploymentInputs]) }) });
|
|
1276
1960
|
const now = this.#now();
|
|
1277
1961
|
const id = `plugin-${randomUUID()}`;
|
|
1278
1962
|
const expiresAt = now + input.ttlMs;
|
|
@@ -1320,7 +2004,11 @@ export class ControlPlaneStore {
|
|
|
1320
2004
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(id);
|
|
1321
2005
|
if (row === undefined)
|
|
1322
2006
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1323
|
-
return
|
|
2007
|
+
return effectiveActivationFromRow(this.#database, row);
|
|
2008
|
+
}
|
|
2009
|
+
currentPlanExecutor(planId) {
|
|
2010
|
+
const context = readHostMaintenanceContext(this.#database, planId);
|
|
2011
|
+
return context.records.at(-1)?.after.executor ?? context.plan.executor;
|
|
1324
2012
|
}
|
|
1325
2013
|
findSourceAdoption(sourcePlanId) {
|
|
1326
2014
|
const row = this.#database.prepare('SELECT activation_plan_id FROM source_adoptions WHERE source_plan_id = ?').get(sourcePlanId);
|
|
@@ -1431,11 +2119,22 @@ export class ControlPlaneStore {
|
|
|
1431
2119
|
const intentDigest = controlPlaneDigest(intent);
|
|
1432
2120
|
const now = this.#now();
|
|
1433
2121
|
this.#assertOwnerTaskFailureGapAdmission(intent.gapId);
|
|
2122
|
+
if (intent.mode === 'create' && this.getOwnerTaskFailureReference(intent.gapId) !== undefined
|
|
2123
|
+
&& intent.creation?.growthRun === undefined) {
|
|
2124
|
+
throw new ControlPlaneStoreError('invalid-input', 'task-backed creation requires a frozen source growth run');
|
|
2125
|
+
}
|
|
1434
2126
|
if (intent.containerName !== `dsh-${input.id}` || basename(intent.worktree) !== `worktree-job-${input.id.slice('source-job-'.length)}`) {
|
|
1435
2127
|
throw new ControlPlaneStoreError('invalid-input', 'source job resource identity is invalid');
|
|
1436
2128
|
}
|
|
1437
2129
|
if (now >= intent.authority.expiresAt)
|
|
1438
2130
|
throw new ControlPlaneStoreError('expired', 'source job authority is expired');
|
|
2131
|
+
if (intent.creation !== undefined && now >= intent.creation.grant.expiresAt)
|
|
2132
|
+
throw new ControlPlaneStoreError('expired', 'source creation grant is expired');
|
|
2133
|
+
if (intent.revision !== undefined && (this.getOwnerTaskFailureReference(intent.gapId) === undefined || now >= intent.revision.grant.expiresAt))
|
|
2134
|
+
throw new ControlPlaneStoreError('expired', 'revision grant or owner task is unavailable');
|
|
2135
|
+
const growthRun = intent.creation?.growthRun ?? intent.revision?.growthRun;
|
|
2136
|
+
if (growthRun !== undefined && (now >= growthRun.generationDeadlineAt || now >= growthRun.expiresAt))
|
|
2137
|
+
throw new ControlPlaneStoreError('expired', 'source growth generation window is expired');
|
|
1439
2138
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
1440
2139
|
try {
|
|
1441
2140
|
const prior = this.#database.prepare('SELECT * FROM source_jobs WHERE authority_id = ? AND idempotency_key = ?')
|
|
@@ -1448,6 +2147,30 @@ export class ControlPlaneStore {
|
|
|
1448
2147
|
this.#database.exec('COMMIT');
|
|
1449
2148
|
return record;
|
|
1450
2149
|
}
|
|
2150
|
+
if (intent.baseline !== undefined) {
|
|
2151
|
+
const rows = this.#database.prepare(`SELECT * FROM source_jobs WHERE json_extract(intent_json, '$.repository') = ?
|
|
2152
|
+
AND json_type(intent_json, '$.baseline') IS NOT NULL AND status IN ('queued', 'running', 'unknown', 'prepared')
|
|
2153
|
+
LIMIT 1001`).all(intent.repository);
|
|
2154
|
+
if (rows.length > 1000)
|
|
2155
|
+
throw new ControlPlaneStoreError('invalid-state', 'managed source job cohort exceeds bound');
|
|
2156
|
+
for (const row of rows) {
|
|
2157
|
+
const competing = sourceJobFromRow(row);
|
|
2158
|
+
if (competing.status !== 'prepared')
|
|
2159
|
+
throw new ControlPlaneStoreError('conflict', 'managed source job is already active for repository');
|
|
2160
|
+
const prepared = competing.planId === undefined ? undefined : this.getSourcePlan(competing.planId);
|
|
2161
|
+
// A revision rebuilds its exact retained parent in a new private tree;
|
|
2162
|
+
// it cannot advance that parent's source release or alter the baseline.
|
|
2163
|
+
if (intent.revision && prepared?.id === intent.revision.parent.planId && prepared.mode === 'prepared-create') {
|
|
2164
|
+
const certificate = this.getCreationVerification(prepared.id);
|
|
2165
|
+
if (certificate && controlPlaneDigest(certificate) === intent.revision.parent.certificateDigest
|
|
2166
|
+
&& certificate.plan.artifactSha256 === intent.revision.parent.artifactSha256)
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
if (prepared === undefined || !['release-complete', 'release-failed', 'expired', 'local-checks-failed'].includes(prepared.status)) {
|
|
2170
|
+
throw new ControlPlaneStoreError('conflict', 'managed source job is awaiting a terminal source release');
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
1451
2174
|
const authority = this.#database.prepare('SELECT * FROM source_job_authorities WHERE authority_id = ?').get(intent.authority.id);
|
|
1452
2175
|
if (authority !== undefined && (authority.authority_digest !== intent.authority.digest || authority.expires_at !== intent.authority.expiresAt
|
|
1453
2176
|
|| authority.max_submissions !== intent.authority.maxSubmissions)) {
|
|
@@ -1461,6 +2184,36 @@ export class ControlPlaneStore {
|
|
|
1461
2184
|
}
|
|
1462
2185
|
this.#database.prepare('UPDATE source_job_authorities SET submissions = submissions + 1 WHERE authority_id = ? AND submissions < max_submissions')
|
|
1463
2186
|
.run(intent.authority.id);
|
|
2187
|
+
if (intent.creation !== undefined) {
|
|
2188
|
+
const grant = intent.creation.grant, digest = controlPlaneDigest(grant);
|
|
2189
|
+
const previous = this.#database.prepare('SELECT * FROM source_creation_grants WHERE grant_id = ?').get(grant.id);
|
|
2190
|
+
if (previous !== undefined && (previous.grant_digest !== digest || previous.expires_at !== grant.expiresAt
|
|
2191
|
+
|| previous.max_creates !== grant.maxCreates))
|
|
2192
|
+
throw new ControlPlaneStoreError('conflict', 'source creation grant definition is immutable');
|
|
2193
|
+
if (previous !== undefined && previous.creates >= previous.max_creates)
|
|
2194
|
+
throw new ControlPlaneStoreError('invalid-state', 'source creation grant quota is exhausted');
|
|
2195
|
+
if (previous === undefined)
|
|
2196
|
+
this.#database.prepare(`INSERT INTO source_creation_grants
|
|
2197
|
+
(grant_id, grant_digest, expires_at, max_creates, creates) VALUES (?, ?, ?, ?, 0)`)
|
|
2198
|
+
.run(grant.id, digest, grant.expiresAt, grant.maxCreates);
|
|
2199
|
+
const quota = this.#database.prepare('UPDATE source_creation_grants SET creates = creates + 1 WHERE grant_id = ? AND creates < max_creates').run(grant.id);
|
|
2200
|
+
if (Number(quota.changes) !== 1)
|
|
2201
|
+
throw new ControlPlaneStoreError('conflict', 'source creation grant quota changed');
|
|
2202
|
+
}
|
|
2203
|
+
if (intent.revision !== undefined) {
|
|
2204
|
+
const grant = intent.revision.grant, digest = controlPlaneDigest(grant);
|
|
2205
|
+
const previous = this.#database.prepare('SELECT * FROM source_revision_grants WHERE grant_id=?').get(grant.id);
|
|
2206
|
+
if (previous && (previous.grant_digest !== digest || previous.expires_at !== grant.expiresAt || previous.max_revisions !== grant.maxRevisions)) {
|
|
2207
|
+
throw new ControlPlaneStoreError('conflict', 'source revision grant definition is immutable');
|
|
2208
|
+
}
|
|
2209
|
+
if (previous && previous.revisions >= previous.max_revisions)
|
|
2210
|
+
throw new ControlPlaneStoreError('invalid-state', 'source revision grant quota is exhausted');
|
|
2211
|
+
if (!previous)
|
|
2212
|
+
this.#database.prepare('INSERT INTO source_revision_grants VALUES(?,?,?,?,0)').run(grant.id, digest, grant.expiresAt, grant.maxRevisions);
|
|
2213
|
+
const quota = this.#database.prepare('UPDATE source_revision_grants SET revisions=revisions+1 WHERE grant_id=? AND revisions<max_revisions').run(grant.id);
|
|
2214
|
+
if (Number(quota.changes) !== 1)
|
|
2215
|
+
throw new ControlPlaneStoreError('conflict', 'source revision grant quota changed');
|
|
2216
|
+
}
|
|
1464
2217
|
this.#database.prepare(`INSERT INTO source_jobs (id, automation_id, authority_id, idempotency_key, intent_json, intent_digest,
|
|
1465
2218
|
status, revision, created_at, expires_at, definition_hash, occurrence_id, plan_id, failure_code, updated_at)
|
|
1466
2219
|
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);
|
|
@@ -1491,34 +2244,67 @@ export class ControlPlaneStore {
|
|
|
1491
2244
|
created_at DESC, id DESC LIMIT ?`).all(limit).map(sourceJobFromRow);
|
|
1492
2245
|
}
|
|
1493
2246
|
/** Prepared owner continuations have a bounded recovery query, independent of job history. */
|
|
1494
|
-
listPreparedSourceApprovalJobs(includeRelease = false, includeExecution = false, includeAdoption = false) {
|
|
2247
|
+
listPreparedSourceApprovalJobs(includeRelease = false, includeExecution = false, includeAdoption = false, includeCreation = false, includeCreationAdoption = false, includeRevision = false, includeRegression = false) {
|
|
1495
2248
|
return this.#database.prepare(`SELECT j.* FROM source_jobs j JOIN source_plans p ON p.id = j.plan_id
|
|
1496
2249
|
JOIN owner_task_failure_gaps g ON g.gap_id = p.gap_id
|
|
1497
2250
|
LEFT JOIN source_adoptions a ON a.source_plan_id = p.id
|
|
1498
2251
|
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'))
|
|
2252
|
+
WHERE j.status = 'prepared' AND ((p.mode = 'modify' AND (p.status = 'pending-approval' OR (? = 1 AND p.status IN ('approved', 'ready-for-human-review'))
|
|
1500
2253
|
OR (? = 1 AND p.status IN ('awaiting-pr', 'awaiting-review', 'awaiting-merge', 'awaiting-build', 'awaiting-sign', 'awaiting-publish',
|
|
1501
2254
|
'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'))))
|
|
1503
|
-
|
|
2255
|
+
OR (? = 1 AND p.status = 'release-complete' AND (ap.id IS NULL OR ap.status NOT IN ('activated', 'rolled-back', 'rejected')))))
|
|
2256
|
+
OR (? = 1 AND p.mode = 'prepared-create' AND p.status = 'pending-approval'
|
|
2257
|
+
AND json_type(j.intent_json, '$.creation.growthRun.creationAcceptance') IS NOT NULL
|
|
2258
|
+
AND NOT EXISTS (SELECT 1 FROM source_creation_verifications v WHERE v.plan_id = p.id))
|
|
2259
|
+
OR (? = 1 AND p.mode = 'prepared-create' AND p.status = 'pending-approval'
|
|
2260
|
+
AND EXISTS (SELECT 1 FROM source_creation_verifications v WHERE v.plan_id = p.id AND v.status = 'verified'))
|
|
2261
|
+
OR (? = 1 AND p.mode = 'prepared-revise' AND p.status = 'pending-approval'
|
|
2262
|
+
AND json_type(j.intent_json, '$.revision.growthRun.revisionAcceptance') IS NOT NULL
|
|
2263
|
+
AND NOT EXISTS (SELECT 1 FROM source_revision_verifications v WHERE v.plan_id=p.id))
|
|
2264
|
+
OR (? = 1 AND p.mode = 'prepared-revise' AND p.status = 'pending-approval'
|
|
2265
|
+
AND json_type(j.intent_json, '$.revision.growthRun.revisionRegressionAcceptance') IS NOT NULL
|
|
2266
|
+
AND EXISTS (SELECT 1 FROM source_revision_verifications v WHERE v.plan_id=p.id AND v.status='verified')
|
|
2267
|
+
AND NOT EXISTS (SELECT 1 FROM source_revision_regressions v WHERE v.plan_id=p.id)))
|
|
2268
|
+
AND (p.expires_at > ? OR ap.status IN ('staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending'))
|
|
2269
|
+
ORDER BY j.created_at, j.id LIMIT 1000`).all(includeRelease ? 1 : 0, includeExecution ? 1 : 0, includeAdoption ? 1 : 0, includeCreation ? 1 : 0, includeCreationAdoption ? 1 : 0, includeRevision ? 1 : 0, includeRegression ? 1 : 0, this.#now()).map(sourceJobFromRow);
|
|
1504
2270
|
}
|
|
1505
2271
|
bindSourceJobDefinition(input) {
|
|
1506
2272
|
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.definitionHash))
|
|
1507
2273
|
throw new ControlPlaneStoreError('invalid-input', 'source job definition binding is invalid');
|
|
1508
2274
|
const now = this.#now();
|
|
1509
|
-
const result = this.#database.prepare(`UPDATE source_jobs SET definition_hash = ?,
|
|
2275
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET definition_hash = ?, previous_definition_hash = NULL,
|
|
2276
|
+
previous_definition_version = NULL, revision = revision + 1, updated_at = ?
|
|
1510
2277
|
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
2278
|
if (Number(result.changes) !== 1)
|
|
1512
2279
|
throw new ControlPlaneStoreError('conflict', 'source job definition binding lost its queued CAS');
|
|
1513
2280
|
return this.getSourceJob(input.id);
|
|
1514
2281
|
}
|
|
2282
|
+
/** Persist the only safe rearm: same queued job, with no claimed occurrence. */
|
|
2283
|
+
rearmQueuedGrowthSourceJob(input) {
|
|
2284
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.priorDefinitionHash)
|
|
2285
|
+
|| !Number.isSafeInteger(input.priorDefinitionVersion) || input.priorDefinitionVersion < 1
|
|
2286
|
+
|| !Number.isSafeInteger(input.dispatchAt) || input.dispatchAt <= this.#now()) {
|
|
2287
|
+
throw new ControlPlaneStoreError('invalid-input', 'source growth rearm request is invalid');
|
|
2288
|
+
}
|
|
2289
|
+
const now = this.#now();
|
|
2290
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET dispatch_at = ?, previous_definition_hash = definition_hash,
|
|
2291
|
+
previous_definition_version = ?,
|
|
2292
|
+
definition_hash = NULL, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ? AND status = 'queued'
|
|
2293
|
+
AND occurrence_id IS NULL AND definition_hash = ? AND expires_at > ? AND (json_type(intent_json, '$.creation.growthRun') IS NOT NULL OR json_type(intent_json, '$.revision.growthRun') IS NOT NULL)`).run(input.dispatchAt, input.priorDefinitionVersion, now, input.id, input.revision, input.priorDefinitionHash, now);
|
|
2294
|
+
if (Number(result.changes) !== 1)
|
|
2295
|
+
throw new ControlPlaneStoreError('conflict', 'source growth rearm lost its queued CAS');
|
|
2296
|
+
return this.getSourceJob(input.id);
|
|
2297
|
+
}
|
|
1515
2298
|
claimSourceJob(input) {
|
|
1516
2299
|
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.definitionHash) || !KEY.test(input.occurrenceId)) {
|
|
1517
2300
|
throw new ControlPlaneStoreError('invalid-input', 'source job claim is invalid');
|
|
1518
2301
|
}
|
|
1519
2302
|
const now = this.#now();
|
|
1520
2303
|
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 >
|
|
2304
|
+
WHERE id = ? AND revision = ? AND status = 'queued' AND definition_hash = ? AND occurrence_id IS NULL AND expires_at > ?
|
|
2305
|
+
AND (json_type(intent_json, '$.creation') IS NULL OR json_extract(intent_json, '$.creation.grant.expiresAt') > ?)
|
|
2306
|
+
AND (json_type(intent_json, '$.revision') IS NULL OR json_extract(intent_json, '$.revision.grant.expiresAt') > ?)`)
|
|
2307
|
+
.run(input.occurrenceId, now, input.id, input.revision, input.definitionHash, now, now, now);
|
|
1522
2308
|
if (Number(result.changes) !== 1)
|
|
1523
2309
|
throw new ControlPlaneStoreError('conflict', 'source job claim lost its queued CAS');
|
|
1524
2310
|
return this.getSourceJob(input.id);
|
|
@@ -1528,7 +2314,8 @@ export class ControlPlaneStore {
|
|
|
1528
2314
|
throw new ControlPlaneStoreError('invalid-input', 'source job settlement is invalid');
|
|
1529
2315
|
const permitted = input.status === 'unknown' ? "status = 'running'" : "status IN ('queued', 'running', 'unknown')";
|
|
1530
2316
|
const now = this.#now();
|
|
1531
|
-
const result = this.#database.prepare(`UPDATE source_jobs SET status = ?, failure_code = ?,
|
|
2317
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = ?, failure_code = ?,
|
|
2318
|
+
previous_definition_hash = NULL, previous_definition_version = NULL, revision = revision + 1, updated_at = ?
|
|
1532
2319
|
WHERE id = ? AND revision = ? AND ${permitted}`).run(input.status, input.failureCode, now, input.id, input.revision);
|
|
1533
2320
|
if (Number(result.changes) !== 1)
|
|
1534
2321
|
throw new ControlPlaneStoreError('conflict', 'source job settlement lost its state CAS');
|
|
@@ -1540,10 +2327,22 @@ export class ControlPlaneStore {
|
|
|
1540
2327
|
revision = revision + 1, updated_at = ? WHERE status = 'running'`).run(now);
|
|
1541
2328
|
return Number(result.changes);
|
|
1542
2329
|
}
|
|
2330
|
+
/** Revoke claimed task-created jobs when their Host run producer disappears. */
|
|
2331
|
+
interruptGrowthSourceJobs() {
|
|
2332
|
+
const now = this.#now();
|
|
2333
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = 'unknown', failure_code = 'source-growth-provider-disposed',
|
|
2334
|
+
revision = revision + 1, updated_at = ? WHERE status = 'running'
|
|
2335
|
+
AND (json_type(intent_json, '$.creation.growthRun') IS NOT NULL OR json_type(intent_json, '$.revision.growthRun') IS NOT NULL)`).run(now);
|
|
2336
|
+
return Number(result.changes);
|
|
2337
|
+
}
|
|
1543
2338
|
createSourcePlan(input) {
|
|
1544
2339
|
const mode = input.mode ?? 'create';
|
|
1545
|
-
if (
|
|
1546
|
-
throw new ControlPlaneStoreError('invalid-input', 'source
|
|
2340
|
+
if (mode !== 'create' && mode !== 'modify' && mode !== 'prepared-create' && mode !== 'prepared-revise')
|
|
2341
|
+
throw new ControlPlaneStoreError('invalid-input', 'source plan mode is invalid');
|
|
2342
|
+
if (input.sourceJob !== undefined && mode === 'create')
|
|
2343
|
+
throw new ControlPlaneStoreError('invalid-input', 'source jobs only complete checked modify or prepared-create plans');
|
|
2344
|
+
if ((mode === 'prepared-create' || mode === 'prepared-revise') && input.sourceJob === undefined)
|
|
2345
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared creation requires a running source job');
|
|
1547
2346
|
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
1548
2347
|
const name = bounded(input.name, 'name', 64);
|
|
1549
2348
|
if (!KEY.test(key) || !PLUGIN_NAME.test(name) || !COMMIT.test(input.baseCommit) || !DIGEST.test(input.generatorDigest)
|
|
@@ -1552,10 +2351,20 @@ export class ControlPlaneStore {
|
|
|
1552
2351
|
if (mode === 'modify' && input.generatorDigest !== MODIFY_GENERATOR_DIGEST) {
|
|
1553
2352
|
throw new ControlPlaneStoreError('invalid-input', 'modify source plans must bind the modify generator constant');
|
|
1554
2353
|
}
|
|
2354
|
+
const creation = input.creation === undefined ? undefined : creationBindingFromStored(input.creation);
|
|
2355
|
+
if ((mode === 'prepared-create') !== (creation !== undefined)
|
|
2356
|
+
|| (creation !== undefined && (creation.generatorDigest !== input.generatorDigest || !name.startsWith(creation.grant.namePrefix)))) {
|
|
2357
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source creation binding is invalid');
|
|
2358
|
+
}
|
|
2359
|
+
const revision = input.revision === undefined ? undefined : revisionBindingFromStored(input.revision);
|
|
2360
|
+
if ((mode === 'prepared-revise') !== (revision !== undefined)
|
|
2361
|
+
|| (revision !== undefined && (revision.generatorDigest !== input.generatorDigest || !name.startsWith(revision.grant.namePrefix)))) {
|
|
2362
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source revision binding is invalid');
|
|
2363
|
+
}
|
|
1555
2364
|
let preparedEvidence;
|
|
1556
|
-
if (mode === 'modify') {
|
|
2365
|
+
if (mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise') {
|
|
1557
2366
|
if (input.prepared === undefined) {
|
|
1558
|
-
throw new ControlPlaneStoreError('invalid-input', '
|
|
2367
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source plan requires check evidence');
|
|
1559
2368
|
}
|
|
1560
2369
|
if (!DIGEST.test(input.prepared.treeDigest) || !DIGEST.test(input.prepared.patchDigest)
|
|
1561
2370
|
|| !Number.isSafeInteger(input.prepared.checkedAt) || input.prepared.checkedAt < 0) {
|
|
@@ -1566,6 +2375,20 @@ export class ControlPlaneStore {
|
|
|
1566
2375
|
else if (input.prepared !== undefined) {
|
|
1567
2376
|
throw new ControlPlaneStoreError('invalid-input', 'create source plans cannot carry prepared evidence');
|
|
1568
2377
|
}
|
|
2378
|
+
let preparedArtifact;
|
|
2379
|
+
if (input.preparedArtifact !== undefined) {
|
|
2380
|
+
if (mode !== 'prepared-create' && mode !== 'prepared-revise' || input.sourceJob === undefined || !Buffer.isBuffer(input.preparedArtifact)
|
|
2381
|
+
|| preparedEvidence === undefined || input.preparedArtifact.length < 1
|
|
2382
|
+
|| input.preparedArtifact.length > MAX_PREPARED_ARTIFACT_BYTES
|
|
2383
|
+
|| input.preparedArtifact.length !== preparedEvidence.pack.sizeBytes
|
|
2384
|
+
|| createHash('sha256').update(input.preparedArtifact).digest('hex') !== preparedEvidence.pack.sha256) {
|
|
2385
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source artifact differs from checked package evidence');
|
|
2386
|
+
}
|
|
2387
|
+
if (this.getOwnerTaskFailureReference(input.gapId) === undefined) {
|
|
2388
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact requires an owner task failure gap');
|
|
2389
|
+
}
|
|
2390
|
+
preparedArtifact = Buffer.from(input.preparedArtifact);
|
|
2391
|
+
}
|
|
1569
2392
|
positiveInteger(input.ttlMs, 'ttlMs');
|
|
1570
2393
|
if (input.ttlMs < 60_000 || input.ttlMs > 86_400_000)
|
|
1571
2394
|
throw new ControlPlaneStoreError('invalid-input', 'ttlMs is invalid');
|
|
@@ -1582,11 +2405,24 @@ export class ControlPlaneStore {
|
|
|
1582
2405
|
this.#assertOwnerTaskFailureGapAdmission(input.gapId);
|
|
1583
2406
|
const sourceJobBinding = input.sourceJob === undefined ? undefined : { jobId: input.sourceJob.jobId, jobRevision: input.sourceJob.jobRevision,
|
|
1584
2407
|
occurrenceId: input.sourceJob.occurrenceId };
|
|
1585
|
-
const inputDigest = controlPlaneDigest({ ...(mode === '
|
|
1586
|
-
|
|
2408
|
+
const inputDigest = controlPlaneDigest({ ...(mode === 'create' ? requestBinding : { ...requestBinding, mode,
|
|
2409
|
+
...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { revision }), prepared: input.prepared }),
|
|
2410
|
+
...(sourceJobBinding === undefined ? {} : { sourceJob: sourceJobBinding }),
|
|
2411
|
+
...(preparedArtifact === undefined ? {} : { preparedArtifactSha256: preparedEvidence.pack.sha256 }) });
|
|
1587
2412
|
const prior = this.#sourcePlanReceiptByKey(key, 'create-source-plan', inputDigest);
|
|
1588
|
-
if (prior !== undefined)
|
|
2413
|
+
if (prior !== undefined) {
|
|
2414
|
+
if (preparedArtifact !== undefined) {
|
|
2415
|
+
const reference = this.#database.prepare('SELECT pack_sha256 FROM source_prepared_artifact_refs WHERE plan_id = ?')
|
|
2416
|
+
.get(prior.result.id);
|
|
2417
|
+
const row = this.#database.prepare('SELECT * FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
2418
|
+
.get(preparedEvidence.pack.sha256);
|
|
2419
|
+
if (reference?.pack_sha256 !== preparedEvidence.pack.sha256 || row === undefined
|
|
2420
|
+
|| !preparedArtifactBytesFromRow(row, preparedEvidence.pack).equals(preparedArtifact)) {
|
|
2421
|
+
throw new ControlPlaneStoreError('invalid-state', 'idempotent prepared source artifact is missing or changed');
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
1589
2424
|
return prior;
|
|
2425
|
+
}
|
|
1590
2426
|
const gap = this.getGap(input.gapId);
|
|
1591
2427
|
if (gap.status !== 'open' || gap.candidateId !== undefined) {
|
|
1592
2428
|
throw new ControlPlaneStoreError('invalid-state', 'only an unreserved open gap can create a source plan');
|
|
@@ -1598,8 +2434,8 @@ export class ControlPlaneStore {
|
|
|
1598
2434
|
const immutable = { schemaVersion: 1, kind: 'source', id, gapId: gap.id, gapSnapshot,
|
|
1599
2435
|
repository: input.repository, worktree: input.worktree, baseCommit: input.baseCommit, name,
|
|
1600
2436
|
generatorDigest: input.generatorDigest, scope, createdAt: now, expiresAt };
|
|
1601
|
-
const digest = controlPlaneDigest(mode
|
|
1602
|
-
? { ...immutable, mode, sourceCheck: { treeDigest: input.prepared.treeDigest, patchDigest: input.prepared.patchDigest,
|
|
2437
|
+
const digest = controlPlaneDigest(mode !== 'create'
|
|
2438
|
+
? { ...immutable, mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), sourceCheck: { treeDigest: input.prepared.treeDigest, patchDigest: input.prepared.patchDigest,
|
|
1603
2439
|
checkedAt: input.prepared.checkedAt }, preparedEvidence }
|
|
1604
2440
|
: immutable);
|
|
1605
2441
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
@@ -1615,11 +2451,42 @@ export class ControlPlaneStore {
|
|
|
1615
2451
|
|| sourceJob.occurrenceId !== sourceJobBinding.occurrenceId || sourceJob.intent.gapId !== gap.id
|
|
1616
2452
|
|| sourceJob.intent.gapRevision !== currentGap.revision || sourceJob.intent.gapDigest !== controlPlaneDigest(currentGap)
|
|
1617
2453
|
|| 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
|
|
2454
|
+
|| sourceJob.intent.repository !== input.repository || sourceJob.intent.worktree !== input.worktree || sourceJob.intent.ttlMs !== input.ttlMs
|
|
2455
|
+
|| (mode === 'prepared-create') !== (sourceJob.intent.mode === 'create')
|
|
2456
|
+
|| (mode === 'prepared-create' && controlPlaneDigest(sourceJob.intent.creation) !== controlPlaneDigest(creation))
|
|
2457
|
+
|| (mode === 'prepared-revise') !== (sourceJob.intent.mode === 'revise-created')
|
|
2458
|
+
|| (mode === 'prepared-revise' && controlPlaneDigest(sourceJob.intent.revision) !== controlPlaneDigest(revision))) {
|
|
1619
2459
|
throw new ControlPlaneStoreError('conflict', 'source job completion lost its immutable running binding');
|
|
1620
2460
|
}
|
|
1621
|
-
if (now >= sourceJob.expiresAt)
|
|
2461
|
+
if (now >= sourceJob.expiresAt || (creation !== undefined && now >= creation.grant.expiresAt) || (revision !== undefined && now >= revision.grant.expiresAt))
|
|
1622
2462
|
throw new ControlPlaneStoreError('expired', 'source job authority is expired');
|
|
2463
|
+
if (mode === 'prepared-create' && this.getOwnerTaskFailureReference(input.gapId) !== undefined
|
|
2464
|
+
&& (!creation?.growthRun || now >= creation.growthRun.expiresAt)) {
|
|
2465
|
+
throw new ControlPlaneStoreError('expired', 'task-backed creation growth run is absent or expired');
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
if (revision !== undefined && (this.getOwnerTaskFailureReference(input.gapId) === undefined || now >= revision.growthRun.expiresAt)) {
|
|
2469
|
+
throw new ControlPlaneStoreError('expired', 'revision requires a current owner task and frozen growth run');
|
|
2470
|
+
}
|
|
2471
|
+
if (preparedArtifact !== undefined) {
|
|
2472
|
+
const pack = preparedEvidence.pack;
|
|
2473
|
+
const existing = this.#database.prepare('SELECT * FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
2474
|
+
.get(pack.sha256);
|
|
2475
|
+
if (existing !== undefined) {
|
|
2476
|
+
if (!preparedArtifactBytesFromRow(existing, pack).equals(preparedArtifact)) {
|
|
2477
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored prepared source artifact differs from checked bytes');
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
const totals = this.#database.prepare('SELECT count(*) AS count, coalesce(sum(size_bytes), 0) AS total FROM source_prepared_artifacts')
|
|
2482
|
+
.get();
|
|
2483
|
+
if (!Number.isSafeInteger(totals.count) || !Number.isSafeInteger(totals.total)
|
|
2484
|
+
|| totals.count >= MAX_STORED_ARTIFACTS || totals.total > MAX_STORED_ARTIFACT_BYTES - preparedArtifact.length) {
|
|
2485
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact storage quota is exhausted');
|
|
2486
|
+
}
|
|
2487
|
+
this.#database.prepare('INSERT INTO source_prepared_artifacts (pack_sha256, size_bytes, bytes, created_at) VALUES (?, ?, ?, ?)')
|
|
2488
|
+
.run(pack.sha256, preparedArtifact.length, preparedArtifact, now);
|
|
2489
|
+
}
|
|
1623
2490
|
}
|
|
1624
2491
|
this.#database.prepare('INSERT INTO gap_plan_claims (gap_id, plan_id, plan_kind, claimed_at) VALUES (?, ?, ?, ?)').run(gap.id, id, 'source', now);
|
|
1625
2492
|
// 'modify' rows carry their checked digests and frozen-build evidence from
|
|
@@ -1627,8 +2494,12 @@ export class ControlPlaneStore {
|
|
|
1627
2494
|
// post-approval scaffold run finishes.
|
|
1628
2495
|
this.#database.prepare(`INSERT INTO source_plans (id, plan_digest, gap_id, gap_snapshot_json, repository, worktree,
|
|
1629
2496
|
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);
|
|
2497
|
+
approval_json, checked_tree_digest, checked_patch_digest, checked_at, prepared_evidence_json, creation_json, revision_json, updated_at)
|
|
2498
|
+
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), creation === undefined ? null : JSON.stringify(creation), revision === undefined ? null : JSON.stringify(revision), now);
|
|
2499
|
+
if (preparedArtifact !== undefined) {
|
|
2500
|
+
this.#database.prepare('INSERT INTO source_prepared_artifact_refs (plan_id, pack_sha256) VALUES (?, ?)')
|
|
2501
|
+
.run(id, preparedEvidence.pack.sha256);
|
|
2502
|
+
}
|
|
1632
2503
|
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
2504
|
if (Number(matched.changes) !== 1)
|
|
1634
2505
|
throw new ControlPlaneStoreError('conflict', 'capability gap changed while source plan was created');
|
|
@@ -1658,6 +2529,735 @@ export class ControlPlaneStore {
|
|
|
1658
2529
|
throw new ControlPlaneStoreError('not-found', 'source plan not found');
|
|
1659
2530
|
return sourceFromRow(row);
|
|
1660
2531
|
}
|
|
2532
|
+
/** Exact durable source job for a currently admitted prepared creation. */
|
|
2533
|
+
getPreparedSourceJob(planId) {
|
|
2534
|
+
const plan = this.getSourcePlan(planId);
|
|
2535
|
+
if (plan.mode !== 'prepared-create' || plan.status !== 'pending-approval'
|
|
2536
|
+
|| plan.creation === undefined || plan.preparedEvidence === undefined
|
|
2537
|
+
|| this.#now() >= plan.expiresAt || this.#now() >= plan.creation.grant.expiresAt) {
|
|
2538
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation requires a current pending plan');
|
|
2539
|
+
}
|
|
2540
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2541
|
+
if (owner === undefined)
|
|
2542
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation requires an owner task failure gap');
|
|
2543
|
+
this.#assertOwnerTaskFailureGapAdmission(plan.gapId);
|
|
2544
|
+
return this.getRetainedPreparedCreation(planId).job;
|
|
2545
|
+
}
|
|
2546
|
+
/** Read-only exact historical binding for an already signed adoption. Caller must hold the current owner fence. */
|
|
2547
|
+
getRetainedPreparedCreation(planId) {
|
|
2548
|
+
const plan = this.getSourcePlan(planId);
|
|
2549
|
+
if (plan.mode !== 'prepared-create' || !['pending-approval', 'expired'].includes(plan.status)
|
|
2550
|
+
|| plan.creation === undefined || plan.preparedEvidence === undefined || plan.sourceCheck === undefined) {
|
|
2551
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained creation requires exact prepared evidence');
|
|
2552
|
+
}
|
|
2553
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2554
|
+
if (owner === undefined)
|
|
2555
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained creation requires an owner task failure gap');
|
|
2556
|
+
const row = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ?')
|
|
2557
|
+
.get(plan.id);
|
|
2558
|
+
if (row === undefined)
|
|
2559
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation has no source job');
|
|
2560
|
+
const job = sourceJobFromRow(row);
|
|
2561
|
+
const gap = this.getGap(plan.gapId);
|
|
2562
|
+
if (job.status !== 'prepared' || job.planId !== plan.id || job.intent.mode !== 'create'
|
|
2563
|
+
|| job.intent.creation === undefined || job.intent.gapId !== plan.gapId
|
|
2564
|
+
|| job.intent.gapRevision !== plan.gapSnapshot.revision
|
|
2565
|
+
|| gap.inputDigest !== plan.gapSnapshot.inputDigest
|
|
2566
|
+
|| job.intent.ownerDigest !== controlPlaneDigest(owner.owner)
|
|
2567
|
+
|| controlPlaneDigest(job.intent.owner) !== job.intent.ownerDigest
|
|
2568
|
+
|| controlPlaneDigest(job.intent.creation) !== controlPlaneDigest(plan.creation)
|
|
2569
|
+
|| job.intent.creation.generatorDigest !== plan.generatorDigest
|
|
2570
|
+
|| job.intent.repository !== plan.repository || job.intent.worktree !== plan.worktree
|
|
2571
|
+
|| job.intent.baseCommit !== plan.baseCommit || job.intent.name !== plan.name
|
|
2572
|
+
|| plan.expiresAt !== plan.createdAt + job.intent.ttlMs) {
|
|
2573
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation source job binding is corrupt');
|
|
2574
|
+
}
|
|
2575
|
+
return { plan, job, reference: owner };
|
|
2576
|
+
}
|
|
2577
|
+
/** Exact durable source job for a currently admitted prepared revision. */
|
|
2578
|
+
getPreparedRevisionJob(planId) {
|
|
2579
|
+
const plan = this.getSourcePlan(planId);
|
|
2580
|
+
if (plan.mode !== 'prepared-revise' || plan.status !== 'pending-approval'
|
|
2581
|
+
|| plan.sourceRevision === undefined || plan.preparedEvidence === undefined
|
|
2582
|
+
|| this.#now() >= plan.expiresAt || this.#now() >= plan.sourceRevision.grant.expiresAt) {
|
|
2583
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision requires a current pending plan');
|
|
2584
|
+
}
|
|
2585
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2586
|
+
if (owner === undefined)
|
|
2587
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision requires an owner task failure gap');
|
|
2588
|
+
this.#assertOwnerTaskFailureGapAdmission(plan.gapId);
|
|
2589
|
+
return this.getRetainedPreparedRevision(planId).job;
|
|
2590
|
+
}
|
|
2591
|
+
/** Read-only exact historical binding for an already signed adoption. Caller must hold the current owner fence. */
|
|
2592
|
+
getRetainedPreparedRevision(planId) {
|
|
2593
|
+
const plan = this.getSourcePlan(planId);
|
|
2594
|
+
if (plan.mode !== 'prepared-revise' || !['pending-approval', 'expired'].includes(plan.status)
|
|
2595
|
+
|| plan.sourceRevision === undefined || plan.preparedEvidence === undefined || plan.sourceCheck === undefined) {
|
|
2596
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained revision requires exact prepared evidence');
|
|
2597
|
+
}
|
|
2598
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2599
|
+
if (owner === undefined)
|
|
2600
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained revision requires an owner task failure gap');
|
|
2601
|
+
const row = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ?')
|
|
2602
|
+
.get(plan.id);
|
|
2603
|
+
if (row === undefined)
|
|
2604
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision has no source job');
|
|
2605
|
+
const job = sourceJobFromRow(row);
|
|
2606
|
+
const gap = this.getGap(plan.gapId);
|
|
2607
|
+
if (job.status !== 'prepared' || job.planId !== plan.id || job.intent.mode !== 'revise-created'
|
|
2608
|
+
|| job.intent.revision === undefined || job.intent.gapId !== plan.gapId
|
|
2609
|
+
|| job.intent.gapRevision !== plan.gapSnapshot.revision
|
|
2610
|
+
|| gap.inputDigest !== plan.gapSnapshot.inputDigest
|
|
2611
|
+
|| job.intent.ownerDigest !== controlPlaneDigest(owner.owner)
|
|
2612
|
+
|| controlPlaneDigest(job.intent.owner) !== job.intent.ownerDigest
|
|
2613
|
+
|| controlPlaneDigest(job.intent.revision) !== controlPlaneDigest(plan.sourceRevision)
|
|
2614
|
+
|| job.intent.revision.generatorDigest !== plan.generatorDigest
|
|
2615
|
+
|| job.intent.repository !== plan.repository || job.intent.worktree !== plan.worktree
|
|
2616
|
+
|| job.intent.baseCommit !== plan.baseCommit || job.intent.name !== plan.name
|
|
2617
|
+
|| plan.expiresAt !== plan.createdAt + job.intent.ttlMs) {
|
|
2618
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision source job binding is corrupt');
|
|
2619
|
+
}
|
|
2620
|
+
return { plan, job, reference: owner };
|
|
2621
|
+
}
|
|
2622
|
+
/** Host-only package bytes for an owner-admitted, still-pending created plugin. */
|
|
2623
|
+
readPreparedSourceArtifact(planId) {
|
|
2624
|
+
const plan = this.getSourcePlan(planId);
|
|
2625
|
+
if (plan.mode !== 'prepared-create' && plan.mode !== 'prepared-revise' || plan.status !== 'pending-approval'
|
|
2626
|
+
|| plan.preparedEvidence === undefined || this.#now() >= plan.expiresAt) {
|
|
2627
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact requires a current pending creation plan');
|
|
2628
|
+
}
|
|
2629
|
+
if (plan.mode === 'prepared-revise')
|
|
2630
|
+
this.getPreparedRevisionJob(planId);
|
|
2631
|
+
else
|
|
2632
|
+
this.getPreparedSourceJob(planId);
|
|
2633
|
+
const pack = plan.preparedEvidence.pack;
|
|
2634
|
+
const reference = this.#database.prepare('SELECT pack_sha256 FROM source_prepared_artifact_refs WHERE plan_id = ?')
|
|
2635
|
+
.get(plan.id);
|
|
2636
|
+
if (reference?.pack_sha256 !== pack.sha256)
|
|
2637
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact reference is missing or corrupt');
|
|
2638
|
+
const row = this.#database.prepare('SELECT * FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
2639
|
+
.get(pack.sha256);
|
|
2640
|
+
if (row === undefined)
|
|
2641
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact is missing');
|
|
2642
|
+
return preparedArtifactBytesFromRow(row, pack);
|
|
2643
|
+
}
|
|
2644
|
+
/** One immutable, independently signed result per exact prepared creation. */
|
|
2645
|
+
recordCreationVerification(certificate) {
|
|
2646
|
+
validatePluginCreationVerificationCertificate(certificate);
|
|
2647
|
+
const plan = this.getSourcePlan(certificate.plan.id);
|
|
2648
|
+
const job = this.getPreparedSourceJob(plan.id);
|
|
2649
|
+
const reference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2650
|
+
if (!reference || !plan.sourceCheck || !plan.preparedEvidence || !plan.creation?.growthRun
|
|
2651
|
+
|| !job.intent.creation?.growthRun || plan.digest !== certificate.plan.digest || plan.name !== certificate.plan.name
|
|
2652
|
+
|| plan.sourceCheck.treeDigest !== certificate.plan.sourceTreeDigest
|
|
2653
|
+
|| plan.sourceCheck.patchDigest !== certificate.plan.sourcePatchDigest
|
|
2654
|
+
|| plan.preparedEvidence.pack.sha256 !== certificate.plan.artifactSha256
|
|
2655
|
+
|| plan.preparedEvidence.pack.sizeBytes !== certificate.plan.artifactBytes
|
|
2656
|
+
|| plan.generatorDigest !== certificate.plan.generatorDigest
|
|
2657
|
+
|| controlPlaneDigest(reference) !== certificate.source.referenceDigest
|
|
2658
|
+
|| controlPlaneDigest(reference.owner) !== certificate.source.ownerDigest
|
|
2659
|
+
|| sourceGrowthRunDigest(plan.creation.growthRun) !== certificate.source.growthRunDigest
|
|
2660
|
+
|| sourceGrowthRunDigest(job.intent.creation.growthRun) !== certificate.source.growthRunDigest
|
|
2661
|
+
|| controlPlaneDigest(plan.creation.growthRun.model) !== controlPlaneDigest(certificate.model)
|
|
2662
|
+
|| certificate.expiresAt > Math.min(plan.expiresAt, plan.creation.grant.expiresAt, plan.creation.growthRun.expiresAt, certificate.authority.expiresAt)) {
|
|
2663
|
+
throw new ControlPlaneStoreError('invalid-input', 'creation verification certificate does not match exact prepared source');
|
|
2664
|
+
}
|
|
2665
|
+
this.readPreparedSourceArtifact(plan.id);
|
|
2666
|
+
const serialized = JSON.stringify(certificate), certificateDigest = controlPlaneDigest(certificate);
|
|
2667
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2668
|
+
try {
|
|
2669
|
+
const previous = this.#database.prepare('SELECT status,certificate_digest FROM source_creation_verifications WHERE plan_id=?')
|
|
2670
|
+
.get(plan.id);
|
|
2671
|
+
if (previous?.status === 'verified' && previous.certificate_digest === certificateDigest) { /* idempotent */ }
|
|
2672
|
+
else if (previous?.status !== 'claimed')
|
|
2673
|
+
throw new ControlPlaneStoreError('conflict', 'creation verification was not claimed or already settled');
|
|
2674
|
+
else
|
|
2675
|
+
this.#database.prepare(`UPDATE source_creation_verifications SET status='verified',certificate_json=?,certificate_digest=?,updated_at=?
|
|
2676
|
+
WHERE plan_id=? AND status='claimed'`).run(serialized, certificateDigest, this.#now(), plan.id);
|
|
2677
|
+
this.#database.exec('COMMIT');
|
|
2678
|
+
}
|
|
2679
|
+
catch (error) {
|
|
2680
|
+
this.#database.exec('ROLLBACK');
|
|
2681
|
+
throw error;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
getCreationVerification(planId) {
|
|
2685
|
+
const row = this.#database.prepare('SELECT status,certificate_json,certificate_digest FROM source_creation_verifications WHERE plan_id=?')
|
|
2686
|
+
.get(planId);
|
|
2687
|
+
if (!row || row.status !== 'verified')
|
|
2688
|
+
return undefined;
|
|
2689
|
+
let certificate;
|
|
2690
|
+
try {
|
|
2691
|
+
const parsed = JSON.parse(row.certificate_json);
|
|
2692
|
+
validatePluginCreationVerificationCertificate(parsed);
|
|
2693
|
+
certificate = parsed;
|
|
2694
|
+
}
|
|
2695
|
+
catch {
|
|
2696
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored creation verification certificate is corrupt');
|
|
2697
|
+
}
|
|
2698
|
+
if (certificate.plan.id !== planId || controlPlaneDigest(certificate) !== row.certificate_digest) {
|
|
2699
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored creation verification certificate digest changed');
|
|
2700
|
+
}
|
|
2701
|
+
return certificate;
|
|
2702
|
+
}
|
|
2703
|
+
getCreationVerificationStatus(planId) {
|
|
2704
|
+
const row = this.#database.prepare('SELECT status FROM source_creation_verifications WHERE plan_id=?')
|
|
2705
|
+
.get(planId);
|
|
2706
|
+
return row?.status;
|
|
2707
|
+
}
|
|
2708
|
+
/** Content-free diagnostic projection; caller must hold current owner/source fence. */
|
|
2709
|
+
inspectCreationVerificationRecord(planId) {
|
|
2710
|
+
const row = this.#database.prepare('SELECT status,reason,updated_at FROM source_creation_verifications WHERE plan_id=?')
|
|
2711
|
+
.get(planId);
|
|
2712
|
+
if (!row)
|
|
2713
|
+
return undefined;
|
|
2714
|
+
if (!['claimed', 'verified', 'unknown', 'rejected'].includes(row.status)
|
|
2715
|
+
|| !Number.isSafeInteger(row.updated_at) || row.updated_at < 1
|
|
2716
|
+
|| (row.reason !== null && !/^[a-z][a-z0-9-]{0,79}$/u.test(row.reason))) {
|
|
2717
|
+
throw new ControlPlaneStoreError('invalid-state', 'creation verification diagnostic record is corrupt');
|
|
2718
|
+
}
|
|
2719
|
+
return { status: row.status, ...(row.reason === null ? {} : { reason: row.reason }), updatedAt: row.updated_at };
|
|
2720
|
+
}
|
|
2721
|
+
claimCreationVerification(planId) {
|
|
2722
|
+
const job = this.getPreparedSourceJob(planId);
|
|
2723
|
+
const pinned = job.intent.creation?.growthRun?.creationAcceptance;
|
|
2724
|
+
if (!pinned || this.#now() >= pinned.expiresAt) {
|
|
2725
|
+
throw new ControlPlaneStoreError('invalid-state', 'creation verification requires a current pre-author policy binding');
|
|
2726
|
+
}
|
|
2727
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2728
|
+
try {
|
|
2729
|
+
const count = this.#database.prepare('SELECT count(*) AS n FROM source_creation_verifications').get();
|
|
2730
|
+
if (count.n >= 256)
|
|
2731
|
+
throw new ControlPlaneStoreError('conflict', 'creation verification record limit reached');
|
|
2732
|
+
const now = this.#now();
|
|
2733
|
+
this.#database.prepare(`INSERT INTO source_creation_verifications
|
|
2734
|
+
(plan_id,status,certificate_json,certificate_digest,reason,created_at,updated_at) VALUES (?,'claimed',NULL,NULL,NULL,?,?)`)
|
|
2735
|
+
.run(planId, now, now);
|
|
2736
|
+
this.#database.exec('COMMIT');
|
|
2737
|
+
}
|
|
2738
|
+
catch (error) {
|
|
2739
|
+
this.#database.exec('ROLLBACK');
|
|
2740
|
+
throw error;
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
settleCreationVerification(planId, status, reason) {
|
|
2744
|
+
if (typeof reason !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/u.test(reason)) {
|
|
2745
|
+
throw new ControlPlaneStoreError('invalid-input', 'creation verification reason is invalid');
|
|
2746
|
+
}
|
|
2747
|
+
const result = this.#database.prepare(`UPDATE source_creation_verifications SET status=?,reason=?,updated_at=?
|
|
2748
|
+
WHERE plan_id=? AND status='claimed'`).run(status, reason, this.#now(), planId);
|
|
2749
|
+
if (Number(result.changes) !== 1)
|
|
2750
|
+
throw new ControlPlaneStoreError('conflict', 'creation verification claim is not current');
|
|
2751
|
+
}
|
|
2752
|
+
/** One immutable, independently signed result per exact prepared revision. */
|
|
2753
|
+
recordRevisionVerification(certificate, source) {
|
|
2754
|
+
validatePluginRevisionVerificationCertificate(certificate);
|
|
2755
|
+
const plan = this.getSourcePlan(certificate.plan.id);
|
|
2756
|
+
const job = this.getPreparedRevisionJob(plan.id);
|
|
2757
|
+
const reference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2758
|
+
validateCreationCapabilitySource(source, certificate);
|
|
2759
|
+
if (!reference || controlPlaneDigest(plan.sourceRevision?.parent) !== controlPlaneDigest(certificate.parent) || !plan.sourceCheck || !plan.preparedEvidence || !plan.sourceRevision?.growthRun
|
|
2760
|
+
|| !job.intent.revision?.growthRun || plan.digest !== certificate.plan.digest || plan.name !== certificate.plan.name
|
|
2761
|
+
|| plan.sourceCheck.treeDigest !== certificate.plan.sourceTreeDigest
|
|
2762
|
+
|| plan.sourceCheck.patchDigest !== certificate.plan.sourcePatchDigest
|
|
2763
|
+
|| plan.preparedEvidence.pack.sha256 !== certificate.plan.artifactSha256
|
|
2764
|
+
|| plan.preparedEvidence.pack.sizeBytes !== certificate.plan.artifactBytes
|
|
2765
|
+
|| plan.generatorDigest !== certificate.plan.generatorDigest
|
|
2766
|
+
|| controlPlaneDigest(reference) !== certificate.source.referenceDigest
|
|
2767
|
+
|| controlPlaneDigest(reference.owner) !== certificate.source.ownerDigest
|
|
2768
|
+
|| sourceGrowthRunDigest(plan.sourceRevision.growthRun) !== certificate.source.growthRunDigest
|
|
2769
|
+
|| sourceGrowthRunDigest(job.intent.revision.growthRun) !== certificate.source.growthRunDigest
|
|
2770
|
+
|| controlPlaneDigest(plan.sourceRevision.growthRun.model) !== controlPlaneDigest(certificate.model)
|
|
2771
|
+
|| certificate.expiresAt > Math.min(plan.expiresAt, plan.sourceRevision.grant.expiresAt, plan.sourceRevision.growthRun.expiresAt, certificate.authority.expiresAt)) {
|
|
2772
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision verification certificate does not match exact prepared source');
|
|
2773
|
+
}
|
|
2774
|
+
this.readPreparedSourceArtifact(plan.id);
|
|
2775
|
+
if (source.baseCommit !== plan.baseCommit || controlPlaneDigest(source.scope) !== controlPlaneDigest(plan.scope))
|
|
2776
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision source archive base or scope changed');
|
|
2777
|
+
const sourceJson = JSON.stringify(source);
|
|
2778
|
+
if (Buffer.byteLength(sourceJson) > 2_097_152)
|
|
2779
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision source archive exceeds bound');
|
|
2780
|
+
const serialized = JSON.stringify(certificate), certificateDigest = controlPlaneDigest(certificate);
|
|
2781
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2782
|
+
try {
|
|
2783
|
+
const previous = this.#database.prepare('SELECT status,certificate_digest FROM source_revision_verifications WHERE plan_id=?')
|
|
2784
|
+
.get(plan.id);
|
|
2785
|
+
if (previous?.status === 'verified' && previous.certificate_digest === certificateDigest) { /* idempotent */ }
|
|
2786
|
+
else if (previous?.status !== 'claimed')
|
|
2787
|
+
throw new ControlPlaneStoreError('conflict', 'revision verification was not claimed or already settled');
|
|
2788
|
+
else
|
|
2789
|
+
this.#database.prepare(`UPDATE source_revision_verifications SET status='verified',certificate_json=?,certificate_digest=?,updated_at=?
|
|
2790
|
+
WHERE plan_id=? AND status='claimed'`).run(serialized, certificateDigest, this.#now(), plan.id);
|
|
2791
|
+
const oldSource = this.#database.prepare('SELECT source_json FROM source_revision_sources WHERE plan_id=?').get(plan.id);
|
|
2792
|
+
if (oldSource && oldSource.source_json !== sourceJson)
|
|
2793
|
+
throw new ControlPlaneStoreError('conflict', 'revision source archive changed');
|
|
2794
|
+
if (!oldSource)
|
|
2795
|
+
this.#database.prepare('INSERT INTO source_revision_sources VALUES(?,?)').run(plan.id, sourceJson);
|
|
2796
|
+
this.#database.exec('COMMIT');
|
|
2797
|
+
}
|
|
2798
|
+
catch (error) {
|
|
2799
|
+
this.#database.exec('ROLLBACK');
|
|
2800
|
+
throw error;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
getRevisionVerification(planId) {
|
|
2804
|
+
const row = this.#database.prepare('SELECT status,certificate_json,certificate_digest FROM source_revision_verifications WHERE plan_id=?')
|
|
2805
|
+
.get(planId);
|
|
2806
|
+
if (!row || row.status !== 'verified')
|
|
2807
|
+
return undefined;
|
|
2808
|
+
let certificate;
|
|
2809
|
+
try {
|
|
2810
|
+
const parsed = JSON.parse(row.certificate_json);
|
|
2811
|
+
validatePluginRevisionVerificationCertificate(parsed);
|
|
2812
|
+
certificate = parsed;
|
|
2813
|
+
}
|
|
2814
|
+
catch {
|
|
2815
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision verification certificate is corrupt');
|
|
2816
|
+
}
|
|
2817
|
+
if (certificate.plan.id !== planId || controlPlaneDigest(certificate) !== row.certificate_digest) {
|
|
2818
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision verification certificate digest changed');
|
|
2819
|
+
}
|
|
2820
|
+
return certificate;
|
|
2821
|
+
}
|
|
2822
|
+
getRevisionVerificationStatus(planId) {
|
|
2823
|
+
const row = this.#database.prepare('SELECT status FROM source_revision_verifications WHERE plan_id=?')
|
|
2824
|
+
.get(planId);
|
|
2825
|
+
return row?.status;
|
|
2826
|
+
}
|
|
2827
|
+
/** Content-free diagnostic projection; caller must hold current owner/source fence. */
|
|
2828
|
+
inspectRevisionVerificationRecord(planId) {
|
|
2829
|
+
const row = this.#database.prepare('SELECT status,reason,updated_at FROM source_revision_verifications WHERE plan_id=?')
|
|
2830
|
+
.get(planId);
|
|
2831
|
+
if (!row)
|
|
2832
|
+
return undefined;
|
|
2833
|
+
if (!['claimed', 'verified', 'unknown', 'rejected'].includes(row.status)
|
|
2834
|
+
|| !Number.isSafeInteger(row.updated_at) || row.updated_at < 1
|
|
2835
|
+
|| (row.reason !== null && !/^[a-z][a-z0-9-]{0,79}$/u.test(row.reason))) {
|
|
2836
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision verification diagnostic record is corrupt');
|
|
2837
|
+
}
|
|
2838
|
+
return { status: row.status, ...(row.reason === null ? {} : { reason: row.reason }), updatedAt: row.updated_at };
|
|
2839
|
+
}
|
|
2840
|
+
claimRevisionVerification(planId) {
|
|
2841
|
+
const job = this.getPreparedRevisionJob(planId);
|
|
2842
|
+
const pinned = job.intent.revision?.growthRun?.revisionAcceptance;
|
|
2843
|
+
if (!pinned || this.#now() >= pinned.expiresAt) {
|
|
2844
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision verification requires a current pre-author policy binding');
|
|
2845
|
+
}
|
|
2846
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2847
|
+
try {
|
|
2848
|
+
const count = this.#database.prepare('SELECT count(*) AS n FROM source_revision_verifications').get();
|
|
2849
|
+
if (count.n >= 256)
|
|
2850
|
+
throw new ControlPlaneStoreError('conflict', 'revision verification record limit reached');
|
|
2851
|
+
const now = this.#now();
|
|
2852
|
+
this.#database.prepare(`INSERT INTO source_revision_verifications
|
|
2853
|
+
(plan_id,status,certificate_json,certificate_digest,reason,created_at,updated_at) VALUES (?,'claimed',NULL,NULL,NULL,?,?)`)
|
|
2854
|
+
.run(planId, now, now);
|
|
2855
|
+
this.#database.exec('COMMIT');
|
|
2856
|
+
}
|
|
2857
|
+
catch (error) {
|
|
2858
|
+
this.#database.exec('ROLLBACK');
|
|
2859
|
+
throw error;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
settleRevisionVerification(planId, status, reason) {
|
|
2863
|
+
if (typeof reason !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/u.test(reason)) {
|
|
2864
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision verification reason is invalid');
|
|
2865
|
+
}
|
|
2866
|
+
const result = this.#database.prepare(`UPDATE source_revision_verifications SET status=?,reason=?,updated_at=?
|
|
2867
|
+
WHERE plan_id=? AND status='claimed'`).run(status, reason, this.#now(), planId);
|
|
2868
|
+
if (Number(result.changes) !== 1)
|
|
2869
|
+
throw new ControlPlaneStoreError('conflict', 'revision verification claim is not current');
|
|
2870
|
+
}
|
|
2871
|
+
/** Purpose-separated paired held-out proof; never changes plan or adoption state. */
|
|
2872
|
+
recordRevisionRegression(certificate) {
|
|
2873
|
+
validatePluginRevisionRegressionCertificate(certificate);
|
|
2874
|
+
const plan = this.getSourcePlan(certificate.plan.id);
|
|
2875
|
+
const job = this.getPreparedRevisionJob(plan.id);
|
|
2876
|
+
const candidate = this.getRevisionVerification(plan.id);
|
|
2877
|
+
const parent = this.getCreationVerification(certificate.parent.planId);
|
|
2878
|
+
const source = this.readRevisionSource(plan.id);
|
|
2879
|
+
const reference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2880
|
+
const run = plan.sourceRevision?.growthRun;
|
|
2881
|
+
const previousJobs = new Set([...(parent?.observations ?? []), ...(candidate?.observations ?? [])].map(item => item.jobId));
|
|
2882
|
+
if (certificate.verifiedAt > this.#now() || certificate.expiresAt <= this.#now()
|
|
2883
|
+
|| !candidate || !parent || !source || !reference || !run?.revisionRegressionAcceptance
|
|
2884
|
+
|| !job.intent.revision?.growthRun.revisionRegressionAcceptance
|
|
2885
|
+
|| controlPlaneDigest(certificate.plan) !== controlPlaneDigest(candidate.plan)
|
|
2886
|
+
|| controlPlaneDigest(certificate.source) !== controlPlaneDigest(candidate.source)
|
|
2887
|
+
|| controlPlaneDigest(certificate.parent) !== controlPlaneDigest(candidate.parent)
|
|
2888
|
+
|| certificate.candidateVerificationDigest !== controlPlaneDigest(candidate)
|
|
2889
|
+
|| certificate.sourceDigest !== source.digest
|
|
2890
|
+
|| certificate.contractDigest !== parent.contractDigest
|
|
2891
|
+
|| certificate.schemaDigest !== candidate.schemaDigest
|
|
2892
|
+
|| controlPlaneDigest(certificate.environment) !== controlPlaneDigest(candidate.environment)
|
|
2893
|
+
|| controlPlaneDigest(certificate.environment) !== controlPlaneDigest(parent.environment)
|
|
2894
|
+
|| controlPlaneDigest(certificate.model) !== controlPlaneDigest(run.model)
|
|
2895
|
+
|| controlPlaneDigest(certificate.authority) !== controlPlaneDigest(run.revisionRegressionAcceptance)
|
|
2896
|
+
|| controlPlaneDigest(certificate.authority) !== controlPlaneDigest(job.intent.revision.growthRun.revisionRegressionAcceptance)
|
|
2897
|
+
|| certificate.source.referenceDigest !== controlPlaneDigest(reference)
|
|
2898
|
+
|| certificate.source.ownerDigest !== controlPlaneDigest(reference.owner)
|
|
2899
|
+
|| certificate.source.growthRunDigest !== sourceGrowthRunDigest(run)
|
|
2900
|
+
|| sourceGrowthRunDigest(run) !== sourceGrowthRunDigest(job.intent.revision.growthRun)
|
|
2901
|
+
|| certificate.observations.length !== parent.observations.length
|
|
2902
|
+
|| certificate.observations.some((item, index) => item.caseId !== parent.observations[index]?.caseId
|
|
2903
|
+
|| previousJobs.has(item.parent.jobId) || previousJobs.has(item.candidate.jobId))
|
|
2904
|
+
|| certificate.expiresAt > Math.min(candidate.expiresAt, plan.expiresAt, run.expiresAt, plan.sourceRevision.grant.expiresAt, certificate.authority.expiresAt)) {
|
|
2905
|
+
throw new ControlPlaneStoreError('invalid-input', 'regression certificate does not match exact parent and candidate');
|
|
2906
|
+
}
|
|
2907
|
+
this.readPreparedSourceArtifact(plan.id);
|
|
2908
|
+
const serialized = JSON.stringify(certificate), digest = controlPlaneDigest(certificate);
|
|
2909
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2910
|
+
try {
|
|
2911
|
+
const old = this.#database.prepare('SELECT status,certificate_digest FROM source_revision_regressions WHERE plan_id=?')
|
|
2912
|
+
.get(plan.id);
|
|
2913
|
+
if (old?.status === 'verified' && old.certificate_digest === digest) { /* immutable idempotent result */ }
|
|
2914
|
+
else if (old?.status !== 'claimed')
|
|
2915
|
+
throw new ControlPlaneStoreError('conflict', 'regression was not claimed or is settled');
|
|
2916
|
+
else
|
|
2917
|
+
this.#database.prepare(`UPDATE source_revision_regressions SET status='verified',certificate_json=?,certificate_digest=?,updated_at=?
|
|
2918
|
+
WHERE plan_id=? AND status='claimed'`).run(serialized, digest, this.#now(), plan.id);
|
|
2919
|
+
this.#database.exec('COMMIT');
|
|
2920
|
+
}
|
|
2921
|
+
catch (error) {
|
|
2922
|
+
this.#database.exec('ROLLBACK');
|
|
2923
|
+
throw error;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
getRevisionRegression(planId) {
|
|
2927
|
+
const row = this.#database.prepare('SELECT status,certificate_json,certificate_digest FROM source_revision_regressions WHERE plan_id=?')
|
|
2928
|
+
.get(planId);
|
|
2929
|
+
if (!row || row.status !== 'verified')
|
|
2930
|
+
return undefined;
|
|
2931
|
+
let certificate;
|
|
2932
|
+
try {
|
|
2933
|
+
const parsed = JSON.parse(row.certificate_json);
|
|
2934
|
+
validatePluginRevisionRegressionCertificate(parsed);
|
|
2935
|
+
certificate = parsed;
|
|
2936
|
+
}
|
|
2937
|
+
catch {
|
|
2938
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision regression certificate is corrupt');
|
|
2939
|
+
}
|
|
2940
|
+
if (certificate.plan.id !== planId || controlPlaneDigest(certificate) !== row.certificate_digest) {
|
|
2941
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision regression certificate digest changed');
|
|
2942
|
+
}
|
|
2943
|
+
return certificate;
|
|
2944
|
+
}
|
|
2945
|
+
getRevisionRegressionStatus(planId) {
|
|
2946
|
+
const row = this.#database.prepare('SELECT status FROM source_revision_regressions WHERE plan_id=?')
|
|
2947
|
+
.get(planId);
|
|
2948
|
+
return row?.status;
|
|
2949
|
+
}
|
|
2950
|
+
/** Content-free diagnostic projection; caller must hold current owner/source fence. */
|
|
2951
|
+
inspectRevisionRegressionRecord(planId) {
|
|
2952
|
+
const row = this.#database.prepare('SELECT status,reason,updated_at FROM source_revision_regressions WHERE plan_id=?')
|
|
2953
|
+
.get(planId);
|
|
2954
|
+
if (!row)
|
|
2955
|
+
return undefined;
|
|
2956
|
+
if (!['claimed', 'verified', 'unknown', 'rejected'].includes(row.status)
|
|
2957
|
+
|| !Number.isSafeInteger(row.updated_at) || row.updated_at < 1
|
|
2958
|
+
|| (row.reason !== null && !/^[a-z][a-z0-9-]{0,79}$/u.test(row.reason))) {
|
|
2959
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision regression diagnostic record is corrupt');
|
|
2960
|
+
}
|
|
2961
|
+
return { status: row.status, ...(row.reason === null ? {} : { reason: row.reason }), updatedAt: row.updated_at };
|
|
2962
|
+
}
|
|
2963
|
+
claimRevisionRegression(planId) {
|
|
2964
|
+
const job = this.getPreparedRevisionJob(planId);
|
|
2965
|
+
const pinned = job.intent.revision?.growthRun?.revisionRegressionAcceptance;
|
|
2966
|
+
if (!pinned || this.#now() >= pinned.expiresAt) {
|
|
2967
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision regression requires a current pre-author policy binding');
|
|
2968
|
+
}
|
|
2969
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2970
|
+
try {
|
|
2971
|
+
const count = this.#database.prepare('SELECT count(*) AS n FROM source_revision_regressions').get();
|
|
2972
|
+
if (count.n >= 256)
|
|
2973
|
+
throw new ControlPlaneStoreError('conflict', 'revision regression record limit reached');
|
|
2974
|
+
const now = this.#now();
|
|
2975
|
+
this.#database.prepare(`INSERT INTO source_revision_regressions
|
|
2976
|
+
(plan_id,status,certificate_json,certificate_digest,reason,created_at,updated_at) VALUES (?,'claimed',NULL,NULL,NULL,?,?)`)
|
|
2977
|
+
.run(planId, now, now);
|
|
2978
|
+
this.#database.exec('COMMIT');
|
|
2979
|
+
}
|
|
2980
|
+
catch (error) {
|
|
2981
|
+
this.#database.exec('ROLLBACK');
|
|
2982
|
+
throw error;
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
settleRevisionRegression(planId, status, reason) {
|
|
2986
|
+
if (typeof reason !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/u.test(reason)) {
|
|
2987
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision regression reason is invalid');
|
|
2988
|
+
}
|
|
2989
|
+
const result = this.#database.prepare(`UPDATE source_revision_regressions SET status=?,reason=?,updated_at=?
|
|
2990
|
+
WHERE plan_id=? AND status='claimed'`).run(status, reason, this.#now(), planId);
|
|
2991
|
+
if (Number(result.changes) !== 1)
|
|
2992
|
+
throw new ControlPlaneStoreError('conflict', 'revision regression claim is not current');
|
|
2993
|
+
}
|
|
2994
|
+
readRevisionSource(planId) {
|
|
2995
|
+
const certificate = this.getRevisionVerification(planId);
|
|
2996
|
+
if (!certificate)
|
|
2997
|
+
return undefined;
|
|
2998
|
+
const row = this.#database.prepare('SELECT source_json FROM source_revision_sources WHERE plan_id=?').get(planId);
|
|
2999
|
+
if (!row || Buffer.byteLength(row.source_json) > 2_097_152)
|
|
3000
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision source archive is absent or oversized');
|
|
3001
|
+
const source = JSON.parse(row.source_json);
|
|
3002
|
+
validateCreationCapabilitySource(source, certificate);
|
|
3003
|
+
const plan = this.getSourcePlan(planId);
|
|
3004
|
+
if (source.baseCommit !== plan.baseCommit || controlPlaneDigest(source.scope) !== controlPlaneDigest(plan.scope))
|
|
3005
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision source archive scope changed');
|
|
3006
|
+
return source;
|
|
3007
|
+
}
|
|
3008
|
+
/** Reclaim only orphaned bytes or bytes referenced solely by expired plans. */
|
|
3009
|
+
deleteExpiredPreparedSourceArtifacts(now) {
|
|
3010
|
+
if (!Number.isSafeInteger(now) || now < 0)
|
|
3011
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact expiry time is invalid');
|
|
3012
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3013
|
+
try {
|
|
3014
|
+
const references = this.#database.prepare('SELECT plan_id, pack_sha256 FROM source_prepared_artifact_refs')
|
|
3015
|
+
.all();
|
|
3016
|
+
for (const reference of references) {
|
|
3017
|
+
const plan = this.getSourcePlan(reference.plan_id);
|
|
3018
|
+
if (plan.mode !== 'prepared-create' && plan.mode !== 'prepared-revise' || plan.preparedEvidence?.pack.sha256 !== reference.pack_sha256) {
|
|
3019
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact reference is corrupt');
|
|
3020
|
+
}
|
|
3021
|
+
if (plan.status === 'expired' && plan.expiresAt < now) {
|
|
3022
|
+
this.#database.prepare('DELETE FROM source_prepared_artifact_refs WHERE plan_id = ?').run(plan.id);
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
const artifacts = this.#database.prepare(`SELECT a.pack_sha256 FROM source_prepared_artifacts a
|
|
3026
|
+
WHERE NOT EXISTS (SELECT 1 FROM source_prepared_artifact_refs r WHERE r.pack_sha256 = a.pack_sha256)`)
|
|
3027
|
+
.all();
|
|
3028
|
+
let deleted = 0;
|
|
3029
|
+
for (const artifact of artifacts) {
|
|
3030
|
+
const result = this.#database.prepare('DELETE FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
3031
|
+
.run(artifact.pack_sha256);
|
|
3032
|
+
deleted += Number(result.changes);
|
|
3033
|
+
}
|
|
3034
|
+
this.#database.exec('COMMIT');
|
|
3035
|
+
return deleted;
|
|
3036
|
+
}
|
|
3037
|
+
catch (error) {
|
|
3038
|
+
this.#database.exec('ROLLBACK');
|
|
3039
|
+
throw error;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
/** Durable, structurally bound applied merge history; receipt signatures are checked by the baseline resolver. */
|
|
3043
|
+
getSourceBaselineHistory(repository) {
|
|
3044
|
+
if (!isAbsolute(repository) || resolve(repository) !== repository)
|
|
3045
|
+
throw new ControlPlaneStoreError('invalid-input', 'source repository path is invalid');
|
|
3046
|
+
// A SAVEPOINT provides one SQLite read snapshot, and also works inside the
|
|
3047
|
+
// start-release BEGIN IMMEDIATE writer without nesting another BEGIN.
|
|
3048
|
+
this.#database.exec('SAVEPOINT source_baseline_history');
|
|
3049
|
+
try {
|
|
3050
|
+
const history = this.#readSourceBaselineHistory(repository);
|
|
3051
|
+
this.#database.exec('RELEASE source_baseline_history');
|
|
3052
|
+
return history;
|
|
3053
|
+
}
|
|
3054
|
+
catch (error) {
|
|
3055
|
+
this.#database.exec('ROLLBACK TO source_baseline_history');
|
|
3056
|
+
this.#database.exec('RELEASE source_baseline_history');
|
|
3057
|
+
throw error;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
/** Must run inside an open read transaction/SAVEPOINT so the caller sees one snapshot. */
|
|
3061
|
+
#readSourceBaselineHistory(repository) {
|
|
3062
|
+
const rows = this.#database.prepare(`SELECT * FROM source_plans WHERE repository = ?
|
|
3063
|
+
AND (release_id IS NOT NULL OR status IN ('release-complete','release-failed'))
|
|
3064
|
+
ORDER BY created_at, id LIMIT 1001`).all(repository);
|
|
3065
|
+
if (rows.length > 1000)
|
|
3066
|
+
throw new ControlPlaneStoreError('invalid-state', 'source release history exceeds bound');
|
|
3067
|
+
const history = [];
|
|
3068
|
+
for (const row of rows) {
|
|
3069
|
+
const plan = sourceFromRow(row);
|
|
3070
|
+
if (plan.release === undefined)
|
|
3071
|
+
throw new ControlPlaneStoreError('invalid-state', 'started source release lacks release identity');
|
|
3072
|
+
if (plan.status !== 'release-complete' && plan.status !== 'release-failed') {
|
|
3073
|
+
throw new ControlPlaneStoreError('conflict', 'source repository has an unfinished release');
|
|
3074
|
+
}
|
|
3075
|
+
const merges = this.#database.prepare(`SELECT * FROM source_release_operations WHERE plan_id = ? AND phase = 'merge'
|
|
3076
|
+
AND status = 'applied' LIMIT 2`).all(plan.id);
|
|
3077
|
+
if (plan.status === 'release-failed') {
|
|
3078
|
+
if (merges.length !== 0)
|
|
3079
|
+
throw new ControlPlaneStoreError('conflict', 'failed source release has unresolved applied remote merge');
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
if (merges.length !== 1)
|
|
3083
|
+
throw new ControlPlaneStoreError('invalid-state', 'completed source release lacks one applied merge');
|
|
3084
|
+
const operation = sourceReleaseOperationFromRow(merges[0]);
|
|
3085
|
+
const receipt = operation.receipt;
|
|
3086
|
+
if (operation.status !== 'applied' || operation.phase !== 'merge' || operation.request.phase !== 'merge' || operation.planId !== plan.id
|
|
3087
|
+
|| operation.request.plan.digest !== plan.digest || operation.request.plan.revision > plan.revision
|
|
3088
|
+
|| operation.request.release.id !== plan.release.id || operation.request.release.fence !== plan.release.fence
|
|
3089
|
+
|| receipt?.outcome !== 'passed' || receipt.evidence.kind !== 'merge'
|
|
3090
|
+
|| receipt.planDigest !== plan.digest || receipt.evidence.targetBranch !== plan.releaseAuthorization?.releasePolicy.targetBranch
|
|
3091
|
+
|| receipt.evidence.reviewedHeadCommit !== operation.request.input.headCommit
|
|
3092
|
+
|| receipt.evidence.reviewEvidenceDigest !== operation.request.input.reviewEvidenceDigest
|
|
3093
|
+
|| receipt.evidence.reviewId !== operation.request.input.reviewId
|
|
3094
|
+
|| receipt.evidence.prId !== operation.request.input.prId) {
|
|
3095
|
+
throw new ControlPlaneStoreError('invalid-state', 'completed source release merge is not bound to current release');
|
|
3096
|
+
}
|
|
3097
|
+
history.push({ plan, operation });
|
|
3098
|
+
}
|
|
3099
|
+
return history;
|
|
3100
|
+
}
|
|
3101
|
+
/** Release edges and maintenance edges in one SQLite snapshot, for preflight consumers that must compare both. */
|
|
3102
|
+
readSourceMaintenanceState(repository) {
|
|
3103
|
+
if (!isAbsolute(repository) || resolve(repository) !== repository)
|
|
3104
|
+
throw new ControlPlaneStoreError('invalid-input', 'source repository path is invalid');
|
|
3105
|
+
this.#database.exec('SAVEPOINT source_maintenance_state');
|
|
3106
|
+
try {
|
|
3107
|
+
const unsettledJob = this.#database.prepare(`SELECT id FROM source_jobs WHERE json_extract(intent_json,'$.repository')=?
|
|
3108
|
+
AND status IN ('queued','running','unknown') LIMIT 1`).get(repository);
|
|
3109
|
+
const unsettledPlan = this.#database.prepare(`SELECT id FROM source_plans WHERE repository=?
|
|
3110
|
+
AND status NOT IN ('expired','local-checks-failed','release-failed','release-complete') LIMIT 1`).get(repository);
|
|
3111
|
+
if (unsettledJob || unsettledPlan)
|
|
3112
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance has unsettled source work');
|
|
3113
|
+
const history = this.#readSourceBaselineHistory(repository);
|
|
3114
|
+
const records = this.getSourceMaintenanceRecords(repository);
|
|
3115
|
+
this.#database.exec('RELEASE source_maintenance_state');
|
|
3116
|
+
return { history, records };
|
|
3117
|
+
}
|
|
3118
|
+
catch (error) {
|
|
3119
|
+
this.#database.exec('ROLLBACK TO source_maintenance_state');
|
|
3120
|
+
this.#database.exec('RELEASE source_maintenance_state');
|
|
3121
|
+
throw error;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
getSourceMaintenanceRecords(repository) {
|
|
3125
|
+
if (!isAbsolute(repository) || resolve(repository) !== repository)
|
|
3126
|
+
throw new ControlPlaneStoreError('invalid-input', 'source repository path is invalid');
|
|
3127
|
+
const rows = this.#database.prepare('SELECT sequence,host_plan_id,record_json,record_digest FROM source_maintenance WHERE repository=? ORDER BY sequence LIMIT 257')
|
|
3128
|
+
.all(repository);
|
|
3129
|
+
if (rows.length > 256)
|
|
3130
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance history exceeds bound');
|
|
3131
|
+
return rows.map((row, index) => {
|
|
3132
|
+
const record = parseSourceMaintenanceRecord(JSON.parse(row.record_json));
|
|
3133
|
+
if (record.repository !== repository || record.sequence !== index + 1 || record.sequence !== row.sequence
|
|
3134
|
+
|| record.host?.planId !== (row.host_plan_id ?? undefined)
|
|
3135
|
+
|| sourceMaintenanceDigest(record) !== row.record_digest) {
|
|
3136
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance stored record changed');
|
|
3137
|
+
}
|
|
3138
|
+
return record;
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
/** Offline owner transaction only. No service/model-facing signing path exists. */
|
|
3142
|
+
async appendSourceMaintenance(recordInput, input) {
|
|
3143
|
+
const record = parseSourceMaintenanceRecord(recordInput), { trust, baseline } = input;
|
|
3144
|
+
validateSourceBaselineConfig(baseline);
|
|
3145
|
+
const key = resolveTrustKey(trust, 'host-attestation', record.authority, record.keyId);
|
|
3146
|
+
if (record.installationId !== trust.installationId || controlPlaneDigest(record.ledger) !== controlPlaneDigest(trust.ledger)
|
|
3147
|
+
|| record.repository !== recordInput.repository || controlPlaneDigest(record.baseline) !== controlPlaneDigest(baseline)
|
|
3148
|
+
|| !sameSourceMaintenancePublicKey(record.publicKeyPem, key.publicKeyPem) || record.issuedAt > this.#now()) {
|
|
3149
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance owner binding changed');
|
|
3150
|
+
}
|
|
3151
|
+
const verifiedHistory = this.getSourceBaselineHistory(record.repository);
|
|
3152
|
+
const verifiedMaintenance = this.getSourceMaintenanceRecords(record.repository);
|
|
3153
|
+
const authorityDigest = controlPlaneDigest({ trust, baseline });
|
|
3154
|
+
await verifySourceBaselineHistory({ repository: record.repository, config: baseline, trust,
|
|
3155
|
+
history: verifiedHistory, maintenance: verifiedMaintenance, signal: new AbortController().signal });
|
|
3156
|
+
const verifiedSnapshot = controlPlaneDigest({ history: verifiedHistory, maintenance: verifiedMaintenance });
|
|
3157
|
+
this.#database.exec('SAVEPOINT append_source_maintenance');
|
|
3158
|
+
try {
|
|
3159
|
+
this.#database.exec('UPDATE source_maintenance SET sequence=sequence WHERE 0');
|
|
3160
|
+
const records = this.getSourceMaintenanceRecords(record.repository);
|
|
3161
|
+
const history = this.getSourceBaselineHistory(record.repository);
|
|
3162
|
+
if (controlPlaneDigest({ trust, baseline }) !== authorityDigest) {
|
|
3163
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance trust or baseline changed before append');
|
|
3164
|
+
}
|
|
3165
|
+
if (controlPlaneDigest({ history, maintenance: records }) !== verifiedSnapshot) {
|
|
3166
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance signed history changed before append');
|
|
3167
|
+
}
|
|
3168
|
+
const priorTransaction = records.find(item => item.transactionId === record.transactionId);
|
|
3169
|
+
if (priorTransaction) {
|
|
3170
|
+
if (sourceMaintenanceDigest(priorTransaction) !== sourceMaintenanceDigest(record)) {
|
|
3171
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance transaction changed');
|
|
3172
|
+
}
|
|
3173
|
+
this.#database.exec('RELEASE append_source_maintenance');
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
verifySourceMaintenanceRecords([...records, record], { installationId: trust.installationId,
|
|
3177
|
+
ledger: trust.ledger, repository: record.repository, baseline, hostIdentity: key });
|
|
3178
|
+
const unsettledJob = this.#database.prepare(`SELECT id FROM source_jobs WHERE json_extract(intent_json,'$.repository')=?
|
|
3179
|
+
AND status IN ('queued','running','unknown') LIMIT 1`).get(record.repository);
|
|
3180
|
+
const unsettledPlan = this.#database.prepare(`SELECT id FROM source_plans WHERE repository=?
|
|
3181
|
+
AND status NOT IN ('expired','local-checks-failed','release-failed','release-complete') LIMIT 1`).get(record.repository);
|
|
3182
|
+
if (unsettledJob || unsettledPlan)
|
|
3183
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance has unsettled source work');
|
|
3184
|
+
const activationState = this.readSourceMaintenanceActivationState(trust.dshHome);
|
|
3185
|
+
if (activationState.kind === 'unsettled') {
|
|
3186
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance activation is unsettled');
|
|
3187
|
+
}
|
|
3188
|
+
if (activationState.kind === 'watched') {
|
|
3189
|
+
const context = readCurrentRuntimeEpochDeployment(this.#database, this.getPlan(activationState.planId).target.profilePath);
|
|
3190
|
+
const { plan, readiness } = context, receipt = readiness.receipt;
|
|
3191
|
+
if (!record.host || record.host.planId !== plan.id || record.host.planDigest !== plan.digest
|
|
3192
|
+
|| record.host.readinessOperationId !== readiness.operationId || !receipt
|
|
3193
|
+
|| record.host.readinessReceiptDigest !== hostMaintenanceDigest(receipt)
|
|
3194
|
+
|| receipt.authority !== record.authority || receipt.keyId !== record.keyId) {
|
|
3195
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance watched Host anchor changed');
|
|
3196
|
+
}
|
|
3197
|
+
const { signature, ...unsigned } = receipt;
|
|
3198
|
+
if (!verify(null, Buffer.from(hostMaintenanceCanonical(unsigned)), createPublicKey(key.publicKeyPem), Buffer.from(signature, 'base64'))) {
|
|
3199
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance original Host receipt signature changed');
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
else if (record.host !== null)
|
|
3203
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance has no watched Host anchor');
|
|
3204
|
+
const current = sourceBaselineChain(baseline, history, records).at(-1);
|
|
3205
|
+
if (record.previousTip !== current)
|
|
3206
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance previous tip is stale');
|
|
3207
|
+
sourceBaselineChain(baseline, history, [...records, record]);
|
|
3208
|
+
assertSourceMaintenanceGit(record, input.physicalHome ?? trust.dshHome, trust.dshHome);
|
|
3209
|
+
this.#database.prepare('INSERT INTO source_maintenance VALUES (?,?,?,?,?,?)').run(record.repository, record.sequence, record.transactionId, record.host?.planId ?? null, JSON.stringify(record), sourceMaintenanceDigest(record));
|
|
3210
|
+
this.#database.exec('RELEASE append_source_maintenance');
|
|
3211
|
+
}
|
|
3212
|
+
catch (error) {
|
|
3213
|
+
this.#database.exec('ROLLBACK TO append_source_maintenance; RELEASE append_source_maintenance');
|
|
3214
|
+
throw error;
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
/** Seed a previously owner-signed pre-adoption sidecar into a new ledger. */
|
|
3218
|
+
async importSourceMaintenanceRecords(recordInputs, input) {
|
|
3219
|
+
if (recordInputs.length === 0 || recordInputs.length > 256)
|
|
3220
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance import is empty or unbounded');
|
|
3221
|
+
const { trust, baseline } = input;
|
|
3222
|
+
validateSourceBaselineConfig(baseline);
|
|
3223
|
+
const records = recordInputs.map(parseSourceMaintenanceRecord), first = records[0];
|
|
3224
|
+
const key = resolveTrustKey(trust, 'host-attestation', first.authority, first.keyId);
|
|
3225
|
+
verifySourceMaintenanceRecords(records, { installationId: trust.installationId, ledger: trust.ledger,
|
|
3226
|
+
repository: first.repository, baseline, hostIdentity: key });
|
|
3227
|
+
if (records.some(record => record.host !== null))
|
|
3228
|
+
throw new ControlPlaneStoreError('invalid-input', 'pre-adoption source history carries a deployment anchor');
|
|
3229
|
+
sourceBaselineChain(baseline, [], records);
|
|
3230
|
+
const authorityDigest = controlPlaneDigest({ trust, baseline, records });
|
|
3231
|
+
this.#database.exec('SAVEPOINT import_source_maintenance');
|
|
3232
|
+
try {
|
|
3233
|
+
this.#database.exec('UPDATE source_maintenance SET sequence=sequence WHERE 0');
|
|
3234
|
+
if (controlPlaneDigest({ trust, baseline, records }) !== authorityDigest)
|
|
3235
|
+
throw new ControlPlaneStoreError('conflict', 'source import authority changed');
|
|
3236
|
+
const present = this.getSourceMaintenanceRecords(first.repository);
|
|
3237
|
+
if (present.length > records.length || present.some((record, index) => sourceMaintenanceDigest(record) !== sourceMaintenanceDigest(records[index]))) {
|
|
3238
|
+
throw new ControlPlaneStoreError('conflict', 'source import differs from existing ledger history');
|
|
3239
|
+
}
|
|
3240
|
+
if (this.getSourceBaselineHistory(first.repository).length)
|
|
3241
|
+
throw new ControlPlaneStoreError('conflict', 'source import cannot cross a published release');
|
|
3242
|
+
const anyJob = this.#database.prepare(`SELECT id FROM source_jobs WHERE json_extract(intent_json,'$.repository')=? LIMIT 1`)
|
|
3243
|
+
.get(first.repository);
|
|
3244
|
+
const anyPlan = this.#database.prepare('SELECT id FROM source_plans WHERE repository=? LIMIT 1').get(first.repository);
|
|
3245
|
+
const anyActivation = this.#database.prepare('SELECT id FROM activation_plans WHERE dsh_home=? LIMIT 1').get(trust.dshHome);
|
|
3246
|
+
if (anyJob || anyPlan || anyActivation)
|
|
3247
|
+
throw new ControlPlaneStoreError('conflict', 'source import requires an empty owner ledger');
|
|
3248
|
+
for (let index = 0; index < records.length; index++) {
|
|
3249
|
+
assertSourceMaintenanceGit(records[index], input.physicalHome ?? trust.dshHome, trust.dshHome, index === records.length - 1);
|
|
3250
|
+
}
|
|
3251
|
+
for (const record of records.slice(present.length)) {
|
|
3252
|
+
this.#database.prepare('INSERT INTO source_maintenance VALUES (?,?,?,?,?,?)').run(record.repository, record.sequence, record.transactionId, null, JSON.stringify(record), sourceMaintenanceDigest(record));
|
|
3253
|
+
}
|
|
3254
|
+
this.#database.exec('RELEASE import_source_maintenance');
|
|
3255
|
+
}
|
|
3256
|
+
catch (error) {
|
|
3257
|
+
this.#database.exec('ROLLBACK TO import_source_maintenance; RELEASE import_source_maintenance');
|
|
3258
|
+
throw error;
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
1661
3261
|
/**
|
|
1662
3262
|
* Enumerate 'modify' plans for isolated-worktree garbage collection. Only
|
|
1663
3263
|
* modify plans own control-plane-created worktrees under the state root;
|
|
@@ -1671,14 +3271,24 @@ export class ControlPlaneStore {
|
|
|
1671
3271
|
.all(filter.expiredBefore);
|
|
1672
3272
|
return rows.map(sourceFromRow);
|
|
1673
3273
|
}
|
|
3274
|
+
/** Isolated worktrees owned by the background prepared flows, including owner-granted creation. */
|
|
3275
|
+
listPreparedSourcePlans(filter) {
|
|
3276
|
+
const rows = filter?.expiredBefore === undefined
|
|
3277
|
+
? this.#database.prepare(`SELECT * FROM source_plans WHERE mode IN ('modify', 'prepared-create', 'prepared-revise') ORDER BY created_at, id`)
|
|
3278
|
+
.all()
|
|
3279
|
+
: this.#database.prepare(`SELECT * FROM source_plans WHERE mode IN ('modify', 'prepared-create', 'prepared-revise') AND expires_at < ? ORDER BY created_at, id`)
|
|
3280
|
+
.all(filter.expiredBefore);
|
|
3281
|
+
return rows.map(sourceFromRow);
|
|
3282
|
+
}
|
|
1674
3283
|
/** Retire an expired prepared plan before removing its worktree. The CAS
|
|
1675
3284
|
* fences owner approval/review, and releases only this plan's gap claim. */
|
|
1676
3285
|
expirePreparedSourcePlan(input) {
|
|
1677
3286
|
if (!Number.isSafeInteger(input.now) || input.now < 0)
|
|
1678
3287
|
throw new ControlPlaneStoreError('invalid-input', 'expiry time is invalid');
|
|
1679
3288
|
const plan = this.getSourcePlan(input.planId);
|
|
1680
|
-
if (plan.mode !== 'modify')
|
|
1681
|
-
throw new ControlPlaneStoreError('invalid-state', 'only prepared
|
|
3289
|
+
if (plan.mode !== 'modify' && plan.mode !== 'prepared-create' && plan.mode !== 'prepared-revise') {
|
|
3290
|
+
throw new ControlPlaneStoreError('invalid-state', 'only prepared source plans may expire here');
|
|
3291
|
+
}
|
|
1682
3292
|
const expiryDigest = controlPlaneDigest({ planId: plan.id, planDigest: plan.digest, expiresAt: plan.expiresAt });
|
|
1683
3293
|
if (plan.status === 'expired') {
|
|
1684
3294
|
const receipt = this.#sourcePlanReceipt(`source-expired:${plan.id}`, 'source-expired', expiryDigest, plan.id);
|
|
@@ -1689,7 +3299,8 @@ export class ControlPlaneStore {
|
|
|
1689
3299
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
1690
3300
|
try {
|
|
1691
3301
|
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')
|
|
3302
|
+
WHERE id = ? AND revision = ? AND ((mode = 'modify' AND status IN ('pending-approval', 'approved'))
|
|
3303
|
+
OR (mode IN ('prepared-create', 'prepared-revise') AND status = 'pending-approval')) AND expires_at < ?`)
|
|
1693
3304
|
.run(input.now, plan.id, input.expectedRevision, input.now);
|
|
1694
3305
|
if (Number(retired.changes) !== 1)
|
|
1695
3306
|
throw new ControlPlaneStoreError('conflict', 'prepared plan changed or is not expired');
|
|
@@ -1714,6 +3325,9 @@ export class ControlPlaneStore {
|
|
|
1714
3325
|
return this.#approvePlan('activation', input);
|
|
1715
3326
|
}
|
|
1716
3327
|
async approveSource(input) {
|
|
3328
|
+
if (['prepared-create', 'prepared-revise'].includes(this.getSourcePlan(input.planId).mode)) {
|
|
3329
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation requires its own owner creation authority');
|
|
3330
|
+
}
|
|
1717
3331
|
return this.#approvePlan('source', input);
|
|
1718
3332
|
}
|
|
1719
3333
|
async #approvePlan(kind, input) {
|
|
@@ -1794,7 +3408,7 @@ export class ControlPlaneStore {
|
|
|
1794
3408
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1795
3409
|
if (row === undefined)
|
|
1796
3410
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1797
|
-
const plan =
|
|
3411
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1798
3412
|
if (plan.revision !== input.expectedRevision)
|
|
1799
3413
|
throw new ControlPlaneStoreError('conflict', 'activation plan revision conflict');
|
|
1800
3414
|
if (row.approval_receipt_json === null || plan.approval === undefined) {
|
|
@@ -1817,7 +3431,7 @@ export class ControlPlaneStore {
|
|
|
1817
3431
|
if (now > plan.expiresAt)
|
|
1818
3432
|
throw new ControlPlaneStoreError('expired', 'activation plan expired before its first claim');
|
|
1819
3433
|
const targetOwner = this.#database.prepare(`SELECT id FROM activation_plans WHERE target_path = ? AND id <> ? AND status IN (
|
|
1820
|
-
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
3434
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
1821
3435
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`).get(plan.target.profilePath, plan.id);
|
|
1822
3436
|
if (targetOwner !== undefined)
|
|
1823
3437
|
throw new ControlPlaneStoreError('conflict', 'target profile already has an active activation');
|
|
@@ -1830,7 +3444,7 @@ export class ControlPlaneStore {
|
|
|
1830
3444
|
const activationId = row.activation_id ?? `activation-${randomUUID()}`;
|
|
1831
3445
|
const status = plan.status === 'approved' ? 'staging' : plan.status;
|
|
1832
3446
|
const result = this.#database.prepare(`UPDATE activation_plans SET status = ?, revision = revision + 1, activation_id = ?,
|
|
1833
|
-
activation_fence = activation_fence +
|
|
3447
|
+
activation_fence = activation_fence + ?, activation_lease_until = ?, updated_at = ? WHERE id = ? AND revision = ?`).run(status, activationId, plan.status === 'commit-pending' && plan.dossier.liveQualification ? 0 : 1, now + input.leaseMs, now, plan.id, input.expectedRevision);
|
|
1834
3448
|
if (Number(result.changes) !== 1)
|
|
1835
3449
|
throw new ControlPlaneStoreError('conflict', 'activation plan changed while being claimed');
|
|
1836
3450
|
const claimed = this.getPlan(plan.id);
|
|
@@ -1863,7 +3477,7 @@ export class ControlPlaneStore {
|
|
|
1863
3477
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1864
3478
|
if (row === undefined)
|
|
1865
3479
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1866
|
-
const plan =
|
|
3480
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1867
3481
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== 'staging'
|
|
1868
3482
|
|| Number(row.activation_lease_until ?? 0) < now)
|
|
1869
3483
|
throw new ControlPlaneStoreError('conflict', 'activation lost its claim before recording the target baseline');
|
|
@@ -1905,7 +3519,7 @@ export class ControlPlaneStore {
|
|
|
1905
3519
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1906
3520
|
if (row === undefined)
|
|
1907
3521
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1908
|
-
const plan =
|
|
3522
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1909
3523
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== 'commit-pending'
|
|
1910
3524
|
|| Number(row.activation_lease_until ?? 0) < now) {
|
|
1911
3525
|
throw new ControlPlaneStoreError('conflict', 'activation lost its claim before recording the installed baseline');
|
|
@@ -1961,7 +3575,8 @@ export class ControlPlaneStore {
|
|
|
1961
3575
|
catch {
|
|
1962
3576
|
throw new ControlPlaneStoreError('invalid-state', 'stored installed activation baseline is corrupt');
|
|
1963
3577
|
}
|
|
1964
|
-
|
|
3578
|
+
const original = activationCoreFiles(value, plan.target.profilePath, 'stored installed activation baseline');
|
|
3579
|
+
return readHostMaintenanceContext(this.#database, plan.id).records.at(-1)?.after.profileFiles ?? original;
|
|
1965
3580
|
}
|
|
1966
3581
|
/** Move a signed closed deployment watch into the existing physical rollback lifecycle. */
|
|
1967
3582
|
beginPostActivationRollback(input) {
|
|
@@ -1971,7 +3586,7 @@ export class ControlPlaneStore {
|
|
|
1971
3586
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1972
3587
|
if (row === undefined)
|
|
1973
3588
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1974
|
-
const plan =
|
|
3589
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1975
3590
|
if (plan.revision !== input.expectedRevision)
|
|
1976
3591
|
throw new ControlPlaneStoreError('conflict', 'activation plan revision conflict');
|
|
1977
3592
|
// A restart or retry must retain the original rollback identity and never
|
|
@@ -1997,7 +3612,7 @@ export class ControlPlaneStore {
|
|
|
1997
3612
|
}
|
|
1998
3613
|
this.getActivationInstalledBaseline(plan.id);
|
|
1999
3614
|
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',
|
|
3615
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2001
3616
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`)
|
|
2002
3617
|
.get(plan.target.profilePath, plan.id);
|
|
2003
3618
|
if (active !== undefined)
|
|
@@ -2038,9 +3653,65 @@ export class ControlPlaneStore {
|
|
|
2038
3653
|
ORDER BY older.exposure_order`).all(current.target.profilePath, current.id, checkpoint.exposure_order);
|
|
2039
3654
|
return rows.map(activationFromRow);
|
|
2040
3655
|
}
|
|
3656
|
+
/** Freeze exact physical pins before a signed deployment becomes Host-visible. */
|
|
3657
|
+
recordActivationHostInputWitness(input) {
|
|
3658
|
+
return this.#withActivationWrite(input.planId, () => {
|
|
3659
|
+
const plan = this.getPlan(input.planId);
|
|
3660
|
+
const row = this.#database.prepare('SELECT activation_lease_until FROM activation_plans WHERE id=?').get(plan.id);
|
|
3661
|
+
const inputs = plan.dossier.hostDeploymentInputs;
|
|
3662
|
+
if (!inputs)
|
|
3663
|
+
throw new ControlPlaneStoreError('invalid-state', 'plan has no signed Host deployment inputs');
|
|
3664
|
+
if (plan.status !== 'staging' || plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence
|
|
3665
|
+
|| (row.activation_lease_until ?? 0) < this.#now() || plan.activation.hostRecoveryRequired
|
|
3666
|
+
|| plan.activation.targetOriginallyExisted === undefined || plan.activation.targetBaselineFiles === undefined) {
|
|
3667
|
+
throw new ControlPlaneStoreError('conflict', 'Host input witness lost its staging claim or target baseline');
|
|
3668
|
+
}
|
|
3669
|
+
let profileFiles;
|
|
3670
|
+
let deploymentFiles;
|
|
3671
|
+
let baselineDeploymentFiles;
|
|
3672
|
+
try {
|
|
3673
|
+
profileFiles = activationCoreFiles(input.profileFiles, plan.target.profilePath, 'Host input witness core files');
|
|
3674
|
+
if (profileFiles.some(file => file.sha256 === null))
|
|
3675
|
+
throw new Error('missing core file');
|
|
3676
|
+
deploymentFiles = hostInputPins(input.deploymentFiles, inputs, plan.target.profilePath, 'Host input witness deployment files');
|
|
3677
|
+
baselineDeploymentFiles = plan.activation.targetOriginallyExisted
|
|
3678
|
+
? hostInputPins(input.baselineDeploymentFiles, inputs, plan.target.profilePath, 'Host input witness baseline files')
|
|
3679
|
+
: input.baselineDeploymentFiles.length === 0 ? [] : (() => { throw new Error('unexpected baseline files'); })();
|
|
3680
|
+
}
|
|
3681
|
+
catch {
|
|
3682
|
+
throw new ControlPlaneStoreError('invalid-input', 'Host input witness pins do not match the signed profile deployment');
|
|
3683
|
+
}
|
|
3684
|
+
const existing = hostInputWitnessFromRow(this.#database, plan);
|
|
3685
|
+
if (existing) {
|
|
3686
|
+
if (controlPlaneDigest(existing.profileFiles) !== controlPlaneDigest(profileFiles)
|
|
3687
|
+
|| controlPlaneDigest(existing.deploymentFiles) !== controlPlaneDigest(deploymentFiles)
|
|
3688
|
+
|| controlPlaneDigest(existing.baselineDeploymentFiles) !== controlPlaneDigest(baselineDeploymentFiles)) {
|
|
3689
|
+
throw new ControlPlaneStoreError('conflict', 'Host input witness is immutable');
|
|
3690
|
+
}
|
|
3691
|
+
return plan;
|
|
3692
|
+
}
|
|
3693
|
+
const unsigned = { schemaVersion: 1, kind: 'dsh-host-input-witness', planId: plan.id,
|
|
3694
|
+
planDigest: plan.digest, activationId: plan.activation.id, fence: input.fence, createdAt: this.#now(),
|
|
3695
|
+
inputs, profileFiles, deploymentFiles, baselineDeploymentFiles };
|
|
3696
|
+
const witness = { ...unsigned, digest: controlPlaneDigest(unsigned) };
|
|
3697
|
+
this.#database.prepare('INSERT INTO activation_host_input_witnesses (plan_id,witness_json,witness_digest) VALUES (?,?,?)')
|
|
3698
|
+
.run(plan.id, JSON.stringify(witness), controlPlaneDigest(witness));
|
|
3699
|
+
return plan;
|
|
3700
|
+
});
|
|
3701
|
+
}
|
|
3702
|
+
getActivationHostInputWitness(planId) {
|
|
3703
|
+
return hostInputWitnessFromRow(this.#database, this.getPlan(planId));
|
|
3704
|
+
}
|
|
2041
3705
|
/** Freeze recovery obligation before the profile can become Host-visible. */
|
|
2042
3706
|
markActivationHostExposure(input) {
|
|
2043
3707
|
return this.#withActivationWrite(input.planId, () => {
|
|
3708
|
+
const before = this.getPlan(input.planId);
|
|
3709
|
+
if (before.dossier.hostDeploymentInputs) {
|
|
3710
|
+
const witness = hostInputWitnessFromRow(this.#database, before);
|
|
3711
|
+
if (!witness || witness.fence !== input.fence || before.revision !== input.expectedRevision) {
|
|
3712
|
+
throw new ControlPlaneStoreError('conflict', 'Host exposure requires an exact staged input witness');
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
2044
3715
|
const now = this.#now();
|
|
2045
3716
|
const result = this.#database.prepare(`UPDATE activation_plans SET host_recovery_required = 1, updated_at = ?
|
|
2046
3717
|
WHERE id = ? AND revision = ? AND activation_fence = ? AND status = 'staging'
|
|
@@ -2048,7 +3719,19 @@ export class ControlPlaneStore {
|
|
|
2048
3719
|
AND activation_lease_until >= ?`).run(now, input.planId, input.expectedRevision, input.fence, now);
|
|
2049
3720
|
if (Number(result.changes) !== 1)
|
|
2050
3721
|
throw new ControlPlaneStoreError('conflict', 'Host exposure lost its claim or original baseline');
|
|
2051
|
-
|
|
3722
|
+
const plan = this.getPlan(input.planId);
|
|
3723
|
+
if (plan.dossier.liveQualification) {
|
|
3724
|
+
const handoff = this.getAdoptionHandoff(plan.id);
|
|
3725
|
+
const deadline = Math.min(now + plan.dossier.liveQualification.maximumWindowMs, plan.expiresAt, plan.approval.expiresAt, handoff?.expiresAt ?? 0);
|
|
3726
|
+
if (deadline <= now)
|
|
3727
|
+
throw new ControlPlaneStoreError('expired', 'live qualification authorization expired before exposure');
|
|
3728
|
+
this.#database.prepare(`INSERT INTO live_qualification_windows (plan_id,activation_id,fence,started_at,deadline_at)
|
|
3729
|
+
VALUES (?,?,?,?,?) ON CONFLICT(plan_id) DO NOTHING`).run(plan.id, plan.activation.id, plan.activation.fence, now, deadline);
|
|
3730
|
+
const prior = this.#database.prepare('SELECT activation_id,fence FROM live_qualification_windows WHERE plan_id=?').get(plan.id);
|
|
3731
|
+
if (prior?.activation_id !== plan.activation.id || prior.fence !== plan.activation.fence)
|
|
3732
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification exposure cannot be replayed');
|
|
3733
|
+
}
|
|
3734
|
+
return plan;
|
|
2052
3735
|
});
|
|
2053
3736
|
}
|
|
2054
3737
|
markRollbackProfileRestored(input) {
|
|
@@ -2082,7 +3765,7 @@ export class ControlPlaneStore {
|
|
|
2082
3765
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
2083
3766
|
if (row === undefined)
|
|
2084
3767
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
2085
|
-
const plan =
|
|
3768
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
2086
3769
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== input.status
|
|
2087
3770
|
|| (requireLiveLease && Number(row.activation_lease_until ?? 0) < this.#now()))
|
|
2088
3771
|
throw new ControlPlaneStoreError('conflict', 'activation no longer owns the filesystem fence');
|
|
@@ -2131,6 +3814,177 @@ export class ControlPlaneStore {
|
|
|
2131
3814
|
throw new ControlPlaneStoreError('conflict', 'activation no longer owns the exact revision/fence/status');
|
|
2132
3815
|
return plan;
|
|
2133
3816
|
}
|
|
3817
|
+
currentRuntimeEpochDeployment(profilePath) {
|
|
3818
|
+
return readCurrentRuntimeEpochDeployment(this.#database, profilePath);
|
|
3819
|
+
}
|
|
3820
|
+
/**
|
|
3821
|
+
* Classify activation state for a Home in one read snapshot, using the exact
|
|
3822
|
+
* watch/plan queries that gate {@link appendSourceMaintenance}. Preflight
|
|
3823
|
+
* producers use it to distinguish a genuinely unanchored ledger (`none`) from
|
|
3824
|
+
* a watched deployment that must bind its readiness receipt (`watched`) and
|
|
3825
|
+
* from any conflict that must abort rather than silently degrade (`unsettled`).
|
|
3826
|
+
*/
|
|
3827
|
+
readSourceMaintenanceActivationState(dshHome) {
|
|
3828
|
+
if (!isAbsolute(dshHome) || resolve(dshHome) !== dshHome) {
|
|
3829
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance Home path is invalid');
|
|
3830
|
+
}
|
|
3831
|
+
this.#database.exec('SAVEPOINT source_maintenance_activation_state');
|
|
3832
|
+
try {
|
|
3833
|
+
const watching = this.#database.prepare(`SELECT plan.id FROM activation_plans plan JOIN activation_watch watch ON watch.plan_id=plan.id
|
|
3834
|
+
WHERE plan.dsh_home=? AND watch.state='watching' LIMIT 2`).all(dshHome);
|
|
3835
|
+
const active = this.#database.prepare(`SELECT id FROM activation_plans WHERE dsh_home=?
|
|
3836
|
+
AND status NOT IN ('rolled-back','activated') LIMIT 1`).get(dshHome);
|
|
3837
|
+
const activated = this.#database.prepare(`SELECT plan.id,plan.target_path,watch.state,
|
|
3838
|
+
checkpoint.exposure_order,checkpoint.successful_order FROM activation_plans plan
|
|
3839
|
+
LEFT JOIN activation_watch watch ON watch.plan_id=plan.id
|
|
3840
|
+
LEFT JOIN activation_deployment_checkpoints checkpoint ON checkpoint.plan_id=plan.id
|
|
3841
|
+
WHERE plan.dsh_home=? AND plan.status='activated' LIMIT 1001`).all(dshHome);
|
|
3842
|
+
if (activated.length > 1000)
|
|
3843
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance activation history exceeds bound');
|
|
3844
|
+
let result = watching.length > 1 || active || watching.length === 0 && activated.length !== 0
|
|
3845
|
+
|| watching.length === 1 && !activated.some(row => row.id === watching[0].id)
|
|
3846
|
+
? { kind: 'unsettled' }
|
|
3847
|
+
: watching.length === 1
|
|
3848
|
+
? { kind: 'watched', planId: watching[0].id }
|
|
3849
|
+
: { kind: 'none' };
|
|
3850
|
+
if (result.kind === 'watched') {
|
|
3851
|
+
const plan = this.getPlan(result.planId), watch = this.getActivationWatch(result.planId);
|
|
3852
|
+
const assertBinding = (candidate, boundWatch) => {
|
|
3853
|
+
if (boundWatch.activationId !== candidate.activation?.id || boundWatch.fence !== candidate.activation.fence
|
|
3854
|
+
|| boundWatch.exact.package !== candidate.candidate.package || boundWatch.exact.version !== candidate.candidate.version
|
|
3855
|
+
|| boundWatch.exact.integrity !== candidate.candidate.integrity) {
|
|
3856
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance activation watch binding is corrupt');
|
|
3857
|
+
}
|
|
3858
|
+
};
|
|
3859
|
+
assertBinding(plan, watch);
|
|
3860
|
+
const current = activated.find(row => row.id === plan.id);
|
|
3861
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id FROM activation_deployment_checkpoints checkpoint
|
|
3862
|
+
JOIN activation_plans plan ON plan.id=checkpoint.plan_id WHERE plan.target_path=?
|
|
3863
|
+
ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(plan.target.profilePath);
|
|
3864
|
+
if (current.exposure_order === null || current.successful_order !== current.exposure_order || latest?.plan_id !== plan.id) {
|
|
3865
|
+
result = { kind: 'unsettled' };
|
|
3866
|
+
}
|
|
3867
|
+
else
|
|
3868
|
+
for (const older of activated) {
|
|
3869
|
+
if (older.id === plan.id)
|
|
3870
|
+
continue;
|
|
3871
|
+
// Closed watches retain activated status after a successor takes over.
|
|
3872
|
+
// Only a proved older success on this same profile is retired history.
|
|
3873
|
+
if (older.target_path !== plan.target.profilePath || !['closed-regressed', 'closed-retracted'].includes(older.state ?? '')
|
|
3874
|
+
|| older.exposure_order === null || older.successful_order !== older.exposure_order
|
|
3875
|
+
|| older.exposure_order >= current.exposure_order) {
|
|
3876
|
+
result = { kind: 'unsettled' };
|
|
3877
|
+
break;
|
|
3878
|
+
}
|
|
3879
|
+
assertBinding(this.getPlan(older.id), this.getActivationWatch(older.id));
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
this.#database.exec('RELEASE source_maintenance_activation_state');
|
|
3883
|
+
return result;
|
|
3884
|
+
}
|
|
3885
|
+
catch (error) {
|
|
3886
|
+
this.#database.exec('ROLLBACK TO source_maintenance_activation_state; RELEASE source_maintenance_activation_state');
|
|
3887
|
+
throw error;
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
prepareRuntimeEpoch(input) {
|
|
3891
|
+
const runtimeDigest = runtimeIdentityDigest(input.runtime);
|
|
3892
|
+
if (input.runtime.observedAt > this.#now() || input.runtime.observedAt < this.#now() - 30_000
|
|
3893
|
+
|| input.runtime.entries.some(entry => !entry.active))
|
|
3894
|
+
throw new ControlPlaneStoreError('invalid-input', 'runtime epoch needs a fresh active runtime');
|
|
3895
|
+
const current = readCurrentRuntimeEpochDeployment(this.#database, input.runtime.profilePath);
|
|
3896
|
+
return this.#withActivationWrite(current.plan.id, () => {
|
|
3897
|
+
const context = readCurrentRuntimeEpochDeployment(this.#database, input.runtime.profilePath), { plan, readiness } = context;
|
|
3898
|
+
const previous = this.#database.prepare(`SELECT * FROM deployment_runtime_epochs WHERE plan_id=? ORDER BY sequence DESC LIMIT 1`)
|
|
3899
|
+
.get(plan.id);
|
|
3900
|
+
if (previous) {
|
|
3901
|
+
const record = runtimeEpochFromRow(previous);
|
|
3902
|
+
if (record.request.runtimeIdentityDigest === runtimeDigest && controlPlaneDigest(record.request.issuer) === controlPlaneDigest(input.issuer)
|
|
3903
|
+
&& record.status !== 'stale'
|
|
3904
|
+
&& (record.status === 'applied' || this.#now() < record.request.requestedAt + record.request.receiptTtlMs)) {
|
|
3905
|
+
return record;
|
|
3906
|
+
}
|
|
3907
|
+
// An expired read-only observation may be replaced, never relabelled or replayed as a new proof.
|
|
3908
|
+
this.#database.prepare("UPDATE deployment_runtime_epochs SET status='stale' WHERE plan_id=? AND status IN ('pending','claimed')").run(plan.id);
|
|
3909
|
+
}
|
|
3910
|
+
const sequence = Number(this.#database.prepare('SELECT max(sequence) AS sequence FROM deployment_runtime_epochs WHERE plan_id=?').get(plan.id)?.sequence ?? 0) + 1;
|
|
3911
|
+
const request = parseRuntimeEpochRequest({ schemaVersion: 1, kind: 'dsh-runtime-epoch-request', operationId: `runtime-epoch-${randomUUID()}`,
|
|
3912
|
+
requestedAt: this.#now(), receiptTtlMs: input.receiptTtlMs, installationId: plan.installationId, ledger: plan.ledger,
|
|
3913
|
+
plan: { id: plan.id, digest: plan.digest }, activation: { id: plan.activation.id, fence: plan.activation.fence },
|
|
3914
|
+
profile: { name: plan.profile, path: plan.target.profilePath }, issuer: input.issuer, predecessor: {
|
|
3915
|
+
operationId: readiness.operationId, receiptDigest: controlPlaneDigest(readiness.receipt), hostGeneration: readiness.receipt.hostGeneration
|
|
3916
|
+
},
|
|
3917
|
+
sequence, runtimeIdentityDigest: runtimeDigest });
|
|
3918
|
+
this.#database.prepare(`INSERT INTO deployment_runtime_epochs VALUES (?,?,?,?,?,?,'pending',NULL,?)`).run(request.operationId, plan.id, sequence, runtimeDigest, JSON.stringify(request), controlPlaneDigest(request), request.requestedAt);
|
|
3919
|
+
return { request, status: 'pending' };
|
|
3920
|
+
});
|
|
3921
|
+
}
|
|
3922
|
+
getRuntimeEpoch(operationId) {
|
|
3923
|
+
const row = this.#database.prepare('SELECT * FROM deployment_runtime_epochs WHERE operation_id=?').get(operationId);
|
|
3924
|
+
if (!row)
|
|
3925
|
+
throw new ControlPlaneStoreError('not-found', 'runtime epoch absent');
|
|
3926
|
+
return runtimeEpochFromRow(row);
|
|
3927
|
+
}
|
|
3928
|
+
latestAppliedRuntimeEpoch(planId) {
|
|
3929
|
+
const row = this.#database.prepare(`SELECT * FROM deployment_runtime_epochs WHERE plan_id=? ORDER BY sequence DESC LIMIT 1`)
|
|
3930
|
+
.get(planId);
|
|
3931
|
+
if (row && row.status !== 'applied')
|
|
3932
|
+
throw new ControlPlaneStoreError('conflict', 'current runtime epoch is not independently verified');
|
|
3933
|
+
return row && runtimeEpochFromRow(row);
|
|
3934
|
+
}
|
|
3935
|
+
pendingRuntimeEpochs(coordinatorId) {
|
|
3936
|
+
return this.#database.prepare(`SELECT epoch.* FROM deployment_runtime_epochs epoch
|
|
3937
|
+
JOIN adoption_handoffs handoff ON handoff.plan_id=epoch.plan_id
|
|
3938
|
+
JOIN activation_plans plan ON plan.id=epoch.plan_id
|
|
3939
|
+
WHERE handoff.coordinator_id=? AND handoff.revoked_at IS NULL AND plan.status='activated'
|
|
3940
|
+
AND epoch.status IN ('pending','claimed') AND epoch.created_at + json_extract(epoch.request_json,'$.receiptTtlMs') > ?
|
|
3941
|
+
AND epoch.sequence=(SELECT max(other.sequence) FROM deployment_runtime_epochs other WHERE other.plan_id=epoch.plan_id)
|
|
3942
|
+
ORDER BY epoch.created_at,epoch.operation_id LIMIT 100`).all(coordinatorId, this.#now()).map(runtimeEpochFromRow);
|
|
3943
|
+
}
|
|
3944
|
+
claimRuntimeEpoch(operationId, coordinatorId) {
|
|
3945
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3946
|
+
try {
|
|
3947
|
+
const record = this.getRuntimeEpoch(operationId);
|
|
3948
|
+
if (!['pending', 'claimed'].includes(record.status) || this.#now() >= record.request.requestedAt + record.request.receiptTtlMs) {
|
|
3949
|
+
throw new ControlPlaneStoreError('expired', 'runtime epoch request is not live');
|
|
3950
|
+
}
|
|
3951
|
+
this.#database.prepare("UPDATE deployment_runtime_epochs SET status='claimed' WHERE operation_id=?").run(operationId);
|
|
3952
|
+
const context = readOwnerRuntimeEpochContext(this.#database, operationId);
|
|
3953
|
+
if (context.handoff.coordinatorId !== coordinatorId || (this.#adoptionCoordinatorId !== undefined && this.#adoptionCoordinatorId !== coordinatorId)) {
|
|
3954
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch coordinator changed');
|
|
3955
|
+
}
|
|
3956
|
+
this.#database.exec('COMMIT');
|
|
3957
|
+
return { ...record, status: 'claimed' };
|
|
3958
|
+
}
|
|
3959
|
+
catch (error) {
|
|
3960
|
+
this.#database.exec('ROLLBACK');
|
|
3961
|
+
throw error;
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
applyRuntimeEpoch(receiptInput, trust) {
|
|
3965
|
+
const receipt = parseRuntimeEpochReceipt(receiptInput);
|
|
3966
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3967
|
+
try {
|
|
3968
|
+
const context = readOwnerRuntimeEpochContext(this.#database, receipt.operationId);
|
|
3969
|
+
if (this.#now() >= context.request.requestedAt + context.request.receiptTtlMs)
|
|
3970
|
+
throw new ControlPlaneStoreError('expired', 'runtime epoch request expired before application');
|
|
3971
|
+
verifyRuntimeEpochReceipt(receipt, context.request, trust, this.#now());
|
|
3972
|
+
if (receipt.outcome !== 'passed' || receipt.evidence.failures !== 0)
|
|
3973
|
+
throw new ControlPlaneStoreError('conflict', 'failed runtime epoch cannot be applied');
|
|
3974
|
+
if (context.request.ledger.id !== trust.ledger.id || context.request.ledger.path !== trust.ledger.path) {
|
|
3975
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch trust ledger changed');
|
|
3976
|
+
}
|
|
3977
|
+
this.#database.prepare("UPDATE deployment_runtime_epochs SET status='applied',receipt_json=? WHERE operation_id=? AND status='claimed'")
|
|
3978
|
+
.run(JSON.stringify(receipt), receipt.operationId);
|
|
3979
|
+
const result = this.getRuntimeEpoch(receipt.operationId);
|
|
3980
|
+
this.#database.exec('COMMIT');
|
|
3981
|
+
return result;
|
|
3982
|
+
}
|
|
3983
|
+
catch (error) {
|
|
3984
|
+
this.#database.exec('ROLLBACK');
|
|
3985
|
+
throw error;
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
2134
3988
|
prepareHostAttestationOperation(input) {
|
|
2135
3989
|
return this.#withActivationSource(input.planId, () => {
|
|
2136
3990
|
const now = this.#now();
|
|
@@ -2283,25 +4137,194 @@ export class ControlPlaneStore {
|
|
|
2283
4137
|
};
|
|
2284
4138
|
return input.requireCurrentSource ? this.#withActivationSource(plan.id, commit) : commit();
|
|
2285
4139
|
}
|
|
4140
|
+
listLiveQualificationDeployments(planId, limit = 1001) {
|
|
4141
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
|
|
4142
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid live task limit');
|
|
4143
|
+
const window = this.getLiveQualificationWindow(planId);
|
|
4144
|
+
const committed = this.#database.prepare('SELECT started_at FROM activation_watch WHERE plan_id=?').get(planId);
|
|
4145
|
+
const cutoff = Math.min(window.deadlineAt, committed ? Number(committed.started_at) : window.deadlineAt);
|
|
4146
|
+
return this.#database.prepare(`SELECT inbox_id FROM foreground_deployments WHERE plan_id=?
|
|
4147
|
+
AND json_extract(record_json,'$.task.dispatchedAt')>=? AND json_extract(record_json,'$.task.dispatchedAt')<?
|
|
4148
|
+
ORDER BY inbox_id LIMIT ?`).all(planId, window.startedAt, cutoff, limit)
|
|
4149
|
+
.map(row => this.getForegroundDeployment(row.inbox_id));
|
|
4150
|
+
}
|
|
4151
|
+
listLiveQualificationPlans(profilePath, limit = 100) {
|
|
4152
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
4153
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid qualification limit');
|
|
4154
|
+
return this.#database.prepare(`SELECT plan.* FROM activation_plans plan WHERE plan.target_path=? AND plan.status IN
|
|
4155
|
+
('awaiting-live-tasks','commit-pending','activated') AND json_type(plan.dossier_json,'$.liveQualification')='object'
|
|
4156
|
+
AND (plan.status<>'activated' OR (plan.id=(SELECT checkpoint.plan_id FROM activation_deployment_checkpoints checkpoint
|
|
4157
|
+
JOIN activation_plans newer ON newer.id=checkpoint.plan_id WHERE newer.target_path=plan.target_path ORDER BY checkpoint.exposure_order DESC LIMIT 1)
|
|
4158
|
+
AND NOT EXISTS (SELECT 1 FROM activation_plans active WHERE active.target_path=plan.target_path AND active.id<>plan.id
|
|
4159
|
+
AND active.status IN ('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay',
|
|
4160
|
+
'awaiting-shadow','awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending'))))
|
|
4161
|
+
ORDER BY plan.created_at DESC,plan.id LIMIT ?`).all(profilePath, limit).map(activationFromRow);
|
|
4162
|
+
}
|
|
4163
|
+
getLiveQualificationDeadline(planId) {
|
|
4164
|
+
const plan = this.getPlan(planId);
|
|
4165
|
+
if (!plan.dossier.liveQualification)
|
|
4166
|
+
return undefined;
|
|
4167
|
+
const window = this.#database.prepare('SELECT deadline_at FROM live_qualification_windows WHERE plan_id=?').get(planId);
|
|
4168
|
+
return window ? Number(window.deadline_at) : undefined;
|
|
4169
|
+
}
|
|
4170
|
+
getLiveQualificationWindow(planId) { return liveQualificationWindow(this.#database, this.getPlan(planId)); }
|
|
4171
|
+
getLiveQualificationReadiness(planId) { return liveQualificationReadiness(this.#database, this.getPlan(planId)); }
|
|
4172
|
+
getLiveQualification(id) { return liveQualificationRecord(this.#database, id); }
|
|
4173
|
+
listLiveQualifications(planId, limit = 100) {
|
|
4174
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
4175
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid qualification record limit');
|
|
4176
|
+
return this.#database.prepare('SELECT id FROM live_qualification_batches WHERE plan_id=? ORDER BY created_at DESC,id LIMIT ?').all(planId, limit).map(row => this.getLiveQualification(row.id));
|
|
4177
|
+
}
|
|
4178
|
+
assertCurrentLiveQualification(id) {
|
|
4179
|
+
const { record, plan } = readLiveQualificationContext(this.#database, id);
|
|
4180
|
+
if (record.state === 'stale' || (plan.status !== 'activated' && (this.#now() >= record.batch.deadlineAt || this.#now() >= record.batch.expiresAt))) {
|
|
4181
|
+
throw new ControlPlaneStoreError('expired', 'live qualification expired or became stale');
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
putLiveQualification(batch) {
|
|
4185
|
+
assertLiveQualificationBatch(batch);
|
|
4186
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4187
|
+
try {
|
|
4188
|
+
const plan = this.getPlan(batch.planId);
|
|
4189
|
+
if (plan.status !== 'awaiting-live-tasks' || this.#now() >= batch.deadlineAt || this.#now() >= batch.expiresAt)
|
|
4190
|
+
throw new ControlPlaneStoreError('expired', 'live qualification admission ended');
|
|
4191
|
+
const prior = this.getLiveQualification(batch.id);
|
|
4192
|
+
if (prior) {
|
|
4193
|
+
if (controlPlaneDigest(prior.batch) !== controlPlaneDigest(batch) || prior.state === 'stale')
|
|
4194
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification identity reused');
|
|
4195
|
+
}
|
|
4196
|
+
else
|
|
4197
|
+
this.#database.prepare(`INSERT INTO live_qualification_batches
|
|
4198
|
+
(id,lane,plan_id,batch_json,batch_digest,state,created_at) VALUES (?,?,?,?,?,'pending',?)`).run(batch.id, batch.lane, batch.planId, JSON.stringify(batch), controlPlaneDigest(batch), this.#now());
|
|
4199
|
+
readLiveQualificationContext(this.#database, batch.id);
|
|
4200
|
+
this.#database.exec('COMMIT');
|
|
4201
|
+
}
|
|
4202
|
+
catch (error) {
|
|
4203
|
+
this.#database.exec('ROLLBACK');
|
|
4204
|
+
throw error;
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
staleLiveQualification(id) {
|
|
4208
|
+
this.#database.prepare("UPDATE live_qualification_batches SET state='stale' WHERE id=? AND state IN ('pending','signed')").run(id);
|
|
4209
|
+
}
|
|
4210
|
+
async applyLiveQualification(input) {
|
|
4211
|
+
const record = this.getLiveQualification(input.receipt.batchId);
|
|
4212
|
+
if (!record)
|
|
4213
|
+
throw new ControlPlaneStoreError('not-found', 'live qualification batch absent');
|
|
4214
|
+
const receipt = verifyLiveQualificationReceipt(input.receipt, record.batch, input.publicKeyPem, this.#now());
|
|
4215
|
+
return input.withSourceFence(() => {
|
|
4216
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4217
|
+
try {
|
|
4218
|
+
const current = this.getLiveQualification(record.batch.id);
|
|
4219
|
+
if (current.state === 'applied') {
|
|
4220
|
+
if (controlPlaneDigest(current.receipt) !== controlPlaneDigest(receipt))
|
|
4221
|
+
throw new ControlPlaneStoreError('conflict', 'qualification receipt changed');
|
|
4222
|
+
this.#database.exec('COMMIT');
|
|
4223
|
+
return this.getPlan(record.batch.planId);
|
|
4224
|
+
}
|
|
4225
|
+
this.assertCurrentLiveQualification(record.batch.id);
|
|
4226
|
+
const plan = this.getPlan(record.batch.planId);
|
|
4227
|
+
if (plan.status !== 'awaiting-live-tasks' || this.#adoptionCoordinatorId !== undefined)
|
|
4228
|
+
throw new ControlPlaneStoreError('invalid-state', 'only target feedback runtime may qualify this deployment');
|
|
4229
|
+
const updated = this.#database.prepare(`UPDATE activation_plans SET status=?,revision=revision+1,updated_at=?,failure_code=?,activation_lease_until=NULL
|
|
4230
|
+
WHERE id=? AND revision=? AND activation_fence=? AND status='awaiting-live-tasks'`).run(receipt.disposition === 'qualified' ? 'commit-pending' : 'rollback-pending', this.#now(), receipt.disposition === 'qualified' ? null : 'live-qualification-failed', plan.id, plan.revision, record.batch.fence);
|
|
4231
|
+
if (Number(updated.changes) !== 1)
|
|
4232
|
+
throw new ControlPlaneStoreError('conflict', 'qualification lost plan CAS');
|
|
4233
|
+
this.#database.prepare("UPDATE live_qualification_batches SET state='applied',receipt_json=?,receipt_digest=? WHERE id=? AND state IN ('pending','signed')")
|
|
4234
|
+
.run(JSON.stringify(receipt), controlPlaneDigest(receipt), record.batch.id);
|
|
4235
|
+
this.#database.exec('COMMIT');
|
|
4236
|
+
return this.getPlan(plan.id);
|
|
4237
|
+
}
|
|
4238
|
+
catch (error) {
|
|
4239
|
+
this.#database.exec('ROLLBACK');
|
|
4240
|
+
throw error;
|
|
4241
|
+
}
|
|
4242
|
+
});
|
|
4243
|
+
}
|
|
4244
|
+
/** Negative-only Host seam. The signed terms permit conservative removal of unconfirmable qualification. */
|
|
4245
|
+
invalidateLiveQualification(input) {
|
|
4246
|
+
const reason = bounded(input.reason, 'qualification invalidation reason', 160);
|
|
4247
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4248
|
+
try {
|
|
4249
|
+
const plan = this.getPlan(input.planId), record = this.getLiveQualification(input.batchId);
|
|
4250
|
+
if (!record || record.batch.planId !== plan.id || record.state !== 'applied' || record.receipt?.disposition !== 'qualified'
|
|
4251
|
+
|| record.batch.planDigest !== plan.digest || record.batch.activationId !== plan.activation?.id || !plan.dossier.liveQualification) {
|
|
4252
|
+
throw new ControlPlaneStoreError('conflict', 'qualification invalidation lacks exact accepted evidence');
|
|
4253
|
+
}
|
|
4254
|
+
if (plan.status === 'rollback-pending' || plan.status === 'rolled-back') {
|
|
4255
|
+
this.#database.exec('COMMIT');
|
|
4256
|
+
return plan;
|
|
4257
|
+
}
|
|
4258
|
+
if (!['commit-pending', 'activated'].includes(plan.status) || record.batch.fence !== plan.activation.fence)
|
|
4259
|
+
throw new ControlPlaneStoreError('conflict', 'qualification invalidation no longer targets current deployment');
|
|
4260
|
+
this.assertNoClaimedHostAttestation(plan.id);
|
|
4261
|
+
if (plan.status === 'activated') {
|
|
4262
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id,checkpoint.successful_order,checkpoint.exposure_order FROM activation_deployment_checkpoints checkpoint
|
|
4263
|
+
JOIN activation_plans candidate ON candidate.id=checkpoint.plan_id WHERE candidate.target_path=? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(plan.target.profilePath);
|
|
4264
|
+
if (!latest || latest.plan_id !== plan.id || latest.successful_order !== latest.exposure_order || !plan.activation?.hostRecoveryRequired
|
|
4265
|
+
|| !this.getActivationInstalledBaseline(plan.id))
|
|
4266
|
+
throw new ControlPlaneStoreError('conflict', 'qualification invalidation cannot restore a superseded deployment');
|
|
4267
|
+
}
|
|
4268
|
+
const competing = this.#database.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND id<>? AND status IN
|
|
4269
|
+
('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow','awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending')`).get(plan.target.profilePath, plan.id);
|
|
4270
|
+
if (competing)
|
|
4271
|
+
throw new ControlPlaneStoreError('conflict', 'another deployment owns the profile');
|
|
4272
|
+
const now = this.#now();
|
|
4273
|
+
this.#database.prepare(`INSERT INTO live_qualification_invalidations
|
|
4274
|
+
(plan_id,batch_id,batch_digest,receipt_digest,activation_id,fence,reason,invalidated_at) VALUES (?,?,?,?,?,?,?,?)`).run(plan.id, record.batch.id, record.batch.digest, controlPlaneDigest(record.receipt), record.batch.activationId, record.batch.fence, reason, now);
|
|
4275
|
+
// A live commit worker is fenced by revision immediately; its final source guard must fail.
|
|
4276
|
+
this.#database.prepare(`UPDATE activation_plans SET status='rollback-pending',revision=revision+1,
|
|
4277
|
+
activation_lease_until=NULL,rollback_profile_restored=0,failure_code='live-qualification-invalidated',updated_at=?
|
|
4278
|
+
WHERE id=? AND revision=? AND activation_fence=?`).run(now, plan.id, plan.revision, plan.activation.fence);
|
|
4279
|
+
this.#database.exec('COMMIT');
|
|
4280
|
+
return this.getPlan(plan.id);
|
|
4281
|
+
}
|
|
4282
|
+
catch (error) {
|
|
4283
|
+
this.#database.exec('ROLLBACK');
|
|
4284
|
+
throw error;
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
#assertLiveQualificationCommit(planId) {
|
|
4288
|
+
const plan = this.getPlan(planId);
|
|
4289
|
+
if (!plan.dossier.liveQualification)
|
|
4290
|
+
return;
|
|
4291
|
+
if (!this.#withLiveQualificationFence)
|
|
4292
|
+
throw new ControlPlaneStoreError('invalid-state', 'target canonical qualification fence unavailable');
|
|
4293
|
+
const record = this.listLiveQualifications(planId).find(item => item.state === 'applied' && item.receipt?.disposition === 'qualified');
|
|
4294
|
+
if (!record || this.#now() >= record.batch.deadlineAt || this.#now() >= record.receipt.expiresAt)
|
|
4295
|
+
throw new ControlPlaneStoreError('expired', 'accepted live qualification is absent or expired');
|
|
4296
|
+
this.assertCurrentLiveQualification(record.batch.id);
|
|
4297
|
+
}
|
|
2286
4298
|
/** Same synchronous transaction owns current-profile selection and capture. */
|
|
2287
4299
|
withForegroundDeployment(task, profilePath, callback) {
|
|
2288
4300
|
assertForegroundTask(task);
|
|
2289
4301
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
2290
4302
|
try {
|
|
2291
4303
|
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',
|
|
4304
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2293
4305
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`).get(profilePath);
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
4306
|
+
let plan;
|
|
4307
|
+
if (active) {
|
|
4308
|
+
plan = this.getPlan(String(active.id));
|
|
4309
|
+
if (!plan.dossier.liveQualification || !['awaiting-live-tasks', 'commit-pending'].includes(plan.status))
|
|
4310
|
+
throw new ControlPlaneStoreError('conflict', 'profile has an unsettled activation');
|
|
4311
|
+
const window = this.getLiveQualificationWindow(plan.id);
|
|
4312
|
+
if (task.dispatchedAt < window.startedAt || task.dispatchedAt >= window.deadlineAt || this.#now() >= window.deadlineAt) {
|
|
4313
|
+
throw new ControlPlaneStoreError('expired', 'task is outside the live qualification window');
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
else {
|
|
4317
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id, checkpoint.exposure_order, checkpoint.successful_order
|
|
4318
|
+
FROM activation_deployment_checkpoints checkpoint JOIN activation_plans plan ON plan.id = checkpoint.plan_id
|
|
4319
|
+
WHERE plan.target_path = ? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(profilePath);
|
|
4320
|
+
if (!latest || latest.exposure_order !== latest.successful_order)
|
|
4321
|
+
throw new ControlPlaneStoreError('invalid-state', 'profile has no current successful deployment');
|
|
4322
|
+
plan = this.getPlan(latest.plan_id);
|
|
4323
|
+
const watch = this.getActivationWatch(plan.id);
|
|
4324
|
+
if (plan.status !== 'activated' || !plan.activation || watch.state !== 'watching'
|
|
4325
|
+
|| watch.activationId !== plan.activation.id || watch.fence !== plan.activation.fence || watch.startedAt > task.dispatchedAt) {
|
|
4326
|
+
throw new ControlPlaneStoreError('invalid-state', 'task did not begin under a watched deployment');
|
|
4327
|
+
}
|
|
2305
4328
|
}
|
|
2306
4329
|
const source = readOwnerSourceAdoptionPlan(this.#database, plan.id).source;
|
|
2307
4330
|
const owner = source.owner;
|
|
@@ -2497,19 +4520,26 @@ export class ControlPlaneStore {
|
|
|
2497
4520
|
}
|
|
2498
4521
|
#isPostActivationRollback(plan) {
|
|
2499
4522
|
if (plan.status !== 'rollback-pending' || plan.activation === undefined || plan.activation.failureCode === undefined
|
|
2500
|
-
|| !['post-activation-regressed', 'post-activation-retracted'].includes(plan.activation.failureCode))
|
|
4523
|
+
|| !['post-activation-regressed', 'post-activation-retracted', 'live-qualification-invalidated'].includes(plan.activation.failureCode))
|
|
2501
4524
|
return false;
|
|
2502
4525
|
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2503
4526
|
.get(plan.id);
|
|
2504
4527
|
return checkpoint !== undefined && checkpoint.successful_order === checkpoint.exposure_order;
|
|
2505
4528
|
}
|
|
2506
4529
|
#hasPostActivationRollbackProvenance(plan) {
|
|
2507
|
-
if (plan.activation === undefined || !['post-activation-regressed', 'post-activation-retracted'].includes(plan.activation.failureCode ?? ''))
|
|
4530
|
+
if (plan.activation === undefined || !['post-activation-regressed', 'post-activation-retracted', 'live-qualification-invalidated'].includes(plan.activation.failureCode ?? ''))
|
|
2508
4531
|
return false;
|
|
2509
4532
|
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2510
4533
|
.get(plan.id);
|
|
2511
4534
|
if (checkpoint === undefined || checkpoint.successful_order !== checkpoint.exposure_order)
|
|
2512
4535
|
return false;
|
|
4536
|
+
if (plan.activation.failureCode === 'live-qualification-invalidated') {
|
|
4537
|
+
const invalidation = this.#database.prepare('SELECT * FROM live_qualification_invalidations WHERE plan_id=?').get(plan.id);
|
|
4538
|
+
const accepted = invalidation && this.getLiveQualification(invalidation.batch_id);
|
|
4539
|
+
return !!invalidation && accepted?.state === 'applied' && accepted.receipt?.disposition === 'qualified'
|
|
4540
|
+
&& invalidation.batch_digest === accepted.batch.digest && invalidation.receipt_digest === controlPlaneDigest(accepted.receipt)
|
|
4541
|
+
&& invalidation.activation_id === plan.activation.id && invalidation.fence === accepted.batch.fence && invalidation.fence <= plan.activation.fence;
|
|
4542
|
+
}
|
|
2513
4543
|
const watch = this.#database.prepare('SELECT activation_id, fence, state, close_signature_digest FROM activation_watch WHERE plan_id = ?').get(plan.id);
|
|
2514
4544
|
return watch !== undefined && (watch.state === 'closed-regressed' || watch.state === 'closed-retracted')
|
|
2515
4545
|
&& watch.close_signature_digest !== null && watch.activation_id === plan.activation.id && watch.fence <= plan.activation.fence;
|
|
@@ -2614,7 +4644,7 @@ export class ControlPlaneStore {
|
|
|
2614
4644
|
AND NOT EXISTS (SELECT 1 FROM host_attestation_operations operation JOIN host_attestation_dispatches dispatch
|
|
2615
4645
|
ON dispatch.operation_id = operation.operation_id WHERE operation.plan_id = activation_plans.id
|
|
2616
4646
|
AND dispatch.status = 'claimed')
|
|
2617
|
-
AND (status IN ('awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
4647
|
+
AND (status IN ('awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2618
4648
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health')
|
|
2619
4649
|
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
4650
|
if (Number(result.changes) !== 1)
|
|
@@ -2623,6 +4653,18 @@ export class ControlPlaneStore {
|
|
|
2623
4653
|
}, true);
|
|
2624
4654
|
}
|
|
2625
4655
|
advanceActivation(input) {
|
|
4656
|
+
if (input.to === 'activated' && this.getPlan(input.planId).dossier.liveQualification && this.#liveQualificationAdmission !== input.planId) {
|
|
4657
|
+
this.#assertLiveQualificationCommit(input.planId);
|
|
4658
|
+
return this.#withLiveQualificationFence(input.planId, () => {
|
|
4659
|
+
this.#liveQualificationAdmission = input.planId;
|
|
4660
|
+
try {
|
|
4661
|
+
return this.withOwnerTaskFailureGapAdmission(this.getPlan(input.planId).gapId, () => this.advanceActivation(input));
|
|
4662
|
+
}
|
|
4663
|
+
finally {
|
|
4664
|
+
this.#liveQualificationAdmission = undefined;
|
|
4665
|
+
}
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
2626
4668
|
return this.#withActivationSource(input.planId, () => {
|
|
2627
4669
|
const allowed = { staging: ['awaiting-reload', 'rollback-pending'],
|
|
2628
4670
|
'rollback-pending': ['rolled-back'], 'commit-pending': ['activated', 'rollback-pending'] };
|
|
@@ -2675,7 +4717,7 @@ export class ControlPlaneStore {
|
|
|
2675
4717
|
if (replay !== undefined) {
|
|
2676
4718
|
const transition = Object.values(expectedAttestation).find(item => item.phase === input.receipt.phase);
|
|
2677
4719
|
const expectedStatus = input.receipt.outcome === 'passed' && transition !== undefined
|
|
2678
|
-
? transition.next : 'rollback-pending';
|
|
4720
|
+
? (input.receipt.phase === 'readiness' && replay.result.dossier.liveQualification ? 'awaiting-live-tasks' : transition.next) : 'rollback-pending';
|
|
2679
4721
|
const expectedFailure = input.receipt.outcome === 'passed' ? undefined : 'host-attestation-failed';
|
|
2680
4722
|
if (replay.result.status !== expectedStatus || replay.result.revision !== input.expectedRevision + 1
|
|
2681
4723
|
|| replay.result.activation?.id !== input.receipt.activationId || replay.result.activation.fence !== input.expectedFence
|
|
@@ -2702,7 +4744,7 @@ export class ControlPlaneStore {
|
|
|
2702
4744
|
throw new ControlPlaneStoreError('conflict', 'non-transition Host receipt changed generation from its predecessor');
|
|
2703
4745
|
}
|
|
2704
4746
|
const now = this.#now();
|
|
2705
|
-
const nextStatus = verified.outcome === 'passed' ? expected.next : 'rollback-pending';
|
|
4747
|
+
const nextStatus = verified.outcome === 'passed' ? (verified.phase === 'readiness' && plan.dossier.liveQualification ? 'awaiting-live-tasks' : expected.next) : 'rollback-pending';
|
|
2706
4748
|
return this.#withActivationSource(input.planId, () => {
|
|
2707
4749
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
2708
4750
|
try {
|
|
@@ -2938,6 +4980,8 @@ export class ControlPlaneStore {
|
|
|
2938
4980
|
ON CONFLICT(plan_id) DO NOTHING`).run(plan.id, plan.candidate.package, plan.candidate.version, plan.candidate.integrity, activationId, fence, now, now);
|
|
2939
4981
|
if (Number(result.changes) !== 1)
|
|
2940
4982
|
throw new ControlPlaneStoreError('invalid-state', 'activated plan has no post-activation watch');
|
|
4983
|
+
if (plan.dossier.liveQualification)
|
|
4984
|
+
this.#database.prepare('UPDATE activation_watch SET last_host_generation=? WHERE plan_id=?').run(this.getLiveQualificationWindow(plan.id).hostGeneration, plan.id);
|
|
2941
4985
|
}
|
|
2942
4986
|
}
|
|
2943
4987
|
#reopenGapAfterActivation(plan, now) {
|
|
@@ -3085,9 +5129,64 @@ export class ControlPlaneStore {
|
|
|
3085
5129
|
const now = this.#now();
|
|
3086
5130
|
if (now > plan.expiresAt || now > verified.expiresAt)
|
|
3087
5131
|
throw new ControlPlaneStoreError('expired', 'source release authorization is no longer applicable');
|
|
5132
|
+
const prelinked = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id=? LIMIT 2')
|
|
5133
|
+
.all(plan.id);
|
|
5134
|
+
const prebaseline = prelinked.length === 1 ? sourceJobFromRow(prelinked[0]).intent.baseline : undefined;
|
|
5135
|
+
const premaintenance = prebaseline ? this.getSourceMaintenanceRecords(plan.repository) : [];
|
|
5136
|
+
let lineageDigest;
|
|
5137
|
+
if (prebaseline && premaintenance.length) {
|
|
5138
|
+
if (!input.trust)
|
|
5139
|
+
throw new ControlPlaneStoreError('invalid-input', 'managed source maintenance requires current trust');
|
|
5140
|
+
const prehistory = this.getSourceBaselineHistory(plan.repository);
|
|
5141
|
+
await verifySourceBaselineHistory({ repository: plan.repository, config: prebaseline, trust: input.trust,
|
|
5142
|
+
history: prehistory, maintenance: premaintenance, signal: new AbortController().signal });
|
|
5143
|
+
lineageDigest = controlPlaneDigest({ history: prehistory, maintenance: premaintenance, trust: input.trust, baseline: prebaseline });
|
|
5144
|
+
}
|
|
3088
5145
|
return withCurrentSource(plan.gapId, () => {
|
|
3089
5146
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
3090
5147
|
try {
|
|
5148
|
+
const linkedRows = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ? LIMIT 2')
|
|
5149
|
+
.all(plan.id);
|
|
5150
|
+
if (linkedRows.length > 1)
|
|
5151
|
+
throw new ControlPlaneStoreError('invalid-state', 'source plan has multiple source jobs');
|
|
5152
|
+
const linked = linkedRows[0] === undefined ? undefined : sourceJobFromRow(linkedRows[0]);
|
|
5153
|
+
const managedRows = this.#database.prepare(`SELECT * FROM source_jobs WHERE json_extract(intent_json, '$.repository') = ?
|
|
5154
|
+
AND json_type(intent_json, '$.baseline') IS NOT NULL AND status IN ('queued', 'running', 'unknown', 'prepared')
|
|
5155
|
+
LIMIT 1001`).all(plan.repository);
|
|
5156
|
+
if (managedRows.length > 1000)
|
|
5157
|
+
throw new ControlPlaneStoreError('invalid-state', 'managed source job cohort exceeds bound');
|
|
5158
|
+
for (const row of managedRows) {
|
|
5159
|
+
const managed = sourceJobFromRow(row);
|
|
5160
|
+
if (managed.planId === plan.id)
|
|
5161
|
+
continue;
|
|
5162
|
+
if (managed.status !== 'prepared' || managed.planId === undefined
|
|
5163
|
+
|| !['release-complete', 'release-failed', 'expired', 'local-checks-failed'].includes(this.getSourcePlan(managed.planId).status)) {
|
|
5164
|
+
throw new ControlPlaneStoreError('conflict', 'managed source job owns this repository release');
|
|
5165
|
+
}
|
|
5166
|
+
}
|
|
5167
|
+
if (linked?.intent.baseline !== undefined) {
|
|
5168
|
+
if (linked.status !== 'prepared' || linked.intent.repository !== plan.repository
|
|
5169
|
+
|| linked.intent.baseCommit !== plan.baseCommit) {
|
|
5170
|
+
throw new ControlPlaneStoreError('invalid-state', 'managed source release is not bound to its prepared job');
|
|
5171
|
+
}
|
|
5172
|
+
const active = this.#database.prepare(`SELECT id FROM source_plans WHERE repository = ? AND id <> ?
|
|
5173
|
+
AND release_id IS NOT NULL AND status NOT IN ('release-complete', 'release-failed') LIMIT 1`)
|
|
5174
|
+
.get(plan.repository, plan.id);
|
|
5175
|
+
if (active !== undefined)
|
|
5176
|
+
throw new ControlPlaneStoreError('conflict', 'source repository has another active release');
|
|
5177
|
+
const history = this.getSourceBaselineHistory(plan.repository);
|
|
5178
|
+
const maintenance = this.getSourceMaintenanceRecords(plan.repository);
|
|
5179
|
+
if (lineageDigest !== undefined && controlPlaneDigest({ history, maintenance, trust: input.trust,
|
|
5180
|
+
baseline: linked.intent.baseline }) !== lineageDigest) {
|
|
5181
|
+
throw new ControlPlaneStoreError('conflict', 'managed source signed lineage changed before release');
|
|
5182
|
+
}
|
|
5183
|
+
if (maintenance.length && lineageDigest === undefined) {
|
|
5184
|
+
throw new ControlPlaneStoreError('conflict', 'managed source maintenance was not preverified');
|
|
5185
|
+
}
|
|
5186
|
+
const expectedCommit = sourceBaselineChain(linked.intent.baseline, history, maintenance).at(-1);
|
|
5187
|
+
if (plan.baseCommit !== expectedCommit)
|
|
5188
|
+
throw new ControlPlaneStoreError('conflict', 'managed source release base commit is stale');
|
|
5189
|
+
}
|
|
3091
5190
|
const releaseId = `release-${randomUUID()}`;
|
|
3092
5191
|
const result = this.#database.prepare(`UPDATE source_plans SET status = 'awaiting-pr', revision = revision + 1,
|
|
3093
5192
|
release_authorization_json = ?, release_authorization_digest = ?, release_id = ?, release_fence = release_fence + 1,
|
|
@@ -3785,7 +5884,7 @@ export class ControlPlaneStore {
|
|
|
3785
5884
|
const row = this.#database.prepare(`SELECT
|
|
3786
5885
|
(SELECT count(*) FROM capability_gaps WHERE status = 'open') AS gaps,
|
|
3787
5886
|
(SELECT count(*) FROM activation_plans WHERE status = 'approved') + (SELECT count(*) FROM source_plans WHERE status = 'approved') AS ready_plans,
|
|
3788
|
-
(SELECT count(*) FROM activation_plans WHERE status IN ('staging', 'awaiting-reload', 'awaiting-readiness',
|
|
5887
|
+
(SELECT count(*) FROM activation_plans WHERE status IN ('staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks',
|
|
3789
5888
|
'awaiting-effect-blocked-replay', 'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending')) AS active_activations,
|
|
3790
5889
|
(SELECT count(*) FROM activation_plans WHERE status = 'rolled-back') +
|
|
3791
5890
|
(SELECT count(*) FROM source_plans WHERE status IN ('local-checks-failed', 'release-failed')) AS failed,
|
|
@@ -3826,15 +5925,32 @@ export class ControlPlaneStore {
|
|
|
3826
5925
|
const replayBinding = { operation: 'create-source-plan', gapId: snapshot.gapId, repository: snapshot.repository,
|
|
3827
5926
|
worktree: snapshot.worktree, baseCommit: snapshot.baseCommit, name: snapshot.name, generatorDigest: snapshot.generatorDigest,
|
|
3828
5927
|
scope: snapshot.scope, ttlMs: snapshot.expiresAt - snapshot.createdAt };
|
|
5928
|
+
const linkedRow = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ?').get(snapshot.id);
|
|
5929
|
+
const linked = linkedRow === undefined ? undefined : sourceJobFromRow(linkedRow);
|
|
5930
|
+
const sourceJob = linked === undefined ? undefined : { jobId: linked.id, jobRevision: linked.revision - 1,
|
|
5931
|
+
occurrenceId: linked.occurrenceId };
|
|
5932
|
+
const replayInput = { ...(snapshot.mode === 'create' ? replayBinding : { ...replayBinding,
|
|
5933
|
+
mode: snapshot.mode, ...(snapshot.creation === undefined ? {} : { creation: snapshot.creation }),
|
|
5934
|
+
...(snapshot.sourceRevision === undefined ? {} : { revision: snapshot.sourceRevision }),
|
|
5935
|
+
prepared: { treeDigest: snapshot.sourceCheck.treeDigest, patchDigest: snapshot.sourceCheck.patchDigest,
|
|
5936
|
+
checkedAt: snapshot.sourceCheck.checkedAt, evidence: snapshot.preparedEvidence } }),
|
|
5937
|
+
...(sourceJob === undefined ? {} : { sourceJob }) };
|
|
5938
|
+
const replayDigest = controlPlaneDigest(replayInput);
|
|
5939
|
+
const artifactReplayDigest = (snapshot.mode === 'prepared-create' || snapshot.mode === 'prepared-revise') && snapshot.preparedEvidence !== undefined
|
|
5940
|
+
? controlPlaneDigest({ ...replayInput, preparedArtifactSha256: snapshot.preparedEvidence.pack.sha256 }) : undefined;
|
|
3829
5941
|
if (snapshot.digest !== authoritative.digest || snapshot.gapId !== authoritative.gapId
|
|
3830
5942
|
|| snapshot.revision !== 1 || snapshot.status !== 'pending-approval' || snapshot.createdAt !== receipt.createdAt
|
|
3831
5943
|
|| snapshot.approval !== undefined || snapshot.releaseAuthorization !== undefined
|
|
3832
|
-
|| snapshot.release !== undefined ||
|
|
3833
|
-
prepared: { treeDigest: snapshot.sourceCheck.treeDigest, patchDigest: snapshot.sourceCheck.patchDigest,
|
|
3834
|
-
checkedAt: snapshot.sourceCheck.checkedAt, evidence: snapshot.preparedEvidence } } : replayBinding) !== inputDigest
|
|
5944
|
+
|| snapshot.release !== undefined || (replayDigest !== inputDigest && artifactReplayDigest !== inputDigest)
|
|
3835
5945
|
|| (snapshot.mode === 'create' && (snapshot.sourceCheck !== undefined || snapshot.preparedEvidence !== undefined))
|
|
3836
5946
|
|| (snapshot.mode === 'modify' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
3837
|
-
|| controlPlaneDigest(snapshot.sourceCheck) !== controlPlaneDigest(authoritative.sourceCheck)))
|
|
5947
|
+
|| controlPlaneDigest(snapshot.sourceCheck) !== controlPlaneDigest(authoritative.sourceCheck)))
|
|
5948
|
+
|| (snapshot.mode === 'prepared-create' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
5949
|
+
|| snapshot.creation === undefined || linked?.intent.mode !== 'create'
|
|
5950
|
+
|| controlPlaneDigest(snapshot.creation) !== controlPlaneDigest(authoritative.creation)))
|
|
5951
|
+
|| (snapshot.mode === 'prepared-revise' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
5952
|
+
|| snapshot.sourceRevision === undefined || linked?.intent.mode !== 'revise-created'
|
|
5953
|
+
|| controlPlaneDigest(snapshot.sourceRevision) !== controlPlaneDigest(authoritative.sourceRevision)))) {
|
|
3838
5954
|
throw new ControlPlaneStoreError('invalid-state', 'stored source operation receipt is not bound to authoritative state');
|
|
3839
5955
|
}
|
|
3840
5956
|
return { ...receipt, result: snapshot };
|
|
@@ -3867,7 +5983,7 @@ export class ControlPlaneStore {
|
|
|
3867
5983
|
catalog: { digest: snapshot.dossier.catalogDigest, provenance: snapshot.dossier.catalogProvenance },
|
|
3868
5984
|
matchedCapabilities: snapshot.dossier.matchedCapabilities, profile: snapshot.profile, target: snapshot.target,
|
|
3869
5985
|
installationId: snapshot.installationId, ledger: snapshot.ledger, executor: snapshot.executor,
|
|
3870
|
-
ttlMs: snapshot.expiresAt - snapshot.createdAt, ...(adoption ? { sourcePlanId: adoption.source_plan_id } : {}), ...(snapshot.dossier.handoff === undefined ? {} : { handoff: snapshot.dossier.handoff }) };
|
|
5986
|
+
ttlMs: snapshot.expiresAt - snapshot.createdAt, ...(adoption ? { sourcePlanId: adoption.source_plan_id } : {}), ...(snapshot.dossier.handoff === undefined ? {} : { handoff: snapshot.dossier.handoff }), ...(snapshot.dossier.liveQualification === undefined ? {} : { liveQualification: snapshot.dossier.liveQualification }) };
|
|
3871
5987
|
if (snapshot.digest !== authoritative.digest || snapshot.gapId !== authoritative.gapId || snapshot.revision !== 1
|
|
3872
5988
|
|| snapshot.status !== 'pending-approval' || snapshot.createdAt !== receipt.createdAt
|
|
3873
5989
|
|| snapshot.approval !== undefined || snapshot.activation !== undefined || controlPlaneDigest(replayBinding) !== inputDigest) {
|