feat(local): bind legacy adoption provenance

This commit is contained in:
whyour
2026-08-23 20:26:01 +08:00
parent 74ae891070
commit 31a9bcbe83
32 changed files with 1437 additions and 81 deletions
@@ -405,7 +405,11 @@ export function createLocalReconciliationSecretConfigDecisionRequirementFactory(
'activeGroupCount',
'activeRowCount',
'adoptedLegacyTaskCount',
'adoptedLegacyTriggerCount',
'adoptionProvenanceTaskCount',
'adoptionProvenanceTriggerCount',
'automationAdoptionRecordCount',
'automationAdoptionProvenanceState',
'automationAdoptionSetDigest',
'bindingReadyCount',
'candidateSetDigest',
@@ -441,6 +445,18 @@ export function createLocalReconciliationSecretConfigDecisionRequirementFactory(
footer.eligiblePreservationCount !==
receipt.eligiblePreservationCount ||
footer.targetConflictCount !== receipt.targetConflictCount ||
footer.automationAdoptionRecordCount !==
receipt.automationAdoptionRecordCount ||
footer.adoptedLegacyTaskCount !==
receipt.adoptedLegacyTaskCount ||
footer.adoptedLegacyTriggerCount !==
receipt.adoptedLegacyTriggerCount ||
footer.adoptionProvenanceTaskCount !==
receipt.adoptionProvenanceTaskCount ||
footer.adoptionProvenanceTriggerCount !==
receipt.adoptionProvenanceTriggerCount ||
footer.automationAdoptionProvenanceState !==
receipt.automationAdoptionProvenanceState ||
footer.outcome !== receipt.outcome ||
candidateCount !==
receipt.eligibleBindingCount +
@@ -8,6 +8,11 @@ import {
type LegacyEnvironmentInventory,
type LegacyEnvironmentRowInspection,
} from '@qinglong/local-admin/reconciliation-secret-and-config-inspection';
import {
LegacyAdoptionPublicationDigest,
legacyAdoptionTaskProvenanceDigest,
legacyAdoptionTriggerProvenanceDigest,
} from '@qinglong/local-sqlite/adoption-provenance';
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
import { cutoverDigest } from '../../../cutover/targetEvidence';
@@ -26,6 +31,8 @@ const MAX_LINE_BYTES = 64 * 1024;
const HASH_BUFFER_BYTES = 64 * 1024;
const MAX_EDGE_AUTOMATION_ADOPTION_RECORDS = 128;
const MAX_STANDALONE_AUTOMATION_ADOPTION_RECORDS = 512;
const MAX_AUTOMATION_ADOPTION_TASKS = 100_000;
const MAX_AUTOMATION_ADOPTION_TRIGGERS = 500_000;
export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
8 * 1024 * 1024;
export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
@@ -104,6 +111,13 @@ export interface LocalReconciliationSecretConfigPlanSummary {
readonly targetConflictCount: number;
readonly automationAdoptionRecordCount: number;
readonly adoptedLegacyTaskCount: number;
readonly adoptedLegacyTriggerCount: number;
readonly adoptionProvenanceTaskCount: number;
readonly adoptionProvenanceTriggerCount: number;
readonly automationAdoptionProvenanceState:
| 'complete'
| 'missing'
| 'drifted';
readonly unadaptedLegacyConfigCount: number;
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
}
@@ -248,6 +262,10 @@ function targetAutomationAdoptionProjection(
): Readonly<{
recordCount: number;
adoptedTaskCount: number;
adoptedTriggerCount: number;
provenanceTaskCount: number;
provenanceTriggerCount: number;
provenanceState: 'complete' | 'missing' | 'drifted';
setDigest: string;
}> {
const maximumRecords =
@@ -257,8 +275,23 @@ function targetAutomationAdoptionProjection(
const hash = createHash('sha256').update(
'qinglong3.local-reconciliation-secret-config-automation-adoption-set.v1\0',
);
const records = new Map<
string,
{
readonly expectedTaskCount: number;
readonly expectedTriggerCount: number;
readonly expectedPublicationDigest: string;
readonly publication: LegacyAdoptionPublicationDigest;
taskCount: number;
triggerCount: number;
}
>();
let recordCount = 0;
let adoptedTaskCount = 0;
let adoptedTriggerCount = 0;
let provenanceTaskCount = 0;
let provenanceTriggerCount = 0;
let drifted = false;
try {
const rows = target
.prepare(
@@ -300,6 +333,15 @@ function targetAutomationAdoptionProjection(
if (!Number.isSafeInteger(adoptedTaskCount)) {
fail('target Automation adoption task count overflowed');
}
const selectedAdoptedTriggerCount = adoptionCount(
row,
'adoptedTriggerCount',
500_000,
);
adoptedTriggerCount += selectedAdoptedTriggerCount;
if (!Number.isSafeInteger(adoptedTriggerCount)) {
fail('target Automation adoption trigger count overflowed');
}
const payload = Object.freeze({
mutationId: adoptionText(row, 'mutationId', UUID_V4_PATTERN),
decisionId: adoptionText(
@@ -327,11 +369,7 @@ function targetAutomationAdoptionProjection(
),
rowCount,
adoptedTaskCount: selectedAdoptedTaskCount,
adoptedTriggerCount: adoptionCount(
row,
'adoptedTriggerCount',
500_000,
),
adoptedTriggerCount: selectedAdoptedTriggerCount,
skippedCount,
auditEventId: adoptionText(row, 'auditEventId', UUID_V4_PATTERN),
createdAtMs: adoptionCount(
@@ -343,15 +381,366 @@ function targetAutomationAdoptionProjection(
if (payload.auditEventId !== payload.mutationId) {
fail('target Automation adoption audit binding drifted');
}
if (records.has(payload.mutationId)) {
fail('target Automation adoption identity is duplicated');
}
records.set(payload.mutationId, {
expectedTaskCount: selectedAdoptedTaskCount,
expectedTriggerCount: selectedAdoptedTriggerCount,
expectedPublicationDigest: payload.publicationDigest,
publication: new LegacyAdoptionPublicationDigest(payload.mutationId),
taskCount: 0,
triggerCount: 0,
});
hash.update('\0').update(JSON.stringify(payload));
}
const tasks = target
.prepare(
`SELECT provenance."adoption_mutation_id" AS "adoptionMutationId",
provenance."row_ordinal" AS "rowOrdinal",
provenance."project_id" AS "projectId",
provenance."source_digest" AS "sourceDigest",
provenance."task_id" AS "taskId",
provenance."task_revision" AS "taskRevision",
provenance."task_mutation_id" AS "taskMutationId",
provenance."task_content_digest" AS "taskContentDigest",
provenance."trigger_count" AS "triggerCount",
provenance."item_digest" AS "itemDigest",
head."current_revision" AS "currentRevision",
revision."mutation_id" AS "storedMutationId",
revision."content_digest" AS "storedContentDigest",
ownership."package_name" AS "packageName"
FROM "QingLong3LegacyAdoptionTasks" AS provenance
JOIN "QingLong3LegacyAdoptions" AS adoption
ON adoption."mutation_id" = provenance."adoption_mutation_id"
AND adoption."project_id" = provenance."project_id"
LEFT JOIN "QingLong3TaskDefinitions" AS head
ON head."project_id" = provenance."project_id"
AND head."task_id" = provenance."task_id"
LEFT JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision."project_id" = provenance."project_id"
AND revision."task_id" = provenance."task_id"
AND revision."revision" = provenance."task_revision"
LEFT JOIN "QingLong3PluginPackageTaskOwnerships" AS ownership
ON ownership."project_id" = provenance."project_id"
AND ownership."task_id" = provenance."task_id"
WHERE adoption."project_id" = ?
ORDER BY adoption."created_at_ms" ASC,
adoption."mutation_id" ASC,
provenance."row_ordinal" ASC`,
)
.iterate(projectId) as Iterable<Readonly<Record<string, unknown>>>;
for (const row of tasks) {
provenanceTaskCount += 1;
if (provenanceTaskCount > MAX_AUTOMATION_ADOPTION_TASKS) {
fail('target Automation adoption Task provenance exceeds budget');
}
const adoptionMutationId = adoptionText(
row,
'adoptionMutationId',
UUID_V4_PATTERN,
);
const selected = records.get(adoptionMutationId);
const rowOrdinal = adoptionCount(
row,
'rowOrdinal',
MAX_AUTOMATION_ADOPTION_TASKS,
);
const triggerCount = adoptionCount(
row,
'triggerCount',
MAX_AUTOMATION_ADOPTION_TRIGGERS,
);
const payload = Object.freeze({
adoptionMutationId,
rowOrdinal,
projectId: adoptionText(row, 'projectId'),
sourceDigest: adoptionText(row, 'sourceDigest', DIGEST_PATTERN),
taskId: adoptionText(row, 'taskId'),
taskRevision: adoptionCount(row, 'taskRevision', 1),
taskMutationId: adoptionText(row, 'taskMutationId', UUID_V4_PATTERN),
taskContentDigest: adoptionText(
row,
'taskContentDigest',
DIGEST_PATTERN,
),
triggerCount,
});
const itemDigest = adoptionText(row, 'itemDigest', DIGEST_PATTERN);
selected && (selected.taskCount += 1);
const currentState = Object.freeze({
currentRevision: row.currentRevision,
storedMutationId: row.storedMutationId,
storedContentDigest: row.storedContentDigest,
pluginOwned: row.packageName !== null,
});
if (
!selected ||
payload.projectId !== projectId ||
payload.rowOrdinal < 1 ||
payload.taskRevision !== 1 ||
legacyAdoptionTaskProvenanceDigest(payload) !== itemDigest ||
currentState.currentRevision !== payload.taskRevision ||
currentState.storedMutationId !== payload.taskMutationId ||
currentState.storedContentDigest !== payload.taskContentDigest ||
currentState.pluginOwned
) {
drifted = true;
}
hash.update('\0task\0').update(
JSON.stringify({
...payload,
itemDigest,
currentState,
}),
);
}
const triggers = target
.prepare(
`SELECT provenance."adoption_mutation_id" AS "adoptionMutationId",
provenance."row_ordinal" AS "rowOrdinal",
provenance."trigger_ordinal" AS "triggerOrdinal",
provenance."project_id" AS "projectId",
provenance."task_id" AS "taskId",
provenance."task_revision" AS "taskRevision",
provenance."trigger_id" AS "triggerId",
provenance."trigger_revision" AS "triggerRevision",
provenance."trigger_mutation_id" AS "triggerMutationId",
provenance."trigger_content_digest" AS "triggerContentDigest",
provenance."item_digest" AS "itemDigest",
head."current_revision" AS "currentRevision",
revision."mutation_id" AS "storedMutationId",
revision."content_digest" AS "storedContentDigest",
schedule."trigger_revision" AS "scheduleRevision"
FROM "QingLong3LegacyAdoptionTriggers" AS provenance
JOIN "QingLong3LegacyAdoptions" AS adoption
ON adoption."mutation_id" = provenance."adoption_mutation_id"
AND adoption."project_id" = provenance."project_id"
LEFT JOIN "QingLong3Triggers" AS head
ON head."project_id" = provenance."project_id"
AND head."trigger_id" = provenance."trigger_id"
AND head."task_id" = provenance."task_id"
LEFT JOIN "QingLong3TriggerRevisions" AS revision
ON revision."project_id" = provenance."project_id"
AND revision."trigger_id" = provenance."trigger_id"
AND revision."revision" = provenance."trigger_revision"
LEFT JOIN "QingLong3LocalTriggerSchedules" AS schedule
ON schedule."project_id" = provenance."project_id"
AND schedule."trigger_id" = provenance."trigger_id"
WHERE adoption."project_id" = ?
ORDER BY adoption."created_at_ms" ASC,
adoption."mutation_id" ASC,
provenance."row_ordinal" ASC,
provenance."trigger_ordinal" ASC`,
)
.iterate(projectId) as Iterable<Readonly<Record<string, unknown>>>;
let previousTaskKey = '';
let previousTriggerOrdinal = 0;
for (const row of triggers) {
provenanceTriggerCount += 1;
if (provenanceTriggerCount > MAX_AUTOMATION_ADOPTION_TRIGGERS) {
fail('target Automation adoption Trigger provenance exceeds budget');
}
const adoptionMutationId = adoptionText(
row,
'adoptionMutationId',
UUID_V4_PATTERN,
);
const selected = records.get(adoptionMutationId);
const rowOrdinal = adoptionCount(
row,
'rowOrdinal',
MAX_AUTOMATION_ADOPTION_TASKS,
);
const triggerOrdinal = adoptionCount(
row,
'triggerOrdinal',
MAX_AUTOMATION_ADOPTION_TRIGGERS,
);
const taskKey = `${adoptionMutationId}\0${rowOrdinal}`;
if (taskKey !== previousTaskKey) {
previousTaskKey = taskKey;
previousTriggerOrdinal = 0;
}
previousTriggerOrdinal += 1;
const payload = Object.freeze({
adoptionMutationId,
rowOrdinal,
triggerOrdinal,
projectId: adoptionText(row, 'projectId'),
taskId: adoptionText(row, 'taskId'),
taskRevision: adoptionCount(row, 'taskRevision', 1),
triggerId: adoptionText(row, 'triggerId'),
triggerRevision: adoptionCount(row, 'triggerRevision', 1),
triggerMutationId: adoptionText(
row,
'triggerMutationId',
UUID_V4_PATTERN,
),
triggerContentDigest: adoptionText(
row,
'triggerContentDigest',
DIGEST_PATTERN,
),
});
const itemDigest = adoptionText(row, 'itemDigest', DIGEST_PATTERN);
selected && (selected.triggerCount += 1);
const currentState = Object.freeze({
currentRevision: row.currentRevision,
storedMutationId: row.storedMutationId,
storedContentDigest: row.storedContentDigest,
scheduleRevision: row.scheduleRevision,
});
if (
!selected ||
payload.projectId !== projectId ||
payload.rowOrdinal < 1 ||
payload.triggerOrdinal !== previousTriggerOrdinal ||
payload.taskRevision !== 1 ||
payload.triggerRevision !== 1 ||
legacyAdoptionTriggerProvenanceDigest(payload) !== itemDigest ||
currentState.currentRevision !== payload.triggerRevision ||
currentState.storedMutationId !== payload.triggerMutationId ||
currentState.storedContentDigest !== payload.triggerContentDigest ||
currentState.scheduleRevision !== payload.triggerRevision
) {
drifted = true;
}
hash.update('\0trigger\0').update(
JSON.stringify({
...payload,
itemDigest,
currentState,
}),
);
}
const publicationRows = target
.prepare(
`SELECT adoption."mutation_id" AS "adoptionMutationId",
task."row_ordinal" AS "rowOrdinal",
task."source_digest" AS "sourceDigest",
task."task_content_digest" AS "taskContentDigest",
task."trigger_count" AS "expectedTriggerCount",
task."item_digest" AS "taskItemDigest",
trigger."trigger_ordinal" AS "triggerOrdinal",
trigger."trigger_content_digest" AS "triggerContentDigest",
trigger."item_digest" AS "triggerItemDigest"
FROM "QingLong3LegacyAdoptions" AS adoption
LEFT JOIN "QingLong3LegacyAdoptionTasks" AS task
ON task."adoption_mutation_id" = adoption."mutation_id"
AND task."project_id" = adoption."project_id"
LEFT JOIN "QingLong3LegacyAdoptionTriggers" AS trigger
ON trigger."adoption_mutation_id" = task."adoption_mutation_id"
AND trigger."row_ordinal" = task."row_ordinal"
WHERE adoption."project_id" = ?
ORDER BY adoption."created_at_ms" ASC,
adoption."mutation_id" ASC,
task."row_ordinal" ASC,
trigger."trigger_ordinal" ASC`,
)
.iterate(projectId) as Iterable<Readonly<Record<string, unknown>>>;
let publicationTaskKey = '';
let publicationExpectedTriggerCount = 0;
let publicationTriggerCount = 0;
for (const row of publicationRows) {
const adoptionMutationId = adoptionText(
row,
'adoptionMutationId',
UUID_V4_PATTERN,
);
const selected = records.get(adoptionMutationId);
if (!selected) {
drifted = true;
continue;
}
if (row.rowOrdinal === null) continue;
const rowOrdinal = adoptionCount(
row,
'rowOrdinal',
MAX_AUTOMATION_ADOPTION_TASKS,
);
const taskKey = `${adoptionMutationId}\0${rowOrdinal}`;
if (taskKey !== publicationTaskKey) {
if (
publicationTaskKey !== '' &&
publicationTriggerCount !== publicationExpectedTriggerCount
) {
drifted = true;
}
publicationTaskKey = taskKey;
publicationTriggerCount = 0;
publicationExpectedTriggerCount = adoptionCount(
row,
'expectedTriggerCount',
MAX_AUTOMATION_ADOPTION_TRIGGERS,
);
selected.publication.appendTask({
rowOrdinal,
sourceDigest: adoptionText(row, 'sourceDigest', DIGEST_PATTERN),
taskContentDigest: adoptionText(
row,
'taskContentDigest',
DIGEST_PATTERN,
),
itemDigest: adoptionText(row, 'taskItemDigest', DIGEST_PATTERN),
});
}
if (row.triggerOrdinal === null) continue;
publicationTriggerCount += 1;
if (
adoptionCount(
row,
'triggerOrdinal',
MAX_AUTOMATION_ADOPTION_TRIGGERS,
) !== publicationTriggerCount
) {
drifted = true;
}
selected.publication.appendTrigger({
triggerContentDigest: adoptionText(
row,
'triggerContentDigest',
DIGEST_PATTERN,
),
itemDigest: adoptionText(row, 'triggerItemDigest', DIGEST_PATTERN),
});
}
if (
publicationTaskKey !== '' &&
publicationTriggerCount !== publicationExpectedTriggerCount
) {
drifted = true;
}
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return fail('target Automation adoption projection is unavailable', error);
}
const missing = [...records.values()].some(
(record) =>
record.taskCount !== record.expectedTaskCount ||
record.triggerCount !== record.expectedTriggerCount,
);
if (!missing) {
for (const record of records.values()) {
if (record.publication.digest() !== record.expectedPublicationDigest) {
drifted = true;
}
}
}
return Object.freeze({
recordCount,
adoptedTaskCount,
adoptedTriggerCount,
provenanceTaskCount,
provenanceTriggerCount,
provenanceState: drifted
? ('drifted' as const)
: missing
? ('missing' as const)
: ('complete' as const),
setDigest: hash.digest('hex'),
});
}
@@ -578,6 +967,10 @@ export function writeLocalReconciliationSecretConfigPlan(
targetConflictCount,
automationAdoptionRecordCount: automationAdoption.recordCount,
adoptedLegacyTaskCount: automationAdoption.adoptedTaskCount,
adoptedLegacyTriggerCount: automationAdoption.adoptedTriggerCount,
adoptionProvenanceTaskCount: automationAdoption.provenanceTaskCount,
adoptionProvenanceTriggerCount: automationAdoption.provenanceTriggerCount,
automationAdoptionProvenanceState: automationAdoption.provenanceState,
unadaptedLegacyConfigCount: header.unadaptedLegacyConfigCount,
outcome:
(inventory.tableState === 'absent' || inventory.rowCount === 0) &&
@@ -586,6 +979,7 @@ export function writeLocalReconciliationSecretConfigPlan(
: !inventory.mutationReady ||
targetConflictCount > 0 ||
header.unadaptedLegacyConfigCount > 0 ||
automationAdoption.provenanceState !== 'complete' ||
(eligibleBindingCount > 0 && automationAdoption.adoptedTaskCount < 1)
? ('manual_required' as const)
: ('ready' as const),
@@ -651,6 +1045,11 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
targetConflictCount: footer.targetConflictCount,
automationAdoptionRecordCount: footer.automationAdoptionRecordCount,
adoptedLegacyTaskCount: footer.adoptedLegacyTaskCount,
adoptedLegacyTriggerCount: footer.adoptedLegacyTriggerCount,
adoptionProvenanceTaskCount: footer.adoptionProvenanceTaskCount,
adoptionProvenanceTriggerCount: footer.adoptionProvenanceTriggerCount,
automationAdoptionProvenanceState:
footer.automationAdoptionProvenanceState,
unadaptedLegacyConfigCount: footer.unadaptedLegacyConfigCount,
outcome: footer.outcome,
preparedAtMs: header.preparedAtMs,
@@ -667,9 +1066,13 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
'activeGroupCount',
'activeRowCount',
'adoptedLegacyTaskCount',
'adoptedLegacyTriggerCount',
'adoptionProvenanceTaskCount',
'adoptionProvenanceTriggerCount',
'applicationId',
'applicationPlanDigest',
'automationAdoptionRecordCount',
'automationAdoptionProvenanceState',
'automationAdoptionSetDigest',
'bindingReadyCount',
'candidateSetDigest',
@@ -736,6 +1139,9 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
receipt.targetConflictCount,
receipt.automationAdoptionRecordCount,
receipt.adoptedLegacyTaskCount,
receipt.adoptedLegacyTriggerCount,
receipt.adoptionProvenanceTaskCount,
receipt.adoptionProvenanceTriggerCount,
receipt.unadaptedLegacyConfigCount,
receipt.planFileBytes,
receipt.preparedAtMs,
@@ -746,6 +1152,9 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
!['ready', 'manual_required', 'no_effect'].includes(
receipt.outcome as string,
) ||
!['complete', 'missing', 'drifted'].includes(
receipt.automationAdoptionProvenanceState as string,
) ||
cutoverDigest(payload) !== receiptDigest
) {
fail('receipt drifted');
@@ -366,9 +366,9 @@ function adoptedDockerHarness(state, options = {}) {
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
],
Labels: {
'io.qinglong.local.sqlite-contract-min': '50',
'io.qinglong.local.sqlite-contract-max': '50',
'io.qinglong.local.sqlite-write-contract': '50',
'io.qinglong.local.sqlite-contract-min': '51',
'io.qinglong.local.sqlite-contract-max': '51',
'io.qinglong.local.sqlite-write-contract': '51',
'io.qinglong.local.application-config': '2,3,4',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -839,7 +839,7 @@ test('preflights adopted Compose identity mounts and rejects mount drift', async
);
assert.equal(ready.status, 'ready');
assert.equal(ready.profile, 'edge');
assert.equal(ready.sqlite.contractVersion, 50);
assert.equal(ready.sqlite.contractVersion, 51);
await assert.rejects(
preflightLocalDeploymentCompose(composePreflightCommand(state, 1), {
runDocker: adoptedDockerHarness(state, { driftMount: true }).runDocker,
@@ -490,9 +490,9 @@ function composeDockerHarness(
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
],
Labels: {
'io.qinglong.local.sqlite-contract-min': '50',
'io.qinglong.local.sqlite-contract-max': '50',
'io.qinglong.local.sqlite-write-contract': '50',
'io.qinglong.local.sqlite-contract-min': '51',
'io.qinglong.local.sqlite-contract-max': '51',
'io.qinglong.local.sqlite-write-contract': '51',
'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -1199,9 +1199,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
],
Labels: {
'io.qinglong.local.sqlite-contract-min': '50',
'io.qinglong.local.sqlite-contract-max': '50',
'io.qinglong.local.sqlite-write-contract': '50',
'io.qinglong.local.sqlite-contract-min': '51',
'io.qinglong.local.sqlite-contract-max': '51',
'io.qinglong.local.sqlite-write-contract': '51',
'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -1259,7 +1259,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async
assert.equal(result.status, 'ready');
assert.equal(result.generation, 1);
assert.equal(result.profile, 'edge');
assert.equal(result.sqlite.contractVersion, 50);
assert.equal(result.sqlite.contractVersion, 51);
assert.equal(result.image.architecture, 'arm64');
assert.equal(calls.length, 2);
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
@@ -1359,8 +1359,8 @@ test('applies one Compose generation and exactly replays its health receipt', as
assert.equal(mode(receiptPath), 0o600);
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
assert.deepEqual(receipt.sqlite, {
contractVersion: 50,
writeContractVersion: 50,
contractVersion: 51,
writeContractVersion: 51,
writeObservation: 'unchanged',
backup: null,
});
@@ -1661,8 +1661,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
`${command.request.rolloutId}.sqlite`,
);
assert.equal(mode(backupPath), 0o600);
assert.equal(receipt.sqlite.contractVersion, 50);
assert.equal(receipt.sqlite.writeContractVersion, 50);
assert.equal(receipt.sqlite.contractVersion, 51);
assert.equal(receipt.sqlite.writeContractVersion, 51);
assert.equal(receipt.sqlite.writeObservation, 'changed');
assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/);
assert.equal(receipt.sqlite.backup.bytes > 0, true);
@@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async
assert.equal(result.status, 'ready');
assert.equal(result.profile, 'edge');
assert.equal(result.storage.contractName, 'local-control-core');
assert.equal(result.storage.contractVersion, 50);
assert.equal(result.storage.migrationCount, 100);
assert.equal(result.storage.contractVersion, 51);
assert.equal(result.storage.migrationCount, 102);
assert.equal(result.storage.journalMode, 'delete');
assert.equal(JSON.stringify(result).includes(state.directory), false);
});
@@ -4,6 +4,10 @@ const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
LegacyAdoptionPublicationDigest,
legacyAdoptionTaskProvenanceDigest,
} = require('@qinglong/local-sqlite/adoption-provenance');
const {
buildLocalReconciliationSecretConfigPlanReceipt,
@@ -78,11 +82,85 @@ function databases() {
audit_event_id TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE "QingLong3TaskDefinitions" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
current_revision INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (project_id, task_id)
);
CREATE TABLE "QingLong3TaskDefinitionRevisions" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
revision INTEGER NOT NULL,
mutation_id TEXT NOT NULL,
content_digest TEXT NOT NULL,
PRIMARY KEY (project_id, task_id, revision)
);
CREATE TABLE "QingLong3PluginPackageTaskOwnerships" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
package_name TEXT NOT NULL,
PRIMARY KEY (project_id, task_id)
);
CREATE TABLE "QingLong3Triggers" (
project_id TEXT NOT NULL,
trigger_id TEXT NOT NULL,
task_id TEXT NOT NULL,
current_revision INTEGER NOT NULL,
PRIMARY KEY (project_id, trigger_id)
);
CREATE TABLE "QingLong3TriggerRevisions" (
project_id TEXT NOT NULL,
trigger_id TEXT NOT NULL,
revision INTEGER NOT NULL,
mutation_id TEXT NOT NULL,
content_digest TEXT NOT NULL,
PRIMARY KEY (project_id, trigger_id, revision)
);
CREATE TABLE "QingLong3LocalTriggerSchedules" (
project_id TEXT NOT NULL,
trigger_id TEXT NOT NULL,
trigger_revision INTEGER NOT NULL,
PRIMARY KEY (project_id, trigger_id)
);
CREATE TABLE "QingLong3LegacyAdoptionTasks" (
adoption_mutation_id TEXT NOT NULL,
row_ordinal INTEGER NOT NULL,
project_id TEXT NOT NULL,
source_digest TEXT NOT NULL,
task_id TEXT NOT NULL,
task_revision INTEGER NOT NULL,
task_mutation_id TEXT NOT NULL,
task_content_digest TEXT NOT NULL,
trigger_count INTEGER NOT NULL,
item_digest TEXT NOT NULL,
PRIMARY KEY (adoption_mutation_id, row_ordinal)
);
CREATE TABLE "QingLong3LegacyAdoptionTriggers" (
adoption_mutation_id TEXT NOT NULL,
row_ordinal INTEGER NOT NULL,
trigger_ordinal INTEGER NOT NULL,
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
task_revision INTEGER NOT NULL,
trigger_id TEXT NOT NULL,
trigger_revision INTEGER NOT NULL,
trigger_mutation_id TEXT NOT NULL,
trigger_content_digest TEXT NOT NULL,
item_digest TEXT NOT NULL,
PRIMARY KEY (adoption_mutation_id, row_ordinal, trigger_ordinal)
);
`);
return { legacy, target };
}
function insertAutomationAdoption(target, adoptedTaskCount = 1) {
function insertAutomationAdoption(
target,
adoptedTaskCount = 1,
withProvenance = true,
) {
const mutationId = '30000000-0000-4000-8000-000000000003';
target
.prepare(
@@ -106,6 +184,70 @@ function insertAutomationAdoption(target, adoptedTaskCount = 1) {
mutationId,
HEADER.preparedAtMs,
);
if (!withProvenance) return;
const publication = new LegacyAdoptionPublicationDigest(mutationId);
for (let rowOrdinal = 1; rowOrdinal <= adoptedTaskCount; rowOrdinal += 1) {
const taskId = `legacy-cron:${rowOrdinal}`;
const taskMutationId = `31000000-0000-4000-8000-${String(rowOrdinal).padStart(12, '0')}`;
const sourceDigest = String(rowOrdinal % 10).repeat(64);
const taskContentDigest = String((rowOrdinal + 1) % 10).repeat(64);
const payload = {
adoptionMutationId: mutationId,
rowOrdinal,
projectId: HEADER.projectId,
sourceDigest,
taskId,
taskRevision: 1,
taskMutationId,
taskContentDigest,
triggerCount: 0,
};
const itemDigest = legacyAdoptionTaskProvenanceDigest(payload);
target
.prepare(
`INSERT INTO "QingLong3TaskDefinitions" VALUES (?, ?, 1, ?, ?)`
)
.run(
HEADER.projectId,
taskId,
HEADER.preparedAtMs,
HEADER.preparedAtMs,
);
target
.prepare(
`INSERT INTO "QingLong3TaskDefinitionRevisions" VALUES
(?, ?, 1, ?, ?)`
)
.run(HEADER.projectId, taskId, taskMutationId, taskContentDigest);
target
.prepare(
`INSERT INTO "QingLong3LegacyAdoptionTasks" VALUES
(?, ?, ?, ?, ?, 1, ?, ?, 0, ?)`
)
.run(
mutationId,
rowOrdinal,
HEADER.projectId,
sourceDigest,
taskId,
taskMutationId,
taskContentDigest,
itemDigest,
);
publication.appendTask({
rowOrdinal,
sourceDigest,
taskContentDigest,
itemDigest,
});
}
target
.prepare(
`UPDATE "QingLong3LegacyAdoptions"
SET publication_digest = ?
WHERE mutation_id = ?`,
)
.run(publication.digest(), mutationId);
}
function writePlan(
@@ -167,6 +309,10 @@ test('writes a content-free Env plan with separate active and disabled candidate
assert.equal(result.footer.targetConflictCount, 0);
assert.equal(result.footer.automationAdoptionRecordCount, 1);
assert.equal(result.footer.adoptedLegacyTaskCount, 1);
assert.equal(result.footer.adoptedLegacyTriggerCount, 0);
assert.equal(result.footer.adoptionProvenanceTaskCount, 1);
assert.equal(result.footer.adoptionProvenanceTriggerCount, 0);
assert.equal(result.footer.automationAdoptionProvenanceState, 'complete');
assert.match(result.footer.automationAdoptionSetDigest, /^[0-9a-f]{64}$/);
const candidates = records.filter((record) =>
record.kind.endsWith('-candidate'),
@@ -218,6 +364,60 @@ test('writes a content-free Env plan with separate active and disabled candidate
}),
/receipt drifted/,
);
const tamperedPayload = {
adoptionMutationId: '30000000-0000-4000-8000-000000000003',
rowOrdinal: 1,
projectId: HEADER.projectId,
sourceDigest: '9'.repeat(64),
taskId: 'legacy-cron:1',
taskRevision: 1,
taskMutationId: '31000000-0000-4000-8000-000000000001',
taskContentDigest: '2'.repeat(64),
triggerCount: 0,
};
target
.prepare(
`UPDATE "QingLong3LegacyAdoptionTasks"
SET source_digest = ?, item_digest = ?
WHERE adoption_mutation_id = ? AND row_ordinal = 1`,
)
.run(
tamperedPayload.sourceDigest,
legacyAdoptionTaskProvenanceDigest(tamperedPayload),
tamperedPayload.adoptionMutationId,
);
const resealedItem = writePlan(t, legacy, target);
assert.equal(resealedItem.result.footer.outcome, 'manual_required');
assert.equal(
resealedItem.result.footer.automationAdoptionProvenanceState,
'drifted',
);
const originalPayload = { ...tamperedPayload, sourceDigest: '1'.repeat(64) };
target
.prepare(
`UPDATE "QingLong3LegacyAdoptionTasks"
SET source_digest = ?, item_digest = ?
WHERE adoption_mutation_id = ? AND row_ordinal = 1`,
)
.run(
originalPayload.sourceDigest,
legacyAdoptionTaskProvenanceDigest(originalPayload),
originalPayload.adoptionMutationId,
);
target.exec(
`UPDATE "QingLong3TaskDefinitions"
SET current_revision = 2
WHERE project_id = 'project-1' AND task_id = 'legacy-cron:1'`,
);
const drifted = writePlan(t, legacy, target);
assert.equal(drifted.result.footer.outcome, 'manual_required');
assert.equal(
drifted.result.footer.automationAdoptionProvenanceState,
'drifted',
);
});
test('captures a target Secret collision without reading plaintext', (t) => {
@@ -310,6 +510,23 @@ test('keeps active Env and historical Configs manual without adoption authority'
assert.equal(withConfigs.result.footer.unadaptedLegacyConfigCount, 1);
});
test('keeps pre-provenance Automation adoption records manual', (t) => {
const { legacy, target } = databases();
t.after(() => legacy.close());
t.after(() => target.close());
legacy.exec(
`INSERT INTO "Envs" VALUES
(1, 'TOKEN', 'private-value', 0, 1, 0, '2026-01-01')`,
);
insertAutomationAdoption(target, 1, false);
const planned = writePlan(t, legacy, target);
assert.equal(planned.result.footer.outcome, 'manual_required');
assert.equal(planned.result.footer.automationAdoptionProvenanceState, 'missing');
assert.equal(planned.result.footer.adoptionProvenanceTaskCount, 0);
assert.equal(planned.serialized.includes('private-value'), false);
});
test('fails closed before exceeding the plan byte budget', (t) => {
const { legacy, target } = databases();
t.after(() => legacy.close());
+5
View File
@@ -75,6 +75,11 @@
"require": "./dist/adoption/legacyAdoptionDatabase.js",
"default": "./dist/adoption/legacyAdoptionDatabase.js"
},
"./adoption-provenance": {
"types": "./dist/adoption/legacyAdoptionProvenance.d.ts",
"require": "./dist/adoption/legacyAdoptionProvenance.js",
"default": "./dist/adoption/legacyAdoptionProvenance.js"
},
"./data-directory-adoption": {
"types": "./dist/adoption/data-directory/dataDirectoryAdoptionDatabase.d.ts",
"require": "./dist/adoption/data-directory/dataDirectoryAdoptionDatabase.js",
@@ -44,6 +44,11 @@ import {
type LocalSqliteReadinessEvidence,
} from '../readiness/readiness';
import { LocalSqliteSecurityAuthorityStore } from '../security/securityAuthorityStore';
import {
LegacyAdoptionPublicationDigest,
legacyAdoptionTaskProvenanceDigest,
legacyAdoptionTriggerProvenanceDigest,
} from './legacyAdoptionProvenance';
export const MAX_LOCAL_LEGACY_ADOPTION_TASKS = 100_000;
export const MAX_LOCAL_LEGACY_ADOPTION_TRIGGERS = 500_000;
@@ -202,6 +207,86 @@ function deterministicMutationId(
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function assertStoredProvenance(
client: DatabaseSync,
adoption: Readonly<LocalLegacyAdoptionRecord>,
): void {
const rows = client
.prepare(
`SELECT task."row_ordinal" AS "rowOrdinal",
task."source_digest" AS "sourceDigest",
task."task_content_digest" AS "taskContentDigest",
task."trigger_count" AS "triggerCount",
task."item_digest" AS "taskItemDigest",
trigger."trigger_ordinal" AS "triggerOrdinal",
trigger."trigger_content_digest" AS "triggerContentDigest",
trigger."item_digest" AS "triggerItemDigest"
FROM "QingLong3LegacyAdoptionTasks" AS task
LEFT JOIN "QingLong3LegacyAdoptionTriggers" AS trigger
ON trigger."adoption_mutation_id" = task."adoption_mutation_id"
AND trigger."row_ordinal" = task."row_ordinal"
WHERE task."adoption_mutation_id" = ?
ORDER BY task."row_ordinal" ASC, trigger."trigger_ordinal" ASC`,
)
.iterate(adoption.mutationId) as Iterable<Row>;
const publication = new LegacyAdoptionPublicationDigest(
adoption.mutationId,
);
let taskCount = 0;
let triggerCount = 0;
let currentRowOrdinal = 0;
let expectedTriggerCount = 0;
let currentTriggerCount = 0;
for (const row of rows) {
const rowOrdinal = integer(row, 'rowOrdinal');
if (rowOrdinal !== currentRowOrdinal) {
if (
currentRowOrdinal !== 0 &&
currentTriggerCount !== expectedTriggerCount
) {
throw new LocalLegacyAdoptionConflictError();
}
if (rowOrdinal <= currentRowOrdinal) {
throw new LocalLegacyAdoptionConflictError();
}
currentRowOrdinal = rowOrdinal;
taskCount += 1;
currentTriggerCount = 0;
expectedTriggerCount = integer(row, 'triggerCount');
publication.appendTask({
rowOrdinal,
sourceDigest: text(row, 'sourceDigest'),
taskContentDigest: text(row, 'taskContentDigest'),
itemDigest: text(row, 'taskItemDigest'),
});
}
if (row.triggerOrdinal === null) {
if (expectedTriggerCount !== 0) {
throw new LocalLegacyAdoptionConflictError();
}
continue;
}
const triggerOrdinal = integer(row, 'triggerOrdinal');
currentTriggerCount += 1;
triggerCount += 1;
if (triggerOrdinal !== currentTriggerCount) {
throw new LocalLegacyAdoptionConflictError();
}
publication.appendTrigger({
triggerContentDigest: text(row, 'triggerContentDigest'),
itemDigest: text(row, 'triggerItemDigest'),
});
}
if (
(currentRowOrdinal !== 0 && currentTriggerCount !== expectedTriggerCount) ||
taskCount !== adoption.adoptedTaskCount ||
triggerCount !== adoption.adoptedTriggerCount ||
publication.digest() !== adoption.publicationDigest
) {
throw new LocalLegacyAdoptionConflictError();
}
}
function exactReplay(
existing: LocalLegacyAdoptionRecord,
command: PublishLocalLegacyAdoptionCommand,
@@ -344,6 +429,7 @@ export class LocalSqliteLegacyAdoptionPublisher {
if (!exactReplay(existing, input)) {
throw new LocalLegacyAdoptionConflictError();
}
assertStoredProvenance(client, existing);
await input.confirmExternalAuthority();
client.exec('COMMIT');
began = false;
@@ -389,9 +475,9 @@ export class LocalSqliteLegacyAdoptionPublisher {
const taskRegistry = createBuiltInTaskSpecSemanticRegistry();
const triggerRegistry = createBuiltInTriggerSpecSemanticRegistry();
const dispatch = new LocalSqliteDispatchDefinitionStore(client);
const publication = createHash('sha256')
.update('qinglong3.legacy-adoption-publication.v1\0')
.update(input.mutationId);
const publication = new LegacyAdoptionPublicationDigest(
input.mutationId,
);
let adoptedTaskCount = 0;
let adoptedTriggerCount = 0;
let previousRowOrdinal = 0;
@@ -488,13 +574,46 @@ export class LocalSqliteLegacyAdoptionPublisher {
compileLocalCommandTaskDefinition(task, taskRegistry),
);
}
publication
.update('\0task\0')
.update(String(candidate.rowOrdinal))
.update('\0')
.update(candidate.sourceDigest)
.update('\0')
.update(task.contentDigest);
const taskProvenance = Object.freeze({
adoptionMutationId: input.mutationId,
rowOrdinal: candidate.rowOrdinal,
projectId: task.projectId,
sourceDigest: candidate.sourceDigest,
taskId: task.taskId,
taskRevision: task.revision,
taskMutationId: task.mutationId,
taskContentDigest: task.contentDigest,
triggerCount: candidate.triggers.length,
});
const taskItemDigest =
legacyAdoptionTaskProvenanceDigest(taskProvenance);
client
.prepare(
`INSERT INTO "QingLong3LegacyAdoptionTasks" (
"adoption_mutation_id", "row_ordinal", "project_id",
"source_digest", "task_id", "task_revision",
"task_mutation_id", "task_content_digest",
"trigger_count", "item_digest"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
taskProvenance.adoptionMutationId,
taskProvenance.rowOrdinal,
taskProvenance.projectId,
taskProvenance.sourceDigest,
taskProvenance.taskId,
taskProvenance.taskRevision,
taskProvenance.taskMutationId,
taskProvenance.taskContentDigest,
taskProvenance.triggerCount,
taskItemDigest,
);
publication.appendTask({
rowOrdinal: candidate.rowOrdinal,
sourceDigest: candidate.sourceDigest,
taskContentDigest: task.contentDigest,
itemDigest: taskItemDigest,
});
for (const [
triggerIndex,
@@ -605,13 +724,54 @@ export class LocalSqliteLegacyAdoptionPublisher {
trigger.revision,
trigger.updatedAtMs,
);
publication.update('\0trigger\0').update(trigger.contentDigest);
const triggerOrdinal = triggerIndex + 1;
const triggerProvenance = Object.freeze({
adoptionMutationId: input.mutationId,
rowOrdinal: candidate.rowOrdinal,
triggerOrdinal,
projectId: trigger.projectId,
taskId: trigger.taskId,
taskRevision: trigger.taskRevision,
triggerId: trigger.triggerId,
triggerRevision: trigger.revision,
triggerMutationId: trigger.mutationId,
triggerContentDigest: trigger.contentDigest,
});
const triggerItemDigest =
legacyAdoptionTriggerProvenanceDigest(triggerProvenance);
client
.prepare(
`INSERT INTO "QingLong3LegacyAdoptionTriggers" (
"adoption_mutation_id", "row_ordinal",
"trigger_ordinal", "project_id", "task_id",
"task_revision", "trigger_id", "trigger_revision",
"trigger_mutation_id", "trigger_content_digest",
"item_digest"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
triggerProvenance.adoptionMutationId,
triggerProvenance.rowOrdinal,
triggerProvenance.triggerOrdinal,
triggerProvenance.projectId,
triggerProvenance.taskId,
triggerProvenance.taskRevision,
triggerProvenance.triggerId,
triggerProvenance.triggerRevision,
triggerProvenance.triggerMutationId,
triggerProvenance.triggerContentDigest,
triggerItemDigest,
);
publication.appendTrigger({
triggerContentDigest: trigger.contentDigest,
itemDigest: triggerItemDigest,
});
}
}
if (adoptedTaskCount + input.skippedCount !== input.rowCount) {
throw new LocalLegacyAdoptionConflictError();
}
const publicationDigest = publication.digest('hex');
const publicationDigest = publication.digest();
insertAudit(client, audit);
client
.prepare(
@@ -650,6 +810,7 @@ export class LocalSqliteLegacyAdoptionPublisher {
)
.get(input.mutationId) as Row,
);
assertStoredProvenance(client, stored);
await input.confirmExternalAuthority();
client.exec('COMMIT');
began = false;
@@ -0,0 +1,95 @@
import { createHash, type Hash } from 'node:crypto';
export interface LegacyAdoptionTaskProvenancePayload {
readonly adoptionMutationId: string;
readonly rowOrdinal: number;
readonly projectId: string;
readonly sourceDigest: string;
readonly taskId: string;
readonly taskRevision: number;
readonly taskMutationId: string;
readonly taskContentDigest: string;
readonly triggerCount: number;
}
export interface LegacyAdoptionTriggerProvenancePayload {
readonly adoptionMutationId: string;
readonly rowOrdinal: number;
readonly triggerOrdinal: number;
readonly projectId: string;
readonly taskId: string;
readonly taskRevision: number;
readonly triggerId: string;
readonly triggerRevision: number;
readonly triggerMutationId: string;
readonly triggerContentDigest: string;
}
function itemDigest(
domain: 'task' | 'trigger',
payload: object,
): string {
return createHash('sha256')
.update(`qinglong3.legacy-adoption-${domain}-provenance.v1\0`)
.update(JSON.stringify(payload))
.digest('hex');
}
export function legacyAdoptionTaskProvenanceDigest(
payload: Readonly<LegacyAdoptionTaskProvenancePayload>,
): string {
return itemDigest('task', payload);
}
export function legacyAdoptionTriggerProvenanceDigest(
payload: Readonly<LegacyAdoptionTriggerProvenancePayload>,
): string {
return itemDigest('trigger', payload);
}
export class LegacyAdoptionPublicationDigest {
readonly #hash: Hash;
#sealed = false;
constructor(mutationId: string) {
this.#hash = createHash('sha256')
.update('qinglong3.legacy-adoption-publication.v2\0')
.update(mutationId);
}
appendTask(input: {
readonly rowOrdinal: number;
readonly sourceDigest: string;
readonly taskContentDigest: string;
readonly itemDigest: string;
}): void {
if (this.#sealed) throw new TypeError('Publication digest is sealed');
this.#hash
.update('\0task\0')
.update(String(input.rowOrdinal))
.update('\0')
.update(input.sourceDigest)
.update('\0')
.update(input.taskContentDigest)
.update('\0')
.update(input.itemDigest);
}
appendTrigger(input: {
readonly triggerContentDigest: string;
readonly itemDigest: string;
}): void {
if (this.#sealed) throw new TypeError('Publication digest is sealed');
this.#hash
.update('\0trigger\0')
.update(input.triggerContentDigest)
.update('\0')
.update(input.itemDigest);
}
digest(): string {
if (this.#sealed) throw new TypeError('Publication digest is sealed');
this.#sealed = true;
return this.#hash.digest('hex');
}
}
@@ -110,6 +110,8 @@ import { local0097PluginPackageSecretBindingTransitionReceiptsMigration } from '
import { local0098CapabilityV49Migration } from '../migrations/0098-capability-v49';
import { local0099LegacyDataDirectoryAdoptionsMigration } from '../migrations/0099-legacy-data-directory-adoptions';
import { local0100CapabilityV50Migration } from '../migrations/0100-capability-v50';
import { local0101LegacyAdoptionProvenanceMigration } from '../migrations/0101-legacy-adoption-provenance';
import { local0102CapabilityV51Migration } from '../migrations/0102-capability-v51';
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
import {
LOCAL_SQLITE_MIGRATION_STREAM_ID,
@@ -232,6 +234,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
local0098CapabilityV49Migration,
local0099LegacyDataDirectoryAdoptionsMigration,
local0100CapabilityV50Migration,
local0101LegacyAdoptionProvenanceMigration,
local0102CapabilityV51Migration,
]),
});
@@ -512,5 +512,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
checksum:
'ea4ef39fe237d8db032da89c8f79dfda631b7201d1e5ac8c743b67e75aad5b07',
}),
Object.freeze({
id: '0101-legacy-adoption-provenance',
checksum:
'9c58cf7bc11d26307bfc2c7cc4a587a51f08f498d0b5e1c0c97d9357175d1ed9',
}),
Object.freeze({
id: '0102-capability-v51',
checksum:
'539f9d60b41b7cfebb203888804329241c178b1ee0105d14949750856260ac1f',
}),
]),
});
@@ -0,0 +1,145 @@
import { defineLocalSqliteMigration } from './sqlMigration';
export const local0101LegacyAdoptionProvenanceMigration =
defineLocalSqliteMigration({
id: '0101-legacy-adoption-provenance',
statements: [
`
CREATE UNIQUE INDEX "ql3_legacy_adoptions_mutation_project_uidx"
ON "QingLong3LegacyAdoptions" ("mutation_id", "project_id")
`,
`
CREATE TABLE "QingLong3LegacyAdoptionTasks" (
"adoption_mutation_id" TEXT NOT NULL,
"row_ordinal" INTEGER NOT NULL,
"project_id" TEXT NOT NULL,
"source_digest" TEXT NOT NULL,
"task_id" TEXT NOT NULL,
"task_revision" INTEGER NOT NULL,
"task_mutation_id" TEXT NOT NULL,
"task_content_digest" TEXT NOT NULL,
"trigger_count" INTEGER NOT NULL,
"item_digest" TEXT NOT NULL,
PRIMARY KEY ("adoption_mutation_id", "row_ordinal"),
CONSTRAINT ql3_legacy_adoption_task_parent_fk
FOREIGN KEY ("adoption_mutation_id", "project_id")
REFERENCES "QingLong3LegacyAdoptions" ("mutation_id", "project_id")
ON DELETE RESTRICT ON UPDATE RESTRICT
DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT ql3_legacy_adoption_task_revision_fk
FOREIGN KEY ("project_id", "task_id", "task_revision")
REFERENCES "QingLong3TaskDefinitionRevisions" (
"project_id", "task_id", "revision"
)
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_adoption_task_mutation_fk
FOREIGN KEY ("task_mutation_id")
REFERENCES "QingLong3TaskDefinitionRevisions" ("mutation_id")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_adoption_task_identity_check CHECK (
"row_ordinal" BETWEEN 1 AND 100000 AND
length("project_id") BETWEEN 1 AND 128 AND
length("task_id") BETWEEN 1 AND 128 AND
"task_revision" = 1 AND
length("task_mutation_id") = 36 AND
replace("task_mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*' AND
"trigger_count" BETWEEN 0 AND 500000
),
CONSTRAINT ql3_legacy_adoption_task_digest_check CHECK (
length("source_digest") = 64 AND
"source_digest" NOT GLOB '*[^0-9a-f]*' AND
length("task_content_digest") = 64 AND
"task_content_digest" NOT GLOB '*[^0-9a-f]*' AND
length("item_digest") = 64 AND
"item_digest" NOT GLOB '*[^0-9a-f]*'
)
)
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_tasks_project_task_uidx"
ON "QingLong3LegacyAdoptionTasks" ("project_id", "task_id")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_tasks_mutation_uidx"
ON "QingLong3LegacyAdoptionTasks" ("task_mutation_id")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_tasks_item_uidx"
ON "QingLong3LegacyAdoptionTasks" ("item_digest")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_tasks_identity_uidx"
ON "QingLong3LegacyAdoptionTasks" (
"adoption_mutation_id", "row_ordinal", "project_id", "task_id",
"task_revision"
)
`,
`
CREATE TABLE "QingLong3LegacyAdoptionTriggers" (
"adoption_mutation_id" TEXT NOT NULL,
"row_ordinal" INTEGER NOT NULL,
"trigger_ordinal" INTEGER NOT NULL,
"project_id" TEXT NOT NULL,
"task_id" TEXT NOT NULL,
"task_revision" INTEGER NOT NULL,
"trigger_id" TEXT NOT NULL,
"trigger_revision" INTEGER NOT NULL,
"trigger_mutation_id" TEXT NOT NULL,
"trigger_content_digest" TEXT NOT NULL,
"item_digest" TEXT NOT NULL,
PRIMARY KEY (
"adoption_mutation_id", "row_ordinal", "trigger_ordinal"
),
CONSTRAINT ql3_legacy_adoption_trigger_parent_fk
FOREIGN KEY (
"adoption_mutation_id", "row_ordinal", "project_id", "task_id",
"task_revision"
)
REFERENCES "QingLong3LegacyAdoptionTasks" (
"adoption_mutation_id", "row_ordinal", "project_id", "task_id",
"task_revision"
)
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_adoption_trigger_revision_fk
FOREIGN KEY ("project_id", "trigger_id", "trigger_revision")
REFERENCES "QingLong3TriggerRevisions" (
"project_id", "trigger_id", "revision"
)
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_adoption_trigger_mutation_fk
FOREIGN KEY ("trigger_mutation_id")
REFERENCES "QingLong3TriggerRevisions" ("mutation_id")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_adoption_trigger_identity_check CHECK (
"row_ordinal" BETWEEN 1 AND 100000 AND
"trigger_ordinal" BETWEEN 1 AND 500000 AND
length("project_id") BETWEEN 1 AND 128 AND
length("task_id") BETWEEN 1 AND 128 AND
"task_revision" = 1 AND
length("trigger_id") BETWEEN 1 AND 128 AND
"trigger_revision" = 1 AND
length("trigger_mutation_id") = 36 AND
replace("trigger_mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*'
),
CONSTRAINT ql3_legacy_adoption_trigger_digest_check CHECK (
length("trigger_content_digest") = 64 AND
"trigger_content_digest" NOT GLOB '*[^0-9a-f]*' AND
length("item_digest") = 64 AND
"item_digest" NOT GLOB '*[^0-9a-f]*'
)
)
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_triggers_project_trigger_uidx"
ON "QingLong3LegacyAdoptionTriggers" ("project_id", "trigger_id")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_triggers_mutation_uidx"
ON "QingLong3LegacyAdoptionTriggers" ("trigger_mutation_id")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_adoption_triggers_item_uidx"
ON "QingLong3LegacyAdoptionTriggers" ("item_digest")
`,
],
});
@@ -0,0 +1,14 @@
import { CAPABILITIES_V50 } from './0100-capability-v50';
import { defineLocalSqliteMigration } from './sqlMigration';
export const CAPABILITIES_V51 = CAPABILITIES_V50.replace(
'"legacy_adoption_ledger":1,',
'"legacy_adoption_ledger":1,"legacy_adoption_provenance":1,',
);
export const local0102CapabilityV51Migration = defineLocalSqliteMigration({
id: '0102-capability-v51',
statements: [
`UPDATE "QingLong3SchemaCapabilities" SET contract_version = 51, migration_id = '0101-legacy-adoption-provenance', capabilities = '${CAPABILITIES_V51}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 50 AND migration_id = '0099-legacy-data-directory-adoptions' AND capabilities = '${CAPABILITIES_V50}'`,
],
});
@@ -12,7 +12,7 @@ import {
} from '../run/stepRunSchemaContract';
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
export const LOCAL_SQLITE_CONTRACT_VERSION = 50;
export const LOCAL_SQLITE_CONTRACT_VERSION = 51;
const LEGACY_DATA_DIRECTORY_ADOPTION_TRIGGERS = Object.freeze([
Object.freeze({
@@ -1395,9 +1395,50 @@ const REQUIRED_SCHEMA = Object.freeze({
]),
indexes: Object.freeze([
'ql3_legacy_adoptions_decision_uidx',
'ql3_legacy_adoptions_mutation_project_uidx',
'ql3_legacy_adoptions_project_time_idx',
]),
}),
QingLong3LegacyAdoptionTasks: Object.freeze({
columns: Object.freeze([
'adoption_mutation_id',
'row_ordinal',
'project_id',
'source_digest',
'task_id',
'task_revision',
'task_mutation_id',
'task_content_digest',
'trigger_count',
'item_digest',
]),
indexes: Object.freeze([
'ql3_legacy_adoption_tasks_project_task_uidx',
'ql3_legacy_adoption_tasks_mutation_uidx',
'ql3_legacy_adoption_tasks_item_uidx',
'ql3_legacy_adoption_tasks_identity_uidx',
]),
}),
QingLong3LegacyAdoptionTriggers: Object.freeze({
columns: Object.freeze([
'adoption_mutation_id',
'row_ordinal',
'trigger_ordinal',
'project_id',
'task_id',
'task_revision',
'trigger_id',
'trigger_revision',
'trigger_mutation_id',
'trigger_content_digest',
'item_digest',
]),
indexes: Object.freeze([
'ql3_legacy_adoption_triggers_project_trigger_uidx',
'ql3_legacy_adoption_triggers_mutation_uidx',
'ql3_legacy_adoption_triggers_item_uidx',
]),
}),
QingLong3LegacyDataDirectoryAdoptions: Object.freeze({
columns: Object.freeze([
'mutation_id',
@@ -2658,10 +2699,10 @@ export async function auditLocalSqliteReadiness(
!capability ||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
capability.migration_id !== '0099-legacy-data-directory-adoptions' ||
capability.migration_id !== '0101-legacy-adoption-provenance' ||
typeof capability.capabilities !== 'string' ||
capability.capabilities !==
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"legacy_data_directory_adoption":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"legacy_adoption_provenance":1,"legacy_data_directory_adoption":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
typeof capability.updated_at_ms !== 'number' ||
!Number.isSafeInteger(capability.updated_at_ms) ||
capability.updated_at_ms < 0
@@ -3320,6 +3320,10 @@ export const legacyAdoptions = sqliteTable(
),
check('ql3_legacy_adoptions_created_check', sql`${table.createdAtMs} >= 0`),
uniqueIndex('ql3_legacy_adoptions_decision_uidx').on(table.decisionId),
uniqueIndex('ql3_legacy_adoptions_mutation_project_uidx').on(
table.mutationId,
table.projectId,
),
index('ql3_legacy_adoptions_project_time_idx').on(
table.projectId,
sql`${table.createdAtMs} desc`,
@@ -3328,6 +3332,146 @@ export const legacyAdoptions = sqliteTable(
],
);
export const legacyAdoptionTasks = sqliteTable(
'QingLong3LegacyAdoptionTasks',
{
adoptionMutationId: text('adoption_mutation_id').notNull(),
rowOrdinal: integer('row_ordinal').notNull(),
projectId: text('project_id').notNull(),
sourceDigest: text('source_digest').notNull(),
taskId: text('task_id').notNull(),
taskRevision: integer('task_revision').notNull(),
taskMutationId: text('task_mutation_id').notNull(),
taskContentDigest: text('task_content_digest').notNull(),
triggerCount: integer('trigger_count').notNull(),
itemDigest: text('item_digest').notNull(),
},
(table) => [
primaryKey({ columns: [table.adoptionMutationId, table.rowOrdinal] }),
foreignKey({
columns: [table.adoptionMutationId, table.projectId],
foreignColumns: [legacyAdoptions.mutationId, legacyAdoptions.projectId],
})
.onDelete('restrict')
.onUpdate('restrict'),
foreignKey({
columns: [table.projectId, table.taskId, table.taskRevision],
foreignColumns: [
taskDefinitionRevisions.projectId,
taskDefinitionRevisions.taskId,
taskDefinitionRevisions.revision,
],
})
.onDelete('restrict')
.onUpdate('restrict'),
foreignKey({
columns: [table.taskMutationId],
foreignColumns: [taskDefinitionRevisions.mutationId],
})
.onDelete('restrict')
.onUpdate('restrict'),
check(
'ql3_legacy_adoption_task_identity_check',
sql`${table.rowOrdinal} between 1 and 100000 and length(${table.projectId}) between 1 and 128 and length(${table.taskId}) between 1 and 128 and ${table.taskRevision} = 1 and length(${table.taskMutationId}) = 36 and replace(${table.taskMutationId}, '-', '') not glob '*[^0-9a-f]*' and ${table.triggerCount} between 0 and 500000`,
),
check(
'ql3_legacy_adoption_task_digest_check',
sql`length(${table.sourceDigest}) = 64 and ${table.sourceDigest} not glob '*[^0-9a-f]*' and length(${table.taskContentDigest}) = 64 and ${table.taskContentDigest} not glob '*[^0-9a-f]*' and length(${table.itemDigest}) = 64 and ${table.itemDigest} not glob '*[^0-9a-f]*'`,
),
uniqueIndex('ql3_legacy_adoption_tasks_project_task_uidx').on(
table.projectId,
table.taskId,
),
uniqueIndex('ql3_legacy_adoption_tasks_mutation_uidx').on(
table.taskMutationId,
),
uniqueIndex('ql3_legacy_adoption_tasks_item_uidx').on(table.itemDigest),
uniqueIndex('ql3_legacy_adoption_tasks_identity_uidx').on(
table.adoptionMutationId,
table.rowOrdinal,
table.projectId,
table.taskId,
table.taskRevision,
),
],
);
export const legacyAdoptionTriggers = sqliteTable(
'QingLong3LegacyAdoptionTriggers',
{
adoptionMutationId: text('adoption_mutation_id').notNull(),
rowOrdinal: integer('row_ordinal').notNull(),
triggerOrdinal: integer('trigger_ordinal').notNull(),
projectId: text('project_id').notNull(),
taskId: text('task_id').notNull(),
taskRevision: integer('task_revision').notNull(),
triggerId: text('trigger_id').notNull(),
triggerRevision: integer('trigger_revision').notNull(),
triggerMutationId: text('trigger_mutation_id').notNull(),
triggerContentDigest: text('trigger_content_digest').notNull(),
itemDigest: text('item_digest').notNull(),
},
(table) => [
primaryKey({
columns: [
table.adoptionMutationId,
table.rowOrdinal,
table.triggerOrdinal,
],
}),
foreignKey({
columns: [
table.adoptionMutationId,
table.rowOrdinal,
table.projectId,
table.taskId,
table.taskRevision,
],
foreignColumns: [
legacyAdoptionTasks.adoptionMutationId,
legacyAdoptionTasks.rowOrdinal,
legacyAdoptionTasks.projectId,
legacyAdoptionTasks.taskId,
legacyAdoptionTasks.taskRevision,
],
})
.onDelete('restrict')
.onUpdate('restrict'),
foreignKey({
columns: [table.projectId, table.triggerId, table.triggerRevision],
foreignColumns: [
triggerRevisions.projectId,
triggerRevisions.triggerId,
triggerRevisions.revision,
],
})
.onDelete('restrict')
.onUpdate('restrict'),
foreignKey({
columns: [table.triggerMutationId],
foreignColumns: [triggerRevisions.mutationId],
})
.onDelete('restrict')
.onUpdate('restrict'),
check(
'ql3_legacy_adoption_trigger_identity_check',
sql`${table.rowOrdinal} between 1 and 100000 and ${table.triggerOrdinal} between 1 and 500000 and length(${table.projectId}) between 1 and 128 and length(${table.taskId}) between 1 and 128 and ${table.taskRevision} = 1 and length(${table.triggerId}) between 1 and 128 and ${table.triggerRevision} = 1 and length(${table.triggerMutationId}) = 36 and replace(${table.triggerMutationId}, '-', '') not glob '*[^0-9a-f]*'`,
),
check(
'ql3_legacy_adoption_trigger_digest_check',
sql`length(${table.triggerContentDigest}) = 64 and ${table.triggerContentDigest} not glob '*[^0-9a-f]*' and length(${table.itemDigest}) = 64 and ${table.itemDigest} not glob '*[^0-9a-f]*'`,
),
uniqueIndex('ql3_legacy_adoption_triggers_project_trigger_uidx').on(
table.projectId,
table.triggerId,
),
uniqueIndex('ql3_legacy_adoption_triggers_mutation_uidx').on(
table.triggerMutationId,
),
uniqueIndex('ql3_legacy_adoption_triggers_item_uidx').on(table.itemDigest),
],
);
export const legacyDataDirectoryAdoptions = sqliteTable(
'QingLong3LegacyDataDirectoryAdoptions',
{
@@ -5180,6 +5324,8 @@ export const localSqliteSchema = Object.freeze({
toolExecutionResultRekeyHeads,
toolResultKeyRetirementReceipts,
legacyAdoptions,
legacyAdoptionTasks,
legacyAdoptionTriggers,
legacyDataDirectoryAdoptions,
legacyDataDirectoryAdoptionSecrets,
localIdentitySubjects,
@@ -157,6 +157,45 @@ test('publishes tasks, execution facts, triggers, audit and ledger atomically',
.get().count,
2,
);
assert.deepEqual(
client
.prepare(
`SELECT task."row_ordinal" AS rowOrdinal,
task."task_id" AS taskId,
task."task_revision" AS taskRevision,
task."trigger_count" AS triggerCount,
trigger."trigger_ordinal" AS triggerOrdinal,
trigger."trigger_id" AS triggerId,
trigger."trigger_revision" AS triggerRevision
FROM "QingLong3LegacyAdoptionTasks" AS task
JOIN "QingLong3LegacyAdoptionTriggers" AS trigger
ON trigger."adoption_mutation_id" = task."adoption_mutation_id"
AND trigger."row_ordinal" = task."row_ordinal"
ORDER BY task."row_ordinal", trigger."trigger_ordinal"`,
)
.all()
.map((row) => ({ ...row })),
[
{
rowOrdinal: 1,
taskId: 'legacy-cron:1',
taskRevision: 1,
triggerCount: 1,
triggerOrdinal: 1,
triggerId: 'legacy-cron:1:cron:1',
triggerRevision: 1,
},
{
rowOrdinal: 2,
taskId: 'legacy-cron:2',
taskRevision: 1,
triggerCount: 1,
triggerOrdinal: 1,
triggerId: 'legacy-cron:2:cron:1',
triggerRevision: 1,
},
],
);
assert.deepEqual(
{
...client
@@ -191,6 +230,8 @@ test('rolls the complete publication back on a later candidate conflict', async
'QingLong3Triggers',
'QingLong3LocalTriggerSchedules',
'QingLong3LegacyAdoptions',
'QingLong3LegacyAdoptionTasks',
'QingLong3LegacyAdoptionTriggers',
'QingLong3SecurityAuditEvents',
]) {
assert.equal(
@@ -235,6 +276,8 @@ test('awaits the final external authority check and rolls back on rejection', as
'QingLong3Triggers',
'QingLong3LocalTriggerSchedules',
'QingLong3LegacyAdoptions',
'QingLong3LegacyAdoptionTasks',
'QingLong3LegacyAdoptionTriggers',
'QingLong3SecurityAuditEvents',
]) {
assert.equal(
@@ -264,3 +307,33 @@ test('rejects stale authorization fences before any adoption mutation', async (t
);
await adoption.close();
});
test('rejects exact replay when durable provenance has drifted', async (t) => {
const databasePath = await preparedDatabase(t);
const input = command([candidate(1)]);
const adoption = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
await adoption.publisher.publish(input);
await adoption.close();
const client = new DatabaseSync(databasePath);
client
.prepare(
`UPDATE "QingLong3LegacyAdoptionTasks"
SET "item_digest" = ? WHERE "adoption_mutation_id" = ?`,
)
.run('f'.repeat(64), MUTATION_ID);
client.close();
const reopened = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
await assert.rejects(
reopened.publisher.publish(input),
LocalLegacyAdoptionConflictError,
);
await reopened.close();
});
@@ -25,7 +25,7 @@ test('authentication projection opens the target read-only without journal or fi
try {
assert.equal(database.profile, 'edge');
assert.equal(database.readiness.contractName, 'local-control-core');
assert.equal(database.readiness.contractVersion, 50);
assert.equal(database.readiness.contractVersion, 51);
assert.equal(await database.apiCredentials.resolve('absent'), null);
assert.equal(await database.ownerPepper.resolveKey('absent'), null);
} finally {
@@ -150,9 +150,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
'0098-capability-v49',
'0099-legacy-data-directory-adoptions',
'0100-capability-v50',
'0101-legacy-adoption-provenance',
'0102-capability-v51',
]);
assert.equal(migrated.readiness.contractName, 'local-control-core');
assert.equal(migrated.readiness.contractVersion, 50);
assert.equal(migrated.readiness.contractVersion, 51);
assert.equal(migrated.readiness.journalMode, 'delete');
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
@@ -598,8 +600,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
.get(),
},
{
contract_version: 50,
migration_id: '0099-legacy-data-directory-adoptions',
contract_version: 51,
migration_id: '0101-legacy-adoption-provenance',
},
);
} finally {
@@ -786,19 +788,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
const options = { databasePath, profile: 'edge' };
await migrateLocalSqlitePath(options);
const client = new DatabaseSync(databasePath);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 83);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 85);
client.exec(
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
);
client.close();
assert.equal((await auditLocalSqlitePath(options)).tableCount, 83);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 85);
const unknownClient = new DatabaseSync(databasePath);
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
unknownClient.close();
assert.equal((await auditLocalSqlitePath(options)).tableCount, 84);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 86);
const triggerClient = new DatabaseSync(databasePath);
triggerClient.exec(`
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
},
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 50);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51);
});
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
},
{ status: 'running', version: 5, eventSequence: 5 },
);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 50);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51);
});
test('fails closed before writing when the exact installation is not active', async (t) => {
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
stepAttemptCount: 0,
},
);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 50);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51);
});
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
await migrateLocalSqlitePath(state);
const prepared = await createLocalSqliteRolloutBackup(state);
assert.equal(prepared.status, 'prepared');
assert.equal(prepared.contractVersion, 50);
assert.equal(prepared.writeContractVersion, 50);
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 50);
assert.equal(prepared.contractVersion, 51);
assert.equal(prepared.writeContractVersion, 51);
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 51);
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
assert.equal(prepared.bytes > 0, true);
assert.equal(prepared.pageCount > 0, true);