@dsh-enhanced/plugin-control-plane 0.1.48 → 0.1.53
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 +187 -15
- package/bin/dsh-live-qualification-authority.js +15 -0
- package/bin/dsh-local-release-adapter.js +331 -9
- 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 +775 -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 +578 -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 +265 -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/native-source-build-worker.d.ts +4 -0
- package/lib/native-source-build-worker.d.ts.map +1 -0
- package/lib/native-source-build-worker.js +87 -0
- package/lib/native-source-build-worker.js.map +1 -0
- package/lib/native-source-build.d.ts +36 -0
- package/lib/native-source-build.d.ts.map +1 -0
- package/lib/native-source-build.js +143 -0
- package/lib/native-source-build.js.map +1 -0
- package/lib/native-source-pack.d.ts +11 -0
- package/lib/native-source-pack.d.ts.map +1 -0
- package/lib/native-source-pack.js +83 -0
- package/lib/native-source-pack.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-protocol.d.ts +3 -3
- package/lib/runtime-observer-protocol.js +5 -5
- package/lib/runtime-observer-protocol.js.map +1 -1
- 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 +248 -4
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1431 -64
- 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-approval-authority.d.ts.map +1 -1
- package/lib/source-approval-authority.js +1 -0
- package/lib/source-approval-authority.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 +43 -9
- package/lib/source-build.d.ts.map +1 -1
- package/lib/source-build.js +670 -57
- 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 +28 -1
- package/lib/source-job-types.d.ts.map +1 -1
- package/lib/source-jobs.d.ts +33 -3
- package/lib/source-jobs.d.ts.map +1 -1
- package/lib/source-jobs.js +420 -43
- 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 +2257 -96
- 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 +57 -7
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +22 -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);
|
|
@@ -373,6 +643,29 @@ function activationFromRow(row) {
|
|
|
373
643
|
}
|
|
374
644
|
function preparedEvidenceFromStored(value) {
|
|
375
645
|
const item = objectRecord(value, 'stored source prepared evidence');
|
|
646
|
+
if (item['schemaVersion'] === 2) {
|
|
647
|
+
exactKeys(item, ['schemaVersion', 'kind', 'boundary', 'runtimeDigest', 'fixedCompilerSha256', 'checks', 'pack', 'preparedAt'], 'stored native source prepared evidence');
|
|
648
|
+
const pack = objectRecord(item['pack'], 'stored native source pack');
|
|
649
|
+
exactKeys(pack, ['name', 'version', 'sizeBytes', 'sha256'], 'stored native source pack');
|
|
650
|
+
const checks = item['checks'];
|
|
651
|
+
if (item['kind'] !== 'dsh-source-prepared-evidence' || item['boundary'] !== 'macos-seatbelt-single-process-v1'
|
|
652
|
+
|| !DIGEST.test(String(item['runtimeDigest'])) || !DIGEST.test(String(item['fixedCompilerSha256']))
|
|
653
|
+
|| !Array.isArray(checks) || checks.length !== 3 || checks[0] !== 'semantic-typecheck'
|
|
654
|
+
|| checks[1] !== 'js-declaration-emit' || checks[2] !== 'host-safe-pack'
|
|
655
|
+
|| typeof pack['name'] !== 'string' || pack['name'] === '' || pack['name'].length > 214
|
|
656
|
+
|| typeof pack['version'] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._+=-]{0,63}$/u.test(pack['version'])
|
|
657
|
+
|| !Number.isSafeInteger(pack['sizeBytes']) || Number(pack['sizeBytes']) < 1 || Number(pack['sizeBytes']) > 512 * 1024
|
|
658
|
+
|| !DIGEST.test(String(pack['sha256']))
|
|
659
|
+
|| !Number.isSafeInteger(item['preparedAt']) || Number(item['preparedAt']) < 0) {
|
|
660
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored native source prepared evidence is corrupt');
|
|
661
|
+
}
|
|
662
|
+
return Object.freeze({ schemaVersion: 2, kind: 'dsh-source-prepared-evidence',
|
|
663
|
+
boundary: 'macos-seatbelt-single-process-v1', runtimeDigest: String(item['runtimeDigest']),
|
|
664
|
+
fixedCompilerSha256: String(item['fixedCompilerSha256']),
|
|
665
|
+
checks: Object.freeze(['semantic-typecheck', 'js-declaration-emit', 'host-safe-pack']),
|
|
666
|
+
pack: Object.freeze({ name: String(pack['name']), version: String(pack['version']),
|
|
667
|
+
sizeBytes: Number(pack['sizeBytes']), sha256: String(pack['sha256']) }), preparedAt: Number(item['preparedAt']) });
|
|
668
|
+
}
|
|
376
669
|
exactKeys(item, ['schemaVersion', 'kind', 'environment', 'commands', 'pack', 'preparedAt'], 'stored source prepared evidence');
|
|
377
670
|
const environment = objectRecord(item['environment'], 'stored source prepared evidence environment');
|
|
378
671
|
exactKeys(environment, ['npmConfigIgnoreScripts', 'frozenLockfile', 'offline', 'nodeVersion', 'pnpmVersion'], 'stored source prepared evidence environment');
|
|
@@ -388,15 +681,18 @@ function preparedEvidenceFromStored(value) {
|
|
|
388
681
|
const commands = item['commands'].map((entry, index) => {
|
|
389
682
|
const command = objectRecord(entry, `stored source prepared evidence command ${index}`);
|
|
390
683
|
exactKeys(command, ['command', 'args', 'exitCode', 'durationMs', 'logDigest'], `stored source prepared evidence command ${index}`);
|
|
684
|
+
const args = command['args'];
|
|
685
|
+
const scriptIndex = command['command'] === 'docker' && Array.isArray(args) && args.at(-2) === '-ceu'
|
|
686
|
+
? args.length - 1 : -1;
|
|
391
687
|
if (typeof command['command'] !== 'string' || command['command'] === '' || command['command'].length > 64
|
|
392
|
-
|| !Array.isArray(
|
|
393
|
-
||
|
|
688
|
+
|| !Array.isArray(args) || args.length > 96
|
|
689
|
+
|| args.some((arg, argIndex) => typeof arg !== 'string' || arg.length > (argIndex === scriptIndex ? 32_768 : 4096))
|
|
394
690
|
|| command['exitCode'] !== 0
|
|
395
691
|
|| !Number.isSafeInteger(command['durationMs']) || Number(command['durationMs']) < 0
|
|
396
692
|
|| !DIGEST.test(String(command['logDigest']))) {
|
|
397
693
|
throw new ControlPlaneStoreError('invalid-state', `stored source prepared evidence command ${index} is corrupt`);
|
|
398
694
|
}
|
|
399
|
-
return Object.freeze({ command: String(command['command']), args: Object.freeze([...
|
|
695
|
+
return Object.freeze({ command: String(command['command']), args: Object.freeze([...args]),
|
|
400
696
|
exitCode: 0, durationMs: Number(command['durationMs']), logDigest: String(command['logDigest']) });
|
|
401
697
|
});
|
|
402
698
|
const pack = objectRecord(item['pack'], 'stored source prepared evidence pack');
|
|
@@ -415,8 +711,21 @@ function preparedEvidenceFromStored(value) {
|
|
|
415
711
|
sizeBytes: Number(pack['sizeBytes']), sha256: String(pack['sha256']) }),
|
|
416
712
|
preparedAt: Number(item['preparedAt']) });
|
|
417
713
|
}
|
|
714
|
+
function preparedArtifactBytesFromRow(row, pack) {
|
|
715
|
+
if (row.pack_sha256 !== pack.sha256 || !Number.isSafeInteger(row.size_bytes)
|
|
716
|
+
|| row.size_bytes < 1 || row.size_bytes > MAX_PREPARED_ARTIFACT_BYTES
|
|
717
|
+
|| row.size_bytes !== pack.sizeBytes || !(row.bytes instanceof Uint8Array)
|
|
718
|
+
|| !Number.isSafeInteger(row.created_at) || row.created_at < 0) {
|
|
719
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored prepared source artifact metadata is corrupt');
|
|
720
|
+
}
|
|
721
|
+
const bytes = Buffer.from(row.bytes);
|
|
722
|
+
if (bytes.length !== row.size_bytes || createHash('sha256').update(bytes).digest('hex') !== pack.sha256) {
|
|
723
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored prepared source artifact bytes are corrupt');
|
|
724
|
+
}
|
|
725
|
+
return bytes;
|
|
726
|
+
}
|
|
418
727
|
function sourceFromRow(row) {
|
|
419
|
-
if (row.mode !== 'create' && row.mode !== 'modify') {
|
|
728
|
+
if (row.mode !== 'create' && row.mode !== 'modify' && row.mode !== 'prepared-create' && row.mode !== 'prepared-revise') {
|
|
420
729
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan has an unknown mode');
|
|
421
730
|
}
|
|
422
731
|
const gapSnapshot = JSON.parse(row.gap_snapshot_json);
|
|
@@ -439,9 +748,23 @@ function sourceFromRow(row) {
|
|
|
439
748
|
throw new ControlPlaneStoreError('invalid-state', 'stored source prepared evidence is corrupt');
|
|
440
749
|
}
|
|
441
750
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
751
|
+
let creation;
|
|
752
|
+
if (row.creation_json !== null) {
|
|
753
|
+
try {
|
|
754
|
+
creation = creationBindingFromStored(JSON.parse(row.creation_json));
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source creation binding is corrupt');
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const revision = row.revision_json === null ? undefined : revisionBindingFromStored(JSON.parse(row.revision_json));
|
|
761
|
+
const prepared = row.mode === 'modify' || row.mode === 'prepared-create' || row.mode === 'prepared-revise';
|
|
762
|
+
if (prepared !== (preparedEvidence !== undefined) || (row.mode === 'modify' && preparedEvidence?.schemaVersion !== 1)
|
|
763
|
+
|| (row.mode === 'prepared-create') !== (creation !== undefined) || (row.mode === 'prepared-revise') !== (revision !== undefined)
|
|
764
|
+
|| ((row.mode === 'prepared-create' || row.mode === 'prepared-revise') && (!['pending-approval', 'expired'].includes(row.status) || (creation ?? revision)?.generatorDigest !== row.generator_digest
|
|
765
|
+
|| row.approval_json !== null || row.release_authorization_json !== null || row.release_id !== null))
|
|
766
|
+
|| (prepared && (row.checked_tree_digest === null || row.checked_patch_digest === null || row.checked_at === null))
|
|
767
|
+
|| (prepared && ['running-local-checks', 'local-checks-failed'].includes(row.status))) {
|
|
445
768
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan mode does not match its evidence or status');
|
|
446
769
|
}
|
|
447
770
|
let approval;
|
|
@@ -463,8 +786,8 @@ function sourceFromRow(row) {
|
|
|
463
786
|
|| !Number.isSafeInteger(sourceCheck.checkedAt) || sourceCheck.checkedAt < 0))) {
|
|
464
787
|
throw new ControlPlaneStoreError('invalid-state', 'stored source check evidence is corrupt');
|
|
465
788
|
}
|
|
466
|
-
const digestBinding =
|
|
467
|
-
? { ...immutable, mode: row.mode, sourceCheck, preparedEvidence }
|
|
789
|
+
const digestBinding = prepared
|
|
790
|
+
? { ...immutable, mode: row.mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), sourceCheck, preparedEvidence }
|
|
468
791
|
: immutable;
|
|
469
792
|
if (controlPlaneDigest(digestBinding) !== row.plan_digest) {
|
|
470
793
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan is corrupt or digest-mismatched');
|
|
@@ -493,7 +816,7 @@ function sourceFromRow(row) {
|
|
|
493
816
|
&& (row.release_id === null || releaseAuthorization === undefined || sourceCheck === undefined))) {
|
|
494
817
|
throw new ControlPlaneStoreError('invalid-state', 'stored source release state is incomplete');
|
|
495
818
|
}
|
|
496
|
-
return { ...immutable, mode: row.mode, digest: row.plan_digest, status: row.status, revision: row.revision,
|
|
819
|
+
return { ...immutable, mode: row.mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), digest: row.plan_digest, status: row.status, revision: row.revision,
|
|
497
820
|
...(approval === undefined ? {} : { approval }),
|
|
498
821
|
...(sourceCheck === undefined ? {} : { sourceCheck }),
|
|
499
822
|
...(preparedEvidence === undefined ? {} : { preparedEvidence }),
|
|
@@ -507,14 +830,14 @@ function sourceSnapshotFromStored(value) {
|
|
|
507
830
|
// `mode` was introduced in schema v13; receipt rows written by older binaries
|
|
508
831
|
// omit it and are backfilled to 'create'. `preparedEvidence` exists only on
|
|
509
832
|
// 'modify' plans.
|
|
510
|
-
const optional = ['approval', 'sourceCheck', 'preparedEvidence', 'releaseAuthorization', 'release']
|
|
833
|
+
const optional = ['approval', 'sourceCheck', 'preparedEvidence', 'creation', 'sourceRevision', 'releaseAuthorization', 'release']
|
|
511
834
|
.filter(key => Object.hasOwn(item, key));
|
|
512
835
|
exactKeys(item, [...SOURCE_PLAN_KEYS, 'mode', ...optional], 'stored source plan snapshot');
|
|
513
836
|
const rawMode = item['mode'];
|
|
514
837
|
const mode = rawMode === undefined
|
|
515
838
|
? 'create'
|
|
516
|
-
: rawMode === 'create' || rawMode === 'modify' ? rawMode : 'create';
|
|
517
|
-
const modeValid = rawMode === undefined || rawMode === 'create' || rawMode === 'modify';
|
|
839
|
+
: rawMode === 'create' || rawMode === 'modify' || rawMode === 'prepared-create' || rawMode === 'prepared-revise' ? rawMode : 'create';
|
|
840
|
+
const modeValid = rawMode === undefined || rawMode === 'create' || rawMode === 'modify' || rawMode === 'prepared-create' || rawMode === 'prepared-revise';
|
|
518
841
|
const gapSnapshot = objectRecord(item['gapSnapshot'], 'stored source gap snapshot');
|
|
519
842
|
exactKeys(gapSnapshot, ['revision', 'inputDigest', 'roi', 'capability'], 'stored source gap snapshot');
|
|
520
843
|
if (item['schemaVersion'] !== 1 || item['kind'] !== 'source' || typeof item['id'] !== 'string'
|
|
@@ -527,8 +850,9 @@ function sourceSnapshotFromStored(value) {
|
|
|
527
850
|
|| typeof item['name'] !== 'string' || !PLUGIN_NAME.test(item['name']) || typeof item['generatorDigest'] !== 'string'
|
|
528
851
|
|| !DIGEST.test(item['generatorDigest']) || !Array.isArray(item['scope'])
|
|
529
852
|
|| controlPlaneDigest(item['scope']) !== controlPlaneDigest(expectedSourceScope(item['name'], mode))
|
|
530
|
-
|| (mode === 'modify'
|
|
531
|
-
|| (mode === 'create'
|
|
853
|
+
|| ((mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise') !== (item['preparedEvidence'] !== undefined))
|
|
854
|
+
|| ((mode === 'prepared-create') !== (item['creation'] !== undefined))
|
|
855
|
+
|| ((mode === 'prepared-revise') !== (item['sourceRevision'] !== undefined))
|
|
532
856
|
|| !Number.isSafeInteger(gapSnapshot['revision']) || Number(gapSnapshot['revision']) < 1
|
|
533
857
|
|| typeof gapSnapshot['inputDigest'] !== 'string' || !DIGEST.test(gapSnapshot['inputDigest'])
|
|
534
858
|
|| typeof gapSnapshot['roi'] !== 'number' || !Number.isFinite(gapSnapshot['roi']) || typeof gapSnapshot['capability'] !== 'string') {
|
|
@@ -538,6 +862,10 @@ function sourceSnapshotFromStored(value) {
|
|
|
538
862
|
repository: item['repository'], worktree: item['worktree'], baseCommit: item['baseCommit'], name: item['name'],
|
|
539
863
|
generatorDigest: item['generatorDigest'], scope: item['scope'], createdAt: item['createdAt'], expiresAt: item['expiresAt'] };
|
|
540
864
|
const preparedEvidence = item['preparedEvidence'] === undefined ? undefined : preparedEvidenceFromStored(item['preparedEvidence']);
|
|
865
|
+
const creation = item['creation'] === undefined ? undefined : creationBindingFromStored(item['creation']);
|
|
866
|
+
const revision = item['sourceRevision'] === undefined ? undefined : revisionBindingFromStored(item['sourceRevision']);
|
|
867
|
+
if ((mode === 'prepared-create' || mode === 'prepared-revise') && (creation ?? revision)?.generatorDigest !== item['generatorDigest'])
|
|
868
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored source creation generator changed');
|
|
541
869
|
const approval = item['approval'] === undefined ? undefined : verifiedApprovalFromStored(item['approval'], item['id'], item['digest'], Number(item['createdAt']), Number(item['expiresAt']));
|
|
542
870
|
let sourceCheck;
|
|
543
871
|
if (item['sourceCheck'] !== undefined) {
|
|
@@ -550,7 +878,8 @@ function sourceSnapshotFromStored(value) {
|
|
|
550
878
|
}
|
|
551
879
|
sourceCheck = check;
|
|
552
880
|
}
|
|
553
|
-
const digestBinding = mode === 'modify'
|
|
881
|
+
const digestBinding = mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise'
|
|
882
|
+
? { ...immutable, mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), sourceCheck, preparedEvidence } : immutable;
|
|
554
883
|
if (controlPlaneDigest(digestBinding) !== item['digest'])
|
|
555
884
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot digest is corrupt');
|
|
556
885
|
let releaseAuthorization;
|
|
@@ -591,18 +920,19 @@ function sourceSnapshotFromStored(value) {
|
|
|
591
920
|
// running-local-checks / local-checks-failed states.
|
|
592
921
|
const postCheckStates = ['ready-for-human-review', 'release-complete',
|
|
593
922
|
'release-failed', 'publish-ambiguous'];
|
|
594
|
-
const requiresCheck = mode === 'modify'
|
|
923
|
+
const requiresCheck = mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise'
|
|
595
924
|
|| expectedSourceRelease(status) !== undefined || postCheckStates.includes(status);
|
|
596
925
|
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))
|
|
926
|
+
if (((mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise') && (['running-local-checks', 'local-checks-failed'].includes(status) || sourceCheck === undefined))
|
|
927
|
+
|| ((mode === 'prepared-create' || mode === 'prepared-revise') && (!['pending-approval', 'expired'].includes(status) || approval !== undefined || releaseAuthorization !== undefined || release !== undefined))
|
|
598
928
|
|| (status !== 'expired' && requiresApproval !== (approval !== undefined))
|
|
599
|
-
|| (status === 'expired' && mode !== 'modify')
|
|
929
|
+
|| (status === 'expired' && mode !== 'modify' && mode !== 'prepared-create' && mode !== 'prepared-revise')
|
|
600
930
|
|| requiresCheck !== (sourceCheck !== undefined)
|
|
601
931
|
|| requiresRelease !== (releaseAuthorization !== undefined && release !== undefined)
|
|
602
932
|
|| (!requiresRelease && (releaseAuthorization !== undefined || release !== undefined))) {
|
|
603
933
|
throw new ControlPlaneStoreError('invalid-state', 'stored source plan snapshot fields do not match its status');
|
|
604
934
|
}
|
|
605
|
-
return { ...immutable, mode, digest: item['digest'], status: item['status'], revision: Number(item['revision']),
|
|
935
|
+
return { ...immutable, mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), digest: item['digest'], status: item['status'], revision: Number(item['revision']),
|
|
606
936
|
...(approval === undefined ? {} : { approval }), ...(sourceCheck === undefined ? {} : { sourceCheck }),
|
|
607
937
|
...(preparedEvidence === undefined ? {} : { preparedEvidence }),
|
|
608
938
|
...(releaseAuthorization === undefined ? {} : { releaseAuthorization }),
|
|
@@ -782,8 +1112,11 @@ function releaseArtifact(evidence) {
|
|
|
782
1112
|
}
|
|
783
1113
|
function sourceJobIntentFromStored(value) {
|
|
784
1114
|
const intent = objectRecord(value, 'source job intent');
|
|
1115
|
+
const native = typeof intent['build'] === 'object' && intent['build'] !== null
|
|
1116
|
+
&& intent['build']['backend'] === 'macos-seatbelt';
|
|
785
1117
|
exactKeys(intent, ['authority', 'owner', 'ownerDigest', 'trustDigest', 'repository', 'name', 'gapId', 'gapRevision', 'gapDigest',
|
|
786
|
-
'baseCommit', 'files', 'ttlMs', 'build', 'worktree', 'containerName'
|
|
1118
|
+
'baseCommit', 'files', 'ttlMs', 'build', 'worktree', native ? 'nativeResource' : 'containerName', ...(Object.hasOwn(intent, 'baseline') ? ['baseline'] : []),
|
|
1119
|
+
...(Object.hasOwn(intent, 'mode') ? ['mode'] : []), ...(Object.hasOwn(intent, 'creation') ? ['creation'] : []), ...(Object.hasOwn(intent, 'revision') ? ['revision'] : [])], 'source job intent');
|
|
787
1120
|
const authority = objectRecord(intent['authority'], 'source job authority');
|
|
788
1121
|
exactKeys(authority, ['id', 'digest', 'expiresAt', 'maxSubmissions'], 'source job authority');
|
|
789
1122
|
const owner = objectRecord(intent['owner'], 'source job owner');
|
|
@@ -805,9 +1138,21 @@ function sourceJobIntentFromStored(value) {
|
|
|
805
1138
|
|| typeof intent['baseCommit'] !== 'string' || !COMMIT.test(intent['baseCommit'])
|
|
806
1139
|
|| !Array.isArray(intent['files']) || intent['files'].length === 0 || intent['files'].length > 64
|
|
807
1140
|
|| !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']))
|
|
1141
|
+
|| (!native && (typeof intent['containerName'] !== 'string' || !/^dsh-source-job-[a-f0-9]{64}$/u.test(intent['containerName'])))
|
|
1142
|
+
|| (native && (intent['mode'] !== 'create' && intent['mode'] !== 'revise-created'))
|
|
1143
|
+
|| (intent['mode'] !== undefined && intent['mode'] !== 'create' && intent['mode'] !== 'revise-created')
|
|
1144
|
+
|| (intent['mode'] === 'create') !== Object.hasOwn(intent, 'creation')
|
|
1145
|
+
|| (intent['mode'] === 'revise-created') !== Object.hasOwn(intent, 'revision')) {
|
|
809
1146
|
throw new ControlPlaneStoreError('invalid-input', 'source job intent is invalid');
|
|
810
1147
|
}
|
|
1148
|
+
if (native) {
|
|
1149
|
+
const resource = objectRecord(intent['nativeResource'], 'native source job resource');
|
|
1150
|
+
exactKeys(resource, ['backend', 'key'], 'native source job resource');
|
|
1151
|
+
if (resource['backend'] !== 'macos-seatbelt' || typeof resource['key'] !== 'string'
|
|
1152
|
+
|| !/^source-job-[a-f0-9]{64}$/u.test(resource['key'])) {
|
|
1153
|
+
throw new ControlPlaneStoreError('invalid-input', 'native source job resource is invalid');
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
811
1156
|
if (Number(owner['principalVersion']) < 1 || Number(owner['bindingVersion']) < 1 || Number(owner['generation']) < 1) {
|
|
812
1157
|
throw new ControlPlaneStoreError('invalid-input', 'source job owner version is invalid');
|
|
813
1158
|
}
|
|
@@ -826,17 +1171,35 @@ function sourceJobIntentFromStored(value) {
|
|
|
826
1171
|
if (new Set(files.map(file => file.path)).size !== files.length)
|
|
827
1172
|
throw new ControlPlaneStoreError('invalid-input', 'source job files are duplicated');
|
|
828
1173
|
try {
|
|
829
|
-
|
|
1174
|
+
if (intent['mode'] === 'create' || intent['mode'] === 'revise-created')
|
|
1175
|
+
validateSourceCreationFiles(files);
|
|
1176
|
+
else
|
|
1177
|
+
validateScopedPluginFiles(files);
|
|
830
1178
|
}
|
|
831
1179
|
catch {
|
|
832
1180
|
throw new ControlPlaneStoreError('invalid-input', 'source job files are invalid');
|
|
833
1181
|
}
|
|
1182
|
+
const creation = intent['mode'] === 'create' ? creationBindingFromStored(intent['creation']) : undefined;
|
|
1183
|
+
const revision = intent['mode'] === 'revise-created' ? revisionBindingFromStored(intent['revision']) : undefined;
|
|
1184
|
+
if (revision !== undefined && !intent['name'].startsWith(revision.grant.namePrefix))
|
|
1185
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job plugin name is outside its revision grant');
|
|
1186
|
+
if (creation !== undefined && !intent['name'].startsWith(creation.grant.namePrefix)) {
|
|
1187
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job plugin name is outside its creation grant');
|
|
1188
|
+
}
|
|
834
1189
|
try {
|
|
835
1190
|
validateSourceBuildConfig(intent['build']);
|
|
836
1191
|
}
|
|
837
1192
|
catch {
|
|
838
1193
|
throw new ControlPlaneStoreError('invalid-input', 'source job build configuration is invalid');
|
|
839
1194
|
}
|
|
1195
|
+
if (Object.hasOwn(intent, 'baseline')) {
|
|
1196
|
+
try {
|
|
1197
|
+
validateSourceBaselineConfig(intent['baseline']);
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
throw new ControlPlaneStoreError('invalid-input', 'source job baseline configuration is invalid');
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
840
1203
|
return Object.freeze({ authority: Object.freeze({ id: authority['id'], digest: authority['digest'], expiresAt: Number(authority['expiresAt']), maxSubmissions: Number(authority['maxSubmissions']) }),
|
|
841
1204
|
owner: Object.freeze({ receiptVersion: 2, authorityId: owner['authorityId'], authorityHash: owner['authorityHash'],
|
|
842
1205
|
principalId: owner['principalId'], principalRecordId: owner['principalRecordId'], principalVersion: Number(owner['principalVersion']),
|
|
@@ -844,7 +1207,13 @@ function sourceJobIntentFromStored(value) {
|
|
|
844
1207
|
ownerDigest: intent['ownerDigest'], trustDigest: intent['trustDigest'], repository: intent['repository'],
|
|
845
1208
|
name: intent['name'], gapId: intent['gapId'], gapRevision: Number(intent['gapRevision']), gapDigest: intent['gapDigest'],
|
|
846
1209
|
baseCommit: intent['baseCommit'], files: Object.freeze(files), ttlMs: Number(intent['ttlMs']), build: intent['build'],
|
|
847
|
-
worktree: intent['worktree'],
|
|
1210
|
+
worktree: intent['worktree'],
|
|
1211
|
+
...(native ? { nativeResource: Object.freeze({ backend: 'macos-seatbelt',
|
|
1212
|
+
key: intent['nativeResource'].key }) }
|
|
1213
|
+
: { containerName: intent['containerName'] }),
|
|
1214
|
+
...(creation === undefined ? {} : { mode: 'create', creation }),
|
|
1215
|
+
...(revision === undefined ? {} : { mode: 'revise-created', revision }),
|
|
1216
|
+
...(Object.hasOwn(intent, 'baseline') ? { baseline: Object.freeze(intent['baseline']) } : {}) });
|
|
848
1217
|
}
|
|
849
1218
|
function exactInputRecord(value, label) {
|
|
850
1219
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -923,10 +1292,14 @@ function sourceJobFromRow(row) {
|
|
|
923
1292
|
throw new ControlPlaneStoreError('invalid-state', 'stored source job intent is corrupt');
|
|
924
1293
|
}
|
|
925
1294
|
if (!DIGEST.test(row.intent_digest) || controlPlaneDigest(intent) !== row.intent_digest || row.authority_id !== intent.authority.id
|
|
1295
|
+
|| ('backend' in intent.build ? intent.nativeResource?.key !== row.id : intent.containerName !== `dsh-${row.id}`)
|
|
926
1296
|
|| row.expires_at !== intent.authority.expiresAt || !KEY.test(row.idempotency_key) || !Number.isSafeInteger(row.revision) || row.revision < 1
|
|
927
1297
|
|| !Number.isSafeInteger(row.created_at) || !Number.isSafeInteger(row.updated_at) || row.updated_at < row.created_at
|
|
928
1298
|
|| !['queued', 'running', 'prepared', 'failed', 'unknown'].includes(row.status)
|
|
929
1299
|
|| (row.definition_hash !== null && !DIGEST.test(row.definition_hash)) || (row.occurrence_id !== null && !KEY.test(row.occurrence_id))
|
|
1300
|
+
|| (row.dispatch_at !== null && (!Number.isSafeInteger(row.dispatch_at) || row.dispatch_at < row.created_at))
|
|
1301
|
+
|| (row.previous_definition_hash !== null && !DIGEST.test(row.previous_definition_hash))
|
|
1302
|
+
|| (row.previous_definition_version !== null && (!Number.isSafeInteger(row.previous_definition_version) || row.previous_definition_version < 1))
|
|
930
1303
|
|| (row.plan_id !== null && !/^source-[a-f0-9-]{36}$/u.test(row.plan_id)) || (row.failure_code !== null && !KEY.test(row.failure_code))) {
|
|
931
1304
|
throw new ControlPlaneStoreError('invalid-state', 'stored source job is corrupt');
|
|
932
1305
|
}
|
|
@@ -936,11 +1309,17 @@ function sourceJobFromRow(row) {
|
|
|
936
1309
|
|| (row.status === 'prepared' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id === null || row.failure_code !== null))
|
|
937
1310
|
|| (row.status === 'unknown' && (row.definition_hash === null || row.occurrence_id === null || row.plan_id !== null || row.failure_code === null))
|
|
938
1311
|
|| (row.status === 'failed' && (row.plan_id !== null || row.failure_code === null))
|
|
939
|
-
|| (!active && row.status !== 'prepared' && row.plan_id !== null)
|
|
1312
|
+
|| (!active && row.status !== 'prepared' && row.plan_id !== null)
|
|
1313
|
+
|| (row.previous_definition_hash === null) !== (row.previous_definition_version === null)
|
|
1314
|
+
|| (row.previous_definition_hash !== null && (row.status !== 'queued' || row.definition_hash !== null || row.dispatch_at === null)))
|
|
940
1315
|
throw new ControlPlaneStoreError('invalid-state', 'stored source job state is corrupt');
|
|
941
1316
|
return Object.freeze({ id: row.id, automationId: row.automation_id, idempotencyKey: row.idempotency_key, intent, intentDigest: row.intent_digest,
|
|
942
1317
|
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 }),
|
|
1318
|
+
...(row.definition_hash === null ? {} : { definitionHash: row.definition_hash }),
|
|
1319
|
+
...(row.dispatch_at === null ? {} : { dispatchAt: row.dispatch_at }),
|
|
1320
|
+
...(row.previous_definition_hash === null ? {} : { previousDefinitionHash: row.previous_definition_hash }),
|
|
1321
|
+
...(row.previous_definition_version === null ? {} : { previousDefinitionVersion: row.previous_definition_version }),
|
|
1322
|
+
...(row.occurrence_id === null ? {} : { occurrenceId: row.occurrence_id }),
|
|
944
1323
|
...(row.plan_id === null ? {} : { planId: row.plan_id }), ...(row.failure_code === null ? {} : { failureCode: row.failure_code }) });
|
|
945
1324
|
}
|
|
946
1325
|
function readOwnerTaskFailureReference(database, gapId) {
|
|
@@ -1014,7 +1393,7 @@ export function readOwnerSourceAdoptionPlan(database, activationPlanId) {
|
|
|
1014
1393
|
const row = database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(activationPlanId);
|
|
1015
1394
|
if (!link || !row)
|
|
1016
1395
|
throw new ControlPlaneStoreError('not-found', 'owner source adoption binding absent');
|
|
1017
|
-
const plan =
|
|
1396
|
+
const plan = effectiveActivationFromRow(database, row), { plan: sourcePlan, source } = readOwnerPreparedSourcePlan(database, link.source_plan_id);
|
|
1018
1397
|
if (sourcePlan.status !== 'release-complete' || !sourcePlan.release || plan.gapId !== sourcePlan.gapId)
|
|
1019
1398
|
throw new ControlPlaneStoreError('invalid-state', 'source adoption requires a completed owner release');
|
|
1020
1399
|
const expected = adoptionBinding(sourcePlan, source, plan), released = readReleaseCandidate(database, sourcePlan);
|
|
@@ -1023,11 +1402,340 @@ export function readOwnerSourceAdoptionPlan(database, activationPlanId) {
|
|
|
1023
1402
|
throw new ControlPlaneStoreError('invalid-state', 'source adoption binding changed');
|
|
1024
1403
|
return { plan, sourcePlan, source, released };
|
|
1025
1404
|
}
|
|
1405
|
+
function runtimeEpochFromRow(row) {
|
|
1406
|
+
const request = parseRuntimeEpochRequest(JSON.parse(row.request_json));
|
|
1407
|
+
if (request.operationId !== row.operation_id || request.plan.id !== row.plan_id || request.sequence !== row.sequence
|
|
1408
|
+
|| request.runtimeIdentityDigest !== row.runtime_digest || controlPlaneDigest(request) !== row.request_digest
|
|
1409
|
+
|| request.requestedAt !== row.created_at || (row.status === 'applied') !== (row.receipt_json !== null)) {
|
|
1410
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch journal binding changed');
|
|
1411
|
+
}
|
|
1412
|
+
return { request, status: row.status, ...(row.receipt_json === null ? {} : { receipt: parseRuntimeEpochReceipt(JSON.parse(row.receipt_json)) }) };
|
|
1413
|
+
}
|
|
1414
|
+
/** Historical adoption remains immutable; only a currently watched deployment can acquire another runtime proof. */
|
|
1415
|
+
export function readCurrentRuntimeEpochDeployment(database, profilePath) {
|
|
1416
|
+
const unsettled = database.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND status IN (
|
|
1417
|
+
'staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow',
|
|
1418
|
+
'awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending') LIMIT 1`).get(profilePath);
|
|
1419
|
+
const latest = database.prepare(`SELECT checkpoint.plan_id,checkpoint.exposure_order,checkpoint.successful_order
|
|
1420
|
+
FROM activation_deployment_checkpoints checkpoint JOIN activation_plans plan ON plan.id=checkpoint.plan_id
|
|
1421
|
+
WHERE plan.target_path=? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(profilePath);
|
|
1422
|
+
if (unsettled || !latest || latest.exposure_order !== latest.successful_order) {
|
|
1423
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch requires the current successful deployment');
|
|
1424
|
+
}
|
|
1425
|
+
const adopted = readOwnerSourceAdoptionPlan(database, latest.plan_id), { plan } = adopted;
|
|
1426
|
+
const watch = database.prepare('SELECT state,activation_id,fence,last_host_generation FROM activation_watch WHERE plan_id=?').get(plan.id);
|
|
1427
|
+
if (plan.status !== 'activated' || !plan.activation || watch?.state !== 'watching'
|
|
1428
|
+
|| watch.activation_id !== plan.activation.id || watch.fence !== plan.activation.fence) {
|
|
1429
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch deployment is no longer watched');
|
|
1430
|
+
}
|
|
1431
|
+
const terms = plan.dossier.handoff;
|
|
1432
|
+
const handoffRow = database.prepare('SELECT * FROM adoption_handoffs WHERE plan_id=?').get(plan.id);
|
|
1433
|
+
if (!terms || !handoffRow || handoffRow.revoked_at !== null || handoffRow.plan_digest !== plan.digest
|
|
1434
|
+
|| handoffRow.coordinator_id !== terms.coordinatorId)
|
|
1435
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch handoff was revoked or changed');
|
|
1436
|
+
const handoff = { planId: plan.id, planDigest: plan.digest, coordinatorId: handoffRow.coordinator_id,
|
|
1437
|
+
createdAt: handoffRow.created_at, expiresAt: handoffRow.expires_at };
|
|
1438
|
+
const approvalRow = database.prepare('SELECT approval_receipt_json FROM activation_plans WHERE id=?').get(plan.id);
|
|
1439
|
+
const approvalReceipt = parseApprovalReceipt(JSON.parse(approvalRow.approval_receipt_json));
|
|
1440
|
+
if (!plan.approval || controlPlaneDigest(projectedApproval(approvalReceipt)) !== controlPlaneDigest(plan.approval)) {
|
|
1441
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch historical approval changed');
|
|
1442
|
+
}
|
|
1443
|
+
const witness = hostInputWitnessFromRow(database, plan);
|
|
1444
|
+
if (!witness || witness.activationId !== plan.activation.id || witness.fence !== plan.activation.fence) {
|
|
1445
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch deployment witness changed');
|
|
1446
|
+
}
|
|
1447
|
+
const readinessRow = database.prepare(`SELECT operation.* FROM host_attestation_operations operation
|
|
1448
|
+
JOIN host_attestations attestation ON attestation.plan_id=operation.plan_id AND attestation.phase=operation.phase
|
|
1449
|
+
AND attestation.receipt_digest=operation.receipt_digest
|
|
1450
|
+
WHERE operation.plan_id=? AND operation.phase='readiness' AND operation.status='applied'`).get(plan.id);
|
|
1451
|
+
if (!readinessRow)
|
|
1452
|
+
throw new ControlPlaneStoreError('invalid-state', 'runtime epoch lacks original applied readiness');
|
|
1453
|
+
const readiness = hostOperationFromRow(readinessRow);
|
|
1454
|
+
if (!readiness.receipt || readiness.receipt.outcome !== 'passed' || readiness.receipt.activationId !== plan.activation.id
|
|
1455
|
+
|| readiness.receipt.fence !== plan.activation.fence || readiness.receipt.hostGeneration !== watch.last_host_generation) {
|
|
1456
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch original readiness changed');
|
|
1457
|
+
}
|
|
1458
|
+
return { ...adopted, handoff, approvalReceipt, witness, readiness,
|
|
1459
|
+
maintenance: readHostMaintenanceContext(database, plan.id).records };
|
|
1460
|
+
}
|
|
1461
|
+
/** Read-only authority seam; current request admission never extends the historical activation authorization. */
|
|
1462
|
+
export function readOwnerRuntimeEpochContext(database, operationId) {
|
|
1463
|
+
const row = database.prepare('SELECT * FROM deployment_runtime_epochs WHERE operation_id=?').get(operationId);
|
|
1464
|
+
if (!row)
|
|
1465
|
+
throw new ControlPlaneStoreError('not-found', 'runtime epoch operation absent');
|
|
1466
|
+
const record = runtimeEpochFromRow(row), request = record.request;
|
|
1467
|
+
if (record.status !== 'claimed')
|
|
1468
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch operation is not claimed');
|
|
1469
|
+
const context = readCurrentRuntimeEpochDeployment(database, request.profile.path), { plan, readiness } = context;
|
|
1470
|
+
const latest = database.prepare('SELECT max(sequence) AS sequence FROM deployment_runtime_epochs WHERE plan_id=?').get(plan.id);
|
|
1471
|
+
if (latest?.sequence !== request.sequence || request.plan.id !== plan.id || request.plan.digest !== plan.digest
|
|
1472
|
+
|| request.installationId !== plan.installationId || controlPlaneDigest(request.ledger) !== controlPlaneDigest(plan.ledger)
|
|
1473
|
+
|| request.profile.name !== plan.profile || request.activation.id !== plan.activation.id || request.activation.fence !== plan.activation.fence
|
|
1474
|
+
|| controlPlaneDigest(request.predecessor) !== controlPlaneDigest({ operationId: readiness.operationId,
|
|
1475
|
+
receiptDigest: controlPlaneDigest(readiness.receipt), hostGeneration: readiness.receipt.hostGeneration })) {
|
|
1476
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch request lost current deployment admission');
|
|
1477
|
+
}
|
|
1478
|
+
return { ...context, request, status: 'claimed', createdAt: row.created_at };
|
|
1479
|
+
}
|
|
1480
|
+
export function readOwnerHostAttestationContext(database, operationId) {
|
|
1481
|
+
if (database.prepare('PRAGMA user_version').get()?.user_version !== controlPlaneSchemaVersion
|
|
1482
|
+
|| typeof operationId !== 'string' || !KEY.test(operationId)) {
|
|
1483
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority database or operation identity is invalid');
|
|
1484
|
+
}
|
|
1485
|
+
const operationRow = database.prepare('SELECT * FROM host_attestation_operations WHERE operation_id=?')
|
|
1486
|
+
.get(operationId);
|
|
1487
|
+
if (!operationRow)
|
|
1488
|
+
throw new ControlPlaneStoreError('not-found', 'Host authority operation is absent');
|
|
1489
|
+
const parsed = hostOperationFromRow(operationRow);
|
|
1490
|
+
if (!isBoundHostRequest(parsed.request) || parsed.status !== 'pending' || parsed.receipt !== undefined
|
|
1491
|
+
|| operationRow.completed_at !== null || operationRow.applied_at !== null) {
|
|
1492
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority requires an uncompleted schema-2 operation');
|
|
1493
|
+
}
|
|
1494
|
+
const operation = parsed;
|
|
1495
|
+
const { plan, sourcePlan, source, released } = readOwnerSourceAdoptionPlan(database, operation.planId);
|
|
1496
|
+
const request = operation.request, activation = plan.activation, expected = expectedAttestation[plan.status];
|
|
1497
|
+
if (!activation || expected?.phase !== operation.phase || request.phase !== operation.phase
|
|
1498
|
+
|| request.operationId !== operation.operationId || request.plan.id !== plan.id || request.plan.digest !== plan.digest
|
|
1499
|
+
|| request.installationId !== plan.installationId || controlPlaneDigest(request.ledger) !== controlPlaneDigest(plan.ledger)
|
|
1500
|
+
|| request.profile.name !== plan.profile || request.profile.path !== plan.target.profilePath
|
|
1501
|
+
|| request.activation.id !== activation.id || request.activation.fence !== activation.fence
|
|
1502
|
+
|| request.requestedAt !== operation.createdAt || request.receiptTtlMs < 1_000 || request.receiptTtlMs > 300_000) {
|
|
1503
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority operation lost its current plan binding');
|
|
1504
|
+
}
|
|
1505
|
+
const dispatchRow = database.prepare('SELECT * FROM host_attestation_dispatches WHERE operation_id=?')
|
|
1506
|
+
.get(operationId);
|
|
1507
|
+
if (!dispatchRow || dispatchRow.status !== 'claimed' || dispatchRow.completed_at !== null
|
|
1508
|
+
|| !Number.isSafeInteger(dispatchRow.claimed_at) || dispatchRow.claimed_at < operation.createdAt) {
|
|
1509
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority operation was not durably claimed');
|
|
1510
|
+
}
|
|
1511
|
+
const handoffRow = database.prepare('SELECT * FROM adoption_handoffs WHERE plan_id=?').get(plan.id);
|
|
1512
|
+
const terms = plan.dossier.handoff;
|
|
1513
|
+
if (!handoffRow || !terms || handoffRow.plan_digest !== plan.digest || handoffRow.coordinator_id !== terms.coordinatorId
|
|
1514
|
+
|| !Number.isSafeInteger(handoffRow.created_at) || handoffRow.created_at < plan.createdAt
|
|
1515
|
+
|| handoffRow.expires_at !== Math.min(handoffRow.created_at + terms.maximumWindowMs, plan.expiresAt, plan.approval?.expiresAt ?? 0)) {
|
|
1516
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority handoff is not bound to the owner plan');
|
|
1517
|
+
}
|
|
1518
|
+
validateAdoptionHandoffTerms(terms);
|
|
1519
|
+
const handoff = { planId: plan.id, planDigest: plan.digest, coordinatorId: terms.coordinatorId,
|
|
1520
|
+
createdAt: handoffRow.created_at, expiresAt: handoffRow.expires_at,
|
|
1521
|
+
...(handoffRow.revoked_at === null ? {} : { revokedAt: handoffRow.revoked_at }) };
|
|
1522
|
+
const planRow = database.prepare('SELECT approval_receipt_json FROM activation_plans WHERE id=?').get(plan.id);
|
|
1523
|
+
let approvalReceipt;
|
|
1524
|
+
try {
|
|
1525
|
+
approvalReceipt = parseApprovalReceipt(JSON.parse(planRow.approval_receipt_json ?? 'null'));
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority approval receipt is corrupt');
|
|
1529
|
+
}
|
|
1530
|
+
if (!plan.approval || controlPlaneDigest(projectedApproval(approvalReceipt)) !== controlPlaneDigest(plan.approval)) {
|
|
1531
|
+
throw new ControlPlaneStoreError('invalid-state', 'Host authority approval projection changed');
|
|
1532
|
+
}
|
|
1533
|
+
const rollback = operation.phase === 'rollback';
|
|
1534
|
+
const now = Date.now();
|
|
1535
|
+
if (!rollback && (now >= plan.expiresAt || now >= plan.approval.expiresAt || now >= handoff.expiresAt
|
|
1536
|
+
|| handoff.revokedAt !== undefined))
|
|
1537
|
+
throw new ControlPlaneStoreError('expired', 'Host authority forward authorization expired');
|
|
1538
|
+
const witness = hostInputWitnessFromRow(database, plan);
|
|
1539
|
+
if (!witness || witness.activationId !== activation.id || (!rollback && witness.fence !== activation.fence)) {
|
|
1540
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority lacks exact deployment witness');
|
|
1541
|
+
}
|
|
1542
|
+
if (rollback) {
|
|
1543
|
+
if (!activation.hostRecoveryRequired || !activation.rollbackProfileRestored || activation.targetBaselineFiles === undefined
|
|
1544
|
+
|| request.requirements.kind !== 'rollback'
|
|
1545
|
+
|| controlPlaneDigest(request.requirements.baselineFiles) !== controlPlaneDigest(activation.targetBaselineFiles)
|
|
1546
|
+
|| request.requirements.action !== (activation.targetOriginallyExisted ? 'restore' : 'stop')) {
|
|
1547
|
+
throw new ControlPlaneStoreError('conflict', 'Host rollback lacks a durable physical recovery obligation');
|
|
1548
|
+
}
|
|
1549
|
+
const checkpoint = database.prepare('SELECT exposure_order,successful_order FROM activation_deployment_checkpoints WHERE plan_id=?')
|
|
1550
|
+
.get(plan.id);
|
|
1551
|
+
const postActivated = ['post-activation-regressed', 'post-activation-retracted', 'live-qualification-invalidated']
|
|
1552
|
+
.includes(activation.failureCode ?? '');
|
|
1553
|
+
if (postActivated && (!checkpoint || checkpoint.exposure_order !== checkpoint.successful_order)) {
|
|
1554
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation rollback lacks a successful deployment checkpoint');
|
|
1555
|
+
}
|
|
1556
|
+
if (postActivated) {
|
|
1557
|
+
const readinessRow = database.prepare(`SELECT o.* FROM host_attestation_operations o
|
|
1558
|
+
JOIN host_attestations a ON a.plan_id=o.plan_id AND a.phase=o.phase AND a.receipt_digest=o.receipt_digest
|
|
1559
|
+
WHERE o.plan_id=? AND o.phase='readiness' AND o.status='applied'`).get(plan.id);
|
|
1560
|
+
const readiness = readinessRow && hostOperationFromRow(readinessRow);
|
|
1561
|
+
if (!readiness?.receipt || readiness.receipt.outcome !== 'passed'
|
|
1562
|
+
|| readiness.request.activation.id !== activation.id || readiness.request.activation.fence !== witness.fence
|
|
1563
|
+
|| readiness.receipt.activationId !== activation.id || readiness.receipt.fence !== witness.fence) {
|
|
1564
|
+
throw new ControlPlaneStoreError('conflict', 'post-activation rollback lacks original applied readiness');
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const active = database.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND id<>? AND status IN
|
|
1568
|
+
('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow',
|
|
1569
|
+
'awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending') LIMIT 1`)
|
|
1570
|
+
.get(plan.target.profilePath, plan.id);
|
|
1571
|
+
const superseded = checkpoint && database.prepare(`SELECT 1 FROM activation_deployment_checkpoints AS c
|
|
1572
|
+
JOIN activation_plans AS p ON p.id=c.plan_id WHERE p.target_path=? AND c.successful_order IS NOT NULL
|
|
1573
|
+
AND c.exposure_order>? LIMIT 1`).get(plan.target.profilePath, checkpoint.exposure_order);
|
|
1574
|
+
if (active || superseded)
|
|
1575
|
+
throw new ControlPlaneStoreError('conflict', 'Host rollback target has a newer owner');
|
|
1576
|
+
}
|
|
1577
|
+
const latestRow = database.prepare(`SELECT max(generation) AS generation FROM (
|
|
1578
|
+
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=?
|
|
1579
|
+
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=?)`)
|
|
1580
|
+
.get(plan.installationId, plan.installationId);
|
|
1581
|
+
const latest = latestRow.generation ?? 0;
|
|
1582
|
+
if ((operation.phase === 'reload' && (request.predecessor !== null || request.requirements.kind !== 'reload'
|
|
1583
|
+
|| request.requirements.previousHostGeneration !== latest))
|
|
1584
|
+
|| (rollback && (request.requirements.kind !== 'rollback' || request.requirements.previousHostGeneration !== latest))) {
|
|
1585
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority generation changed after operation reservation');
|
|
1586
|
+
}
|
|
1587
|
+
if (operation.phase !== 'reload') {
|
|
1588
|
+
const predecessor = request.predecessor;
|
|
1589
|
+
const priorPhase = rollback ? undefined : predecessorPhase[operation.phase];
|
|
1590
|
+
const priorRows = database.prepare(`SELECT o.* FROM host_attestation_operations AS o
|
|
1591
|
+
JOIN host_attestations AS a ON a.plan_id=o.plan_id AND a.phase=o.phase AND a.receipt_digest=o.receipt_digest
|
|
1592
|
+
WHERE o.plan_id=? AND o.status='applied' ${priorPhase === undefined ? '' : 'AND o.phase=?'}
|
|
1593
|
+
ORDER BY o.applied_at DESC,
|
|
1594
|
+
CASE o.phase WHEN 'reload' THEN 1 WHEN 'readiness' THEN 2 WHEN 'effect-blocked-replay' THEN 3
|
|
1595
|
+
WHEN 'shadow' THEN 4 WHEN 'canary' THEN 5 WHEN 'soak' THEN 6 WHEN 'health' THEN 7 WHEN 'rollback' THEN 8 END DESC`)
|
|
1596
|
+
.all(...(priorPhase === undefined ? [plan.id] : [plan.id, priorPhase]));
|
|
1597
|
+
let actual = null;
|
|
1598
|
+
for (const priorRow of priorRows) {
|
|
1599
|
+
const prior = hostOperationFromRow(priorRow), receipt = prior.receipt;
|
|
1600
|
+
if (!receipt || priorRow.receipt_digest === null || prior.request.activation.id !== activation.id
|
|
1601
|
+
|| receipt.activationId !== activation.id
|
|
1602
|
+
|| (!rollback && (prior.request.activation.fence !== activation.fence || receipt.fence !== activation.fence))
|
|
1603
|
+
|| (rollback && (prior.request.activation.fence >= activation.fence || receipt.fence >= activation.fence)))
|
|
1604
|
+
continue;
|
|
1605
|
+
actual = { operationId: prior.operationId, receiptId: receipt.receiptId, phase: prior.phase,
|
|
1606
|
+
receiptDigest: priorRow.receipt_digest, hostGeneration: receipt.hostGeneration };
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
if (controlPlaneDigest(actual) !== controlPlaneDigest(predecessor)) {
|
|
1610
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority predecessor is no longer current');
|
|
1611
|
+
}
|
|
1612
|
+
if (predecessor !== null) {
|
|
1613
|
+
const prior = database.prepare(`SELECT o.*, a.host_generation AS applied_generation FROM host_attestation_operations o
|
|
1614
|
+
JOIN host_attestations a ON a.plan_id=o.plan_id AND a.phase=o.phase AND a.receipt_digest=o.receipt_digest
|
|
1615
|
+
WHERE o.operation_id=? AND o.plan_id=? AND o.status='applied'`).get(predecessor.operationId, plan.id);
|
|
1616
|
+
if (!prior)
|
|
1617
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority predecessor is not applied');
|
|
1618
|
+
const predecessorOperation = hostOperationFromRow(prior);
|
|
1619
|
+
if (!predecessorOperation.receipt || predecessorOperation.receipt.receiptId !== predecessor.receiptId
|
|
1620
|
+
|| predecessorOperation.phase !== predecessor.phase || prior.receipt_digest !== predecessor.receiptDigest
|
|
1621
|
+
|| prior.applied_generation !== predecessor.hostGeneration
|
|
1622
|
+
|| predecessorOperation.request.activation.id !== activation.id
|
|
1623
|
+
|| (!rollback && (predecessor.phase !== predecessorPhase[operation.phase] || predecessor.hostGeneration !== latest
|
|
1624
|
+
|| predecessorOperation.receipt.outcome !== 'passed'))
|
|
1625
|
+
|| (rollback && predecessorOperation.request.activation.fence >= activation.fence && witness.fence < activation.fence)) {
|
|
1626
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority predecessor binding changed');
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
else if (!rollback)
|
|
1630
|
+
throw new ControlPlaneStoreError('conflict', 'Host authority normal phase lacks predecessor');
|
|
1631
|
+
}
|
|
1632
|
+
return { plan, sourcePlan, source, released, handoff, approvalReceipt, operation,
|
|
1633
|
+
dispatch: { status: 'claimed', claimedAt: dispatchRow.claimed_at }, witness };
|
|
1634
|
+
}
|
|
1635
|
+
function liveQualificationRecord(db, id) {
|
|
1636
|
+
const row = db.prepare('SELECT * FROM live_qualification_batches WHERE id=?').get(id);
|
|
1637
|
+
if (!row)
|
|
1638
|
+
return undefined;
|
|
1639
|
+
if (Buffer.byteLength(row.batch_json) > 262_144)
|
|
1640
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification batch too large');
|
|
1641
|
+
const batch = JSON.parse(row.batch_json);
|
|
1642
|
+
assertLiveQualificationBatch(batch);
|
|
1643
|
+
const receipt = row.receipt_json === null ? undefined : parseLiveQualificationReceipt(JSON.parse(row.receipt_json));
|
|
1644
|
+
if (row.id !== batch.id || row.plan_id !== batch.planId || row.batch_digest !== controlPlaneDigest(batch)
|
|
1645
|
+
|| (receipt && (row.receipt_digest !== controlPlaneDigest(receipt) || receipt.batchId !== batch.id || receipt.batchDigest !== batch.digest))
|
|
1646
|
+
|| (row.state === 'applied' && !receipt))
|
|
1647
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification journal changed');
|
|
1648
|
+
return { batch, state: row.state, ...(receipt ? { receipt } : {}) };
|
|
1649
|
+
}
|
|
1650
|
+
function liveQualificationReadiness(db, plan) {
|
|
1651
|
+
const row = db.prepare(`SELECT operation.* FROM host_attestation_operations operation
|
|
1652
|
+
JOIN host_attestations attestation ON attestation.plan_id=operation.plan_id AND attestation.phase=operation.phase
|
|
1653
|
+
AND attestation.receipt_digest=operation.receipt_digest
|
|
1654
|
+
WHERE operation.plan_id=? AND operation.phase='readiness' AND operation.status='applied'`).get(plan.id);
|
|
1655
|
+
if (!row)
|
|
1656
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification lacks applied readiness');
|
|
1657
|
+
const operation = hostOperationFromRow(row), receipt = operation.receipt;
|
|
1658
|
+
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);
|
|
1659
|
+
if (!receipt || receipt.outcome !== 'passed' || operation.request.schemaVersion !== 2
|
|
1660
|
+
|| receipt.planDigest !== plan.digest || receipt.activationId !== plan.activation?.id || receipt.fence !== plan.activation?.fence
|
|
1661
|
+
|| receipt.hostGeneration !== generation.generation || operation.request.activation.fence !== receipt.fence) {
|
|
1662
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification readiness generation changed');
|
|
1663
|
+
}
|
|
1664
|
+
return operation;
|
|
1665
|
+
}
|
|
1666
|
+
function liveQualificationWindow(db, plan) {
|
|
1667
|
+
const row = db.prepare('SELECT * FROM live_qualification_windows WHERE plan_id=?').get(plan.id);
|
|
1668
|
+
const terms = plan.dossier.liveQualification;
|
|
1669
|
+
if (!terms || !row || row.activation_id !== plan.activation?.id || row.fence !== plan.activation?.fence
|
|
1670
|
+
|| row.deadline_at <= row.started_at || row.deadline_at > row.started_at + terms.maximumWindowMs
|
|
1671
|
+
|| row.deadline_at > plan.expiresAt || row.deadline_at > (plan.approval?.expiresAt ?? 0)) {
|
|
1672
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification window changed');
|
|
1673
|
+
}
|
|
1674
|
+
const readiness = liveQualificationReadiness(db, plan);
|
|
1675
|
+
return { startedAt: row.started_at, deadlineAt: row.deadline_at,
|
|
1676
|
+
readinessDigest: controlPlaneDigest(readiness.receipt), hostGeneration: readiness.receipt.hostGeneration };
|
|
1677
|
+
}
|
|
1678
|
+
/** Independent signers use this read-only seam, never a migrating Store. */
|
|
1679
|
+
export function readLiveQualificationContext(db, id) {
|
|
1680
|
+
if (Number(db.prepare('PRAGMA user_version').get()?.user_version) !== controlPlaneSchemaVersion)
|
|
1681
|
+
throw new ControlPlaneStoreError('invalid-state', 'live qualification schema differs');
|
|
1682
|
+
const record = liveQualificationRecord(db, id);
|
|
1683
|
+
if (!record)
|
|
1684
|
+
throw new ControlPlaneStoreError('not-found', 'live qualification batch absent');
|
|
1685
|
+
const { batch } = record;
|
|
1686
|
+
const { plan, source } = readOwnerSourceAdoptionPlan(db, batch.planId);
|
|
1687
|
+
const window = liveQualificationWindow(db, plan);
|
|
1688
|
+
if (!['awaiting-live-tasks', 'commit-pending', 'activated'].includes(plan.status)
|
|
1689
|
+
|| !plan.dossier.liveQualification || batch.planDigest !== plan.digest || batch.installationId !== plan.installationId
|
|
1690
|
+
|| batch.profilePath !== plan.target.profilePath || batch.activationId !== plan.activation?.id || batch.fence !== plan.activation?.fence
|
|
1691
|
+
|| controlPlaneDigest(batch.terms) !== controlPlaneDigest(plan.dossier.liveQualification)
|
|
1692
|
+
|| ['authorityId', 'authorityHash', 'principalId', 'principalRecordId', 'principalVersion', 'workspace', 'agentPreset'].some(key => batch.owner[key] !== source.owner[key])
|
|
1693
|
+
|| batch.startedAt !== window.startedAt || batch.deadlineAt !== window.deadlineAt
|
|
1694
|
+
|| batch.readinessDigest !== window.readinessDigest || batch.hostGeneration !== window.hostGeneration
|
|
1695
|
+
|| db.prepare('SELECT 1 FROM live_qualification_invalidations WHERE plan_id=?').get(plan.id)) {
|
|
1696
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification deployment or owner changed');
|
|
1697
|
+
}
|
|
1698
|
+
const competing = db.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND id<>? AND status IN
|
|
1699
|
+
('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay','awaiting-shadow',
|
|
1700
|
+
'awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending')`).get(plan.target.profilePath, plan.id);
|
|
1701
|
+
if (competing)
|
|
1702
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification target superseded');
|
|
1703
|
+
if (plan.status === 'activated') {
|
|
1704
|
+
const latest = db.prepare(`SELECT checkpoint.plan_id FROM activation_deployment_checkpoints checkpoint
|
|
1705
|
+
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);
|
|
1706
|
+
if (latest?.plan_id !== plan.id)
|
|
1707
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification deployment superseded');
|
|
1708
|
+
}
|
|
1709
|
+
const deployments = batch.votes.map(vote => {
|
|
1710
|
+
const row = db.prepare('SELECT record_json,record_digest,plan_id FROM foreground_deployments WHERE inbox_id=?').get(vote.inboxId);
|
|
1711
|
+
if (!row || row.plan_id !== plan.id || Buffer.byteLength(row.record_json) > 262_144)
|
|
1712
|
+
throw new ControlPlaneStoreError('invalid-state', 'live task witness absent');
|
|
1713
|
+
const deployment = JSON.parse(row.record_json);
|
|
1714
|
+
assertForegroundDeployment(deployment);
|
|
1715
|
+
if (controlPlaneDigest(deployment) !== row.record_digest || row.record_digest !== vote.deploymentDigest
|
|
1716
|
+
|| deployment.state !== 'observed' || !deployment.execution || !deployment.execution.quiescent
|
|
1717
|
+
|| deployment.execution.status !== 'succeeded' || deployment.execution.modelSelectionState !== 'frozen' || !deployment.execution.modelSelection
|
|
1718
|
+
|| deployment.task.inboxId !== vote.inboxId || deployment.execution.completedAt !== vote.completedAt
|
|
1719
|
+
|| deployment.task.dispatchedAt < window.startedAt || deployment.task.dispatchedAt >= window.deadlineAt
|
|
1720
|
+
|| vote.completedAt >= window.deadlineAt || deployment.task.owner.principalRecordId !== batch.owner.principalRecordId
|
|
1721
|
+
|| deployment.task.owner.principalVersion !== batch.owner.principalVersion || deployment.task.scope.workspace !== batch.owner.workspace
|
|
1722
|
+
|| deployment.task.scope.preset !== batch.owner.agentPreset || deployment.readiness.planDigest !== plan.digest
|
|
1723
|
+
|| deployment.readiness.planId !== plan.id || deployment.readiness.fence !== batch.fence
|
|
1724
|
+
|| deployment.readiness.hostGeneration !== batch.hostGeneration || deployment.readiness.receiptDigest !== batch.readinessDigest
|
|
1725
|
+
|| controlPlaneDigest(deployment.readiness.exact) !== controlPlaneDigest({ package: plan.candidate.package, version: plan.candidate.version, integrity: plan.candidate.integrity })) {
|
|
1726
|
+
throw new ControlPlaneStoreError('conflict', 'live task witness differs');
|
|
1727
|
+
}
|
|
1728
|
+
return deployment;
|
|
1729
|
+
});
|
|
1730
|
+
return { record, plan, source, deployments };
|
|
1731
|
+
}
|
|
1026
1732
|
export class ControlPlaneStore {
|
|
1027
1733
|
#database;
|
|
1028
1734
|
#now;
|
|
1029
1735
|
#ownerTaskFailureGapAdmission;
|
|
1030
1736
|
#foregroundDeploymentAdmission;
|
|
1737
|
+
#liveQualificationAdmission;
|
|
1738
|
+
#withLiveQualificationFence;
|
|
1031
1739
|
#withOwnerActivationFence;
|
|
1032
1740
|
#adoptionCoordinatorId;
|
|
1033
1741
|
constructor(options) {
|
|
@@ -1036,8 +1744,9 @@ export class ControlPlaneStore {
|
|
|
1036
1744
|
}
|
|
1037
1745
|
if (options.adoptionCoordinatorId && options.withOwnerActivationFence)
|
|
1038
1746
|
throw new ControlPlaneStoreError('invalid-input', 'adoption coordinator cannot hold owner fence');
|
|
1039
|
-
this.#database = openControlPlaneDatabase(options.path);
|
|
1747
|
+
this.#database = options.readOnly === true ? openControlPlaneDatabaseReadOnly(options.path) : openControlPlaneDatabase(options.path);
|
|
1040
1748
|
this.#now = options.now ?? Date.now;
|
|
1749
|
+
this.#withLiveQualificationFence = options.withLiveQualificationFence;
|
|
1041
1750
|
this.#withOwnerActivationFence = options.withOwnerActivationFence;
|
|
1042
1751
|
this.#adoptionCoordinatorId = options.adoptionCoordinatorId;
|
|
1043
1752
|
}
|
|
@@ -1177,7 +1886,7 @@ export class ControlPlaneStore {
|
|
|
1177
1886
|
this.#assertAdoptionHandoffBinding(planId, this.#adoptionCoordinatorId);
|
|
1178
1887
|
return callback();
|
|
1179
1888
|
}
|
|
1180
|
-
if (!['approved', 'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay',
|
|
1889
|
+
if (!['approved', 'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay',
|
|
1181
1890
|
'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health'].includes(plan.status)) {
|
|
1182
1891
|
throw new ControlPlaneStoreError('invalid-state', 'adoption coordinator cannot advance this activation state');
|
|
1183
1892
|
}
|
|
@@ -1190,6 +1899,10 @@ export class ControlPlaneStore {
|
|
|
1190
1899
|
const current = this.getPlan(planId);
|
|
1191
1900
|
if (current.status === 'commit-pending' && current.dossier.handoff !== undefined)
|
|
1192
1901
|
this.#assertAdoptionHandoff(planId);
|
|
1902
|
+
if (current.status === 'commit-pending' && current.dossier.liveQualification) {
|
|
1903
|
+
this.#assertLiveQualificationCommit(planId);
|
|
1904
|
+
return callback();
|
|
1905
|
+
}
|
|
1193
1906
|
return callback();
|
|
1194
1907
|
};
|
|
1195
1908
|
if (this.#ownerTaskFailureGapAdmission === plan.gapId)
|
|
@@ -1240,9 +1953,17 @@ export class ControlPlaneStore {
|
|
|
1240
1953
|
const requestBinding = { operation: 'create-activation-plan', gapId: input.gapId, candidate, catalog: input.catalog,
|
|
1241
1954
|
matchedCapabilities, profile, target: input.target, installationId: input.installationId,
|
|
1242
1955
|
ledger: input.ledger, executor: input.executor, ttlMs: input.ttlMs,
|
|
1243
|
-
...(input.sourcePlanId === undefined ? {} : { sourcePlanId: input.sourcePlanId }), ...(input.handoff === undefined ? {} : { handoff: input.handoff }) }
|
|
1956
|
+
...(input.sourcePlanId === undefined ? {} : { sourcePlanId: input.sourcePlanId }), ...(input.handoff === undefined ? {} : { handoff: input.handoff }), ...(input.liveQualification === undefined ? {} : { liveQualification: input.liveQualification }),
|
|
1957
|
+
...(input.hostDeploymentInputs === undefined ? {} : { hostDeploymentInputs: input.hostDeploymentInputs }) };
|
|
1244
1958
|
this.#assertOwnerTaskFailureGapAdmission(input.gapId);
|
|
1245
1959
|
const source = input.sourcePlanId === undefined ? undefined : readOwnerPreparedSourcePlan(this.#database, input.sourcePlanId);
|
|
1960
|
+
if (input.liveQualification !== undefined) {
|
|
1961
|
+
validateLiveQualificationTerms(input.liveQualification);
|
|
1962
|
+
if (!source || !input.handoff)
|
|
1963
|
+
throw new ControlPlaneStoreError('invalid-input', 'bounded-live adoption requires an exact owner release and external coordinator');
|
|
1964
|
+
}
|
|
1965
|
+
if (input.hostDeploymentInputs !== undefined)
|
|
1966
|
+
validateHostDeploymentInputs(input.hostDeploymentInputs);
|
|
1246
1967
|
if (input.handoff !== undefined) {
|
|
1247
1968
|
validateAdoptionHandoffTerms(input.handoff);
|
|
1248
1969
|
if (!source)
|
|
@@ -1272,7 +1993,9 @@ export class ControlPlaneStore {
|
|
|
1272
1993
|
const gapSnapshot = Object.freeze({ revision: gap.revision, inputDigest: gap.inputDigest, roi: gap.roi, capability: gap.capability });
|
|
1273
1994
|
const dossier = Object.freeze({ catalogDigest: input.catalog.digest, catalogProvenance: input.catalog.provenance,
|
|
1274
1995
|
matchedCapabilities: Object.freeze(matchedCapabilities), authorities: Object.freeze([...candidate.authorities]), packages,
|
|
1275
|
-
...(input.handoff === undefined ? {} : { handoff: Object.freeze({ ...input.handoff }) })
|
|
1996
|
+
...(input.handoff === undefined ? {} : { handoff: Object.freeze({ ...input.handoff }) }),
|
|
1997
|
+
...(input.liveQualification === undefined ? {} : { liveQualification: Object.freeze({ ...input.liveQualification }) }),
|
|
1998
|
+
...(input.hostDeploymentInputs === undefined ? {} : { hostDeploymentInputs: Object.freeze([...input.hostDeploymentInputs]) }) });
|
|
1276
1999
|
const now = this.#now();
|
|
1277
2000
|
const id = `plugin-${randomUUID()}`;
|
|
1278
2001
|
const expiresAt = now + input.ttlMs;
|
|
@@ -1320,7 +2043,11 @@ export class ControlPlaneStore {
|
|
|
1320
2043
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(id);
|
|
1321
2044
|
if (row === undefined)
|
|
1322
2045
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1323
|
-
return
|
|
2046
|
+
return effectiveActivationFromRow(this.#database, row);
|
|
2047
|
+
}
|
|
2048
|
+
currentPlanExecutor(planId) {
|
|
2049
|
+
const context = readHostMaintenanceContext(this.#database, planId);
|
|
2050
|
+
return context.records.at(-1)?.after.executor ?? context.plan.executor;
|
|
1324
2051
|
}
|
|
1325
2052
|
findSourceAdoption(sourcePlanId) {
|
|
1326
2053
|
const row = this.#database.prepare('SELECT activation_plan_id FROM source_adoptions WHERE source_plan_id = ?').get(sourcePlanId);
|
|
@@ -1431,11 +2158,23 @@ export class ControlPlaneStore {
|
|
|
1431
2158
|
const intentDigest = controlPlaneDigest(intent);
|
|
1432
2159
|
const now = this.#now();
|
|
1433
2160
|
this.#assertOwnerTaskFailureGapAdmission(intent.gapId);
|
|
1434
|
-
if (intent.
|
|
2161
|
+
if (intent.mode === 'create' && this.getOwnerTaskFailureReference(intent.gapId) !== undefined
|
|
2162
|
+
&& intent.creation?.growthRun === undefined) {
|
|
2163
|
+
throw new ControlPlaneStoreError('invalid-input', 'task-backed creation requires a frozen source growth run');
|
|
2164
|
+
}
|
|
2165
|
+
if (('backend' in intent.build ? intent.nativeResource?.key !== input.id : intent.containerName !== `dsh-${input.id}`)
|
|
2166
|
+
|| basename(intent.worktree) !== `worktree-job-${input.id.slice('source-job-'.length)}`) {
|
|
1435
2167
|
throw new ControlPlaneStoreError('invalid-input', 'source job resource identity is invalid');
|
|
1436
2168
|
}
|
|
1437
2169
|
if (now >= intent.authority.expiresAt)
|
|
1438
2170
|
throw new ControlPlaneStoreError('expired', 'source job authority is expired');
|
|
2171
|
+
if (intent.creation !== undefined && now >= intent.creation.grant.expiresAt)
|
|
2172
|
+
throw new ControlPlaneStoreError('expired', 'source creation grant is expired');
|
|
2173
|
+
if (intent.revision !== undefined && (this.getOwnerTaskFailureReference(intent.gapId) === undefined || now >= intent.revision.grant.expiresAt))
|
|
2174
|
+
throw new ControlPlaneStoreError('expired', 'revision grant or owner task is unavailable');
|
|
2175
|
+
const growthRun = intent.creation?.growthRun ?? intent.revision?.growthRun;
|
|
2176
|
+
if (growthRun !== undefined && (now >= growthRun.generationDeadlineAt || now >= growthRun.expiresAt))
|
|
2177
|
+
throw new ControlPlaneStoreError('expired', 'source growth generation window is expired');
|
|
1439
2178
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
1440
2179
|
try {
|
|
1441
2180
|
const prior = this.#database.prepare('SELECT * FROM source_jobs WHERE authority_id = ? AND idempotency_key = ?')
|
|
@@ -1448,6 +2187,30 @@ export class ControlPlaneStore {
|
|
|
1448
2187
|
this.#database.exec('COMMIT');
|
|
1449
2188
|
return record;
|
|
1450
2189
|
}
|
|
2190
|
+
if (intent.baseline !== undefined) {
|
|
2191
|
+
const rows = this.#database.prepare(`SELECT * FROM source_jobs WHERE json_extract(intent_json, '$.repository') = ?
|
|
2192
|
+
AND json_type(intent_json, '$.baseline') IS NOT NULL AND status IN ('queued', 'running', 'unknown', 'prepared')
|
|
2193
|
+
LIMIT 1001`).all(intent.repository);
|
|
2194
|
+
if (rows.length > 1000)
|
|
2195
|
+
throw new ControlPlaneStoreError('invalid-state', 'managed source job cohort exceeds bound');
|
|
2196
|
+
for (const row of rows) {
|
|
2197
|
+
const competing = sourceJobFromRow(row);
|
|
2198
|
+
if (competing.status !== 'prepared')
|
|
2199
|
+
throw new ControlPlaneStoreError('conflict', 'managed source job is already active for repository');
|
|
2200
|
+
const prepared = competing.planId === undefined ? undefined : this.getSourcePlan(competing.planId);
|
|
2201
|
+
// A revision rebuilds its exact retained parent in a new private tree;
|
|
2202
|
+
// it cannot advance that parent's source release or alter the baseline.
|
|
2203
|
+
if (intent.revision && prepared?.id === intent.revision.parent.planId && prepared.mode === 'prepared-create') {
|
|
2204
|
+
const certificate = this.getCreationVerification(prepared.id);
|
|
2205
|
+
if (certificate && controlPlaneDigest(certificate) === intent.revision.parent.certificateDigest
|
|
2206
|
+
&& certificate.plan.artifactSha256 === intent.revision.parent.artifactSha256)
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
if (prepared === undefined || !['release-complete', 'release-failed', 'expired', 'local-checks-failed'].includes(prepared.status)) {
|
|
2210
|
+
throw new ControlPlaneStoreError('conflict', 'managed source job is awaiting a terminal source release');
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
1451
2214
|
const authority = this.#database.prepare('SELECT * FROM source_job_authorities WHERE authority_id = ?').get(intent.authority.id);
|
|
1452
2215
|
if (authority !== undefined && (authority.authority_digest !== intent.authority.digest || authority.expires_at !== intent.authority.expiresAt
|
|
1453
2216
|
|| authority.max_submissions !== intent.authority.maxSubmissions)) {
|
|
@@ -1461,6 +2224,36 @@ export class ControlPlaneStore {
|
|
|
1461
2224
|
}
|
|
1462
2225
|
this.#database.prepare('UPDATE source_job_authorities SET submissions = submissions + 1 WHERE authority_id = ? AND submissions < max_submissions')
|
|
1463
2226
|
.run(intent.authority.id);
|
|
2227
|
+
if (intent.creation !== undefined) {
|
|
2228
|
+
const grant = intent.creation.grant, digest = controlPlaneDigest(grant);
|
|
2229
|
+
const previous = this.#database.prepare('SELECT * FROM source_creation_grants WHERE grant_id = ?').get(grant.id);
|
|
2230
|
+
if (previous !== undefined && (previous.grant_digest !== digest || previous.expires_at !== grant.expiresAt
|
|
2231
|
+
|| previous.max_creates !== grant.maxCreates))
|
|
2232
|
+
throw new ControlPlaneStoreError('conflict', 'source creation grant definition is immutable');
|
|
2233
|
+
if (previous !== undefined && previous.creates >= previous.max_creates)
|
|
2234
|
+
throw new ControlPlaneStoreError('invalid-state', 'source creation grant quota is exhausted');
|
|
2235
|
+
if (previous === undefined)
|
|
2236
|
+
this.#database.prepare(`INSERT INTO source_creation_grants
|
|
2237
|
+
(grant_id, grant_digest, expires_at, max_creates, creates) VALUES (?, ?, ?, ?, 0)`)
|
|
2238
|
+
.run(grant.id, digest, grant.expiresAt, grant.maxCreates);
|
|
2239
|
+
const quota = this.#database.prepare('UPDATE source_creation_grants SET creates = creates + 1 WHERE grant_id = ? AND creates < max_creates').run(grant.id);
|
|
2240
|
+
if (Number(quota.changes) !== 1)
|
|
2241
|
+
throw new ControlPlaneStoreError('conflict', 'source creation grant quota changed');
|
|
2242
|
+
}
|
|
2243
|
+
if (intent.revision !== undefined) {
|
|
2244
|
+
const grant = intent.revision.grant, digest = controlPlaneDigest(grant);
|
|
2245
|
+
const previous = this.#database.prepare('SELECT * FROM source_revision_grants WHERE grant_id=?').get(grant.id);
|
|
2246
|
+
if (previous && (previous.grant_digest !== digest || previous.expires_at !== grant.expiresAt || previous.max_revisions !== grant.maxRevisions)) {
|
|
2247
|
+
throw new ControlPlaneStoreError('conflict', 'source revision grant definition is immutable');
|
|
2248
|
+
}
|
|
2249
|
+
if (previous && previous.revisions >= previous.max_revisions)
|
|
2250
|
+
throw new ControlPlaneStoreError('invalid-state', 'source revision grant quota is exhausted');
|
|
2251
|
+
if (!previous)
|
|
2252
|
+
this.#database.prepare('INSERT INTO source_revision_grants VALUES(?,?,?,?,0)').run(grant.id, digest, grant.expiresAt, grant.maxRevisions);
|
|
2253
|
+
const quota = this.#database.prepare('UPDATE source_revision_grants SET revisions=revisions+1 WHERE grant_id=? AND revisions<max_revisions').run(grant.id);
|
|
2254
|
+
if (Number(quota.changes) !== 1)
|
|
2255
|
+
throw new ControlPlaneStoreError('conflict', 'source revision grant quota changed');
|
|
2256
|
+
}
|
|
1464
2257
|
this.#database.prepare(`INSERT INTO source_jobs (id, automation_id, authority_id, idempotency_key, intent_json, intent_digest,
|
|
1465
2258
|
status, revision, created_at, expires_at, definition_hash, occurrence_id, plan_id, failure_code, updated_at)
|
|
1466
2259
|
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 +2284,67 @@ export class ControlPlaneStore {
|
|
|
1491
2284
|
created_at DESC, id DESC LIMIT ?`).all(limit).map(sourceJobFromRow);
|
|
1492
2285
|
}
|
|
1493
2286
|
/** Prepared owner continuations have a bounded recovery query, independent of job history. */
|
|
1494
|
-
listPreparedSourceApprovalJobs(includeRelease = false, includeExecution = false, includeAdoption = false) {
|
|
2287
|
+
listPreparedSourceApprovalJobs(includeRelease = false, includeExecution = false, includeAdoption = false, includeCreation = false, includeCreationAdoption = false, includeRevision = false, includeRegression = false) {
|
|
1495
2288
|
return this.#database.prepare(`SELECT j.* FROM source_jobs j JOIN source_plans p ON p.id = j.plan_id
|
|
1496
2289
|
JOIN owner_task_failure_gaps g ON g.gap_id = p.gap_id
|
|
1497
2290
|
LEFT JOIN source_adoptions a ON a.source_plan_id = p.id
|
|
1498
2291
|
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'))
|
|
2292
|
+
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
2293
|
OR (? = 1 AND p.status IN ('awaiting-pr', 'awaiting-review', 'awaiting-merge', 'awaiting-build', 'awaiting-sign', 'awaiting-publish',
|
|
1501
2294
|
'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
|
-
|
|
2295
|
+
OR (? = 1 AND p.status = 'release-complete' AND (ap.id IS NULL OR ap.status NOT IN ('activated', 'rolled-back', 'rejected')))))
|
|
2296
|
+
OR (? = 1 AND p.mode = 'prepared-create' AND p.status = 'pending-approval'
|
|
2297
|
+
AND json_type(j.intent_json, '$.creation.growthRun.creationAcceptance') IS NOT NULL
|
|
2298
|
+
AND NOT EXISTS (SELECT 1 FROM source_creation_verifications v WHERE v.plan_id = p.id))
|
|
2299
|
+
OR (? = 1 AND p.mode = 'prepared-create' AND p.status = 'pending-approval'
|
|
2300
|
+
AND EXISTS (SELECT 1 FROM source_creation_verifications v WHERE v.plan_id = p.id AND v.status = 'verified'))
|
|
2301
|
+
OR (? = 1 AND p.mode = 'prepared-revise' AND p.status = 'pending-approval'
|
|
2302
|
+
AND json_type(j.intent_json, '$.revision.growthRun.revisionAcceptance') IS NOT NULL
|
|
2303
|
+
AND NOT EXISTS (SELECT 1 FROM source_revision_verifications v WHERE v.plan_id=p.id))
|
|
2304
|
+
OR (? = 1 AND p.mode = 'prepared-revise' AND p.status = 'pending-approval'
|
|
2305
|
+
AND json_type(j.intent_json, '$.revision.growthRun.revisionRegressionAcceptance') IS NOT NULL
|
|
2306
|
+
AND EXISTS (SELECT 1 FROM source_revision_verifications v WHERE v.plan_id=p.id AND v.status='verified')
|
|
2307
|
+
AND NOT EXISTS (SELECT 1 FROM source_revision_regressions v WHERE v.plan_id=p.id)))
|
|
2308
|
+
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'))
|
|
2309
|
+
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
2310
|
}
|
|
1505
2311
|
bindSourceJobDefinition(input) {
|
|
1506
2312
|
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.definitionHash))
|
|
1507
2313
|
throw new ControlPlaneStoreError('invalid-input', 'source job definition binding is invalid');
|
|
1508
2314
|
const now = this.#now();
|
|
1509
|
-
const result = this.#database.prepare(`UPDATE source_jobs SET definition_hash = ?,
|
|
2315
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET definition_hash = ?, previous_definition_hash = NULL,
|
|
2316
|
+
previous_definition_version = NULL, revision = revision + 1, updated_at = ?
|
|
1510
2317
|
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
2318
|
if (Number(result.changes) !== 1)
|
|
1512
2319
|
throw new ControlPlaneStoreError('conflict', 'source job definition binding lost its queued CAS');
|
|
1513
2320
|
return this.getSourceJob(input.id);
|
|
1514
2321
|
}
|
|
2322
|
+
/** Persist the only safe rearm: same queued job, with no claimed occurrence. */
|
|
2323
|
+
rearmQueuedGrowthSourceJob(input) {
|
|
2324
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.priorDefinitionHash)
|
|
2325
|
+
|| !Number.isSafeInteger(input.priorDefinitionVersion) || input.priorDefinitionVersion < 1
|
|
2326
|
+
|| !Number.isSafeInteger(input.dispatchAt) || input.dispatchAt <= this.#now()) {
|
|
2327
|
+
throw new ControlPlaneStoreError('invalid-input', 'source growth rearm request is invalid');
|
|
2328
|
+
}
|
|
2329
|
+
const now = this.#now();
|
|
2330
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET dispatch_at = ?, previous_definition_hash = definition_hash,
|
|
2331
|
+
previous_definition_version = ?,
|
|
2332
|
+
definition_hash = NULL, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ? AND status = 'queued'
|
|
2333
|
+
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);
|
|
2334
|
+
if (Number(result.changes) !== 1)
|
|
2335
|
+
throw new ControlPlaneStoreError('conflict', 'source growth rearm lost its queued CAS');
|
|
2336
|
+
return this.getSourceJob(input.id);
|
|
2337
|
+
}
|
|
1515
2338
|
claimSourceJob(input) {
|
|
1516
2339
|
if (!Number.isSafeInteger(input.revision) || input.revision < 1 || !DIGEST.test(input.definitionHash) || !KEY.test(input.occurrenceId)) {
|
|
1517
2340
|
throw new ControlPlaneStoreError('invalid-input', 'source job claim is invalid');
|
|
1518
2341
|
}
|
|
1519
2342
|
const now = this.#now();
|
|
1520
2343
|
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 >
|
|
2344
|
+
WHERE id = ? AND revision = ? AND status = 'queued' AND definition_hash = ? AND occurrence_id IS NULL AND expires_at > ?
|
|
2345
|
+
AND (json_type(intent_json, '$.creation') IS NULL OR json_extract(intent_json, '$.creation.grant.expiresAt') > ?)
|
|
2346
|
+
AND (json_type(intent_json, '$.revision') IS NULL OR json_extract(intent_json, '$.revision.grant.expiresAt') > ?)`)
|
|
2347
|
+
.run(input.occurrenceId, now, input.id, input.revision, input.definitionHash, now, now, now);
|
|
1522
2348
|
if (Number(result.changes) !== 1)
|
|
1523
2349
|
throw new ControlPlaneStoreError('conflict', 'source job claim lost its queued CAS');
|
|
1524
2350
|
return this.getSourceJob(input.id);
|
|
@@ -1528,7 +2354,8 @@ export class ControlPlaneStore {
|
|
|
1528
2354
|
throw new ControlPlaneStoreError('invalid-input', 'source job settlement is invalid');
|
|
1529
2355
|
const permitted = input.status === 'unknown' ? "status = 'running'" : "status IN ('queued', 'running', 'unknown')";
|
|
1530
2356
|
const now = this.#now();
|
|
1531
|
-
const result = this.#database.prepare(`UPDATE source_jobs SET status = ?, failure_code = ?,
|
|
2357
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = ?, failure_code = ?,
|
|
2358
|
+
previous_definition_hash = NULL, previous_definition_version = NULL, revision = revision + 1, updated_at = ?
|
|
1532
2359
|
WHERE id = ? AND revision = ? AND ${permitted}`).run(input.status, input.failureCode, now, input.id, input.revision);
|
|
1533
2360
|
if (Number(result.changes) !== 1)
|
|
1534
2361
|
throw new ControlPlaneStoreError('conflict', 'source job settlement lost its state CAS');
|
|
@@ -1540,10 +2367,22 @@ export class ControlPlaneStore {
|
|
|
1540
2367
|
revision = revision + 1, updated_at = ? WHERE status = 'running'`).run(now);
|
|
1541
2368
|
return Number(result.changes);
|
|
1542
2369
|
}
|
|
2370
|
+
/** Revoke claimed task-created jobs when their Host run producer disappears. */
|
|
2371
|
+
interruptGrowthSourceJobs() {
|
|
2372
|
+
const now = this.#now();
|
|
2373
|
+
const result = this.#database.prepare(`UPDATE source_jobs SET status = 'unknown', failure_code = 'source-growth-provider-disposed',
|
|
2374
|
+
revision = revision + 1, updated_at = ? WHERE status = 'running'
|
|
2375
|
+
AND (json_type(intent_json, '$.creation.growthRun') IS NOT NULL OR json_type(intent_json, '$.revision.growthRun') IS NOT NULL)`).run(now);
|
|
2376
|
+
return Number(result.changes);
|
|
2377
|
+
}
|
|
1543
2378
|
createSourcePlan(input) {
|
|
1544
2379
|
const mode = input.mode ?? 'create';
|
|
1545
|
-
if (
|
|
1546
|
-
throw new ControlPlaneStoreError('invalid-input', 'source
|
|
2380
|
+
if (mode !== 'create' && mode !== 'modify' && mode !== 'prepared-create' && mode !== 'prepared-revise')
|
|
2381
|
+
throw new ControlPlaneStoreError('invalid-input', 'source plan mode is invalid');
|
|
2382
|
+
if (input.sourceJob !== undefined && mode === 'create')
|
|
2383
|
+
throw new ControlPlaneStoreError('invalid-input', 'source jobs only complete checked modify or prepared-create plans');
|
|
2384
|
+
if ((mode === 'prepared-create' || mode === 'prepared-revise') && input.sourceJob === undefined)
|
|
2385
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared creation requires a running source job');
|
|
1547
2386
|
const key = bounded(input.idempotencyKey, 'idempotencyKey', 160);
|
|
1548
2387
|
const name = bounded(input.name, 'name', 64);
|
|
1549
2388
|
if (!KEY.test(key) || !PLUGIN_NAME.test(name) || !COMMIT.test(input.baseCommit) || !DIGEST.test(input.generatorDigest)
|
|
@@ -1552,20 +2391,48 @@ export class ControlPlaneStore {
|
|
|
1552
2391
|
if (mode === 'modify' && input.generatorDigest !== MODIFY_GENERATOR_DIGEST) {
|
|
1553
2392
|
throw new ControlPlaneStoreError('invalid-input', 'modify source plans must bind the modify generator constant');
|
|
1554
2393
|
}
|
|
2394
|
+
const creation = input.creation === undefined ? undefined : creationBindingFromStored(input.creation);
|
|
2395
|
+
if ((mode === 'prepared-create') !== (creation !== undefined)
|
|
2396
|
+
|| (creation !== undefined && (creation.generatorDigest !== input.generatorDigest || !name.startsWith(creation.grant.namePrefix)))) {
|
|
2397
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source creation binding is invalid');
|
|
2398
|
+
}
|
|
2399
|
+
const revision = input.revision === undefined ? undefined : revisionBindingFromStored(input.revision);
|
|
2400
|
+
if ((mode === 'prepared-revise') !== (revision !== undefined)
|
|
2401
|
+
|| (revision !== undefined && (revision.generatorDigest !== input.generatorDigest || !name.startsWith(revision.grant.namePrefix)))) {
|
|
2402
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source revision binding is invalid');
|
|
2403
|
+
}
|
|
1555
2404
|
let preparedEvidence;
|
|
1556
|
-
if (mode === 'modify') {
|
|
2405
|
+
if (mode === 'modify' || mode === 'prepared-create' || mode === 'prepared-revise') {
|
|
1557
2406
|
if (input.prepared === undefined) {
|
|
1558
|
-
throw new ControlPlaneStoreError('invalid-input', '
|
|
2407
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source plan requires check evidence');
|
|
1559
2408
|
}
|
|
1560
2409
|
if (!DIGEST.test(input.prepared.treeDigest) || !DIGEST.test(input.prepared.patchDigest)
|
|
1561
2410
|
|| !Number.isSafeInteger(input.prepared.checkedAt) || input.prepared.checkedAt < 0) {
|
|
1562
2411
|
throw new ControlPlaneStoreError('invalid-input', 'modify source plan prepared digests are invalid');
|
|
1563
2412
|
}
|
|
1564
2413
|
preparedEvidence = preparedEvidenceFromStored(input.prepared.evidence);
|
|
2414
|
+
if ((mode === 'modify' && preparedEvidence.schemaVersion !== 1)
|
|
2415
|
+
|| (preparedEvidence.schemaVersion === 2 && input.sourceJob === undefined)) {
|
|
2416
|
+
throw new ControlPlaneStoreError('invalid-input', 'native source evidence requires a durable created plugin job');
|
|
2417
|
+
}
|
|
1565
2418
|
}
|
|
1566
2419
|
else if (input.prepared !== undefined) {
|
|
1567
2420
|
throw new ControlPlaneStoreError('invalid-input', 'create source plans cannot carry prepared evidence');
|
|
1568
2421
|
}
|
|
2422
|
+
let preparedArtifact;
|
|
2423
|
+
if (input.preparedArtifact !== undefined) {
|
|
2424
|
+
if (mode !== 'prepared-create' && mode !== 'prepared-revise' || input.sourceJob === undefined || !Buffer.isBuffer(input.preparedArtifact)
|
|
2425
|
+
|| preparedEvidence === undefined || input.preparedArtifact.length < 1
|
|
2426
|
+
|| input.preparedArtifact.length > MAX_PREPARED_ARTIFACT_BYTES
|
|
2427
|
+
|| input.preparedArtifact.length !== preparedEvidence.pack.sizeBytes
|
|
2428
|
+
|| createHash('sha256').update(input.preparedArtifact).digest('hex') !== preparedEvidence.pack.sha256) {
|
|
2429
|
+
throw new ControlPlaneStoreError('invalid-input', 'prepared source artifact differs from checked package evidence');
|
|
2430
|
+
}
|
|
2431
|
+
if (this.getOwnerTaskFailureReference(input.gapId) === undefined) {
|
|
2432
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact requires an owner task failure gap');
|
|
2433
|
+
}
|
|
2434
|
+
preparedArtifact = Buffer.from(input.preparedArtifact);
|
|
2435
|
+
}
|
|
1569
2436
|
positiveInteger(input.ttlMs, 'ttlMs');
|
|
1570
2437
|
if (input.ttlMs < 60_000 || input.ttlMs > 86_400_000)
|
|
1571
2438
|
throw new ControlPlaneStoreError('invalid-input', 'ttlMs is invalid');
|
|
@@ -1582,11 +2449,24 @@ export class ControlPlaneStore {
|
|
|
1582
2449
|
this.#assertOwnerTaskFailureGapAdmission(input.gapId);
|
|
1583
2450
|
const sourceJobBinding = input.sourceJob === undefined ? undefined : { jobId: input.sourceJob.jobId, jobRevision: input.sourceJob.jobRevision,
|
|
1584
2451
|
occurrenceId: input.sourceJob.occurrenceId };
|
|
1585
|
-
const inputDigest = controlPlaneDigest({ ...(mode === '
|
|
1586
|
-
|
|
2452
|
+
const inputDigest = controlPlaneDigest({ ...(mode === 'create' ? requestBinding : { ...requestBinding, mode,
|
|
2453
|
+
...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { revision }), prepared: input.prepared }),
|
|
2454
|
+
...(sourceJobBinding === undefined ? {} : { sourceJob: sourceJobBinding }),
|
|
2455
|
+
...(preparedArtifact === undefined ? {} : { preparedArtifactSha256: preparedEvidence.pack.sha256 }) });
|
|
1587
2456
|
const prior = this.#sourcePlanReceiptByKey(key, 'create-source-plan', inputDigest);
|
|
1588
|
-
if (prior !== undefined)
|
|
2457
|
+
if (prior !== undefined) {
|
|
2458
|
+
if (preparedArtifact !== undefined) {
|
|
2459
|
+
const reference = this.#database.prepare('SELECT pack_sha256 FROM source_prepared_artifact_refs WHERE plan_id = ?')
|
|
2460
|
+
.get(prior.result.id);
|
|
2461
|
+
const row = this.#database.prepare('SELECT * FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
2462
|
+
.get(preparedEvidence.pack.sha256);
|
|
2463
|
+
if (reference?.pack_sha256 !== preparedEvidence.pack.sha256 || row === undefined
|
|
2464
|
+
|| !preparedArtifactBytesFromRow(row, preparedEvidence.pack).equals(preparedArtifact)) {
|
|
2465
|
+
throw new ControlPlaneStoreError('invalid-state', 'idempotent prepared source artifact is missing or changed');
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
1589
2468
|
return prior;
|
|
2469
|
+
}
|
|
1590
2470
|
const gap = this.getGap(input.gapId);
|
|
1591
2471
|
if (gap.status !== 'open' || gap.candidateId !== undefined) {
|
|
1592
2472
|
throw new ControlPlaneStoreError('invalid-state', 'only an unreserved open gap can create a source plan');
|
|
@@ -1598,8 +2478,8 @@ export class ControlPlaneStore {
|
|
|
1598
2478
|
const immutable = { schemaVersion: 1, kind: 'source', id, gapId: gap.id, gapSnapshot,
|
|
1599
2479
|
repository: input.repository, worktree: input.worktree, baseCommit: input.baseCommit, name,
|
|
1600
2480
|
generatorDigest: input.generatorDigest, scope, createdAt: now, expiresAt };
|
|
1601
|
-
const digest = controlPlaneDigest(mode
|
|
1602
|
-
? { ...immutable, mode, sourceCheck: { treeDigest: input.prepared.treeDigest, patchDigest: input.prepared.patchDigest,
|
|
2481
|
+
const digest = controlPlaneDigest(mode !== 'create'
|
|
2482
|
+
? { ...immutable, mode, ...(creation === undefined ? {} : { creation }), ...(revision === undefined ? {} : { sourceRevision: revision }), sourceCheck: { treeDigest: input.prepared.treeDigest, patchDigest: input.prepared.patchDigest,
|
|
1603
2483
|
checkedAt: input.prepared.checkedAt }, preparedEvidence }
|
|
1604
2484
|
: immutable);
|
|
1605
2485
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
@@ -1615,11 +2495,43 @@ export class ControlPlaneStore {
|
|
|
1615
2495
|
|| sourceJob.occurrenceId !== sourceJobBinding.occurrenceId || sourceJob.intent.gapId !== gap.id
|
|
1616
2496
|
|| sourceJob.intent.gapRevision !== currentGap.revision || sourceJob.intent.gapDigest !== controlPlaneDigest(currentGap)
|
|
1617
2497
|
|| 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
|
|
2498
|
+
|| sourceJob.intent.repository !== input.repository || sourceJob.intent.worktree !== input.worktree || sourceJob.intent.ttlMs !== input.ttlMs
|
|
2499
|
+
|| (mode === 'prepared-create') !== (sourceJob.intent.mode === 'create')
|
|
2500
|
+
|| (preparedEvidence !== undefined && ('backend' in sourceJob.intent.build) !== (preparedEvidence.schemaVersion === 2))
|
|
2501
|
+
|| (mode === 'prepared-create' && controlPlaneDigest(sourceJob.intent.creation) !== controlPlaneDigest(creation))
|
|
2502
|
+
|| (mode === 'prepared-revise') !== (sourceJob.intent.mode === 'revise-created')
|
|
2503
|
+
|| (mode === 'prepared-revise' && controlPlaneDigest(sourceJob.intent.revision) !== controlPlaneDigest(revision))) {
|
|
1619
2504
|
throw new ControlPlaneStoreError('conflict', 'source job completion lost its immutable running binding');
|
|
1620
2505
|
}
|
|
1621
|
-
if (now >= sourceJob.expiresAt)
|
|
2506
|
+
if (now >= sourceJob.expiresAt || (creation !== undefined && now >= creation.grant.expiresAt) || (revision !== undefined && now >= revision.grant.expiresAt))
|
|
1622
2507
|
throw new ControlPlaneStoreError('expired', 'source job authority is expired');
|
|
2508
|
+
if (mode === 'prepared-create' && this.getOwnerTaskFailureReference(input.gapId) !== undefined
|
|
2509
|
+
&& (!creation?.growthRun || now >= creation.growthRun.expiresAt)) {
|
|
2510
|
+
throw new ControlPlaneStoreError('expired', 'task-backed creation growth run is absent or expired');
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
if (revision !== undefined && (this.getOwnerTaskFailureReference(input.gapId) === undefined || now >= revision.growthRun.expiresAt)) {
|
|
2514
|
+
throw new ControlPlaneStoreError('expired', 'revision requires a current owner task and frozen growth run');
|
|
2515
|
+
}
|
|
2516
|
+
if (preparedArtifact !== undefined) {
|
|
2517
|
+
const pack = preparedEvidence.pack;
|
|
2518
|
+
const existing = this.#database.prepare('SELECT * FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
2519
|
+
.get(pack.sha256);
|
|
2520
|
+
if (existing !== undefined) {
|
|
2521
|
+
if (!preparedArtifactBytesFromRow(existing, pack).equals(preparedArtifact)) {
|
|
2522
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored prepared source artifact differs from checked bytes');
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
else {
|
|
2526
|
+
const totals = this.#database.prepare('SELECT count(*) AS count, coalesce(sum(size_bytes), 0) AS total FROM source_prepared_artifacts')
|
|
2527
|
+
.get();
|
|
2528
|
+
if (!Number.isSafeInteger(totals.count) || !Number.isSafeInteger(totals.total)
|
|
2529
|
+
|| totals.count >= MAX_STORED_ARTIFACTS || totals.total > MAX_STORED_ARTIFACT_BYTES - preparedArtifact.length) {
|
|
2530
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact storage quota is exhausted');
|
|
2531
|
+
}
|
|
2532
|
+
this.#database.prepare('INSERT INTO source_prepared_artifacts (pack_sha256, size_bytes, bytes, created_at) VALUES (?, ?, ?, ?)')
|
|
2533
|
+
.run(pack.sha256, preparedArtifact.length, preparedArtifact, now);
|
|
2534
|
+
}
|
|
1623
2535
|
}
|
|
1624
2536
|
this.#database.prepare('INSERT INTO gap_plan_claims (gap_id, plan_id, plan_kind, claimed_at) VALUES (?, ?, ?, ?)').run(gap.id, id, 'source', now);
|
|
1625
2537
|
// 'modify' rows carry their checked digests and frozen-build evidence from
|
|
@@ -1627,8 +2539,12 @@ export class ControlPlaneStore {
|
|
|
1627
2539
|
// post-approval scaffold run finishes.
|
|
1628
2540
|
this.#database.prepare(`INSERT INTO source_plans (id, plan_digest, gap_id, gap_snapshot_json, repository, worktree,
|
|
1629
2541
|
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);
|
|
2542
|
+
approval_json, checked_tree_digest, checked_patch_digest, checked_at, prepared_evidence_json, creation_json, revision_json, updated_at)
|
|
2543
|
+
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);
|
|
2544
|
+
if (preparedArtifact !== undefined) {
|
|
2545
|
+
this.#database.prepare('INSERT INTO source_prepared_artifact_refs (plan_id, pack_sha256) VALUES (?, ?)')
|
|
2546
|
+
.run(id, preparedEvidence.pack.sha256);
|
|
2547
|
+
}
|
|
1632
2548
|
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
2549
|
if (Number(matched.changes) !== 1)
|
|
1634
2550
|
throw new ControlPlaneStoreError('conflict', 'capability gap changed while source plan was created');
|
|
@@ -1658,6 +2574,735 @@ export class ControlPlaneStore {
|
|
|
1658
2574
|
throw new ControlPlaneStoreError('not-found', 'source plan not found');
|
|
1659
2575
|
return sourceFromRow(row);
|
|
1660
2576
|
}
|
|
2577
|
+
/** Exact durable source job for a currently admitted prepared creation. */
|
|
2578
|
+
getPreparedSourceJob(planId) {
|
|
2579
|
+
const plan = this.getSourcePlan(planId);
|
|
2580
|
+
if (plan.mode !== 'prepared-create' || plan.status !== 'pending-approval'
|
|
2581
|
+
|| plan.creation === undefined || plan.preparedEvidence === undefined
|
|
2582
|
+
|| this.#now() >= plan.expiresAt || this.#now() >= plan.creation.grant.expiresAt) {
|
|
2583
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation requires a current pending plan');
|
|
2584
|
+
}
|
|
2585
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2586
|
+
if (owner === undefined)
|
|
2587
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation requires an owner task failure gap');
|
|
2588
|
+
this.#assertOwnerTaskFailureGapAdmission(plan.gapId);
|
|
2589
|
+
return this.getRetainedPreparedCreation(planId).job;
|
|
2590
|
+
}
|
|
2591
|
+
/** Read-only exact historical binding for an already signed adoption. Caller must hold the current owner fence. */
|
|
2592
|
+
getRetainedPreparedCreation(planId) {
|
|
2593
|
+
const plan = this.getSourcePlan(planId);
|
|
2594
|
+
if (plan.mode !== 'prepared-create' || !['pending-approval', 'expired'].includes(plan.status)
|
|
2595
|
+
|| plan.creation === undefined || plan.preparedEvidence === undefined || plan.sourceCheck === undefined) {
|
|
2596
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained creation requires exact prepared evidence');
|
|
2597
|
+
}
|
|
2598
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2599
|
+
if (owner === undefined)
|
|
2600
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained creation 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 creation 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 !== 'create'
|
|
2608
|
+
|| job.intent.creation === 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.creation) !== controlPlaneDigest(plan.creation)
|
|
2614
|
+
|| job.intent.creation.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 creation source job binding is corrupt');
|
|
2619
|
+
}
|
|
2620
|
+
return { plan, job, reference: owner };
|
|
2621
|
+
}
|
|
2622
|
+
/** Exact durable source job for a currently admitted prepared revision. */
|
|
2623
|
+
getPreparedRevisionJob(planId) {
|
|
2624
|
+
const plan = this.getSourcePlan(planId);
|
|
2625
|
+
if (plan.mode !== 'prepared-revise' || plan.status !== 'pending-approval'
|
|
2626
|
+
|| plan.sourceRevision === undefined || plan.preparedEvidence === undefined
|
|
2627
|
+
|| this.#now() >= plan.expiresAt || this.#now() >= plan.sourceRevision.grant.expiresAt) {
|
|
2628
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision requires a current pending plan');
|
|
2629
|
+
}
|
|
2630
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2631
|
+
if (owner === undefined)
|
|
2632
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision requires an owner task failure gap');
|
|
2633
|
+
this.#assertOwnerTaskFailureGapAdmission(plan.gapId);
|
|
2634
|
+
return this.getRetainedPreparedRevision(planId).job;
|
|
2635
|
+
}
|
|
2636
|
+
/** Read-only exact historical binding for an already signed adoption. Caller must hold the current owner fence. */
|
|
2637
|
+
getRetainedPreparedRevision(planId) {
|
|
2638
|
+
const plan = this.getSourcePlan(planId);
|
|
2639
|
+
if (plan.mode !== 'prepared-revise' || !['pending-approval', 'expired'].includes(plan.status)
|
|
2640
|
+
|| plan.sourceRevision === undefined || plan.preparedEvidence === undefined || plan.sourceCheck === undefined) {
|
|
2641
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained revision requires exact prepared evidence');
|
|
2642
|
+
}
|
|
2643
|
+
const owner = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2644
|
+
if (owner === undefined)
|
|
2645
|
+
throw new ControlPlaneStoreError('invalid-state', 'retained revision requires an owner task failure gap');
|
|
2646
|
+
const row = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ?')
|
|
2647
|
+
.get(plan.id);
|
|
2648
|
+
if (row === undefined)
|
|
2649
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision has no source job');
|
|
2650
|
+
const job = sourceJobFromRow(row);
|
|
2651
|
+
const gap = this.getGap(plan.gapId);
|
|
2652
|
+
if (job.status !== 'prepared' || job.planId !== plan.id || job.intent.mode !== 'revise-created'
|
|
2653
|
+
|| job.intent.revision === undefined || job.intent.gapId !== plan.gapId
|
|
2654
|
+
|| job.intent.gapRevision !== plan.gapSnapshot.revision
|
|
2655
|
+
|| gap.inputDigest !== plan.gapSnapshot.inputDigest
|
|
2656
|
+
|| job.intent.ownerDigest !== controlPlaneDigest(owner.owner)
|
|
2657
|
+
|| controlPlaneDigest(job.intent.owner) !== job.intent.ownerDigest
|
|
2658
|
+
|| controlPlaneDigest(job.intent.revision) !== controlPlaneDigest(plan.sourceRevision)
|
|
2659
|
+
|| job.intent.revision.generatorDigest !== plan.generatorDigest
|
|
2660
|
+
|| job.intent.repository !== plan.repository || job.intent.worktree !== plan.worktree
|
|
2661
|
+
|| job.intent.baseCommit !== plan.baseCommit || job.intent.name !== plan.name
|
|
2662
|
+
|| plan.expiresAt !== plan.createdAt + job.intent.ttlMs) {
|
|
2663
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared revision source job binding is corrupt');
|
|
2664
|
+
}
|
|
2665
|
+
return { plan, job, reference: owner };
|
|
2666
|
+
}
|
|
2667
|
+
/** Host-only package bytes for an owner-admitted, still-pending created plugin. */
|
|
2668
|
+
readPreparedSourceArtifact(planId) {
|
|
2669
|
+
const plan = this.getSourcePlan(planId);
|
|
2670
|
+
if (plan.mode !== 'prepared-create' && plan.mode !== 'prepared-revise' || plan.status !== 'pending-approval'
|
|
2671
|
+
|| plan.preparedEvidence === undefined || this.#now() >= plan.expiresAt) {
|
|
2672
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact requires a current pending creation plan');
|
|
2673
|
+
}
|
|
2674
|
+
if (plan.mode === 'prepared-revise')
|
|
2675
|
+
this.getPreparedRevisionJob(planId);
|
|
2676
|
+
else
|
|
2677
|
+
this.getPreparedSourceJob(planId);
|
|
2678
|
+
const pack = plan.preparedEvidence.pack;
|
|
2679
|
+
const reference = this.#database.prepare('SELECT pack_sha256 FROM source_prepared_artifact_refs WHERE plan_id = ?')
|
|
2680
|
+
.get(plan.id);
|
|
2681
|
+
if (reference?.pack_sha256 !== pack.sha256)
|
|
2682
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact reference is missing or corrupt');
|
|
2683
|
+
const row = this.#database.prepare('SELECT * FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
2684
|
+
.get(pack.sha256);
|
|
2685
|
+
if (row === undefined)
|
|
2686
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact is missing');
|
|
2687
|
+
return preparedArtifactBytesFromRow(row, pack);
|
|
2688
|
+
}
|
|
2689
|
+
/** One immutable, independently signed result per exact prepared creation. */
|
|
2690
|
+
recordCreationVerification(certificate) {
|
|
2691
|
+
validatePluginCreationVerificationCertificate(certificate);
|
|
2692
|
+
const plan = this.getSourcePlan(certificate.plan.id);
|
|
2693
|
+
const job = this.getPreparedSourceJob(plan.id);
|
|
2694
|
+
const reference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2695
|
+
if (!reference || !plan.sourceCheck || !plan.preparedEvidence || !plan.creation?.growthRun
|
|
2696
|
+
|| !job.intent.creation?.growthRun || plan.digest !== certificate.plan.digest || plan.name !== certificate.plan.name
|
|
2697
|
+
|| plan.sourceCheck.treeDigest !== certificate.plan.sourceTreeDigest
|
|
2698
|
+
|| plan.sourceCheck.patchDigest !== certificate.plan.sourcePatchDigest
|
|
2699
|
+
|| plan.preparedEvidence.pack.sha256 !== certificate.plan.artifactSha256
|
|
2700
|
+
|| plan.preparedEvidence.pack.sizeBytes !== certificate.plan.artifactBytes
|
|
2701
|
+
|| plan.generatorDigest !== certificate.plan.generatorDigest
|
|
2702
|
+
|| controlPlaneDigest(reference) !== certificate.source.referenceDigest
|
|
2703
|
+
|| controlPlaneDigest(reference.owner) !== certificate.source.ownerDigest
|
|
2704
|
+
|| sourceGrowthRunDigest(plan.creation.growthRun) !== certificate.source.growthRunDigest
|
|
2705
|
+
|| sourceGrowthRunDigest(job.intent.creation.growthRun) !== certificate.source.growthRunDigest
|
|
2706
|
+
|| controlPlaneDigest(plan.creation.growthRun.model) !== controlPlaneDigest(certificate.model)
|
|
2707
|
+
|| certificate.expiresAt > Math.min(plan.expiresAt, plan.creation.grant.expiresAt, plan.creation.growthRun.expiresAt, certificate.authority.expiresAt)) {
|
|
2708
|
+
throw new ControlPlaneStoreError('invalid-input', 'creation verification certificate does not match exact prepared source');
|
|
2709
|
+
}
|
|
2710
|
+
this.readPreparedSourceArtifact(plan.id);
|
|
2711
|
+
const serialized = JSON.stringify(certificate), certificateDigest = controlPlaneDigest(certificate);
|
|
2712
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2713
|
+
try {
|
|
2714
|
+
const previous = this.#database.prepare('SELECT status,certificate_digest FROM source_creation_verifications WHERE plan_id=?')
|
|
2715
|
+
.get(plan.id);
|
|
2716
|
+
if (previous?.status === 'verified' && previous.certificate_digest === certificateDigest) { /* idempotent */ }
|
|
2717
|
+
else if (previous?.status !== 'claimed')
|
|
2718
|
+
throw new ControlPlaneStoreError('conflict', 'creation verification was not claimed or already settled');
|
|
2719
|
+
else
|
|
2720
|
+
this.#database.prepare(`UPDATE source_creation_verifications SET status='verified',certificate_json=?,certificate_digest=?,updated_at=?
|
|
2721
|
+
WHERE plan_id=? AND status='claimed'`).run(serialized, certificateDigest, this.#now(), plan.id);
|
|
2722
|
+
this.#database.exec('COMMIT');
|
|
2723
|
+
}
|
|
2724
|
+
catch (error) {
|
|
2725
|
+
this.#database.exec('ROLLBACK');
|
|
2726
|
+
throw error;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
getCreationVerification(planId) {
|
|
2730
|
+
const row = this.#database.prepare('SELECT status,certificate_json,certificate_digest FROM source_creation_verifications WHERE plan_id=?')
|
|
2731
|
+
.get(planId);
|
|
2732
|
+
if (!row || row.status !== 'verified')
|
|
2733
|
+
return undefined;
|
|
2734
|
+
let certificate;
|
|
2735
|
+
try {
|
|
2736
|
+
const parsed = JSON.parse(row.certificate_json);
|
|
2737
|
+
validatePluginCreationVerificationCertificate(parsed);
|
|
2738
|
+
certificate = parsed;
|
|
2739
|
+
}
|
|
2740
|
+
catch {
|
|
2741
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored creation verification certificate is corrupt');
|
|
2742
|
+
}
|
|
2743
|
+
if (certificate.plan.id !== planId || controlPlaneDigest(certificate) !== row.certificate_digest) {
|
|
2744
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored creation verification certificate digest changed');
|
|
2745
|
+
}
|
|
2746
|
+
return certificate;
|
|
2747
|
+
}
|
|
2748
|
+
getCreationVerificationStatus(planId) {
|
|
2749
|
+
const row = this.#database.prepare('SELECT status FROM source_creation_verifications WHERE plan_id=?')
|
|
2750
|
+
.get(planId);
|
|
2751
|
+
return row?.status;
|
|
2752
|
+
}
|
|
2753
|
+
/** Content-free diagnostic projection; caller must hold current owner/source fence. */
|
|
2754
|
+
inspectCreationVerificationRecord(planId) {
|
|
2755
|
+
const row = this.#database.prepare('SELECT status,reason,updated_at FROM source_creation_verifications WHERE plan_id=?')
|
|
2756
|
+
.get(planId);
|
|
2757
|
+
if (!row)
|
|
2758
|
+
return undefined;
|
|
2759
|
+
if (!['claimed', 'verified', 'unknown', 'rejected'].includes(row.status)
|
|
2760
|
+
|| !Number.isSafeInteger(row.updated_at) || row.updated_at < 1
|
|
2761
|
+
|| (row.reason !== null && !/^[a-z][a-z0-9-]{0,79}$/u.test(row.reason))) {
|
|
2762
|
+
throw new ControlPlaneStoreError('invalid-state', 'creation verification diagnostic record is corrupt');
|
|
2763
|
+
}
|
|
2764
|
+
return { status: row.status, ...(row.reason === null ? {} : { reason: row.reason }), updatedAt: row.updated_at };
|
|
2765
|
+
}
|
|
2766
|
+
claimCreationVerification(planId) {
|
|
2767
|
+
const job = this.getPreparedSourceJob(planId);
|
|
2768
|
+
const pinned = job.intent.creation?.growthRun?.creationAcceptance;
|
|
2769
|
+
if (!pinned || this.#now() >= pinned.expiresAt) {
|
|
2770
|
+
throw new ControlPlaneStoreError('invalid-state', 'creation verification requires a current pre-author policy binding');
|
|
2771
|
+
}
|
|
2772
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2773
|
+
try {
|
|
2774
|
+
const count = this.#database.prepare('SELECT count(*) AS n FROM source_creation_verifications').get();
|
|
2775
|
+
if (count.n >= 256)
|
|
2776
|
+
throw new ControlPlaneStoreError('conflict', 'creation verification record limit reached');
|
|
2777
|
+
const now = this.#now();
|
|
2778
|
+
this.#database.prepare(`INSERT INTO source_creation_verifications
|
|
2779
|
+
(plan_id,status,certificate_json,certificate_digest,reason,created_at,updated_at) VALUES (?,'claimed',NULL,NULL,NULL,?,?)`)
|
|
2780
|
+
.run(planId, now, now);
|
|
2781
|
+
this.#database.exec('COMMIT');
|
|
2782
|
+
}
|
|
2783
|
+
catch (error) {
|
|
2784
|
+
this.#database.exec('ROLLBACK');
|
|
2785
|
+
throw error;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
settleCreationVerification(planId, status, reason) {
|
|
2789
|
+
if (typeof reason !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/u.test(reason)) {
|
|
2790
|
+
throw new ControlPlaneStoreError('invalid-input', 'creation verification reason is invalid');
|
|
2791
|
+
}
|
|
2792
|
+
const result = this.#database.prepare(`UPDATE source_creation_verifications SET status=?,reason=?,updated_at=?
|
|
2793
|
+
WHERE plan_id=? AND status='claimed'`).run(status, reason, this.#now(), planId);
|
|
2794
|
+
if (Number(result.changes) !== 1)
|
|
2795
|
+
throw new ControlPlaneStoreError('conflict', 'creation verification claim is not current');
|
|
2796
|
+
}
|
|
2797
|
+
/** One immutable, independently signed result per exact prepared revision. */
|
|
2798
|
+
recordRevisionVerification(certificate, source) {
|
|
2799
|
+
validatePluginRevisionVerificationCertificate(certificate);
|
|
2800
|
+
const plan = this.getSourcePlan(certificate.plan.id);
|
|
2801
|
+
const job = this.getPreparedRevisionJob(plan.id);
|
|
2802
|
+
const reference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2803
|
+
validateCreationCapabilitySource(source, certificate);
|
|
2804
|
+
if (!reference || controlPlaneDigest(plan.sourceRevision?.parent) !== controlPlaneDigest(certificate.parent) || !plan.sourceCheck || !plan.preparedEvidence || !plan.sourceRevision?.growthRun
|
|
2805
|
+
|| !job.intent.revision?.growthRun || plan.digest !== certificate.plan.digest || plan.name !== certificate.plan.name
|
|
2806
|
+
|| plan.sourceCheck.treeDigest !== certificate.plan.sourceTreeDigest
|
|
2807
|
+
|| plan.sourceCheck.patchDigest !== certificate.plan.sourcePatchDigest
|
|
2808
|
+
|| plan.preparedEvidence.pack.sha256 !== certificate.plan.artifactSha256
|
|
2809
|
+
|| plan.preparedEvidence.pack.sizeBytes !== certificate.plan.artifactBytes
|
|
2810
|
+
|| plan.generatorDigest !== certificate.plan.generatorDigest
|
|
2811
|
+
|| controlPlaneDigest(reference) !== certificate.source.referenceDigest
|
|
2812
|
+
|| controlPlaneDigest(reference.owner) !== certificate.source.ownerDigest
|
|
2813
|
+
|| sourceGrowthRunDigest(plan.sourceRevision.growthRun) !== certificate.source.growthRunDigest
|
|
2814
|
+
|| sourceGrowthRunDigest(job.intent.revision.growthRun) !== certificate.source.growthRunDigest
|
|
2815
|
+
|| controlPlaneDigest(plan.sourceRevision.growthRun.model) !== controlPlaneDigest(certificate.model)
|
|
2816
|
+
|| certificate.expiresAt > Math.min(plan.expiresAt, plan.sourceRevision.grant.expiresAt, plan.sourceRevision.growthRun.expiresAt, certificate.authority.expiresAt)) {
|
|
2817
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision verification certificate does not match exact prepared source');
|
|
2818
|
+
}
|
|
2819
|
+
this.readPreparedSourceArtifact(plan.id);
|
|
2820
|
+
if (source.baseCommit !== plan.baseCommit || controlPlaneDigest(source.scope) !== controlPlaneDigest(plan.scope))
|
|
2821
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision source archive base or scope changed');
|
|
2822
|
+
const sourceJson = JSON.stringify(source);
|
|
2823
|
+
if (Buffer.byteLength(sourceJson) > 2_097_152)
|
|
2824
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision source archive exceeds bound');
|
|
2825
|
+
const serialized = JSON.stringify(certificate), certificateDigest = controlPlaneDigest(certificate);
|
|
2826
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2827
|
+
try {
|
|
2828
|
+
const previous = this.#database.prepare('SELECT status,certificate_digest FROM source_revision_verifications WHERE plan_id=?')
|
|
2829
|
+
.get(plan.id);
|
|
2830
|
+
if (previous?.status === 'verified' && previous.certificate_digest === certificateDigest) { /* idempotent */ }
|
|
2831
|
+
else if (previous?.status !== 'claimed')
|
|
2832
|
+
throw new ControlPlaneStoreError('conflict', 'revision verification was not claimed or already settled');
|
|
2833
|
+
else
|
|
2834
|
+
this.#database.prepare(`UPDATE source_revision_verifications SET status='verified',certificate_json=?,certificate_digest=?,updated_at=?
|
|
2835
|
+
WHERE plan_id=? AND status='claimed'`).run(serialized, certificateDigest, this.#now(), plan.id);
|
|
2836
|
+
const oldSource = this.#database.prepare('SELECT source_json FROM source_revision_sources WHERE plan_id=?').get(plan.id);
|
|
2837
|
+
if (oldSource && oldSource.source_json !== sourceJson)
|
|
2838
|
+
throw new ControlPlaneStoreError('conflict', 'revision source archive changed');
|
|
2839
|
+
if (!oldSource)
|
|
2840
|
+
this.#database.prepare('INSERT INTO source_revision_sources VALUES(?,?)').run(plan.id, sourceJson);
|
|
2841
|
+
this.#database.exec('COMMIT');
|
|
2842
|
+
}
|
|
2843
|
+
catch (error) {
|
|
2844
|
+
this.#database.exec('ROLLBACK');
|
|
2845
|
+
throw error;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
getRevisionVerification(planId) {
|
|
2849
|
+
const row = this.#database.prepare('SELECT status,certificate_json,certificate_digest FROM source_revision_verifications WHERE plan_id=?')
|
|
2850
|
+
.get(planId);
|
|
2851
|
+
if (!row || row.status !== 'verified')
|
|
2852
|
+
return undefined;
|
|
2853
|
+
let certificate;
|
|
2854
|
+
try {
|
|
2855
|
+
const parsed = JSON.parse(row.certificate_json);
|
|
2856
|
+
validatePluginRevisionVerificationCertificate(parsed);
|
|
2857
|
+
certificate = parsed;
|
|
2858
|
+
}
|
|
2859
|
+
catch {
|
|
2860
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision verification certificate is corrupt');
|
|
2861
|
+
}
|
|
2862
|
+
if (certificate.plan.id !== planId || controlPlaneDigest(certificate) !== row.certificate_digest) {
|
|
2863
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision verification certificate digest changed');
|
|
2864
|
+
}
|
|
2865
|
+
return certificate;
|
|
2866
|
+
}
|
|
2867
|
+
getRevisionVerificationStatus(planId) {
|
|
2868
|
+
const row = this.#database.prepare('SELECT status FROM source_revision_verifications WHERE plan_id=?')
|
|
2869
|
+
.get(planId);
|
|
2870
|
+
return row?.status;
|
|
2871
|
+
}
|
|
2872
|
+
/** Content-free diagnostic projection; caller must hold current owner/source fence. */
|
|
2873
|
+
inspectRevisionVerificationRecord(planId) {
|
|
2874
|
+
const row = this.#database.prepare('SELECT status,reason,updated_at FROM source_revision_verifications WHERE plan_id=?')
|
|
2875
|
+
.get(planId);
|
|
2876
|
+
if (!row)
|
|
2877
|
+
return undefined;
|
|
2878
|
+
if (!['claimed', 'verified', 'unknown', 'rejected'].includes(row.status)
|
|
2879
|
+
|| !Number.isSafeInteger(row.updated_at) || row.updated_at < 1
|
|
2880
|
+
|| (row.reason !== null && !/^[a-z][a-z0-9-]{0,79}$/u.test(row.reason))) {
|
|
2881
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision verification diagnostic record is corrupt');
|
|
2882
|
+
}
|
|
2883
|
+
return { status: row.status, ...(row.reason === null ? {} : { reason: row.reason }), updatedAt: row.updated_at };
|
|
2884
|
+
}
|
|
2885
|
+
claimRevisionVerification(planId) {
|
|
2886
|
+
const job = this.getPreparedRevisionJob(planId);
|
|
2887
|
+
const pinned = job.intent.revision?.growthRun?.revisionAcceptance;
|
|
2888
|
+
if (!pinned || this.#now() >= pinned.expiresAt) {
|
|
2889
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision verification requires a current pre-author policy binding');
|
|
2890
|
+
}
|
|
2891
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2892
|
+
try {
|
|
2893
|
+
const count = this.#database.prepare('SELECT count(*) AS n FROM source_revision_verifications').get();
|
|
2894
|
+
if (count.n >= 256)
|
|
2895
|
+
throw new ControlPlaneStoreError('conflict', 'revision verification record limit reached');
|
|
2896
|
+
const now = this.#now();
|
|
2897
|
+
this.#database.prepare(`INSERT INTO source_revision_verifications
|
|
2898
|
+
(plan_id,status,certificate_json,certificate_digest,reason,created_at,updated_at) VALUES (?,'claimed',NULL,NULL,NULL,?,?)`)
|
|
2899
|
+
.run(planId, now, now);
|
|
2900
|
+
this.#database.exec('COMMIT');
|
|
2901
|
+
}
|
|
2902
|
+
catch (error) {
|
|
2903
|
+
this.#database.exec('ROLLBACK');
|
|
2904
|
+
throw error;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
settleRevisionVerification(planId, status, reason) {
|
|
2908
|
+
if (typeof reason !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/u.test(reason)) {
|
|
2909
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision verification reason is invalid');
|
|
2910
|
+
}
|
|
2911
|
+
const result = this.#database.prepare(`UPDATE source_revision_verifications SET status=?,reason=?,updated_at=?
|
|
2912
|
+
WHERE plan_id=? AND status='claimed'`).run(status, reason, this.#now(), planId);
|
|
2913
|
+
if (Number(result.changes) !== 1)
|
|
2914
|
+
throw new ControlPlaneStoreError('conflict', 'revision verification claim is not current');
|
|
2915
|
+
}
|
|
2916
|
+
/** Purpose-separated paired held-out proof; never changes plan or adoption state. */
|
|
2917
|
+
recordRevisionRegression(certificate) {
|
|
2918
|
+
validatePluginRevisionRegressionCertificate(certificate);
|
|
2919
|
+
const plan = this.getSourcePlan(certificate.plan.id);
|
|
2920
|
+
const job = this.getPreparedRevisionJob(plan.id);
|
|
2921
|
+
const candidate = this.getRevisionVerification(plan.id);
|
|
2922
|
+
const parent = this.getCreationVerification(certificate.parent.planId);
|
|
2923
|
+
const source = this.readRevisionSource(plan.id);
|
|
2924
|
+
const reference = this.getOwnerTaskFailureReference(plan.gapId);
|
|
2925
|
+
const run = plan.sourceRevision?.growthRun;
|
|
2926
|
+
const previousJobs = new Set([...(parent?.observations ?? []), ...(candidate?.observations ?? [])].map(item => item.jobId));
|
|
2927
|
+
if (certificate.verifiedAt > this.#now() || certificate.expiresAt <= this.#now()
|
|
2928
|
+
|| !candidate || !parent || !source || !reference || !run?.revisionRegressionAcceptance
|
|
2929
|
+
|| !job.intent.revision?.growthRun.revisionRegressionAcceptance
|
|
2930
|
+
|| controlPlaneDigest(certificate.plan) !== controlPlaneDigest(candidate.plan)
|
|
2931
|
+
|| controlPlaneDigest(certificate.source) !== controlPlaneDigest(candidate.source)
|
|
2932
|
+
|| controlPlaneDigest(certificate.parent) !== controlPlaneDigest(candidate.parent)
|
|
2933
|
+
|| certificate.candidateVerificationDigest !== controlPlaneDigest(candidate)
|
|
2934
|
+
|| certificate.sourceDigest !== source.digest
|
|
2935
|
+
|| certificate.contractDigest !== parent.contractDigest
|
|
2936
|
+
|| certificate.schemaDigest !== candidate.schemaDigest
|
|
2937
|
+
|| controlPlaneDigest(certificate.environment) !== controlPlaneDigest(candidate.environment)
|
|
2938
|
+
|| controlPlaneDigest(certificate.environment) !== controlPlaneDigest(parent.environment)
|
|
2939
|
+
|| controlPlaneDigest(certificate.model) !== controlPlaneDigest(run.model)
|
|
2940
|
+
|| controlPlaneDigest(certificate.authority) !== controlPlaneDigest(run.revisionRegressionAcceptance)
|
|
2941
|
+
|| controlPlaneDigest(certificate.authority) !== controlPlaneDigest(job.intent.revision.growthRun.revisionRegressionAcceptance)
|
|
2942
|
+
|| certificate.source.referenceDigest !== controlPlaneDigest(reference)
|
|
2943
|
+
|| certificate.source.ownerDigest !== controlPlaneDigest(reference.owner)
|
|
2944
|
+
|| certificate.source.growthRunDigest !== sourceGrowthRunDigest(run)
|
|
2945
|
+
|| sourceGrowthRunDigest(run) !== sourceGrowthRunDigest(job.intent.revision.growthRun)
|
|
2946
|
+
|| certificate.observations.length !== parent.observations.length
|
|
2947
|
+
|| certificate.observations.some((item, index) => item.caseId !== parent.observations[index]?.caseId
|
|
2948
|
+
|| previousJobs.has(item.parent.jobId) || previousJobs.has(item.candidate.jobId))
|
|
2949
|
+
|| certificate.expiresAt > Math.min(candidate.expiresAt, plan.expiresAt, run.expiresAt, plan.sourceRevision.grant.expiresAt, certificate.authority.expiresAt)) {
|
|
2950
|
+
throw new ControlPlaneStoreError('invalid-input', 'regression certificate does not match exact parent and candidate');
|
|
2951
|
+
}
|
|
2952
|
+
this.readPreparedSourceArtifact(plan.id);
|
|
2953
|
+
const serialized = JSON.stringify(certificate), digest = controlPlaneDigest(certificate);
|
|
2954
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
2955
|
+
try {
|
|
2956
|
+
const old = this.#database.prepare('SELECT status,certificate_digest FROM source_revision_regressions WHERE plan_id=?')
|
|
2957
|
+
.get(plan.id);
|
|
2958
|
+
if (old?.status === 'verified' && old.certificate_digest === digest) { /* immutable idempotent result */ }
|
|
2959
|
+
else if (old?.status !== 'claimed')
|
|
2960
|
+
throw new ControlPlaneStoreError('conflict', 'regression was not claimed or is settled');
|
|
2961
|
+
else
|
|
2962
|
+
this.#database.prepare(`UPDATE source_revision_regressions SET status='verified',certificate_json=?,certificate_digest=?,updated_at=?
|
|
2963
|
+
WHERE plan_id=? AND status='claimed'`).run(serialized, digest, this.#now(), plan.id);
|
|
2964
|
+
this.#database.exec('COMMIT');
|
|
2965
|
+
}
|
|
2966
|
+
catch (error) {
|
|
2967
|
+
this.#database.exec('ROLLBACK');
|
|
2968
|
+
throw error;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
getRevisionRegression(planId) {
|
|
2972
|
+
const row = this.#database.prepare('SELECT status,certificate_json,certificate_digest FROM source_revision_regressions WHERE plan_id=?')
|
|
2973
|
+
.get(planId);
|
|
2974
|
+
if (!row || row.status !== 'verified')
|
|
2975
|
+
return undefined;
|
|
2976
|
+
let certificate;
|
|
2977
|
+
try {
|
|
2978
|
+
const parsed = JSON.parse(row.certificate_json);
|
|
2979
|
+
validatePluginRevisionRegressionCertificate(parsed);
|
|
2980
|
+
certificate = parsed;
|
|
2981
|
+
}
|
|
2982
|
+
catch {
|
|
2983
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision regression certificate is corrupt');
|
|
2984
|
+
}
|
|
2985
|
+
if (certificate.plan.id !== planId || controlPlaneDigest(certificate) !== row.certificate_digest) {
|
|
2986
|
+
throw new ControlPlaneStoreError('invalid-state', 'stored revision regression certificate digest changed');
|
|
2987
|
+
}
|
|
2988
|
+
return certificate;
|
|
2989
|
+
}
|
|
2990
|
+
getRevisionRegressionStatus(planId) {
|
|
2991
|
+
const row = this.#database.prepare('SELECT status FROM source_revision_regressions WHERE plan_id=?')
|
|
2992
|
+
.get(planId);
|
|
2993
|
+
return row?.status;
|
|
2994
|
+
}
|
|
2995
|
+
/** Content-free diagnostic projection; caller must hold current owner/source fence. */
|
|
2996
|
+
inspectRevisionRegressionRecord(planId) {
|
|
2997
|
+
const row = this.#database.prepare('SELECT status,reason,updated_at FROM source_revision_regressions WHERE plan_id=?')
|
|
2998
|
+
.get(planId);
|
|
2999
|
+
if (!row)
|
|
3000
|
+
return undefined;
|
|
3001
|
+
if (!['claimed', 'verified', 'unknown', 'rejected'].includes(row.status)
|
|
3002
|
+
|| !Number.isSafeInteger(row.updated_at) || row.updated_at < 1
|
|
3003
|
+
|| (row.reason !== null && !/^[a-z][a-z0-9-]{0,79}$/u.test(row.reason))) {
|
|
3004
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision regression diagnostic record is corrupt');
|
|
3005
|
+
}
|
|
3006
|
+
return { status: row.status, ...(row.reason === null ? {} : { reason: row.reason }), updatedAt: row.updated_at };
|
|
3007
|
+
}
|
|
3008
|
+
claimRevisionRegression(planId) {
|
|
3009
|
+
const job = this.getPreparedRevisionJob(planId);
|
|
3010
|
+
const pinned = job.intent.revision?.growthRun?.revisionRegressionAcceptance;
|
|
3011
|
+
if (!pinned || this.#now() >= pinned.expiresAt) {
|
|
3012
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision regression requires a current pre-author policy binding');
|
|
3013
|
+
}
|
|
3014
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3015
|
+
try {
|
|
3016
|
+
const count = this.#database.prepare('SELECT count(*) AS n FROM source_revision_regressions').get();
|
|
3017
|
+
if (count.n >= 256)
|
|
3018
|
+
throw new ControlPlaneStoreError('conflict', 'revision regression record limit reached');
|
|
3019
|
+
const now = this.#now();
|
|
3020
|
+
this.#database.prepare(`INSERT INTO source_revision_regressions
|
|
3021
|
+
(plan_id,status,certificate_json,certificate_digest,reason,created_at,updated_at) VALUES (?,'claimed',NULL,NULL,NULL,?,?)`)
|
|
3022
|
+
.run(planId, now, now);
|
|
3023
|
+
this.#database.exec('COMMIT');
|
|
3024
|
+
}
|
|
3025
|
+
catch (error) {
|
|
3026
|
+
this.#database.exec('ROLLBACK');
|
|
3027
|
+
throw error;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
settleRevisionRegression(planId, status, reason) {
|
|
3031
|
+
if (typeof reason !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/u.test(reason)) {
|
|
3032
|
+
throw new ControlPlaneStoreError('invalid-input', 'revision regression reason is invalid');
|
|
3033
|
+
}
|
|
3034
|
+
const result = this.#database.prepare(`UPDATE source_revision_regressions SET status=?,reason=?,updated_at=?
|
|
3035
|
+
WHERE plan_id=? AND status='claimed'`).run(status, reason, this.#now(), planId);
|
|
3036
|
+
if (Number(result.changes) !== 1)
|
|
3037
|
+
throw new ControlPlaneStoreError('conflict', 'revision regression claim is not current');
|
|
3038
|
+
}
|
|
3039
|
+
readRevisionSource(planId) {
|
|
3040
|
+
const certificate = this.getRevisionVerification(planId);
|
|
3041
|
+
if (!certificate)
|
|
3042
|
+
return undefined;
|
|
3043
|
+
const row = this.#database.prepare('SELECT source_json FROM source_revision_sources WHERE plan_id=?').get(planId);
|
|
3044
|
+
if (!row || Buffer.byteLength(row.source_json) > 2_097_152)
|
|
3045
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision source archive is absent or oversized');
|
|
3046
|
+
const source = JSON.parse(row.source_json);
|
|
3047
|
+
validateCreationCapabilitySource(source, certificate);
|
|
3048
|
+
const plan = this.getSourcePlan(planId);
|
|
3049
|
+
if (source.baseCommit !== plan.baseCommit || controlPlaneDigest(source.scope) !== controlPlaneDigest(plan.scope))
|
|
3050
|
+
throw new ControlPlaneStoreError('invalid-state', 'revision source archive scope changed');
|
|
3051
|
+
return source;
|
|
3052
|
+
}
|
|
3053
|
+
/** Reclaim only orphaned bytes or bytes referenced solely by expired plans. */
|
|
3054
|
+
deleteExpiredPreparedSourceArtifacts(now) {
|
|
3055
|
+
if (!Number.isSafeInteger(now) || now < 0)
|
|
3056
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact expiry time is invalid');
|
|
3057
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3058
|
+
try {
|
|
3059
|
+
const references = this.#database.prepare('SELECT plan_id, pack_sha256 FROM source_prepared_artifact_refs')
|
|
3060
|
+
.all();
|
|
3061
|
+
for (const reference of references) {
|
|
3062
|
+
const plan = this.getSourcePlan(reference.plan_id);
|
|
3063
|
+
if (plan.mode !== 'prepared-create' && plan.mode !== 'prepared-revise' || plan.preparedEvidence?.pack.sha256 !== reference.pack_sha256) {
|
|
3064
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared source artifact reference is corrupt');
|
|
3065
|
+
}
|
|
3066
|
+
if (plan.status === 'expired' && plan.expiresAt < now) {
|
|
3067
|
+
this.#database.prepare('DELETE FROM source_prepared_artifact_refs WHERE plan_id = ?').run(plan.id);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
const artifacts = this.#database.prepare(`SELECT a.pack_sha256 FROM source_prepared_artifacts a
|
|
3071
|
+
WHERE NOT EXISTS (SELECT 1 FROM source_prepared_artifact_refs r WHERE r.pack_sha256 = a.pack_sha256)`)
|
|
3072
|
+
.all();
|
|
3073
|
+
let deleted = 0;
|
|
3074
|
+
for (const artifact of artifacts) {
|
|
3075
|
+
const result = this.#database.prepare('DELETE FROM source_prepared_artifacts WHERE pack_sha256 = ?')
|
|
3076
|
+
.run(artifact.pack_sha256);
|
|
3077
|
+
deleted += Number(result.changes);
|
|
3078
|
+
}
|
|
3079
|
+
this.#database.exec('COMMIT');
|
|
3080
|
+
return deleted;
|
|
3081
|
+
}
|
|
3082
|
+
catch (error) {
|
|
3083
|
+
this.#database.exec('ROLLBACK');
|
|
3084
|
+
throw error;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
/** Durable, structurally bound applied merge history; receipt signatures are checked by the baseline resolver. */
|
|
3088
|
+
getSourceBaselineHistory(repository) {
|
|
3089
|
+
if (!isAbsolute(repository) || resolve(repository) !== repository)
|
|
3090
|
+
throw new ControlPlaneStoreError('invalid-input', 'source repository path is invalid');
|
|
3091
|
+
// A SAVEPOINT provides one SQLite read snapshot, and also works inside the
|
|
3092
|
+
// start-release BEGIN IMMEDIATE writer without nesting another BEGIN.
|
|
3093
|
+
this.#database.exec('SAVEPOINT source_baseline_history');
|
|
3094
|
+
try {
|
|
3095
|
+
const history = this.#readSourceBaselineHistory(repository);
|
|
3096
|
+
this.#database.exec('RELEASE source_baseline_history');
|
|
3097
|
+
return history;
|
|
3098
|
+
}
|
|
3099
|
+
catch (error) {
|
|
3100
|
+
this.#database.exec('ROLLBACK TO source_baseline_history');
|
|
3101
|
+
this.#database.exec('RELEASE source_baseline_history');
|
|
3102
|
+
throw error;
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
/** Must run inside an open read transaction/SAVEPOINT so the caller sees one snapshot. */
|
|
3106
|
+
#readSourceBaselineHistory(repository) {
|
|
3107
|
+
const rows = this.#database.prepare(`SELECT * FROM source_plans WHERE repository = ?
|
|
3108
|
+
AND (release_id IS NOT NULL OR status IN ('release-complete','release-failed'))
|
|
3109
|
+
ORDER BY created_at, id LIMIT 1001`).all(repository);
|
|
3110
|
+
if (rows.length > 1000)
|
|
3111
|
+
throw new ControlPlaneStoreError('invalid-state', 'source release history exceeds bound');
|
|
3112
|
+
const history = [];
|
|
3113
|
+
for (const row of rows) {
|
|
3114
|
+
const plan = sourceFromRow(row);
|
|
3115
|
+
if (plan.release === undefined)
|
|
3116
|
+
throw new ControlPlaneStoreError('invalid-state', 'started source release lacks release identity');
|
|
3117
|
+
if (plan.status !== 'release-complete' && plan.status !== 'release-failed') {
|
|
3118
|
+
throw new ControlPlaneStoreError('conflict', 'source repository has an unfinished release');
|
|
3119
|
+
}
|
|
3120
|
+
const merges = this.#database.prepare(`SELECT * FROM source_release_operations WHERE plan_id = ? AND phase = 'merge'
|
|
3121
|
+
AND status = 'applied' LIMIT 2`).all(plan.id);
|
|
3122
|
+
if (plan.status === 'release-failed') {
|
|
3123
|
+
if (merges.length !== 0)
|
|
3124
|
+
throw new ControlPlaneStoreError('conflict', 'failed source release has unresolved applied remote merge');
|
|
3125
|
+
continue;
|
|
3126
|
+
}
|
|
3127
|
+
if (merges.length !== 1)
|
|
3128
|
+
throw new ControlPlaneStoreError('invalid-state', 'completed source release lacks one applied merge');
|
|
3129
|
+
const operation = sourceReleaseOperationFromRow(merges[0]);
|
|
3130
|
+
const receipt = operation.receipt;
|
|
3131
|
+
if (operation.status !== 'applied' || operation.phase !== 'merge' || operation.request.phase !== 'merge' || operation.planId !== plan.id
|
|
3132
|
+
|| operation.request.plan.digest !== plan.digest || operation.request.plan.revision > plan.revision
|
|
3133
|
+
|| operation.request.release.id !== plan.release.id || operation.request.release.fence !== plan.release.fence
|
|
3134
|
+
|| receipt?.outcome !== 'passed' || receipt.evidence.kind !== 'merge'
|
|
3135
|
+
|| receipt.planDigest !== plan.digest || receipt.evidence.targetBranch !== plan.releaseAuthorization?.releasePolicy.targetBranch
|
|
3136
|
+
|| receipt.evidence.reviewedHeadCommit !== operation.request.input.headCommit
|
|
3137
|
+
|| receipt.evidence.reviewEvidenceDigest !== operation.request.input.reviewEvidenceDigest
|
|
3138
|
+
|| receipt.evidence.reviewId !== operation.request.input.reviewId
|
|
3139
|
+
|| receipt.evidence.prId !== operation.request.input.prId) {
|
|
3140
|
+
throw new ControlPlaneStoreError('invalid-state', 'completed source release merge is not bound to current release');
|
|
3141
|
+
}
|
|
3142
|
+
history.push({ plan, operation });
|
|
3143
|
+
}
|
|
3144
|
+
return history;
|
|
3145
|
+
}
|
|
3146
|
+
/** Release edges and maintenance edges in one SQLite snapshot, for preflight consumers that must compare both. */
|
|
3147
|
+
readSourceMaintenanceState(repository) {
|
|
3148
|
+
if (!isAbsolute(repository) || resolve(repository) !== repository)
|
|
3149
|
+
throw new ControlPlaneStoreError('invalid-input', 'source repository path is invalid');
|
|
3150
|
+
this.#database.exec('SAVEPOINT source_maintenance_state');
|
|
3151
|
+
try {
|
|
3152
|
+
const unsettledJob = this.#database.prepare(`SELECT id FROM source_jobs WHERE json_extract(intent_json,'$.repository')=?
|
|
3153
|
+
AND status IN ('queued','running','unknown') LIMIT 1`).get(repository);
|
|
3154
|
+
const unsettledPlan = this.#database.prepare(`SELECT id FROM source_plans WHERE repository=?
|
|
3155
|
+
AND status NOT IN ('expired','local-checks-failed','release-failed','release-complete') LIMIT 1`).get(repository);
|
|
3156
|
+
if (unsettledJob || unsettledPlan)
|
|
3157
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance has unsettled source work');
|
|
3158
|
+
const history = this.#readSourceBaselineHistory(repository);
|
|
3159
|
+
const records = this.getSourceMaintenanceRecords(repository);
|
|
3160
|
+
this.#database.exec('RELEASE source_maintenance_state');
|
|
3161
|
+
return { history, records };
|
|
3162
|
+
}
|
|
3163
|
+
catch (error) {
|
|
3164
|
+
this.#database.exec('ROLLBACK TO source_maintenance_state');
|
|
3165
|
+
this.#database.exec('RELEASE source_maintenance_state');
|
|
3166
|
+
throw error;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
getSourceMaintenanceRecords(repository) {
|
|
3170
|
+
if (!isAbsolute(repository) || resolve(repository) !== repository)
|
|
3171
|
+
throw new ControlPlaneStoreError('invalid-input', 'source repository path is invalid');
|
|
3172
|
+
const rows = this.#database.prepare('SELECT sequence,host_plan_id,record_json,record_digest FROM source_maintenance WHERE repository=? ORDER BY sequence LIMIT 257')
|
|
3173
|
+
.all(repository);
|
|
3174
|
+
if (rows.length > 256)
|
|
3175
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance history exceeds bound');
|
|
3176
|
+
return rows.map((row, index) => {
|
|
3177
|
+
const record = parseSourceMaintenanceRecord(JSON.parse(row.record_json));
|
|
3178
|
+
if (record.repository !== repository || record.sequence !== index + 1 || record.sequence !== row.sequence
|
|
3179
|
+
|| record.host?.planId !== (row.host_plan_id ?? undefined)
|
|
3180
|
+
|| sourceMaintenanceDigest(record) !== row.record_digest) {
|
|
3181
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance stored record changed');
|
|
3182
|
+
}
|
|
3183
|
+
return record;
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
/** Offline owner transaction only. No service/model-facing signing path exists. */
|
|
3187
|
+
async appendSourceMaintenance(recordInput, input) {
|
|
3188
|
+
const record = parseSourceMaintenanceRecord(recordInput), { trust, baseline } = input;
|
|
3189
|
+
validateSourceBaselineConfig(baseline);
|
|
3190
|
+
const key = resolveTrustKey(trust, 'host-attestation', record.authority, record.keyId);
|
|
3191
|
+
if (record.installationId !== trust.installationId || controlPlaneDigest(record.ledger) !== controlPlaneDigest(trust.ledger)
|
|
3192
|
+
|| record.repository !== recordInput.repository || controlPlaneDigest(record.baseline) !== controlPlaneDigest(baseline)
|
|
3193
|
+
|| !sameSourceMaintenancePublicKey(record.publicKeyPem, key.publicKeyPem) || record.issuedAt > this.#now()) {
|
|
3194
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance owner binding changed');
|
|
3195
|
+
}
|
|
3196
|
+
const verifiedHistory = this.getSourceBaselineHistory(record.repository);
|
|
3197
|
+
const verifiedMaintenance = this.getSourceMaintenanceRecords(record.repository);
|
|
3198
|
+
const authorityDigest = controlPlaneDigest({ trust, baseline });
|
|
3199
|
+
await verifySourceBaselineHistory({ repository: record.repository, config: baseline, trust,
|
|
3200
|
+
history: verifiedHistory, maintenance: verifiedMaintenance, signal: new AbortController().signal });
|
|
3201
|
+
const verifiedSnapshot = controlPlaneDigest({ history: verifiedHistory, maintenance: verifiedMaintenance });
|
|
3202
|
+
this.#database.exec('SAVEPOINT append_source_maintenance');
|
|
3203
|
+
try {
|
|
3204
|
+
this.#database.exec('UPDATE source_maintenance SET sequence=sequence WHERE 0');
|
|
3205
|
+
const records = this.getSourceMaintenanceRecords(record.repository);
|
|
3206
|
+
const history = this.getSourceBaselineHistory(record.repository);
|
|
3207
|
+
if (controlPlaneDigest({ trust, baseline }) !== authorityDigest) {
|
|
3208
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance trust or baseline changed before append');
|
|
3209
|
+
}
|
|
3210
|
+
if (controlPlaneDigest({ history, maintenance: records }) !== verifiedSnapshot) {
|
|
3211
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance signed history changed before append');
|
|
3212
|
+
}
|
|
3213
|
+
const priorTransaction = records.find(item => item.transactionId === record.transactionId);
|
|
3214
|
+
if (priorTransaction) {
|
|
3215
|
+
if (sourceMaintenanceDigest(priorTransaction) !== sourceMaintenanceDigest(record)) {
|
|
3216
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance transaction changed');
|
|
3217
|
+
}
|
|
3218
|
+
this.#database.exec('RELEASE append_source_maintenance');
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
verifySourceMaintenanceRecords([...records, record], { installationId: trust.installationId,
|
|
3222
|
+
ledger: trust.ledger, repository: record.repository, baseline, hostIdentity: key });
|
|
3223
|
+
const unsettledJob = this.#database.prepare(`SELECT id FROM source_jobs WHERE json_extract(intent_json,'$.repository')=?
|
|
3224
|
+
AND status IN ('queued','running','unknown') LIMIT 1`).get(record.repository);
|
|
3225
|
+
const unsettledPlan = this.#database.prepare(`SELECT id FROM source_plans WHERE repository=?
|
|
3226
|
+
AND status NOT IN ('expired','local-checks-failed','release-failed','release-complete') LIMIT 1`).get(record.repository);
|
|
3227
|
+
if (unsettledJob || unsettledPlan)
|
|
3228
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance has unsettled source work');
|
|
3229
|
+
const activationState = this.readSourceMaintenanceActivationState(trust.dshHome);
|
|
3230
|
+
if (activationState.kind === 'unsettled') {
|
|
3231
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance activation is unsettled');
|
|
3232
|
+
}
|
|
3233
|
+
if (activationState.kind === 'watched') {
|
|
3234
|
+
const context = readCurrentRuntimeEpochDeployment(this.#database, this.getPlan(activationState.planId).target.profilePath);
|
|
3235
|
+
const { plan, readiness } = context, receipt = readiness.receipt;
|
|
3236
|
+
if (!record.host || record.host.planId !== plan.id || record.host.planDigest !== plan.digest
|
|
3237
|
+
|| record.host.readinessOperationId !== readiness.operationId || !receipt
|
|
3238
|
+
|| record.host.readinessReceiptDigest !== hostMaintenanceDigest(receipt)
|
|
3239
|
+
|| receipt.authority !== record.authority || receipt.keyId !== record.keyId) {
|
|
3240
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance watched Host anchor changed');
|
|
3241
|
+
}
|
|
3242
|
+
const { signature, ...unsigned } = receipt;
|
|
3243
|
+
if (!verify(null, Buffer.from(hostMaintenanceCanonical(unsigned)), createPublicKey(key.publicKeyPem), Buffer.from(signature, 'base64'))) {
|
|
3244
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance original Host receipt signature changed');
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
else if (record.host !== null)
|
|
3248
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance has no watched Host anchor');
|
|
3249
|
+
const current = sourceBaselineChain(baseline, history, records).at(-1);
|
|
3250
|
+
if (record.previousTip !== current)
|
|
3251
|
+
throw new ControlPlaneStoreError('conflict', 'source maintenance previous tip is stale');
|
|
3252
|
+
sourceBaselineChain(baseline, history, [...records, record]);
|
|
3253
|
+
assertSourceMaintenanceGit(record, input.physicalHome ?? trust.dshHome, trust.dshHome);
|
|
3254
|
+
this.#database.prepare('INSERT INTO source_maintenance VALUES (?,?,?,?,?,?)').run(record.repository, record.sequence, record.transactionId, record.host?.planId ?? null, JSON.stringify(record), sourceMaintenanceDigest(record));
|
|
3255
|
+
this.#database.exec('RELEASE append_source_maintenance');
|
|
3256
|
+
}
|
|
3257
|
+
catch (error) {
|
|
3258
|
+
this.#database.exec('ROLLBACK TO append_source_maintenance; RELEASE append_source_maintenance');
|
|
3259
|
+
throw error;
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
/** Seed a previously owner-signed pre-adoption sidecar into a new ledger. */
|
|
3263
|
+
async importSourceMaintenanceRecords(recordInputs, input) {
|
|
3264
|
+
if (recordInputs.length === 0 || recordInputs.length > 256)
|
|
3265
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance import is empty or unbounded');
|
|
3266
|
+
const { trust, baseline } = input;
|
|
3267
|
+
validateSourceBaselineConfig(baseline);
|
|
3268
|
+
const records = recordInputs.map(parseSourceMaintenanceRecord), first = records[0];
|
|
3269
|
+
const key = resolveTrustKey(trust, 'host-attestation', first.authority, first.keyId);
|
|
3270
|
+
verifySourceMaintenanceRecords(records, { installationId: trust.installationId, ledger: trust.ledger,
|
|
3271
|
+
repository: first.repository, baseline, hostIdentity: key });
|
|
3272
|
+
if (records.some(record => record.host !== null))
|
|
3273
|
+
throw new ControlPlaneStoreError('invalid-input', 'pre-adoption source history carries a deployment anchor');
|
|
3274
|
+
sourceBaselineChain(baseline, [], records);
|
|
3275
|
+
const authorityDigest = controlPlaneDigest({ trust, baseline, records });
|
|
3276
|
+
this.#database.exec('SAVEPOINT import_source_maintenance');
|
|
3277
|
+
try {
|
|
3278
|
+
this.#database.exec('UPDATE source_maintenance SET sequence=sequence WHERE 0');
|
|
3279
|
+
if (controlPlaneDigest({ trust, baseline, records }) !== authorityDigest)
|
|
3280
|
+
throw new ControlPlaneStoreError('conflict', 'source import authority changed');
|
|
3281
|
+
const present = this.getSourceMaintenanceRecords(first.repository);
|
|
3282
|
+
if (present.length > records.length || present.some((record, index) => sourceMaintenanceDigest(record) !== sourceMaintenanceDigest(records[index]))) {
|
|
3283
|
+
throw new ControlPlaneStoreError('conflict', 'source import differs from existing ledger history');
|
|
3284
|
+
}
|
|
3285
|
+
if (this.getSourceBaselineHistory(first.repository).length)
|
|
3286
|
+
throw new ControlPlaneStoreError('conflict', 'source import cannot cross a published release');
|
|
3287
|
+
const anyJob = this.#database.prepare(`SELECT id FROM source_jobs WHERE json_extract(intent_json,'$.repository')=? LIMIT 1`)
|
|
3288
|
+
.get(first.repository);
|
|
3289
|
+
const anyPlan = this.#database.prepare('SELECT id FROM source_plans WHERE repository=? LIMIT 1').get(first.repository);
|
|
3290
|
+
const anyActivation = this.#database.prepare('SELECT id FROM activation_plans WHERE dsh_home=? LIMIT 1').get(trust.dshHome);
|
|
3291
|
+
if (anyJob || anyPlan || anyActivation)
|
|
3292
|
+
throw new ControlPlaneStoreError('conflict', 'source import requires an empty owner ledger');
|
|
3293
|
+
for (let index = 0; index < records.length; index++) {
|
|
3294
|
+
assertSourceMaintenanceGit(records[index], input.physicalHome ?? trust.dshHome, trust.dshHome, index === records.length - 1);
|
|
3295
|
+
}
|
|
3296
|
+
for (const record of records.slice(present.length)) {
|
|
3297
|
+
this.#database.prepare('INSERT INTO source_maintenance VALUES (?,?,?,?,?,?)').run(record.repository, record.sequence, record.transactionId, null, JSON.stringify(record), sourceMaintenanceDigest(record));
|
|
3298
|
+
}
|
|
3299
|
+
this.#database.exec('RELEASE import_source_maintenance');
|
|
3300
|
+
}
|
|
3301
|
+
catch (error) {
|
|
3302
|
+
this.#database.exec('ROLLBACK TO import_source_maintenance; RELEASE import_source_maintenance');
|
|
3303
|
+
throw error;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
1661
3306
|
/**
|
|
1662
3307
|
* Enumerate 'modify' plans for isolated-worktree garbage collection. Only
|
|
1663
3308
|
* modify plans own control-plane-created worktrees under the state root;
|
|
@@ -1671,14 +3316,24 @@ export class ControlPlaneStore {
|
|
|
1671
3316
|
.all(filter.expiredBefore);
|
|
1672
3317
|
return rows.map(sourceFromRow);
|
|
1673
3318
|
}
|
|
3319
|
+
/** Isolated worktrees owned by the background prepared flows, including owner-granted creation. */
|
|
3320
|
+
listPreparedSourcePlans(filter) {
|
|
3321
|
+
const rows = filter?.expiredBefore === undefined
|
|
3322
|
+
? this.#database.prepare(`SELECT * FROM source_plans WHERE mode IN ('modify', 'prepared-create', 'prepared-revise') ORDER BY created_at, id`)
|
|
3323
|
+
.all()
|
|
3324
|
+
: this.#database.prepare(`SELECT * FROM source_plans WHERE mode IN ('modify', 'prepared-create', 'prepared-revise') AND expires_at < ? ORDER BY created_at, id`)
|
|
3325
|
+
.all(filter.expiredBefore);
|
|
3326
|
+
return rows.map(sourceFromRow);
|
|
3327
|
+
}
|
|
1674
3328
|
/** Retire an expired prepared plan before removing its worktree. The CAS
|
|
1675
3329
|
* fences owner approval/review, and releases only this plan's gap claim. */
|
|
1676
3330
|
expirePreparedSourcePlan(input) {
|
|
1677
3331
|
if (!Number.isSafeInteger(input.now) || input.now < 0)
|
|
1678
3332
|
throw new ControlPlaneStoreError('invalid-input', 'expiry time is invalid');
|
|
1679
3333
|
const plan = this.getSourcePlan(input.planId);
|
|
1680
|
-
if (plan.mode !== 'modify')
|
|
1681
|
-
throw new ControlPlaneStoreError('invalid-state', 'only prepared
|
|
3334
|
+
if (plan.mode !== 'modify' && plan.mode !== 'prepared-create' && plan.mode !== 'prepared-revise') {
|
|
3335
|
+
throw new ControlPlaneStoreError('invalid-state', 'only prepared source plans may expire here');
|
|
3336
|
+
}
|
|
1682
3337
|
const expiryDigest = controlPlaneDigest({ planId: plan.id, planDigest: plan.digest, expiresAt: plan.expiresAt });
|
|
1683
3338
|
if (plan.status === 'expired') {
|
|
1684
3339
|
const receipt = this.#sourcePlanReceipt(`source-expired:${plan.id}`, 'source-expired', expiryDigest, plan.id);
|
|
@@ -1689,7 +3344,8 @@ export class ControlPlaneStore {
|
|
|
1689
3344
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
1690
3345
|
try {
|
|
1691
3346
|
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')
|
|
3347
|
+
WHERE id = ? AND revision = ? AND ((mode = 'modify' AND status IN ('pending-approval', 'approved'))
|
|
3348
|
+
OR (mode IN ('prepared-create', 'prepared-revise') AND status = 'pending-approval')) AND expires_at < ?`)
|
|
1693
3349
|
.run(input.now, plan.id, input.expectedRevision, input.now);
|
|
1694
3350
|
if (Number(retired.changes) !== 1)
|
|
1695
3351
|
throw new ControlPlaneStoreError('conflict', 'prepared plan changed or is not expired');
|
|
@@ -1714,6 +3370,9 @@ export class ControlPlaneStore {
|
|
|
1714
3370
|
return this.#approvePlan('activation', input);
|
|
1715
3371
|
}
|
|
1716
3372
|
async approveSource(input) {
|
|
3373
|
+
if (['prepared-create', 'prepared-revise'].includes(this.getSourcePlan(input.planId).mode)) {
|
|
3374
|
+
throw new ControlPlaneStoreError('invalid-state', 'prepared creation requires its own owner creation authority');
|
|
3375
|
+
}
|
|
1717
3376
|
return this.#approvePlan('source', input);
|
|
1718
3377
|
}
|
|
1719
3378
|
async #approvePlan(kind, input) {
|
|
@@ -1794,7 +3453,7 @@ export class ControlPlaneStore {
|
|
|
1794
3453
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1795
3454
|
if (row === undefined)
|
|
1796
3455
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1797
|
-
const plan =
|
|
3456
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1798
3457
|
if (plan.revision !== input.expectedRevision)
|
|
1799
3458
|
throw new ControlPlaneStoreError('conflict', 'activation plan revision conflict');
|
|
1800
3459
|
if (row.approval_receipt_json === null || plan.approval === undefined) {
|
|
@@ -1817,7 +3476,7 @@ export class ControlPlaneStore {
|
|
|
1817
3476
|
if (now > plan.expiresAt)
|
|
1818
3477
|
throw new ControlPlaneStoreError('expired', 'activation plan expired before its first claim');
|
|
1819
3478
|
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',
|
|
3479
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
1821
3480
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`).get(plan.target.profilePath, plan.id);
|
|
1822
3481
|
if (targetOwner !== undefined)
|
|
1823
3482
|
throw new ControlPlaneStoreError('conflict', 'target profile already has an active activation');
|
|
@@ -1830,7 +3489,7 @@ export class ControlPlaneStore {
|
|
|
1830
3489
|
const activationId = row.activation_id ?? `activation-${randomUUID()}`;
|
|
1831
3490
|
const status = plan.status === 'approved' ? 'staging' : plan.status;
|
|
1832
3491
|
const result = this.#database.prepare(`UPDATE activation_plans SET status = ?, revision = revision + 1, activation_id = ?,
|
|
1833
|
-
activation_fence = activation_fence +
|
|
3492
|
+
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
3493
|
if (Number(result.changes) !== 1)
|
|
1835
3494
|
throw new ControlPlaneStoreError('conflict', 'activation plan changed while being claimed');
|
|
1836
3495
|
const claimed = this.getPlan(plan.id);
|
|
@@ -1863,7 +3522,7 @@ export class ControlPlaneStore {
|
|
|
1863
3522
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1864
3523
|
if (row === undefined)
|
|
1865
3524
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1866
|
-
const plan =
|
|
3525
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1867
3526
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== 'staging'
|
|
1868
3527
|
|| Number(row.activation_lease_until ?? 0) < now)
|
|
1869
3528
|
throw new ControlPlaneStoreError('conflict', 'activation lost its claim before recording the target baseline');
|
|
@@ -1905,7 +3564,7 @@ export class ControlPlaneStore {
|
|
|
1905
3564
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1906
3565
|
if (row === undefined)
|
|
1907
3566
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1908
|
-
const plan =
|
|
3567
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1909
3568
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== 'commit-pending'
|
|
1910
3569
|
|| Number(row.activation_lease_until ?? 0) < now) {
|
|
1911
3570
|
throw new ControlPlaneStoreError('conflict', 'activation lost its claim before recording the installed baseline');
|
|
@@ -1961,7 +3620,8 @@ export class ControlPlaneStore {
|
|
|
1961
3620
|
catch {
|
|
1962
3621
|
throw new ControlPlaneStoreError('invalid-state', 'stored installed activation baseline is corrupt');
|
|
1963
3622
|
}
|
|
1964
|
-
|
|
3623
|
+
const original = activationCoreFiles(value, plan.target.profilePath, 'stored installed activation baseline');
|
|
3624
|
+
return readHostMaintenanceContext(this.#database, plan.id).records.at(-1)?.after.profileFiles ?? original;
|
|
1965
3625
|
}
|
|
1966
3626
|
/** Move a signed closed deployment watch into the existing physical rollback lifecycle. */
|
|
1967
3627
|
beginPostActivationRollback(input) {
|
|
@@ -1971,7 +3631,7 @@ export class ControlPlaneStore {
|
|
|
1971
3631
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
1972
3632
|
if (row === undefined)
|
|
1973
3633
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
1974
|
-
const plan =
|
|
3634
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
1975
3635
|
if (plan.revision !== input.expectedRevision)
|
|
1976
3636
|
throw new ControlPlaneStoreError('conflict', 'activation plan revision conflict');
|
|
1977
3637
|
// A restart or retry must retain the original rollback identity and never
|
|
@@ -1997,7 +3657,7 @@ export class ControlPlaneStore {
|
|
|
1997
3657
|
}
|
|
1998
3658
|
this.getActivationInstalledBaseline(plan.id);
|
|
1999
3659
|
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',
|
|
3660
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2001
3661
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending', 'rollback-pending') LIMIT 1`)
|
|
2002
3662
|
.get(plan.target.profilePath, plan.id);
|
|
2003
3663
|
if (active !== undefined)
|
|
@@ -2038,9 +3698,65 @@ export class ControlPlaneStore {
|
|
|
2038
3698
|
ORDER BY older.exposure_order`).all(current.target.profilePath, current.id, checkpoint.exposure_order);
|
|
2039
3699
|
return rows.map(activationFromRow);
|
|
2040
3700
|
}
|
|
3701
|
+
/** Freeze exact physical pins before a signed deployment becomes Host-visible. */
|
|
3702
|
+
recordActivationHostInputWitness(input) {
|
|
3703
|
+
return this.#withActivationWrite(input.planId, () => {
|
|
3704
|
+
const plan = this.getPlan(input.planId);
|
|
3705
|
+
const row = this.#database.prepare('SELECT activation_lease_until FROM activation_plans WHERE id=?').get(plan.id);
|
|
3706
|
+
const inputs = plan.dossier.hostDeploymentInputs;
|
|
3707
|
+
if (!inputs)
|
|
3708
|
+
throw new ControlPlaneStoreError('invalid-state', 'plan has no signed Host deployment inputs');
|
|
3709
|
+
if (plan.status !== 'staging' || plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence
|
|
3710
|
+
|| (row.activation_lease_until ?? 0) < this.#now() || plan.activation.hostRecoveryRequired
|
|
3711
|
+
|| plan.activation.targetOriginallyExisted === undefined || plan.activation.targetBaselineFiles === undefined) {
|
|
3712
|
+
throw new ControlPlaneStoreError('conflict', 'Host input witness lost its staging claim or target baseline');
|
|
3713
|
+
}
|
|
3714
|
+
let profileFiles;
|
|
3715
|
+
let deploymentFiles;
|
|
3716
|
+
let baselineDeploymentFiles;
|
|
3717
|
+
try {
|
|
3718
|
+
profileFiles = activationCoreFiles(input.profileFiles, plan.target.profilePath, 'Host input witness core files');
|
|
3719
|
+
if (profileFiles.some(file => file.sha256 === null))
|
|
3720
|
+
throw new Error('missing core file');
|
|
3721
|
+
deploymentFiles = hostInputPins(input.deploymentFiles, inputs, plan.target.profilePath, 'Host input witness deployment files');
|
|
3722
|
+
baselineDeploymentFiles = plan.activation.targetOriginallyExisted
|
|
3723
|
+
? hostInputPins(input.baselineDeploymentFiles, inputs, plan.target.profilePath, 'Host input witness baseline files')
|
|
3724
|
+
: input.baselineDeploymentFiles.length === 0 ? [] : (() => { throw new Error('unexpected baseline files'); })();
|
|
3725
|
+
}
|
|
3726
|
+
catch {
|
|
3727
|
+
throw new ControlPlaneStoreError('invalid-input', 'Host input witness pins do not match the signed profile deployment');
|
|
3728
|
+
}
|
|
3729
|
+
const existing = hostInputWitnessFromRow(this.#database, plan);
|
|
3730
|
+
if (existing) {
|
|
3731
|
+
if (controlPlaneDigest(existing.profileFiles) !== controlPlaneDigest(profileFiles)
|
|
3732
|
+
|| controlPlaneDigest(existing.deploymentFiles) !== controlPlaneDigest(deploymentFiles)
|
|
3733
|
+
|| controlPlaneDigest(existing.baselineDeploymentFiles) !== controlPlaneDigest(baselineDeploymentFiles)) {
|
|
3734
|
+
throw new ControlPlaneStoreError('conflict', 'Host input witness is immutable');
|
|
3735
|
+
}
|
|
3736
|
+
return plan;
|
|
3737
|
+
}
|
|
3738
|
+
const unsigned = { schemaVersion: 1, kind: 'dsh-host-input-witness', planId: plan.id,
|
|
3739
|
+
planDigest: plan.digest, activationId: plan.activation.id, fence: input.fence, createdAt: this.#now(),
|
|
3740
|
+
inputs, profileFiles, deploymentFiles, baselineDeploymentFiles };
|
|
3741
|
+
const witness = { ...unsigned, digest: controlPlaneDigest(unsigned) };
|
|
3742
|
+
this.#database.prepare('INSERT INTO activation_host_input_witnesses (plan_id,witness_json,witness_digest) VALUES (?,?,?)')
|
|
3743
|
+
.run(plan.id, JSON.stringify(witness), controlPlaneDigest(witness));
|
|
3744
|
+
return plan;
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
getActivationHostInputWitness(planId) {
|
|
3748
|
+
return hostInputWitnessFromRow(this.#database, this.getPlan(planId));
|
|
3749
|
+
}
|
|
2041
3750
|
/** Freeze recovery obligation before the profile can become Host-visible. */
|
|
2042
3751
|
markActivationHostExposure(input) {
|
|
2043
3752
|
return this.#withActivationWrite(input.planId, () => {
|
|
3753
|
+
const before = this.getPlan(input.planId);
|
|
3754
|
+
if (before.dossier.hostDeploymentInputs) {
|
|
3755
|
+
const witness = hostInputWitnessFromRow(this.#database, before);
|
|
3756
|
+
if (!witness || witness.fence !== input.fence || before.revision !== input.expectedRevision) {
|
|
3757
|
+
throw new ControlPlaneStoreError('conflict', 'Host exposure requires an exact staged input witness');
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
2044
3760
|
const now = this.#now();
|
|
2045
3761
|
const result = this.#database.prepare(`UPDATE activation_plans SET host_recovery_required = 1, updated_at = ?
|
|
2046
3762
|
WHERE id = ? AND revision = ? AND activation_fence = ? AND status = 'staging'
|
|
@@ -2048,7 +3764,19 @@ export class ControlPlaneStore {
|
|
|
2048
3764
|
AND activation_lease_until >= ?`).run(now, input.planId, input.expectedRevision, input.fence, now);
|
|
2049
3765
|
if (Number(result.changes) !== 1)
|
|
2050
3766
|
throw new ControlPlaneStoreError('conflict', 'Host exposure lost its claim or original baseline');
|
|
2051
|
-
|
|
3767
|
+
const plan = this.getPlan(input.planId);
|
|
3768
|
+
if (plan.dossier.liveQualification) {
|
|
3769
|
+
const handoff = this.getAdoptionHandoff(plan.id);
|
|
3770
|
+
const deadline = Math.min(now + plan.dossier.liveQualification.maximumWindowMs, plan.expiresAt, plan.approval.expiresAt, handoff?.expiresAt ?? 0);
|
|
3771
|
+
if (deadline <= now)
|
|
3772
|
+
throw new ControlPlaneStoreError('expired', 'live qualification authorization expired before exposure');
|
|
3773
|
+
this.#database.prepare(`INSERT INTO live_qualification_windows (plan_id,activation_id,fence,started_at,deadline_at)
|
|
3774
|
+
VALUES (?,?,?,?,?) ON CONFLICT(plan_id) DO NOTHING`).run(plan.id, plan.activation.id, plan.activation.fence, now, deadline);
|
|
3775
|
+
const prior = this.#database.prepare('SELECT activation_id,fence FROM live_qualification_windows WHERE plan_id=?').get(plan.id);
|
|
3776
|
+
if (prior?.activation_id !== plan.activation.id || prior.fence !== plan.activation.fence)
|
|
3777
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification exposure cannot be replayed');
|
|
3778
|
+
}
|
|
3779
|
+
return plan;
|
|
2052
3780
|
});
|
|
2053
3781
|
}
|
|
2054
3782
|
markRollbackProfileRestored(input) {
|
|
@@ -2082,7 +3810,7 @@ export class ControlPlaneStore {
|
|
|
2082
3810
|
const row = this.#database.prepare('SELECT * FROM activation_plans WHERE id = ?').get(input.planId);
|
|
2083
3811
|
if (row === undefined)
|
|
2084
3812
|
throw new ControlPlaneStoreError('not-found', 'activation plan not found');
|
|
2085
|
-
const plan =
|
|
3813
|
+
const plan = effectiveActivationFromRow(this.#database, row);
|
|
2086
3814
|
if (plan.revision !== input.expectedRevision || plan.activation?.fence !== input.fence || plan.status !== input.status
|
|
2087
3815
|
|| (requireLiveLease && Number(row.activation_lease_until ?? 0) < this.#now()))
|
|
2088
3816
|
throw new ControlPlaneStoreError('conflict', 'activation no longer owns the filesystem fence');
|
|
@@ -2131,6 +3859,177 @@ export class ControlPlaneStore {
|
|
|
2131
3859
|
throw new ControlPlaneStoreError('conflict', 'activation no longer owns the exact revision/fence/status');
|
|
2132
3860
|
return plan;
|
|
2133
3861
|
}
|
|
3862
|
+
currentRuntimeEpochDeployment(profilePath) {
|
|
3863
|
+
return readCurrentRuntimeEpochDeployment(this.#database, profilePath);
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* Classify activation state for a Home in one read snapshot, using the exact
|
|
3867
|
+
* watch/plan queries that gate {@link appendSourceMaintenance}. Preflight
|
|
3868
|
+
* producers use it to distinguish a genuinely unanchored ledger (`none`) from
|
|
3869
|
+
* a watched deployment that must bind its readiness receipt (`watched`) and
|
|
3870
|
+
* from any conflict that must abort rather than silently degrade (`unsettled`).
|
|
3871
|
+
*/
|
|
3872
|
+
readSourceMaintenanceActivationState(dshHome) {
|
|
3873
|
+
if (!isAbsolute(dshHome) || resolve(dshHome) !== dshHome) {
|
|
3874
|
+
throw new ControlPlaneStoreError('invalid-input', 'source maintenance Home path is invalid');
|
|
3875
|
+
}
|
|
3876
|
+
this.#database.exec('SAVEPOINT source_maintenance_activation_state');
|
|
3877
|
+
try {
|
|
3878
|
+
const watching = this.#database.prepare(`SELECT plan.id FROM activation_plans plan JOIN activation_watch watch ON watch.plan_id=plan.id
|
|
3879
|
+
WHERE plan.dsh_home=? AND watch.state='watching' LIMIT 2`).all(dshHome);
|
|
3880
|
+
const active = this.#database.prepare(`SELECT id FROM activation_plans WHERE dsh_home=?
|
|
3881
|
+
AND status NOT IN ('rolled-back','activated') LIMIT 1`).get(dshHome);
|
|
3882
|
+
const activated = this.#database.prepare(`SELECT plan.id,plan.target_path,watch.state,
|
|
3883
|
+
checkpoint.exposure_order,checkpoint.successful_order FROM activation_plans plan
|
|
3884
|
+
LEFT JOIN activation_watch watch ON watch.plan_id=plan.id
|
|
3885
|
+
LEFT JOIN activation_deployment_checkpoints checkpoint ON checkpoint.plan_id=plan.id
|
|
3886
|
+
WHERE plan.dsh_home=? AND plan.status='activated' LIMIT 1001`).all(dshHome);
|
|
3887
|
+
if (activated.length > 1000)
|
|
3888
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance activation history exceeds bound');
|
|
3889
|
+
let result = watching.length > 1 || active || watching.length === 0 && activated.length !== 0
|
|
3890
|
+
|| watching.length === 1 && !activated.some(row => row.id === watching[0].id)
|
|
3891
|
+
? { kind: 'unsettled' }
|
|
3892
|
+
: watching.length === 1
|
|
3893
|
+
? { kind: 'watched', planId: watching[0].id }
|
|
3894
|
+
: { kind: 'none' };
|
|
3895
|
+
if (result.kind === 'watched') {
|
|
3896
|
+
const plan = this.getPlan(result.planId), watch = this.getActivationWatch(result.planId);
|
|
3897
|
+
const assertBinding = (candidate, boundWatch) => {
|
|
3898
|
+
if (boundWatch.activationId !== candidate.activation?.id || boundWatch.fence !== candidate.activation.fence
|
|
3899
|
+
|| boundWatch.exact.package !== candidate.candidate.package || boundWatch.exact.version !== candidate.candidate.version
|
|
3900
|
+
|| boundWatch.exact.integrity !== candidate.candidate.integrity) {
|
|
3901
|
+
throw new ControlPlaneStoreError('invalid-state', 'source maintenance activation watch binding is corrupt');
|
|
3902
|
+
}
|
|
3903
|
+
};
|
|
3904
|
+
assertBinding(plan, watch);
|
|
3905
|
+
const current = activated.find(row => row.id === plan.id);
|
|
3906
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id FROM activation_deployment_checkpoints checkpoint
|
|
3907
|
+
JOIN activation_plans plan ON plan.id=checkpoint.plan_id WHERE plan.target_path=?
|
|
3908
|
+
ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(plan.target.profilePath);
|
|
3909
|
+
if (current.exposure_order === null || current.successful_order !== current.exposure_order || latest?.plan_id !== plan.id) {
|
|
3910
|
+
result = { kind: 'unsettled' };
|
|
3911
|
+
}
|
|
3912
|
+
else
|
|
3913
|
+
for (const older of activated) {
|
|
3914
|
+
if (older.id === plan.id)
|
|
3915
|
+
continue;
|
|
3916
|
+
// Closed watches retain activated status after a successor takes over.
|
|
3917
|
+
// Only a proved older success on this same profile is retired history.
|
|
3918
|
+
if (older.target_path !== plan.target.profilePath || !['closed-regressed', 'closed-retracted'].includes(older.state ?? '')
|
|
3919
|
+
|| older.exposure_order === null || older.successful_order !== older.exposure_order
|
|
3920
|
+
|| older.exposure_order >= current.exposure_order) {
|
|
3921
|
+
result = { kind: 'unsettled' };
|
|
3922
|
+
break;
|
|
3923
|
+
}
|
|
3924
|
+
assertBinding(this.getPlan(older.id), this.getActivationWatch(older.id));
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
this.#database.exec('RELEASE source_maintenance_activation_state');
|
|
3928
|
+
return result;
|
|
3929
|
+
}
|
|
3930
|
+
catch (error) {
|
|
3931
|
+
this.#database.exec('ROLLBACK TO source_maintenance_activation_state; RELEASE source_maintenance_activation_state');
|
|
3932
|
+
throw error;
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
prepareRuntimeEpoch(input) {
|
|
3936
|
+
const runtimeDigest = runtimeIdentityDigest(input.runtime);
|
|
3937
|
+
if (input.runtime.observedAt > this.#now() || input.runtime.observedAt < this.#now() - 30_000
|
|
3938
|
+
|| input.runtime.entries.some(entry => !entry.active))
|
|
3939
|
+
throw new ControlPlaneStoreError('invalid-input', 'runtime epoch needs a fresh active runtime');
|
|
3940
|
+
const current = readCurrentRuntimeEpochDeployment(this.#database, input.runtime.profilePath);
|
|
3941
|
+
return this.#withActivationWrite(current.plan.id, () => {
|
|
3942
|
+
const context = readCurrentRuntimeEpochDeployment(this.#database, input.runtime.profilePath), { plan, readiness } = context;
|
|
3943
|
+
const previous = this.#database.prepare(`SELECT * FROM deployment_runtime_epochs WHERE plan_id=? ORDER BY sequence DESC LIMIT 1`)
|
|
3944
|
+
.get(plan.id);
|
|
3945
|
+
if (previous) {
|
|
3946
|
+
const record = runtimeEpochFromRow(previous);
|
|
3947
|
+
if (record.request.runtimeIdentityDigest === runtimeDigest && controlPlaneDigest(record.request.issuer) === controlPlaneDigest(input.issuer)
|
|
3948
|
+
&& record.status !== 'stale'
|
|
3949
|
+
&& (record.status === 'applied' || this.#now() < record.request.requestedAt + record.request.receiptTtlMs)) {
|
|
3950
|
+
return record;
|
|
3951
|
+
}
|
|
3952
|
+
// An expired read-only observation may be replaced, never relabelled or replayed as a new proof.
|
|
3953
|
+
this.#database.prepare("UPDATE deployment_runtime_epochs SET status='stale' WHERE plan_id=? AND status IN ('pending','claimed')").run(plan.id);
|
|
3954
|
+
}
|
|
3955
|
+
const sequence = Number(this.#database.prepare('SELECT max(sequence) AS sequence FROM deployment_runtime_epochs WHERE plan_id=?').get(plan.id)?.sequence ?? 0) + 1;
|
|
3956
|
+
const request = parseRuntimeEpochRequest({ schemaVersion: 1, kind: 'dsh-runtime-epoch-request', operationId: `runtime-epoch-${randomUUID()}`,
|
|
3957
|
+
requestedAt: this.#now(), receiptTtlMs: input.receiptTtlMs, installationId: plan.installationId, ledger: plan.ledger,
|
|
3958
|
+
plan: { id: plan.id, digest: plan.digest }, activation: { id: plan.activation.id, fence: plan.activation.fence },
|
|
3959
|
+
profile: { name: plan.profile, path: plan.target.profilePath }, issuer: input.issuer, predecessor: {
|
|
3960
|
+
operationId: readiness.operationId, receiptDigest: controlPlaneDigest(readiness.receipt), hostGeneration: readiness.receipt.hostGeneration
|
|
3961
|
+
},
|
|
3962
|
+
sequence, runtimeIdentityDigest: runtimeDigest });
|
|
3963
|
+
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);
|
|
3964
|
+
return { request, status: 'pending' };
|
|
3965
|
+
});
|
|
3966
|
+
}
|
|
3967
|
+
getRuntimeEpoch(operationId) {
|
|
3968
|
+
const row = this.#database.prepare('SELECT * FROM deployment_runtime_epochs WHERE operation_id=?').get(operationId);
|
|
3969
|
+
if (!row)
|
|
3970
|
+
throw new ControlPlaneStoreError('not-found', 'runtime epoch absent');
|
|
3971
|
+
return runtimeEpochFromRow(row);
|
|
3972
|
+
}
|
|
3973
|
+
latestAppliedRuntimeEpoch(planId) {
|
|
3974
|
+
const row = this.#database.prepare(`SELECT * FROM deployment_runtime_epochs WHERE plan_id=? ORDER BY sequence DESC LIMIT 1`)
|
|
3975
|
+
.get(planId);
|
|
3976
|
+
if (row && row.status !== 'applied')
|
|
3977
|
+
throw new ControlPlaneStoreError('conflict', 'current runtime epoch is not independently verified');
|
|
3978
|
+
return row && runtimeEpochFromRow(row);
|
|
3979
|
+
}
|
|
3980
|
+
pendingRuntimeEpochs(coordinatorId) {
|
|
3981
|
+
return this.#database.prepare(`SELECT epoch.* FROM deployment_runtime_epochs epoch
|
|
3982
|
+
JOIN adoption_handoffs handoff ON handoff.plan_id=epoch.plan_id
|
|
3983
|
+
JOIN activation_plans plan ON plan.id=epoch.plan_id
|
|
3984
|
+
WHERE handoff.coordinator_id=? AND handoff.revoked_at IS NULL AND plan.status='activated'
|
|
3985
|
+
AND epoch.status IN ('pending','claimed') AND epoch.created_at + json_extract(epoch.request_json,'$.receiptTtlMs') > ?
|
|
3986
|
+
AND epoch.sequence=(SELECT max(other.sequence) FROM deployment_runtime_epochs other WHERE other.plan_id=epoch.plan_id)
|
|
3987
|
+
ORDER BY epoch.created_at,epoch.operation_id LIMIT 100`).all(coordinatorId, this.#now()).map(runtimeEpochFromRow);
|
|
3988
|
+
}
|
|
3989
|
+
claimRuntimeEpoch(operationId, coordinatorId) {
|
|
3990
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
3991
|
+
try {
|
|
3992
|
+
const record = this.getRuntimeEpoch(operationId);
|
|
3993
|
+
if (!['pending', 'claimed'].includes(record.status) || this.#now() >= record.request.requestedAt + record.request.receiptTtlMs) {
|
|
3994
|
+
throw new ControlPlaneStoreError('expired', 'runtime epoch request is not live');
|
|
3995
|
+
}
|
|
3996
|
+
this.#database.prepare("UPDATE deployment_runtime_epochs SET status='claimed' WHERE operation_id=?").run(operationId);
|
|
3997
|
+
const context = readOwnerRuntimeEpochContext(this.#database, operationId);
|
|
3998
|
+
if (context.handoff.coordinatorId !== coordinatorId || (this.#adoptionCoordinatorId !== undefined && this.#adoptionCoordinatorId !== coordinatorId)) {
|
|
3999
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch coordinator changed');
|
|
4000
|
+
}
|
|
4001
|
+
this.#database.exec('COMMIT');
|
|
4002
|
+
return { ...record, status: 'claimed' };
|
|
4003
|
+
}
|
|
4004
|
+
catch (error) {
|
|
4005
|
+
this.#database.exec('ROLLBACK');
|
|
4006
|
+
throw error;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
applyRuntimeEpoch(receiptInput, trust) {
|
|
4010
|
+
const receipt = parseRuntimeEpochReceipt(receiptInput);
|
|
4011
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4012
|
+
try {
|
|
4013
|
+
const context = readOwnerRuntimeEpochContext(this.#database, receipt.operationId);
|
|
4014
|
+
if (this.#now() >= context.request.requestedAt + context.request.receiptTtlMs)
|
|
4015
|
+
throw new ControlPlaneStoreError('expired', 'runtime epoch request expired before application');
|
|
4016
|
+
verifyRuntimeEpochReceipt(receipt, context.request, trust, this.#now());
|
|
4017
|
+
if (receipt.outcome !== 'passed' || receipt.evidence.failures !== 0)
|
|
4018
|
+
throw new ControlPlaneStoreError('conflict', 'failed runtime epoch cannot be applied');
|
|
4019
|
+
if (context.request.ledger.id !== trust.ledger.id || context.request.ledger.path !== trust.ledger.path) {
|
|
4020
|
+
throw new ControlPlaneStoreError('conflict', 'runtime epoch trust ledger changed');
|
|
4021
|
+
}
|
|
4022
|
+
this.#database.prepare("UPDATE deployment_runtime_epochs SET status='applied',receipt_json=? WHERE operation_id=? AND status='claimed'")
|
|
4023
|
+
.run(JSON.stringify(receipt), receipt.operationId);
|
|
4024
|
+
const result = this.getRuntimeEpoch(receipt.operationId);
|
|
4025
|
+
this.#database.exec('COMMIT');
|
|
4026
|
+
return result;
|
|
4027
|
+
}
|
|
4028
|
+
catch (error) {
|
|
4029
|
+
this.#database.exec('ROLLBACK');
|
|
4030
|
+
throw error;
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
2134
4033
|
prepareHostAttestationOperation(input) {
|
|
2135
4034
|
return this.#withActivationSource(input.planId, () => {
|
|
2136
4035
|
const now = this.#now();
|
|
@@ -2283,25 +4182,194 @@ export class ControlPlaneStore {
|
|
|
2283
4182
|
};
|
|
2284
4183
|
return input.requireCurrentSource ? this.#withActivationSource(plan.id, commit) : commit();
|
|
2285
4184
|
}
|
|
4185
|
+
listLiveQualificationDeployments(planId, limit = 1001) {
|
|
4186
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
|
|
4187
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid live task limit');
|
|
4188
|
+
const window = this.getLiveQualificationWindow(planId);
|
|
4189
|
+
const committed = this.#database.prepare('SELECT started_at FROM activation_watch WHERE plan_id=?').get(planId);
|
|
4190
|
+
const cutoff = Math.min(window.deadlineAt, committed ? Number(committed.started_at) : window.deadlineAt);
|
|
4191
|
+
return this.#database.prepare(`SELECT inbox_id FROM foreground_deployments WHERE plan_id=?
|
|
4192
|
+
AND json_extract(record_json,'$.task.dispatchedAt')>=? AND json_extract(record_json,'$.task.dispatchedAt')<?
|
|
4193
|
+
ORDER BY inbox_id LIMIT ?`).all(planId, window.startedAt, cutoff, limit)
|
|
4194
|
+
.map(row => this.getForegroundDeployment(row.inbox_id));
|
|
4195
|
+
}
|
|
4196
|
+
listLiveQualificationPlans(profilePath, limit = 100) {
|
|
4197
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
4198
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid qualification limit');
|
|
4199
|
+
return this.#database.prepare(`SELECT plan.* FROM activation_plans plan WHERE plan.target_path=? AND plan.status IN
|
|
4200
|
+
('awaiting-live-tasks','commit-pending','activated') AND json_type(plan.dossier_json,'$.liveQualification')='object'
|
|
4201
|
+
AND (plan.status<>'activated' OR (plan.id=(SELECT checkpoint.plan_id FROM activation_deployment_checkpoints checkpoint
|
|
4202
|
+
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)
|
|
4203
|
+
AND NOT EXISTS (SELECT 1 FROM activation_plans active WHERE active.target_path=plan.target_path AND active.id<>plan.id
|
|
4204
|
+
AND active.status IN ('staging','awaiting-reload','awaiting-readiness','awaiting-live-tasks','awaiting-effect-blocked-replay',
|
|
4205
|
+
'awaiting-shadow','awaiting-canary','awaiting-soak','awaiting-health','commit-pending','rollback-pending'))))
|
|
4206
|
+
ORDER BY plan.created_at DESC,plan.id LIMIT ?`).all(profilePath, limit).map(activationFromRow);
|
|
4207
|
+
}
|
|
4208
|
+
getLiveQualificationDeadline(planId) {
|
|
4209
|
+
const plan = this.getPlan(planId);
|
|
4210
|
+
if (!plan.dossier.liveQualification)
|
|
4211
|
+
return undefined;
|
|
4212
|
+
const window = this.#database.prepare('SELECT deadline_at FROM live_qualification_windows WHERE plan_id=?').get(planId);
|
|
4213
|
+
return window ? Number(window.deadline_at) : undefined;
|
|
4214
|
+
}
|
|
4215
|
+
getLiveQualificationWindow(planId) { return liveQualificationWindow(this.#database, this.getPlan(planId)); }
|
|
4216
|
+
getLiveQualificationReadiness(planId) { return liveQualificationReadiness(this.#database, this.getPlan(planId)); }
|
|
4217
|
+
getLiveQualification(id) { return liveQualificationRecord(this.#database, id); }
|
|
4218
|
+
listLiveQualifications(planId, limit = 100) {
|
|
4219
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
4220
|
+
throw new ControlPlaneStoreError('invalid-input', 'invalid qualification record limit');
|
|
4221
|
+
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));
|
|
4222
|
+
}
|
|
4223
|
+
assertCurrentLiveQualification(id) {
|
|
4224
|
+
const { record, plan } = readLiveQualificationContext(this.#database, id);
|
|
4225
|
+
if (record.state === 'stale' || (plan.status !== 'activated' && (this.#now() >= record.batch.deadlineAt || this.#now() >= record.batch.expiresAt))) {
|
|
4226
|
+
throw new ControlPlaneStoreError('expired', 'live qualification expired or became stale');
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
putLiveQualification(batch) {
|
|
4230
|
+
assertLiveQualificationBatch(batch);
|
|
4231
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4232
|
+
try {
|
|
4233
|
+
const plan = this.getPlan(batch.planId);
|
|
4234
|
+
if (plan.status !== 'awaiting-live-tasks' || this.#now() >= batch.deadlineAt || this.#now() >= batch.expiresAt)
|
|
4235
|
+
throw new ControlPlaneStoreError('expired', 'live qualification admission ended');
|
|
4236
|
+
const prior = this.getLiveQualification(batch.id);
|
|
4237
|
+
if (prior) {
|
|
4238
|
+
if (controlPlaneDigest(prior.batch) !== controlPlaneDigest(batch) || prior.state === 'stale')
|
|
4239
|
+
throw new ControlPlaneStoreError('conflict', 'live qualification identity reused');
|
|
4240
|
+
}
|
|
4241
|
+
else
|
|
4242
|
+
this.#database.prepare(`INSERT INTO live_qualification_batches
|
|
4243
|
+
(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());
|
|
4244
|
+
readLiveQualificationContext(this.#database, batch.id);
|
|
4245
|
+
this.#database.exec('COMMIT');
|
|
4246
|
+
}
|
|
4247
|
+
catch (error) {
|
|
4248
|
+
this.#database.exec('ROLLBACK');
|
|
4249
|
+
throw error;
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
staleLiveQualification(id) {
|
|
4253
|
+
this.#database.prepare("UPDATE live_qualification_batches SET state='stale' WHERE id=? AND state IN ('pending','signed')").run(id);
|
|
4254
|
+
}
|
|
4255
|
+
async applyLiveQualification(input) {
|
|
4256
|
+
const record = this.getLiveQualification(input.receipt.batchId);
|
|
4257
|
+
if (!record)
|
|
4258
|
+
throw new ControlPlaneStoreError('not-found', 'live qualification batch absent');
|
|
4259
|
+
const receipt = verifyLiveQualificationReceipt(input.receipt, record.batch, input.publicKeyPem, this.#now());
|
|
4260
|
+
return input.withSourceFence(() => {
|
|
4261
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4262
|
+
try {
|
|
4263
|
+
const current = this.getLiveQualification(record.batch.id);
|
|
4264
|
+
if (current.state === 'applied') {
|
|
4265
|
+
if (controlPlaneDigest(current.receipt) !== controlPlaneDigest(receipt))
|
|
4266
|
+
throw new ControlPlaneStoreError('conflict', 'qualification receipt changed');
|
|
4267
|
+
this.#database.exec('COMMIT');
|
|
4268
|
+
return this.getPlan(record.batch.planId);
|
|
4269
|
+
}
|
|
4270
|
+
this.assertCurrentLiveQualification(record.batch.id);
|
|
4271
|
+
const plan = this.getPlan(record.batch.planId);
|
|
4272
|
+
if (plan.status !== 'awaiting-live-tasks' || this.#adoptionCoordinatorId !== undefined)
|
|
4273
|
+
throw new ControlPlaneStoreError('invalid-state', 'only target feedback runtime may qualify this deployment');
|
|
4274
|
+
const updated = this.#database.prepare(`UPDATE activation_plans SET status=?,revision=revision+1,updated_at=?,failure_code=?,activation_lease_until=NULL
|
|
4275
|
+
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);
|
|
4276
|
+
if (Number(updated.changes) !== 1)
|
|
4277
|
+
throw new ControlPlaneStoreError('conflict', 'qualification lost plan CAS');
|
|
4278
|
+
this.#database.prepare("UPDATE live_qualification_batches SET state='applied',receipt_json=?,receipt_digest=? WHERE id=? AND state IN ('pending','signed')")
|
|
4279
|
+
.run(JSON.stringify(receipt), controlPlaneDigest(receipt), record.batch.id);
|
|
4280
|
+
this.#database.exec('COMMIT');
|
|
4281
|
+
return this.getPlan(plan.id);
|
|
4282
|
+
}
|
|
4283
|
+
catch (error) {
|
|
4284
|
+
this.#database.exec('ROLLBACK');
|
|
4285
|
+
throw error;
|
|
4286
|
+
}
|
|
4287
|
+
});
|
|
4288
|
+
}
|
|
4289
|
+
/** Negative-only Host seam. The signed terms permit conservative removal of unconfirmable qualification. */
|
|
4290
|
+
invalidateLiveQualification(input) {
|
|
4291
|
+
const reason = bounded(input.reason, 'qualification invalidation reason', 160);
|
|
4292
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
4293
|
+
try {
|
|
4294
|
+
const plan = this.getPlan(input.planId), record = this.getLiveQualification(input.batchId);
|
|
4295
|
+
if (!record || record.batch.planId !== plan.id || record.state !== 'applied' || record.receipt?.disposition !== 'qualified'
|
|
4296
|
+
|| record.batch.planDigest !== plan.digest || record.batch.activationId !== plan.activation?.id || !plan.dossier.liveQualification) {
|
|
4297
|
+
throw new ControlPlaneStoreError('conflict', 'qualification invalidation lacks exact accepted evidence');
|
|
4298
|
+
}
|
|
4299
|
+
if (plan.status === 'rollback-pending' || plan.status === 'rolled-back') {
|
|
4300
|
+
this.#database.exec('COMMIT');
|
|
4301
|
+
return plan;
|
|
4302
|
+
}
|
|
4303
|
+
if (!['commit-pending', 'activated'].includes(plan.status) || record.batch.fence !== plan.activation.fence)
|
|
4304
|
+
throw new ControlPlaneStoreError('conflict', 'qualification invalidation no longer targets current deployment');
|
|
4305
|
+
this.assertNoClaimedHostAttestation(plan.id);
|
|
4306
|
+
if (plan.status === 'activated') {
|
|
4307
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id,checkpoint.successful_order,checkpoint.exposure_order FROM activation_deployment_checkpoints checkpoint
|
|
4308
|
+
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);
|
|
4309
|
+
if (!latest || latest.plan_id !== plan.id || latest.successful_order !== latest.exposure_order || !plan.activation?.hostRecoveryRequired
|
|
4310
|
+
|| !this.getActivationInstalledBaseline(plan.id))
|
|
4311
|
+
throw new ControlPlaneStoreError('conflict', 'qualification invalidation cannot restore a superseded deployment');
|
|
4312
|
+
}
|
|
4313
|
+
const competing = this.#database.prepare(`SELECT 1 FROM activation_plans WHERE target_path=? AND id<>? AND status IN
|
|
4314
|
+
('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);
|
|
4315
|
+
if (competing)
|
|
4316
|
+
throw new ControlPlaneStoreError('conflict', 'another deployment owns the profile');
|
|
4317
|
+
const now = this.#now();
|
|
4318
|
+
this.#database.prepare(`INSERT INTO live_qualification_invalidations
|
|
4319
|
+
(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);
|
|
4320
|
+
// A live commit worker is fenced by revision immediately; its final source guard must fail.
|
|
4321
|
+
this.#database.prepare(`UPDATE activation_plans SET status='rollback-pending',revision=revision+1,
|
|
4322
|
+
activation_lease_until=NULL,rollback_profile_restored=0,failure_code='live-qualification-invalidated',updated_at=?
|
|
4323
|
+
WHERE id=? AND revision=? AND activation_fence=?`).run(now, plan.id, plan.revision, plan.activation.fence);
|
|
4324
|
+
this.#database.exec('COMMIT');
|
|
4325
|
+
return this.getPlan(plan.id);
|
|
4326
|
+
}
|
|
4327
|
+
catch (error) {
|
|
4328
|
+
this.#database.exec('ROLLBACK');
|
|
4329
|
+
throw error;
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
#assertLiveQualificationCommit(planId) {
|
|
4333
|
+
const plan = this.getPlan(planId);
|
|
4334
|
+
if (!plan.dossier.liveQualification)
|
|
4335
|
+
return;
|
|
4336
|
+
if (!this.#withLiveQualificationFence)
|
|
4337
|
+
throw new ControlPlaneStoreError('invalid-state', 'target canonical qualification fence unavailable');
|
|
4338
|
+
const record = this.listLiveQualifications(planId).find(item => item.state === 'applied' && item.receipt?.disposition === 'qualified');
|
|
4339
|
+
if (!record || this.#now() >= record.batch.deadlineAt || this.#now() >= record.receipt.expiresAt)
|
|
4340
|
+
throw new ControlPlaneStoreError('expired', 'accepted live qualification is absent or expired');
|
|
4341
|
+
this.assertCurrentLiveQualification(record.batch.id);
|
|
4342
|
+
}
|
|
2286
4343
|
/** Same synchronous transaction owns current-profile selection and capture. */
|
|
2287
4344
|
withForegroundDeployment(task, profilePath, callback) {
|
|
2288
4345
|
assertForegroundTask(task);
|
|
2289
4346
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
2290
4347
|
try {
|
|
2291
4348
|
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',
|
|
4349
|
+
'staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2293
4350
|
'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
|
-
|
|
4351
|
+
let plan;
|
|
4352
|
+
if (active) {
|
|
4353
|
+
plan = this.getPlan(String(active.id));
|
|
4354
|
+
if (!plan.dossier.liveQualification || !['awaiting-live-tasks', 'commit-pending'].includes(plan.status))
|
|
4355
|
+
throw new ControlPlaneStoreError('conflict', 'profile has an unsettled activation');
|
|
4356
|
+
const window = this.getLiveQualificationWindow(plan.id);
|
|
4357
|
+
if (task.dispatchedAt < window.startedAt || task.dispatchedAt >= window.deadlineAt || this.#now() >= window.deadlineAt) {
|
|
4358
|
+
throw new ControlPlaneStoreError('expired', 'task is outside the live qualification window');
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
else {
|
|
4362
|
+
const latest = this.#database.prepare(`SELECT checkpoint.plan_id, checkpoint.exposure_order, checkpoint.successful_order
|
|
4363
|
+
FROM activation_deployment_checkpoints checkpoint JOIN activation_plans plan ON plan.id = checkpoint.plan_id
|
|
4364
|
+
WHERE plan.target_path = ? ORDER BY checkpoint.exposure_order DESC LIMIT 1`).get(profilePath);
|
|
4365
|
+
if (!latest || latest.exposure_order !== latest.successful_order)
|
|
4366
|
+
throw new ControlPlaneStoreError('invalid-state', 'profile has no current successful deployment');
|
|
4367
|
+
plan = this.getPlan(latest.plan_id);
|
|
4368
|
+
const watch = this.getActivationWatch(plan.id);
|
|
4369
|
+
if (plan.status !== 'activated' || !plan.activation || watch.state !== 'watching'
|
|
4370
|
+
|| watch.activationId !== plan.activation.id || watch.fence !== plan.activation.fence || watch.startedAt > task.dispatchedAt) {
|
|
4371
|
+
throw new ControlPlaneStoreError('invalid-state', 'task did not begin under a watched deployment');
|
|
4372
|
+
}
|
|
2305
4373
|
}
|
|
2306
4374
|
const source = readOwnerSourceAdoptionPlan(this.#database, plan.id).source;
|
|
2307
4375
|
const owner = source.owner;
|
|
@@ -2497,19 +4565,26 @@ export class ControlPlaneStore {
|
|
|
2497
4565
|
}
|
|
2498
4566
|
#isPostActivationRollback(plan) {
|
|
2499
4567
|
if (plan.status !== 'rollback-pending' || plan.activation === undefined || plan.activation.failureCode === undefined
|
|
2500
|
-
|| !['post-activation-regressed', 'post-activation-retracted'].includes(plan.activation.failureCode))
|
|
4568
|
+
|| !['post-activation-regressed', 'post-activation-retracted', 'live-qualification-invalidated'].includes(plan.activation.failureCode))
|
|
2501
4569
|
return false;
|
|
2502
4570
|
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2503
4571
|
.get(plan.id);
|
|
2504
4572
|
return checkpoint !== undefined && checkpoint.successful_order === checkpoint.exposure_order;
|
|
2505
4573
|
}
|
|
2506
4574
|
#hasPostActivationRollbackProvenance(plan) {
|
|
2507
|
-
if (plan.activation === undefined || !['post-activation-regressed', 'post-activation-retracted'].includes(plan.activation.failureCode ?? ''))
|
|
4575
|
+
if (plan.activation === undefined || !['post-activation-regressed', 'post-activation-retracted', 'live-qualification-invalidated'].includes(plan.activation.failureCode ?? ''))
|
|
2508
4576
|
return false;
|
|
2509
4577
|
const checkpoint = this.#database.prepare('SELECT successful_order, exposure_order FROM activation_deployment_checkpoints WHERE plan_id = ?')
|
|
2510
4578
|
.get(plan.id);
|
|
2511
4579
|
if (checkpoint === undefined || checkpoint.successful_order !== checkpoint.exposure_order)
|
|
2512
4580
|
return false;
|
|
4581
|
+
if (plan.activation.failureCode === 'live-qualification-invalidated') {
|
|
4582
|
+
const invalidation = this.#database.prepare('SELECT * FROM live_qualification_invalidations WHERE plan_id=?').get(plan.id);
|
|
4583
|
+
const accepted = invalidation && this.getLiveQualification(invalidation.batch_id);
|
|
4584
|
+
return !!invalidation && accepted?.state === 'applied' && accepted.receipt?.disposition === 'qualified'
|
|
4585
|
+
&& invalidation.batch_digest === accepted.batch.digest && invalidation.receipt_digest === controlPlaneDigest(accepted.receipt)
|
|
4586
|
+
&& invalidation.activation_id === plan.activation.id && invalidation.fence === accepted.batch.fence && invalidation.fence <= plan.activation.fence;
|
|
4587
|
+
}
|
|
2513
4588
|
const watch = this.#database.prepare('SELECT activation_id, fence, state, close_signature_digest FROM activation_watch WHERE plan_id = ?').get(plan.id);
|
|
2514
4589
|
return watch !== undefined && (watch.state === 'closed-regressed' || watch.state === 'closed-retracted')
|
|
2515
4590
|
&& watch.close_signature_digest !== null && watch.activation_id === plan.activation.id && watch.fence <= plan.activation.fence;
|
|
@@ -2614,7 +4689,7 @@ export class ControlPlaneStore {
|
|
|
2614
4689
|
AND NOT EXISTS (SELECT 1 FROM host_attestation_operations operation JOIN host_attestation_dispatches dispatch
|
|
2615
4690
|
ON dispatch.operation_id = operation.operation_id WHERE operation.plan_id = activation_plans.id
|
|
2616
4691
|
AND dispatch.status = 'claimed')
|
|
2617
|
-
AND (status IN ('awaiting-reload', 'awaiting-readiness', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
4692
|
+
AND (status IN ('awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks', 'awaiting-effect-blocked-replay', 'awaiting-shadow',
|
|
2618
4693
|
'awaiting-canary', 'awaiting-soak', 'awaiting-health')
|
|
2619
4694
|
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
4695
|
if (Number(result.changes) !== 1)
|
|
@@ -2623,6 +4698,18 @@ export class ControlPlaneStore {
|
|
|
2623
4698
|
}, true);
|
|
2624
4699
|
}
|
|
2625
4700
|
advanceActivation(input) {
|
|
4701
|
+
if (input.to === 'activated' && this.getPlan(input.planId).dossier.liveQualification && this.#liveQualificationAdmission !== input.planId) {
|
|
4702
|
+
this.#assertLiveQualificationCommit(input.planId);
|
|
4703
|
+
return this.#withLiveQualificationFence(input.planId, () => {
|
|
4704
|
+
this.#liveQualificationAdmission = input.planId;
|
|
4705
|
+
try {
|
|
4706
|
+
return this.withOwnerTaskFailureGapAdmission(this.getPlan(input.planId).gapId, () => this.advanceActivation(input));
|
|
4707
|
+
}
|
|
4708
|
+
finally {
|
|
4709
|
+
this.#liveQualificationAdmission = undefined;
|
|
4710
|
+
}
|
|
4711
|
+
});
|
|
4712
|
+
}
|
|
2626
4713
|
return this.#withActivationSource(input.planId, () => {
|
|
2627
4714
|
const allowed = { staging: ['awaiting-reload', 'rollback-pending'],
|
|
2628
4715
|
'rollback-pending': ['rolled-back'], 'commit-pending': ['activated', 'rollback-pending'] };
|
|
@@ -2675,7 +4762,7 @@ export class ControlPlaneStore {
|
|
|
2675
4762
|
if (replay !== undefined) {
|
|
2676
4763
|
const transition = Object.values(expectedAttestation).find(item => item.phase === input.receipt.phase);
|
|
2677
4764
|
const expectedStatus = input.receipt.outcome === 'passed' && transition !== undefined
|
|
2678
|
-
? transition.next : 'rollback-pending';
|
|
4765
|
+
? (input.receipt.phase === 'readiness' && replay.result.dossier.liveQualification ? 'awaiting-live-tasks' : transition.next) : 'rollback-pending';
|
|
2679
4766
|
const expectedFailure = input.receipt.outcome === 'passed' ? undefined : 'host-attestation-failed';
|
|
2680
4767
|
if (replay.result.status !== expectedStatus || replay.result.revision !== input.expectedRevision + 1
|
|
2681
4768
|
|| replay.result.activation?.id !== input.receipt.activationId || replay.result.activation.fence !== input.expectedFence
|
|
@@ -2702,7 +4789,7 @@ export class ControlPlaneStore {
|
|
|
2702
4789
|
throw new ControlPlaneStoreError('conflict', 'non-transition Host receipt changed generation from its predecessor');
|
|
2703
4790
|
}
|
|
2704
4791
|
const now = this.#now();
|
|
2705
|
-
const nextStatus = verified.outcome === 'passed' ? expected.next : 'rollback-pending';
|
|
4792
|
+
const nextStatus = verified.outcome === 'passed' ? (verified.phase === 'readiness' && plan.dossier.liveQualification ? 'awaiting-live-tasks' : expected.next) : 'rollback-pending';
|
|
2706
4793
|
return this.#withActivationSource(input.planId, () => {
|
|
2707
4794
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
2708
4795
|
try {
|
|
@@ -2938,6 +5025,8 @@ export class ControlPlaneStore {
|
|
|
2938
5025
|
ON CONFLICT(plan_id) DO NOTHING`).run(plan.id, plan.candidate.package, plan.candidate.version, plan.candidate.integrity, activationId, fence, now, now);
|
|
2939
5026
|
if (Number(result.changes) !== 1)
|
|
2940
5027
|
throw new ControlPlaneStoreError('invalid-state', 'activated plan has no post-activation watch');
|
|
5028
|
+
if (plan.dossier.liveQualification)
|
|
5029
|
+
this.#database.prepare('UPDATE activation_watch SET last_host_generation=? WHERE plan_id=?').run(this.getLiveQualificationWindow(plan.id).hostGeneration, plan.id);
|
|
2941
5030
|
}
|
|
2942
5031
|
}
|
|
2943
5032
|
#reopenGapAfterActivation(plan, now) {
|
|
@@ -3085,9 +5174,64 @@ export class ControlPlaneStore {
|
|
|
3085
5174
|
const now = this.#now();
|
|
3086
5175
|
if (now > plan.expiresAt || now > verified.expiresAt)
|
|
3087
5176
|
throw new ControlPlaneStoreError('expired', 'source release authorization is no longer applicable');
|
|
5177
|
+
const prelinked = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id=? LIMIT 2')
|
|
5178
|
+
.all(plan.id);
|
|
5179
|
+
const prebaseline = prelinked.length === 1 ? sourceJobFromRow(prelinked[0]).intent.baseline : undefined;
|
|
5180
|
+
const premaintenance = prebaseline ? this.getSourceMaintenanceRecords(plan.repository) : [];
|
|
5181
|
+
let lineageDigest;
|
|
5182
|
+
if (prebaseline && premaintenance.length) {
|
|
5183
|
+
if (!input.trust)
|
|
5184
|
+
throw new ControlPlaneStoreError('invalid-input', 'managed source maintenance requires current trust');
|
|
5185
|
+
const prehistory = this.getSourceBaselineHistory(plan.repository);
|
|
5186
|
+
await verifySourceBaselineHistory({ repository: plan.repository, config: prebaseline, trust: input.trust,
|
|
5187
|
+
history: prehistory, maintenance: premaintenance, signal: new AbortController().signal });
|
|
5188
|
+
lineageDigest = controlPlaneDigest({ history: prehistory, maintenance: premaintenance, trust: input.trust, baseline: prebaseline });
|
|
5189
|
+
}
|
|
3088
5190
|
return withCurrentSource(plan.gapId, () => {
|
|
3089
5191
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
3090
5192
|
try {
|
|
5193
|
+
const linkedRows = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ? LIMIT 2')
|
|
5194
|
+
.all(plan.id);
|
|
5195
|
+
if (linkedRows.length > 1)
|
|
5196
|
+
throw new ControlPlaneStoreError('invalid-state', 'source plan has multiple source jobs');
|
|
5197
|
+
const linked = linkedRows[0] === undefined ? undefined : sourceJobFromRow(linkedRows[0]);
|
|
5198
|
+
const managedRows = this.#database.prepare(`SELECT * FROM source_jobs WHERE json_extract(intent_json, '$.repository') = ?
|
|
5199
|
+
AND json_type(intent_json, '$.baseline') IS NOT NULL AND status IN ('queued', 'running', 'unknown', 'prepared')
|
|
5200
|
+
LIMIT 1001`).all(plan.repository);
|
|
5201
|
+
if (managedRows.length > 1000)
|
|
5202
|
+
throw new ControlPlaneStoreError('invalid-state', 'managed source job cohort exceeds bound');
|
|
5203
|
+
for (const row of managedRows) {
|
|
5204
|
+
const managed = sourceJobFromRow(row);
|
|
5205
|
+
if (managed.planId === plan.id)
|
|
5206
|
+
continue;
|
|
5207
|
+
if (managed.status !== 'prepared' || managed.planId === undefined
|
|
5208
|
+
|| !['release-complete', 'release-failed', 'expired', 'local-checks-failed'].includes(this.getSourcePlan(managed.planId).status)) {
|
|
5209
|
+
throw new ControlPlaneStoreError('conflict', 'managed source job owns this repository release');
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
if (linked?.intent.baseline !== undefined) {
|
|
5213
|
+
if (linked.status !== 'prepared' || linked.intent.repository !== plan.repository
|
|
5214
|
+
|| linked.intent.baseCommit !== plan.baseCommit) {
|
|
5215
|
+
throw new ControlPlaneStoreError('invalid-state', 'managed source release is not bound to its prepared job');
|
|
5216
|
+
}
|
|
5217
|
+
const active = this.#database.prepare(`SELECT id FROM source_plans WHERE repository = ? AND id <> ?
|
|
5218
|
+
AND release_id IS NOT NULL AND status NOT IN ('release-complete', 'release-failed') LIMIT 1`)
|
|
5219
|
+
.get(plan.repository, plan.id);
|
|
5220
|
+
if (active !== undefined)
|
|
5221
|
+
throw new ControlPlaneStoreError('conflict', 'source repository has another active release');
|
|
5222
|
+
const history = this.getSourceBaselineHistory(plan.repository);
|
|
5223
|
+
const maintenance = this.getSourceMaintenanceRecords(plan.repository);
|
|
5224
|
+
if (lineageDigest !== undefined && controlPlaneDigest({ history, maintenance, trust: input.trust,
|
|
5225
|
+
baseline: linked.intent.baseline }) !== lineageDigest) {
|
|
5226
|
+
throw new ControlPlaneStoreError('conflict', 'managed source signed lineage changed before release');
|
|
5227
|
+
}
|
|
5228
|
+
if (maintenance.length && lineageDigest === undefined) {
|
|
5229
|
+
throw new ControlPlaneStoreError('conflict', 'managed source maintenance was not preverified');
|
|
5230
|
+
}
|
|
5231
|
+
const expectedCommit = sourceBaselineChain(linked.intent.baseline, history, maintenance).at(-1);
|
|
5232
|
+
if (plan.baseCommit !== expectedCommit)
|
|
5233
|
+
throw new ControlPlaneStoreError('conflict', 'managed source release base commit is stale');
|
|
5234
|
+
}
|
|
3091
5235
|
const releaseId = `release-${randomUUID()}`;
|
|
3092
5236
|
const result = this.#database.prepare(`UPDATE source_plans SET status = 'awaiting-pr', revision = revision + 1,
|
|
3093
5237
|
release_authorization_json = ?, release_authorization_digest = ?, release_id = ?, release_fence = release_fence + 1,
|
|
@@ -3785,7 +5929,7 @@ export class ControlPlaneStore {
|
|
|
3785
5929
|
const row = this.#database.prepare(`SELECT
|
|
3786
5930
|
(SELECT count(*) FROM capability_gaps WHERE status = 'open') AS gaps,
|
|
3787
5931
|
(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',
|
|
5932
|
+
(SELECT count(*) FROM activation_plans WHERE status IN ('staging', 'awaiting-reload', 'awaiting-readiness', 'awaiting-live-tasks',
|
|
3789
5933
|
'awaiting-effect-blocked-replay', 'awaiting-shadow', 'awaiting-canary', 'awaiting-soak', 'awaiting-health', 'commit-pending')) AS active_activations,
|
|
3790
5934
|
(SELECT count(*) FROM activation_plans WHERE status = 'rolled-back') +
|
|
3791
5935
|
(SELECT count(*) FROM source_plans WHERE status IN ('local-checks-failed', 'release-failed')) AS failed,
|
|
@@ -3826,15 +5970,32 @@ export class ControlPlaneStore {
|
|
|
3826
5970
|
const replayBinding = { operation: 'create-source-plan', gapId: snapshot.gapId, repository: snapshot.repository,
|
|
3827
5971
|
worktree: snapshot.worktree, baseCommit: snapshot.baseCommit, name: snapshot.name, generatorDigest: snapshot.generatorDigest,
|
|
3828
5972
|
scope: snapshot.scope, ttlMs: snapshot.expiresAt - snapshot.createdAt };
|
|
5973
|
+
const linkedRow = this.#database.prepare('SELECT * FROM source_jobs WHERE plan_id = ?').get(snapshot.id);
|
|
5974
|
+
const linked = linkedRow === undefined ? undefined : sourceJobFromRow(linkedRow);
|
|
5975
|
+
const sourceJob = linked === undefined ? undefined : { jobId: linked.id, jobRevision: linked.revision - 1,
|
|
5976
|
+
occurrenceId: linked.occurrenceId };
|
|
5977
|
+
const replayInput = { ...(snapshot.mode === 'create' ? replayBinding : { ...replayBinding,
|
|
5978
|
+
mode: snapshot.mode, ...(snapshot.creation === undefined ? {} : { creation: snapshot.creation }),
|
|
5979
|
+
...(snapshot.sourceRevision === undefined ? {} : { revision: snapshot.sourceRevision }),
|
|
5980
|
+
prepared: { treeDigest: snapshot.sourceCheck.treeDigest, patchDigest: snapshot.sourceCheck.patchDigest,
|
|
5981
|
+
checkedAt: snapshot.sourceCheck.checkedAt, evidence: snapshot.preparedEvidence } }),
|
|
5982
|
+
...(sourceJob === undefined ? {} : { sourceJob }) };
|
|
5983
|
+
const replayDigest = controlPlaneDigest(replayInput);
|
|
5984
|
+
const artifactReplayDigest = (snapshot.mode === 'prepared-create' || snapshot.mode === 'prepared-revise') && snapshot.preparedEvidence !== undefined
|
|
5985
|
+
? controlPlaneDigest({ ...replayInput, preparedArtifactSha256: snapshot.preparedEvidence.pack.sha256 }) : undefined;
|
|
3829
5986
|
if (snapshot.digest !== authoritative.digest || snapshot.gapId !== authoritative.gapId
|
|
3830
5987
|
|| snapshot.revision !== 1 || snapshot.status !== 'pending-approval' || snapshot.createdAt !== receipt.createdAt
|
|
3831
5988
|
|| 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
|
|
5989
|
+
|| snapshot.release !== undefined || (replayDigest !== inputDigest && artifactReplayDigest !== inputDigest)
|
|
3835
5990
|
|| (snapshot.mode === 'create' && (snapshot.sourceCheck !== undefined || snapshot.preparedEvidence !== undefined))
|
|
3836
5991
|
|| (snapshot.mode === 'modify' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
3837
|
-
|| controlPlaneDigest(snapshot.sourceCheck) !== controlPlaneDigest(authoritative.sourceCheck)))
|
|
5992
|
+
|| controlPlaneDigest(snapshot.sourceCheck) !== controlPlaneDigest(authoritative.sourceCheck)))
|
|
5993
|
+
|| (snapshot.mode === 'prepared-create' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
5994
|
+
|| snapshot.creation === undefined || linked?.intent.mode !== 'create'
|
|
5995
|
+
|| controlPlaneDigest(snapshot.creation) !== controlPlaneDigest(authoritative.creation)))
|
|
5996
|
+
|| (snapshot.mode === 'prepared-revise' && (snapshot.sourceCheck === undefined || snapshot.preparedEvidence === undefined
|
|
5997
|
+
|| snapshot.sourceRevision === undefined || linked?.intent.mode !== 'revise-created'
|
|
5998
|
+
|| controlPlaneDigest(snapshot.sourceRevision) !== controlPlaneDigest(authoritative.sourceRevision)))) {
|
|
3838
5999
|
throw new ControlPlaneStoreError('invalid-state', 'stored source operation receipt is not bound to authoritative state');
|
|
3839
6000
|
}
|
|
3840
6001
|
return { ...receipt, result: snapshot };
|
|
@@ -3867,7 +6028,7 @@ export class ControlPlaneStore {
|
|
|
3867
6028
|
catalog: { digest: snapshot.dossier.catalogDigest, provenance: snapshot.dossier.catalogProvenance },
|
|
3868
6029
|
matchedCapabilities: snapshot.dossier.matchedCapabilities, profile: snapshot.profile, target: snapshot.target,
|
|
3869
6030
|
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 }) };
|
|
6031
|
+
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
6032
|
if (snapshot.digest !== authoritative.digest || snapshot.gapId !== authoritative.gapId || snapshot.revision !== 1
|
|
3872
6033
|
|| snapshot.status !== 'pending-approval' || snapshot.createdAt !== receipt.createdAt
|
|
3873
6034
|
|| snapshot.approval !== undefined || snapshot.activation !== undefined || controlPlaneDigest(replayBinding) !== inputDigest) {
|