@entrorove/skill 0.1.23 → 0.1.25
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/AGREEMENT.md +6 -6
- package/CAPABILITY-MAP.md +6 -0
- package/CHANGELOG.md +10 -0
- package/SKILL.md +18 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +35 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +24 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-chain-bindings.mjs +93 -0
- package/local-chain-broker.mjs +61 -0
- package/local-chain-contracts.mjs +21 -0
- package/local-chain-controller.mjs +321 -0
- package/local-chain-human.mjs +48 -0
- package/local-chain-lock.mjs +54 -0
- package/local-chain-operations.mjs +7 -0
- package/local-chain-plan.mjs +232 -0
- package/local-chain-release-labels.mjs +12 -0
- package/local-chain-results.mjs +75 -0
- package/local-cli.mjs +65 -24
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +126 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-error.mjs +23 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +106 -0
- package/local-formula-engine.mjs +125 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +142 -0
- package/local-ledger.mjs +15 -9
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +103 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +89 -0
- package/local-mutation-files.mjs +270 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +41 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +164 -3
- package/release-state.json +164 -18
- package/release.mjs +242 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1910 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createPublicKey, sign, verify, randomUUID } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonical, sha256, fail, identifier, paths } from './local-merge-storage.mjs';
|
|
4
|
+
import { loadTaskHandoffSigner } from './local-task-handoff-observation.mjs';
|
|
5
|
+
|
|
6
|
+
export const MERGE_LEASE_FIELDS = ['schemaVersion', 'mergeId', 'taskId', 'flowId', 'stepId', 'ownerId', 'taskDigest', 'chainDigest',
|
|
7
|
+
'chainRevision', 'planId', 'planDigest', 'runnerId', 'runnerKeyId', 'repositoryId', 'repositoryDigest', 'scopeBindingDigest',
|
|
8
|
+
'scopeArtifactDigest', 'scopeContractDigest', 'scopeContractArtifactDigest', 'architectureContractDigest', 'architectureLedgerDigest', 'baselineRef', 'paths',
|
|
9
|
+
'scopePaths', 'mappings', 'mappingDigest', 'mappingProof',
|
|
10
|
+
'leaseId', 'generation', 'epoch', 'issuedAt', 'expiresAt', 'nonce', 'keyId', 'signature'];
|
|
11
|
+
|
|
12
|
+
export function pinnedKey(encoded) {
|
|
13
|
+
try {
|
|
14
|
+
const bytes = Buffer.from(encoded ?? '', 'base64'), key = createPublicKey({ key: bytes, type: 'spki', format: 'der' });
|
|
15
|
+
if (bytes.toString('base64') !== encoded || key.asymmetricKeyType !== 'ed25519') throw new Error();
|
|
16
|
+
return { key, keyId: sha256(bytes) };
|
|
17
|
+
} catch { fail('MERGE_AUTHORITY_KEY', 'Independent pinned Ed25519 authority is unavailable or invalid'); }
|
|
18
|
+
}
|
|
19
|
+
export function inspectSigned(value, schemaVersion, encoded, now = Date.now()) {
|
|
20
|
+
if (!value || value.schemaVersion !== schemaVersion || typeof value.signature !== 'string') fail('MERGE_AUTHORITY_SCHEMA', 'Signed merge authority schema is invalid');
|
|
21
|
+
const { signature, ...payload } = value, trusted = pinnedKey(encoded);
|
|
22
|
+
const bytes = Buffer.from(signature, 'base64url'), issued = Date.parse(value.issuedAt), expires = Date.parse(value.expiresAt);
|
|
23
|
+
identifier(value.nonce);
|
|
24
|
+
if (value.keyId !== trusted.keyId || bytes.length !== 64 || bytes.toString('base64url') !== signature
|
|
25
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || issued > now || expires <= now || expires <= issued || expires - issued > 300_000
|
|
26
|
+
|| !verify(null, Buffer.from(canonical(payload)), trusted.key, bytes)) fail('MERGE_AUTHORITY_INVALID', 'Merge authority signature, lifetime or independent identity is invalid');
|
|
27
|
+
return { payload, keyId: trusted.keyId, digest: sha256(canonical(value)) };
|
|
28
|
+
}
|
|
29
|
+
export async function signMergeReceipt(input, payload) {
|
|
30
|
+
const file = input.runnerSignerConfigPath;
|
|
31
|
+
if (typeof file !== 'string' || !path.isAbsolute(file)) fail('MERGE_RUNNER_SIGNER_REQUIRED', 'Merge requires an independently pinned configured runner signer');
|
|
32
|
+
const trusted = pinnedKey(process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY);
|
|
33
|
+
const signer = await loadTaskHandoffSigner({ root: input.repositoryRoot }, file, trusted.keyId);
|
|
34
|
+
const value = { ...payload, issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + signer.config.receiptTtlMs).toISOString(), nonce: randomUUID(), keyId: trusted.keyId };
|
|
35
|
+
return { ...value, signature: sign(null, Buffer.from(canonical(value)), signer.privateKey).toString('base64url') };
|
|
36
|
+
}
|
|
37
|
+
export function match(value, expected, fields, code = 'MERGE_AUTHORITY_BINDING') {
|
|
38
|
+
if (fields.some(field => canonical(value[field] ?? null) !== canonical(expected[field] ?? null))) fail(code, 'Merge authority changed its frozen task, refs, scope or artifact binding');
|
|
39
|
+
}
|
|
40
|
+
export function inspectMergeLease(lease, ctx, state, registration) {
|
|
41
|
+
inspectSigned(lease, 'entrorove.merge-lease/1.0', process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY);
|
|
42
|
+
if (Object.keys(lease).sort().join('|') !== [...MERGE_LEASE_FIELDS].sort().join('|')) fail('MERGE_LEASE_SCHEMA', 'Merge lease must preserve its exact signed protocol fields');
|
|
43
|
+
match(lease, registration, ['taskId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId']);
|
|
44
|
+
match(lease, { repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: ctx.stepId, epoch: state.epoch }, ['repositoryDigest', 'flowId', 'stepId', 'epoch']);
|
|
45
|
+
if (canonical(paths(lease.paths)) !== canonical(lease.paths) || !Number.isSafeInteger(lease.generation) || lease.generation < 1
|
|
46
|
+
|| !Number.isSafeInteger(lease.chainRevision) || lease.chainRevision < 1 || typeof lease.baselineRef !== 'string' || !lease.baselineRef.trim()) {
|
|
47
|
+
fail('MERGE_LEASE_BINDING', 'Merge lease paths, baseline, generation or chain revision is invalid');
|
|
48
|
+
}
|
|
49
|
+
for (const field of ['mergeId', 'leaseId', 'planId', 'runnerId', 'repositoryId']) identifier(lease[field]);
|
|
50
|
+
for (const field of ['planDigest', 'scopeBindingDigest', 'scopeArtifactDigest', 'scopeContractDigest', 'scopeContractArtifactDigest', 'architectureContractDigest', 'architectureLedgerDigest']) {
|
|
51
|
+
if (!/^[0-9a-f]{64}$/.test(lease[field] ?? '')) fail('MERGE_LEASE_BINDING', 'Merge lease requires original frozen authority digests');
|
|
52
|
+
}
|
|
53
|
+
const proof = lease.mappingProof;
|
|
54
|
+
if (!Array.isArray(lease.mappings) || !lease.mappings.length || !proof || proof.schemaVersion !== 'entrorove.merge-artifact-mapping/1.0'
|
|
55
|
+
|| lease.mappingDigest !== sha256(canonical(proof)) || !Array.isArray(proof.entries)
|
|
56
|
+
|| canonical(paths(lease.mappings.map(item => item.targetPath))) !== canonical(lease.mappings.map(item => item.targetPath))) fail('MERGE_MAPPING_BINDING', 'Merge mapping must retain its actual immutable plan-bound digest');
|
|
57
|
+
match(proof, lease, ['flowId', 'ownerId', 'planId', 'planDigest', 'taskDigest', 'chainDigest']);
|
|
58
|
+
if (Object.keys(proof).sort().join('|') !== 'blueprintBinding|chainDigest|entries|flowId|ownerId|planDigest|planId|schemaVersion|scopeArtifactDigest|taskDigest'
|
|
59
|
+
|| !proof.blueprintBinding || typeof proof.blueprintBinding !== 'object' || Array.isArray(proof.blueprintBinding)
|
|
60
|
+
|| !Object.keys(proof.blueprintBinding).length) fail('MERGE_MAPPING_BINDING', 'Mapping proof must retain its exact original native Blueprint shape');
|
|
61
|
+
const proofTargets = paths(proof.entries.map(entry => entry.targetPath)); paths(proof.entries.map(entry => entry.sourcePath));
|
|
62
|
+
if (canonical(proofTargets) !== canonical(proof.entries.map(entry => entry.targetPath))) fail('MERGE_MAPPING_BINDING', 'Original mapping proof must have unique sorted target entries');
|
|
63
|
+
for (const entry of proof.entries) {
|
|
64
|
+
if (Object.keys(entry).sort().join('|') !== 'producerNodeRef|producerStepId|sha256|sourcePath|stepId|targetPath'
|
|
65
|
+
|| !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '')) fail('MERGE_MAPPING_BINDING', 'Mapping entry must retain its exact actual native producer identity');
|
|
66
|
+
for (const field of ['producerNodeRef', 'producerStepId', 'stepId']) identifier(entry[field]);
|
|
67
|
+
}
|
|
68
|
+
if (proof.scopeArtifactDigest !== lease.scopeContractArtifactDigest || canonical(paths(lease.scopePaths)) !== canonical(lease.scopePaths)) fail('MERGE_MAPPING_BINDING', 'Native artifact mapping lost its original frozen task, chain or scope');
|
|
69
|
+
paths(lease.mappings.map(item => item.sourcePath));
|
|
70
|
+
for (const mapping of lease.mappings) {
|
|
71
|
+
if (Object.keys(mapping).sort().join('|') !== 'sourcePath|targetPath' || !lease.scopePaths.includes(mapping.sourcePath) || !lease.scopePaths.includes(mapping.targetPath)
|
|
72
|
+
|| !proof.entries.some(entry => entry.sourcePath === mapping.sourcePath && entry.targetPath === mapping.targetPath && /^[0-9a-f]{64}$/.test(entry.sha256))) {
|
|
73
|
+
fail('MERGE_MAPPING_SCOPE', 'Every actual source origin and target path must remain in the original frozen scope');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (canonical(lease.paths) !== canonical(lease.mappings.map(item => item.targetPath))) fail('MERGE_MAPPING_SCOPE', 'Merge lease paths must be exactly its authorized mapping targets');
|
|
77
|
+
return lease;
|
|
78
|
+
}
|
|
79
|
+
export function inspectMergeContract(contract, ctx, preparation, preflight, evidence) {
|
|
80
|
+
const checked = inspectSigned(contract, 'entrorove.merge-contract/1.0', process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY);
|
|
81
|
+
match(contract, preparation, ['repositoryDigest', 'flowId', 'stepId', 'baselineRef', 'sourceRef', 'targetRef', 'paths', 'scopePaths', 'sourceFiles', 'targetFiles', 'afterManifest', 'beforeFullManifest', 'mappings', 'mappingDigest', 'mappingProof', 'mergedArtifactSha256', 'snapshotDigest', 'architectureContractDigest']);
|
|
82
|
+
match(contract, { preparationDigest: sha256(canonical(preparation)), archguardPreflightDigest: sha256(canonical(preflight)),
|
|
83
|
+
evidenceDigests: evidence.map(item => sha256(canonical(item))).sort() }, ['preparationDigest', 'archguardPreflightDigest', 'evidenceDigests']);
|
|
84
|
+
match(contract, { repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: ctx.stepId }, ['repositoryDigest', 'flowId', 'stepId']);
|
|
85
|
+
for (const field of ['contractId', 'ownerId', 'leaseId']) identifier(contract[field]);
|
|
86
|
+
if (!Number.isSafeInteger(contract.generation) || contract.generation < 1 || !/^[0-9a-f]{64}$/.test(contract.taskDigest ?? '')
|
|
87
|
+
|| !/^[0-9a-f]{64}$/.test(contract.chainDigest ?? '')) fail('MERGE_AUTHORITY_BINDING', 'Merge contract requires the original owner, task, chain and live lease');
|
|
88
|
+
return checked;
|
|
89
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { array, evidenceArray, integer, ledgerIdentifier as id, nullable, object, paths,
|
|
2
|
+
record, relativePath, sha256, text } from './local-contract-types.mjs';
|
|
3
|
+
|
|
4
|
+
const exact = properties => object(properties, Object.keys(properties));
|
|
5
|
+
const bounded = (items, maximum = 512) => ({ ...array(items, 1), maxItems: maximum });
|
|
6
|
+
const reference = exact({ kind: { const: 'snapshot' }, artifactId: id, manifestDigest: sha256 });
|
|
7
|
+
const stored = exact({ path: relativePath, exists: { type: 'boolean' }, sha256, mode: nullable(integer(0, 0o777)) });
|
|
8
|
+
const source = exact({ path: relativePath, sourcePath: relativePath, sha256, mode: integer(0, 0o777) });
|
|
9
|
+
const native = exact({ path: relativePath, sha256 });
|
|
10
|
+
const mappings = bounded(exact({ sourcePath: relativePath, targetPath: relativePath }));
|
|
11
|
+
const mappingProof = exact({ schemaVersion: { const: 'entrorove.merge-artifact-mapping/1.0' }, flowId: id,
|
|
12
|
+
ownerId: id, taskDigest: sha256, chainDigest: sha256, planId: id, planDigest: sha256,
|
|
13
|
+
blueprintBinding: record, scopeArtifactDigest: sha256,
|
|
14
|
+
entries: bounded(exact({ sourcePath: relativePath, targetPath: relativePath, sha256, producerNodeRef: id, producerStepId: id, stepId: id })) });
|
|
15
|
+
const signature = { issuedAt: text, expiresAt: text, nonce: id, keyId: sha256, signature: { ...text, pattern: '^[A-Za-z0-9_-]{86}$' } };
|
|
16
|
+
const identity = { mergeId: id, taskId: id, flowId: id, stepId: id, ownerId: id, taskDigest: sha256, chainDigest: sha256,
|
|
17
|
+
leaseId: id, generation: integer(1), epoch: integer(0), repositoryDigest: { ...text, pattern: '^[0-9a-f]{32}$' } };
|
|
18
|
+
const lease = { ...identity, chainRevision: integer(1), planId: id, planDigest: sha256, runnerId: id, runnerKeyId: sha256,
|
|
19
|
+
repositoryId: id, scopeBindingDigest: sha256, scopeArtifactDigest: sha256, scopeContractDigest: sha256,
|
|
20
|
+
scopeContractArtifactDigest: sha256, architectureContractDigest: sha256, architectureLedgerDigest: sha256,
|
|
21
|
+
baselineRef: text, paths, scopePaths: paths, mappings, mappingProof, mappingDigest: sha256, ...signature };
|
|
22
|
+
export const MERGE_LEASE_SCHEMA = exact({ schemaVersion: { const: 'entrorove.merge-lease/1.0' }, ...lease });
|
|
23
|
+
const artifacts = { sourceRef: reference, targetRef: reference, sourceFiles: bounded(source), targetFiles: bounded(stored),
|
|
24
|
+
afterManifest: bounded(native), beforeFullManifest: bounded(stored), mergedArtifactSha256: sha256,
|
|
25
|
+
snapshotDigest: sha256, snapshotReceiptDigest: sha256, mergeLeaseDigest: sha256 };
|
|
26
|
+
export const MERGE_PREPARATION_SCHEMA = exact({ schemaVersion: { const: 'entrorove.merge-preparation-receipt/1.0' },
|
|
27
|
+
...identity, baselineRef: text, paths, scopePaths: paths, mappings, mappingProof, mappingDigest: sha256,
|
|
28
|
+
architectureContractDigest: sha256, architectureLedgerDigest: sha256, ...artifacts, ...signature });
|
|
29
|
+
export const MERGE_CONTRACT_SCHEMA = exact({ schemaVersion: { const: 'entrorove.merge-contract/1.0' }, ...lease,
|
|
30
|
+
...artifacts, contractId: id, preparationDigest: sha256, archguardPreflightDigest: sha256,
|
|
31
|
+
evidenceDigests: bounded(sha256, 128), evidenceBindings: bounded(exact({ evidenceDigest: sha256, subjectDigest: sha256,
|
|
32
|
+
flowId: id, stepId: id, snapshotDigest: sha256, contractDigest: sha256, artifactSha256: sha256 }), 128) });
|
|
33
|
+
export const MERGE_ROLLBACK_AUTHORIZATION_SCHEMA = exact({ schemaVersion: { const: 'entrorove.merge-rollback-authorization/1.0' }, ...lease,
|
|
34
|
+
transactionId: id, originalStepId: id, originalLeaseId: id, originalGeneration: integer(1), originalContractDigest: sha256,
|
|
35
|
+
originalResultDigest: sha256, expectedJournalDigest: sha256, headReceiptDigest: sha256, contractRawDigest: sha256,
|
|
36
|
+
nativeLedgerHeadDigest: sha256, sourceRef: reference, targetRef: reference, beforeFiles: bounded(stored), restoreFiles: bounded(stored) });
|
|
37
|
+
const base = { repositoryRoot: text, home: text, flowId: id, stepId: id, taskId: id };
|
|
38
|
+
const actual = { ...base, runnerSignerConfigPath: text, mergeLease: MERGE_LEASE_SCHEMA, preparation: MERGE_PREPARATION_SCHEMA };
|
|
39
|
+
const freeze = object({ ...base, sourceRoot: text, snapshotReceipt: record, mergeLease: MERGE_LEASE_SCHEMA,
|
|
40
|
+
runnerSignerConfigPath: text }, ['repositoryRoot', 'flowId', 'stepId', 'snapshotReceipt', 'mergeLease', 'runnerSignerConfigPath']);
|
|
41
|
+
const preflight = object(actual, ['repositoryRoot', 'flowId', 'stepId', 'taskId', 'mergeLease', 'preparation']);
|
|
42
|
+
const verified = object({ ...actual, contract: MERGE_CONTRACT_SCHEMA, sourceRef: reference, targetRef: reference,
|
|
43
|
+
preflight: record, evidence: evidenceArray, gatePassPath: text, executionAttemptNonce: id,
|
|
44
|
+
generation: integer(1), leaseId: id, workerId: id,
|
|
45
|
+
changes: bounded(object({ targetPath: relativePath, gatePassPath: text, content: {}, expectedSha256: nullable(sha256) }, ['targetPath'])) },
|
|
46
|
+
['repositoryRoot', 'flowId', 'stepId', 'taskId', 'runnerSignerConfigPath', 'mergeLease', 'preparation', 'contract', 'preflight', 'evidence', 'gatePassPath', 'changes']);
|
|
47
|
+
const rollback = { oneOf: [
|
|
48
|
+
object({ ...base, transactionId: id, rollbackAuthorization: MERGE_ROLLBACK_AUTHORIZATION_SCHEMA, gatePassPath: text, runnerSignerConfigPath: text,
|
|
49
|
+
executionAttemptNonce: id, generation: integer(1), leaseId: id },
|
|
50
|
+
['repositoryRoot', 'flowId', 'stepId', 'transactionId', 'rollbackAuthorization', 'gatePassPath', 'runnerSignerConfigPath']),
|
|
51
|
+
object({ ...base, snapshotId: id, gatePassPath: text, runnerSignerConfigPath: text, executionAttemptNonce: id,
|
|
52
|
+
generation: integer(1), leaseId: id }, ['repositoryRoot', 'flowId', 'snapshotId', 'gatePassPath', 'runnerSignerConfigPath']),
|
|
53
|
+
] };
|
|
54
|
+
export const MERGE_LOCAL_OPERATION_SCHEMAS = Object.freeze({ 'merge-freeze': freeze, 'merge-preflight': preflight,
|
|
55
|
+
'merge-verified': verified, rollback, 'merge-journal-head': object({ ...base, transactionId: id, runnerSignerConfigPath: text },
|
|
56
|
+
['repositoryRoot', 'flowId', 'stepId', 'transactionId', 'runnerSignerConfigPath']) });
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { verifyTestEvidence } from './local-evidence-verification.mjs';
|
|
3
|
+
import { inspectMergePreparation, inspectMergeArchguardPreflight, mergePreflightBinding } from './local-merge-archguard-verification.mjs';
|
|
4
|
+
import { canonical, sha256, context, withMergeFence, fail, read } from './local-merge-storage.mjs';
|
|
5
|
+
import { inspectMergeContract, inspectMergeLease, inspectSigned, match, signMergeReceipt } from './local-merge-authority.mjs';
|
|
6
|
+
import { readFrozenMergeArtifact } from './local-merge-artifacts.mjs';
|
|
7
|
+
import { applyFileBatch, assertBatchImage, metadata } from './local-merge-transaction.mjs';
|
|
8
|
+
import { readMergeJournal, saveMergeJournal, appendMergeEvent, reserveMergeNonces, mergeNonceKeys, verifyPersistentMergeSignature,
|
|
9
|
+
assertNoMergeFailure, recordMergeFailure } from './local-merge-journal.mjs';
|
|
10
|
+
import { requireMergeMutationBridge, requireMergeOwnershipBridge, requireMergeArchguardBridge, verifyMergeMutationPass, verifyMergeMutationReceipt } from './local-merge-mutation.mjs';
|
|
11
|
+
import { inspectStoredMergeSuccess } from './local-merge-head.mjs';
|
|
12
|
+
export { rollbackMerge } from './local-merge-rollback.mjs';
|
|
13
|
+
|
|
14
|
+
const SCHEMA = 'entrorove.local-execution/1.0';
|
|
15
|
+
function reject(error) { return { schemaVersion: SCHEMA, status: 'blocked', merged: false, code: error.code ?? 'MERGE_BLOCKED', reason: error.message }; }
|
|
16
|
+
function requireVerified(value, label) { if (!value.verified) fail('MERGE_UNVERIFIED', `${label}: ${value.reason ?? 'independent authority is required'}`); }
|
|
17
|
+
function validateEvidence(input, preparation, contract) {
|
|
18
|
+
if (!Array.isArray(input.evidence) || !input.evidence.length || input.evidence.length > 128) fail('MERGE_EVIDENCE_REQUIRED', 'Merge requires complete independently trusted execution evidence');
|
|
19
|
+
if (!Array.isArray(contract.evidenceBindings) || contract.evidenceBindings.length !== input.evidence.length) fail('MERGE_EVIDENCE_BINDING', 'Frozen contract must bind every original verified business receipt');
|
|
20
|
+
const covered = new Set();
|
|
21
|
+
const checks = input.evidence.map(evidence => {
|
|
22
|
+
const binding = contract.evidenceBindings.find(item => item.evidenceDigest === sha256(canonical(evidence)));
|
|
23
|
+
if (!binding || Object.keys(binding).sort().join('|') !== 'artifactSha256|contractDigest|evidenceDigest|flowId|snapshotDigest|stepId|subjectDigest') fail('MERGE_EVIDENCE_BINDING', 'Evidence differs from its original independently frozen business binding');
|
|
24
|
+
const checked = verifyTestEvidence(evidence, { publicKey: process.env.ENTROROVE_VALIDATOR_RECEIPT_PUBLIC_KEY, binding, requireBinding: true });
|
|
25
|
+
if (!checked.verified || !checked.successful) fail('MERGE_EVIDENCE_UNVERIFIED', 'Every merge receipt must independently prove successful actual execution');
|
|
26
|
+
for (const file of preparation.sourceFiles) {
|
|
27
|
+
const entry = preparation.mappingProof.entries.find(item => item.sourcePath === file.sourcePath && item.targetPath === file.path && item.sha256 === file.sha256);
|
|
28
|
+
if (entry?.stepId === binding.stepId && evidence.subject.files.some(actual => actual.path === file.sourcePath && actual.sha256 === file.sha256)) covered.add(file.path);
|
|
29
|
+
}
|
|
30
|
+
return checked;
|
|
31
|
+
});
|
|
32
|
+
if (new Set(input.evidence.map(item => item.evidenceId)).size !== checks.length) fail('MERGE_EVIDENCE_DUPLICATE', 'Merge evidence identities must be unique');
|
|
33
|
+
if (covered.size !== preparation.sourceFiles.length) fail('MERGE_EVIDENCE_COVERAGE', 'Original trusted business evidence does not cover every native-mapped actual source output');
|
|
34
|
+
return checks;
|
|
35
|
+
}
|
|
36
|
+
async function validateAuthority(ctx, input, state, registration, verifyTargets = true) {
|
|
37
|
+
inspectMergeLease(input.mergeLease, ctx, state, registration);
|
|
38
|
+
const prepared = inspectMergePreparation(input.preparation, {}, { publicKey: process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY });
|
|
39
|
+
requireVerified(prepared, 'Frozen actual preparation');
|
|
40
|
+
for (const field of ['sourceRef', 'targetRef']) {
|
|
41
|
+
if (input[field] !== undefined && canonical(input[field]) !== canonical(input.preparation[field])) fail('MERGE_REF_BINDING', 'Explicit merge reference differs from its actual frozen preparation');
|
|
42
|
+
}
|
|
43
|
+
match(input.preparation, input.mergeLease, ['mergeId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch', 'mappings', 'mappingDigest']);
|
|
44
|
+
const preflight = inspectMergeArchguardPreflight(input.preflight, mergePreflightBinding(input.preparation),
|
|
45
|
+
{ preparation: input.preparation, mergeLease: input.mergeLease });
|
|
46
|
+
requireVerified(preflight, 'Independent actual Archguard preflight');
|
|
47
|
+
const checked = inspectMergeContract(input.contract, ctx, input.preparation, input.preflight, input.evidence);
|
|
48
|
+
validateEvidence(input, input.preparation, input.contract);
|
|
49
|
+
match(input.contract, input.mergeLease, ['mergeId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch', 'mappings', 'mappingDigest', 'mappingProof', 'scopePaths']);
|
|
50
|
+
const source = await readFrozenMergeArtifact(ctx.input, input.preparation.sourceRef), target = await readFrozenMergeArtifact(ctx.input, input.preparation.targetRef);
|
|
51
|
+
if (source.manifest.role !== 'source' || target.manifest.role !== 'target'
|
|
52
|
+
|| canonical(source.manifest.files.map(({ exists, ...file }) => file)) !== canonical(input.preparation.sourceFiles)
|
|
53
|
+
|| canonical(target.manifest.files) !== canonical(input.preparation.beforeFullManifest)
|
|
54
|
+
|| canonical(target.manifest.files.filter(file => input.mergeLease.paths.includes(file.path))) !== canonical(input.preparation.targetFiles)) fail('MERGE_ARTIFACT_BINDING', 'Frozen merge source or target differs from its authority');
|
|
55
|
+
const changes = input.changes;
|
|
56
|
+
if (!Array.isArray(changes) || !changes.length || canonical(changes.map(change => change.targetPath).sort()) !== canonical(input.preparation.paths)) fail('MERGE_CHANGES_REQUIRED', 'Merge changes must exactly cover its nonempty actual frozen differences');
|
|
57
|
+
for (const change of changes) {
|
|
58
|
+
const file = source.files.find(item => item.path === change.targetPath);
|
|
59
|
+
if (change.gatePassPath !== undefined && change.gatePassPath !== input.gatePassPath) fail('MERGE_PASS_BINDING', 'Explicit path gate cannot replace its original atomic batch gate');
|
|
60
|
+
const baseline = target.files.find(item => item.path === change.targetPath);
|
|
61
|
+
if (change.expectedSha256 !== undefined && change.expectedSha256 !== (baseline.exists ? baseline.sha256 : null)) {
|
|
62
|
+
fail('MERGE_TARGET_CAS', 'Caller baseline must retain the actual frozen target, including its original absence');
|
|
63
|
+
}
|
|
64
|
+
if (change.content !== undefined && (!Buffer.from(typeof change.content === 'string' ? change.content : JSON.stringify(change.content)).equals(file.bytes))) fail('MERGE_CONTENT_BINDING', 'Caller content cannot replace frozen actual source bytes');
|
|
65
|
+
}
|
|
66
|
+
if (verifyTargets) await assertBatchImage(ctx.input, target.files);
|
|
67
|
+
const mutation = verifyTargets ? await verifyMergeMutationPass(ctx, input, input.preparation.paths, target.files) : null;
|
|
68
|
+
const afterWide = target.files.map(file => source.files.find(item => item.path === file.path) ?? file);
|
|
69
|
+
return { checked, source, target, afterWide, mutation };
|
|
70
|
+
}
|
|
71
|
+
async function commit(ctx, input, state, registration, images, assertCurrent) {
|
|
72
|
+
const bridge = await requireMergeMutationBridge(), ownershipGuard = await requireMergeOwnershipBridge();
|
|
73
|
+
const transactionId = input.contract.mergeId, contractDigest = sha256(canonical(input.contract));
|
|
74
|
+
let token = null, startedAt;
|
|
75
|
+
const binding = { ...resultBinding(input), transactionId, contractDigest, ...images.mutation };
|
|
76
|
+
const writes = input.preparation.sourceFiles.filter(file => input.preparation.paths.includes(file.path))
|
|
77
|
+
.map(file => ({ path: file.path, status: 'written', contentDigest: file.sha256 }));
|
|
78
|
+
let journal = { transactionId, operation: 'merge', status: 'pending', contractDigest, contract: input.contract,
|
|
79
|
+
preparation: input.preparation, mergeLease: input.mergeLease, before: images.target.files.map(metadata), after: images.afterWide.map(metadata),
|
|
80
|
+
touched: [], createdParents: [], receipt: null, mutationPass: images.mutation.pass,
|
|
81
|
+
nonceKeys: mergeNonceKeys(input.contract, input.preparation, input.preflight, input.evidence) };
|
|
82
|
+
const outcome = await ownershipGuard({ ...ctx.input, paths: input.preparation.paths }, images.mutation.pass, () => applyFileBatch(ctx.input, images.target.files, images.afterWide, {
|
|
83
|
+
prepare: async () => { startedAt = new Date().toISOString(); journal.startedAt = startedAt; await saveMergeJournal(ctx, journal);
|
|
84
|
+
await reserveMergeNonces(ctx, transactionId, journal.nonceKeys, contractDigest);
|
|
85
|
+
token = await bridge.beginMutationBatch14({ ...ctx.input, signerConfigPath: input.runnerSignerConfigPath }, {
|
|
86
|
+
mutationPass: images.mutation.pass, contract: input.contract, contractDigest, transactionId,
|
|
87
|
+
beforeFiles: journal.before.filter(file => input.preparation.paths.includes(file.path)),
|
|
88
|
+
afterFiles: journal.after.filter(file => input.preparation.paths.includes(file.path)) }); },
|
|
89
|
+
progress: async progress => { journal = { ...journal, ...progress }; await saveMergeJournal(ctx, journal); },
|
|
90
|
+
finish: async () => {
|
|
91
|
+
await validateAuthority(ctx, input, state, registration, false);
|
|
92
|
+
await assertCurrent();
|
|
93
|
+
const mutationBatchReceipt = verifyMergeMutationReceipt(await bridge.finishMutationBatch14(token), binding, writes, true,
|
|
94
|
+
{ files: journal.after.filter(file => input.preparation.paths.includes(file.path)) });
|
|
95
|
+
await appendMergeEvent(ctx, transactionId, 'files-applied-reported', { contractDigest });
|
|
96
|
+
const receipt = await signMergeReceipt(ctx.input, { schemaVersion: 'entrorove.merge-result-receipt/1.0', operation: 'merge',
|
|
97
|
+
status: 'verified', transactionId, contractDigest, ...resultBinding(input), beforeFiles: journal.before, afterFiles: journal.after,
|
|
98
|
+
...mutationReceiptFields(images.mutation), mutationBatchReceipt, startedAt,
|
|
99
|
+
rollbackOutcome: 'retained', recoveryRequired: false });
|
|
100
|
+
assertMergeDeadline(input, state, images.mutation, mutationBatchReceipt, receipt);
|
|
101
|
+
journal = await saveMergeJournal(ctx, { ...journal, status: 'verified', receipt });
|
|
102
|
+
await assertCurrent();
|
|
103
|
+
assertMergeDeadline(input, state, images.mutation, mutationBatchReceipt, receipt); return receipt;
|
|
104
|
+
},
|
|
105
|
+
failed: async failure => {
|
|
106
|
+
await recordMergeFailure(ctx, transactionId, contractDigest, failure); let receipt = null, proofFailure = null;
|
|
107
|
+
try {
|
|
108
|
+
if (!token) fail('MERGE_CONSUMPTION_UNRESOLVED', 'Begin did not return an original mutation token; consumption cannot be fabricated');
|
|
109
|
+
const afterFiles = await Promise.all(journal.before.map(async file => metadata({ path: file.path, ...await read(path.join(ctx.root, file.path), ctx.input, file.path, true) })));
|
|
110
|
+
const current = afterFiles.filter(file => input.preparation.paths.includes(file.path));
|
|
111
|
+
const mutationBatchReceipt = verifyMergeMutationReceipt(await bridge.failMutationBatch14(token, {
|
|
112
|
+
code: /^[A-Z][A-Z0-9_]{0,127}$/.test(failure.code) ? failure.code : 'MERGE_BATCH_FAILED', rollbackOutcome: failure.rollbackOutcome }), binding,
|
|
113
|
+
current.map(file => ({ path: file.path, status: 'failed', contentDigest: file.sha256 })), false, { files: current });
|
|
114
|
+
receipt = await signMergeReceipt(ctx.input, { schemaVersion: 'entrorove.merge-result-receipt/1.0', operation: 'merge', status: 'failed',
|
|
115
|
+
transactionId, contractDigest, ...resultBinding(input), beforeFiles: journal.before, afterFiles,
|
|
116
|
+
...mutationReceiptFields(images.mutation), mutationBatchReceipt, startedAt, rollbackOutcome: failure.rollbackOutcome, recoveryRequired: failure.recoveryRequired });
|
|
117
|
+
} catch (error) { proofFailure = { code: error.code ?? 'MERGE_FAILURE_PROOF', reason: error.message }; }
|
|
118
|
+
journal = await saveMergeJournal(ctx, { ...journal, status: 'failed', failure, receipt, proofFailure });
|
|
119
|
+
await appendMergeEvent(ctx, transactionId, 'failed', { contractDigest, failure, proofFailure });
|
|
120
|
+
if (proofFailure) throw Object.assign(new Error(proofFailure.reason), { code: proofFailure.code });
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
return outcome.applied ? { schemaVersion: SCHEMA, status: 'verified', merged: true, transactionId, changes: input.preparation.paths.length, receipt: outcome.result }
|
|
124
|
+
: { schemaVersion: SCHEMA, status: 'failed', merged: false, transactionId, receipt: journal.receipt, ...outcome };
|
|
125
|
+
}
|
|
126
|
+
function mutationReceiptFields(value) {
|
|
127
|
+
return Object.fromEntries(['mutationPassDigest', 'mutationSnapshotDigest', 'executionAuthorityDigest', 'executionAttemptNonce'].map(field => [field, value[field]]));
|
|
128
|
+
}
|
|
129
|
+
function assertMergeDeadline(input, state, mutation, proof, receipt) {
|
|
130
|
+
const attempt = state.executionAttempts.find(value => value.authority.leaseId === mutation.pass.leaseId
|
|
131
|
+
&& value.authority.generation === mutation.pass.generation);
|
|
132
|
+
const authorities = [input.contract, input.mergeLease, input.preparation, input.preflight, mutation.pass, proof, receipt,
|
|
133
|
+
attempt?.authority, ...input.evidence.map(value => value.receipt)];
|
|
134
|
+
if (authorities.some(value => !value || !Number.isFinite(Date.parse(value.expiresAt)) || Date.parse(value.expiresAt) <= Date.now())) {
|
|
135
|
+
fail('MERGE_AUTHORITY_EXPIRED', 'Whole merge must finish inside every original live lease, execution, pass, preparation, preflight and evidence lifetime');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function resultBinding(input) {
|
|
139
|
+
const value = input.contract;
|
|
140
|
+
return Object.fromEntries(['mergeId', 'taskId', 'flowId', 'stepId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch',
|
|
141
|
+
'repositoryDigest', 'sourceRef', 'targetRef', 'paths', 'mergedArtifactSha256', 'mappingDigest', 'afterManifest'].map(field => [field, value[field]]));
|
|
142
|
+
}
|
|
143
|
+
export async function mergeVerified(input) {
|
|
144
|
+
try {
|
|
145
|
+
const ctx = await context(input);
|
|
146
|
+
return await withMergeFence(ctx, async (state, registration) => {
|
|
147
|
+
if (input.contract?.mergeId) await assertNoMergeFailure(ctx, input.contract.mergeId);
|
|
148
|
+
const previous = input.contract?.mergeId ? await readMergeJournal(ctx, input.contract.mergeId, true) : null;
|
|
149
|
+
if (previous) {
|
|
150
|
+
if (previous.contractDigest !== sha256(canonical(input.contract))) fail('MERGE_TRANSACTION_CONFLICT', 'An existing transaction cannot change its original frozen contract');
|
|
151
|
+
if (previous.status !== 'verified') fail('MERGE_RECOVERY_REQUIRED', 'A pending or failed transaction requires explicit authorized recovery');
|
|
152
|
+
await inspectStoredMergeSuccess(ctx, previous, registration);
|
|
153
|
+
match(previous.contract, { ...registration, epoch: state.epoch }, ['ownerId', 'taskDigest', 'chainDigest', 'taskId', 'epoch']);
|
|
154
|
+
if (Date.parse(previous.contract.expiresAt) <= Date.now()) return { schemaVersion: SCHEMA, status: 'reported', merged: false, historicalResult: previous.receipt, code: 'MERGE_HISTORICAL_RESULT' };
|
|
155
|
+
const archguard = await requireMergeArchguardBridge();
|
|
156
|
+
return archguard(ctx.input, input.preflight, async assertCurrent => {
|
|
157
|
+
await validateAuthority(ctx, input, state, registration, false); await assertCurrent();
|
|
158
|
+
inspectSigned(previous.receipt, 'entrorove.merge-result-receipt/1.0', process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY);
|
|
159
|
+
inspectSigned(previous.receipt.mutationBatchReceipt, 'entrorove.mutation-batch-consumption-receipt/1.0', process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY);
|
|
160
|
+
return { schemaVersion: SCHEMA, status: 'verified', merged: true, transactionId: previous.transactionId, recoveredResult: true, receipt: previous.receipt };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const archguard = await requireMergeArchguardBridge();
|
|
164
|
+
return archguard(ctx.input, input.preflight, async assertCurrent => {
|
|
165
|
+
const images = await validateAuthority(ctx, input, state, registration);
|
|
166
|
+
return commit(ctx, input, state, registration, images, assertCurrent);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
} catch (error) { return reject(error); }
|
|
170
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { canonical, sha256, context, withMergeFence, fail } from './local-merge-storage.mjs';
|
|
2
|
+
import { inspectSigned, match, signMergeReceipt } from './local-merge-authority.mjs';
|
|
3
|
+
import { readMergeJournal, assertNoMergeFailure, assertMergeTransactionLedger, verifyPersistentMergeSignature } from './local-merge-journal.mjs';
|
|
4
|
+
import { readFrozenMergeArtifact } from './local-merge-artifacts.mjs';
|
|
5
|
+
import { assertBatchImage, metadata } from './local-merge-transaction.mjs';
|
|
6
|
+
import { verifyMergeMutationReceipt, inspectMergeMutationHistory } from './local-merge-mutation.mjs';
|
|
7
|
+
|
|
8
|
+
export async function inspectStoredMergeSuccess(ctx, journal, registration) {
|
|
9
|
+
const contract = journal.contract, result = journal.receipt;
|
|
10
|
+
if (journal.status !== 'verified' || journal.operation !== 'merge' || !result || result.status !== 'verified'
|
|
11
|
+
|| result.operation !== 'merge' || result.rollbackOutcome !== 'retained' || result.recoveryRequired) {
|
|
12
|
+
fail('MERGE_JOURNAL_NOT_SUCCESSFUL', 'Rollback head requires the original actual successful merge transaction');
|
|
13
|
+
}
|
|
14
|
+
inspectSigned(contract, 'entrorove.merge-contract/1.0', process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY, Date.parse(contract.issuedAt));
|
|
15
|
+
verifyPersistentMergeSignature(result, 'entrorove.merge-result-receipt/1.0', ctx.repo);
|
|
16
|
+
match(contract, registration, ['ownerId', 'taskId', 'taskDigest', 'chainDigest', 'flowId', 'runnerKeyId']);
|
|
17
|
+
match(result, { ...contract, transactionId: journal.transactionId, contractDigest: journal.contractDigest },
|
|
18
|
+
['transactionId', 'contractDigest', 'flowId', 'stepId', 'ownerId', 'taskId', 'taskDigest', 'chainDigest',
|
|
19
|
+
'sourceRef', 'targetRef', 'mappingDigest', 'afterManifest', 'paths']);
|
|
20
|
+
if (journal.contractDigest !== sha256(canonical(contract))) fail('MERGE_JOURNAL_BINDING', 'Original merge contract bytes changed');
|
|
21
|
+
const proof = result.mutationBatchReceipt;
|
|
22
|
+
const writes = contract.sourceFiles.filter(file => contract.paths.includes(file.path)).map(file => ({ path: file.path, status: 'written', contentDigest: file.sha256 }));
|
|
23
|
+
verifyMergeMutationReceipt(proof, result, writes, true,
|
|
24
|
+
{ now: Date.parse(proof?.issuedAt), files: result.afterFiles.filter(file => contract.paths.includes(file.path)) });
|
|
25
|
+
await inspectMergeMutationHistory(ctx.input, journal.mutationPass, proof,
|
|
26
|
+
result.beforeFiles.filter(file => contract.paths.includes(file.path)), result.afterFiles.filter(file => contract.paths.includes(file.path)));
|
|
27
|
+
await assertNoMergeFailure(ctx, journal.transactionId); await assertMergeTransactionLedger(ctx, journal);
|
|
28
|
+
const originalInput = { ...ctx.input, stepId: contract.stepId };
|
|
29
|
+
const source = await readFrozenMergeArtifact(originalInput, contract.sourceRef), target = await readFrozenMergeArtifact(originalInput, contract.targetRef);
|
|
30
|
+
if (canonical(target.manifest.files) !== canonical(contract.beforeFullManifest)) fail('MERGE_JOURNAL_BINDING', 'Original wide target artifact changed');
|
|
31
|
+
const after = target.files.map(file => source.files.find(item => item.path === file.path) ?? file);
|
|
32
|
+
if (canonical(journal.before) !== canonical(target.files.map(metadata)) || canonical(journal.after) !== canonical(after.map(metadata))
|
|
33
|
+
|| canonical(result.beforeFiles) !== canonical(journal.before) || canonical(result.afterFiles) !== canonical(journal.after)) {
|
|
34
|
+
fail('MERGE_JOURNAL_BINDING', 'Original actual full-batch result differs from its immutable bytes');
|
|
35
|
+
}
|
|
36
|
+
await assertBatchImage(ctx.input, after); return { source, target, after };
|
|
37
|
+
}
|
|
38
|
+
export async function produceMergeJournalHead(input) {
|
|
39
|
+
const ctx = await context(input);
|
|
40
|
+
return withMergeFence(ctx, async (state, registration) => {
|
|
41
|
+
const journal = await readMergeJournal(ctx, input.transactionId);
|
|
42
|
+
if (ctx.stepId !== journal.contract.stepId) fail('MERGE_JOURNAL_BINDING', 'Journal head locator must retain its original merge step');
|
|
43
|
+
const contract = journal.contract;
|
|
44
|
+
const native = await import('./local-merge-archguard.mjs');
|
|
45
|
+
if (typeof native.withMergeArchguardStateFence !== 'function') fail('MERGE_ARCH_CURRENT_UNAVAILABLE', 'Fresh journal head requires the actual architecture and native history fence');
|
|
46
|
+
return native.withMergeArchguardStateFence(ctx.input, { architectureContractDigest: contract.architectureContractDigest }, async (assertCurrent, current) => {
|
|
47
|
+
await inspectStoredMergeSuccess(ctx, journal, registration); await assertCurrent();
|
|
48
|
+
const head = await signMergeReceipt(ctx.input, { schemaVersion: 'entrorove.merge-journal-head-receipt/1.0', transactionId: journal.transactionId,
|
|
49
|
+
mergeId: contract.mergeId, flowId: ctx.flowId, stepId: contract.stepId, repositoryDigest: ctx.repo, epoch: state.epoch,
|
|
50
|
+
...Object.fromEntries(['ownerId', 'taskId', 'taskDigest', 'chainDigest', 'sourceRef', 'targetRef', 'paths', 'scopePaths'].map(field => [field, contract[field]])),
|
|
51
|
+
contractDigest: journal.contractDigest, originalResultDigest: sha256(canonical(journal.receipt)), journalDigest: sha256(canonical(journal)),
|
|
52
|
+
beforeFiles: journal.before, afterFiles: journal.after, ...current });
|
|
53
|
+
await assertCurrent(); return head;
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { verify } from 'node:crypto';
|
|
3
|
+
import { canonical, sha256, identifier, json, writeJson, fail } from './local-merge-storage.mjs';
|
|
4
|
+
import { pinnedKey, signMergeReceipt } from './local-merge-authority.mjs';
|
|
5
|
+
|
|
6
|
+
export function verifyPersistentMergeSignature(value, schema, repo) {
|
|
7
|
+
const { signature, ...payload } = value ?? {}, trusted = pinnedKey(process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY);
|
|
8
|
+
if (value?.schemaVersion !== schema || value.repositoryDigest !== repo || value.keyId !== trusted.keyId
|
|
9
|
+
|| typeof signature !== 'string' || Buffer.from(signature, 'base64url').length !== 64
|
|
10
|
+
|| Buffer.from(signature, 'base64url').toString('base64url') !== signature
|
|
11
|
+
|| !verify(null, Buffer.from(canonical(payload)), trusted.key, Buffer.from(signature, 'base64url'))) {
|
|
12
|
+
fail('MERGE_JOURNAL_TAMPERED', 'Durable merge state lost its original independently pinned runner signature');
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export const journalFile = (ctx, transactionId) => path.join(ctx.state, 'transactions', identifier(transactionId), 'journal.json');
|
|
17
|
+
const failureFile = (ctx, transactionId) => path.join(ctx.state, 'transactions', identifier(transactionId), 'failure.json');
|
|
18
|
+
export async function assertNoMergeFailure(ctx, transactionId) {
|
|
19
|
+
try { await json(failureFile(ctx, transactionId)); }
|
|
20
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
21
|
+
fail('MERGE_RECOVERY_REQUIRED', 'An actual failed transaction remains permanently recorded and cannot be replayed as a new green merge');
|
|
22
|
+
}
|
|
23
|
+
export async function recordMergeFailure(ctx, transactionId, contractDigest, failure) {
|
|
24
|
+
await writeJson(failureFile(ctx, transactionId), { schemaVersion: 'entrorove.merge-failure-marker/1.0', status: 'failed',
|
|
25
|
+
repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: ctx.stepId, transactionId, contractDigest, failure,
|
|
26
|
+
at: new Date().toISOString(), authority: 'blocking-local-audit' });
|
|
27
|
+
}
|
|
28
|
+
export async function readMergeJournal(ctx, transactionId, optional = false) {
|
|
29
|
+
try {
|
|
30
|
+
const value = verifyPersistentMergeSignature(await json(journalFile(ctx, transactionId)), 'entrorove.merge-journal/1.0', ctx.repo);
|
|
31
|
+
if (value.transactionId !== transactionId || value.flowId !== ctx.flowId || value.stepId !== value.contract?.stepId) fail('MERGE_JOURNAL_BINDING', 'Merge journal belongs to another actual task or original transaction step');
|
|
32
|
+
return value;
|
|
33
|
+
} catch (error) { if (optional && error.code === 'ENOENT') return null; throw error; }
|
|
34
|
+
}
|
|
35
|
+
export async function saveMergeJournal(ctx, value) {
|
|
36
|
+
const signed = await signMergeReceipt(ctx.input, { ...value, schemaVersion: 'entrorove.merge-journal/1.0',
|
|
37
|
+
repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: value.contract?.stepId ?? ctx.stepId, operationStepId: ctx.stepId });
|
|
38
|
+
await writeJson(journalFile(ctx, value.transactionId), signed); return signed;
|
|
39
|
+
}
|
|
40
|
+
export function mergeNonceKeys(contract, preparation, preflight, evidence) {
|
|
41
|
+
return [contract, preparation, preflight, ...evidence.map(item => item.receipt)].map(value => `${value.keyId}:${value.nonce}`).sort();
|
|
42
|
+
}
|
|
43
|
+
async function readLedger(ctx) {
|
|
44
|
+
const file = path.join(ctx.home, '.entrorove', 'merges', ctx.repo, 'ledger.json'); let state;
|
|
45
|
+
try { state = verifyPersistentMergeSignature(await json(file), 'entrorove.merge-local-ledger/1.0', ctx.repo); }
|
|
46
|
+
catch (error) { if (error.code !== 'ENOENT') throw error;
|
|
47
|
+
state = { schemaVersion: 'entrorove.merge-local-ledger/1.0', repositoryDigest: ctx.repo, entries: [] }; }
|
|
48
|
+
if (!Array.isArray(state.entries) || state.entries.length > 50000) fail('MERGE_LEDGER_INVALID', 'Merge ledger must be bounded and append-only');
|
|
49
|
+
for (const [index, entry] of state.entries.entries()) {
|
|
50
|
+
const { entryDigest, ...body } = entry;
|
|
51
|
+
if (entry.sequence !== index + 1 || entry.previousDigest !== (state.entries[index - 1]?.entryDigest ?? null)
|
|
52
|
+
|| entryDigest !== sha256(canonical(body))) fail('MERGE_LEDGER_TAMPERED', 'Merge ledger hash chain changed');
|
|
53
|
+
}
|
|
54
|
+
return { file, state };
|
|
55
|
+
}
|
|
56
|
+
export async function assertMergeTransactionLedger(ctx, journal) {
|
|
57
|
+
const { state } = await readLedger(ctx), entries = state.entries.filter(entry => entry.transactionId === journal.transactionId);
|
|
58
|
+
const pending = entries.find(entry => entry.event === 'pending');
|
|
59
|
+
if (!pending || pending.details.contractDigest !== journal.contractDigest
|
|
60
|
+
|| canonical(pending.details.nonceKeys) !== canonical(journal.nonceKeys)
|
|
61
|
+
|| !entries.some(entry => entry.event === 'files-applied-reported') || entries.some(entry => entry.event === 'failed')) {
|
|
62
|
+
fail('MERGE_LEDGER_BINDING', 'A cached merge requires its original signed append-only consumption and actual file-report history');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function appendMergeEvent(ctx, transactionId, event, details = {}) {
|
|
66
|
+
const { file, state } = await readLedger(ctx), entries = state.entries;
|
|
67
|
+
const body = { sequence: entries.length + 1, previousDigest: entries.at(-1)?.entryDigest ?? null,
|
|
68
|
+
transactionId, flowId: ctx.flowId, stepId: ctx.stepId, event, details, at: new Date().toISOString() };
|
|
69
|
+
entries.push({ ...body, entryDigest: sha256(canonical(body)) });
|
|
70
|
+
await writeJson(file, await signMergeReceipt(ctx.input, { schemaVersion: state.schemaVersion, repositoryDigest: ctx.repo, entries }));
|
|
71
|
+
}
|
|
72
|
+
export async function reserveMergeNonces(ctx, transactionId, nonceKeys, contractDigest) {
|
|
73
|
+
if (new Set(nonceKeys).size !== nonceKeys.length || nonceKeys.some(key => !/^[0-9a-f]{64}:[A-Za-z0-9._:-]+$/.test(key))) fail('MERGE_REPLAY', 'Duplicate or unknown signed evidence nonce cannot authorize a merge');
|
|
74
|
+
const { state } = await readLedger(ctx);
|
|
75
|
+
if (state.entries.some(entry => entry.details.nonceKeys?.some(key => nonceKeys.includes(key)))) fail('MERGE_REPLAY', 'A merge authority or execution receipt nonce has already been consumed');
|
|
76
|
+
await appendMergeEvent(ctx, transactionId, 'pending', { contractDigest, nonceKeys });
|
|
77
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonical, sha256, fail, identifier, paths, MISSING_SHA256, noLinks } from './local-merge-storage.mjs';
|
|
3
|
+
import { match, inspectSigned } from './local-merge-authority.mjs';
|
|
4
|
+
import { metadata } from './local-merge-transaction.mjs';
|
|
5
|
+
|
|
6
|
+
export async function requireMergeMutationBridge() {
|
|
7
|
+
let bridge;
|
|
8
|
+
try { bridge = await import('./local-mutation-batch.mjs'); }
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
|
11
|
+
fail('MERGE_MUTATION_BATCH_UNAVAILABLE', 'Real merge requires the original mutation authority batch-consumption implementation');
|
|
12
|
+
}
|
|
13
|
+
for (const name of ['beginMutationBatch14', 'finishMutationBatch14', 'failMutationBatch14']) {
|
|
14
|
+
if (typeof bridge[name] !== 'function') fail('MERGE_MUTATION_BATCH_UNAVAILABLE', 'Mutation authority has no complete original batch-consumption interface');
|
|
15
|
+
}
|
|
16
|
+
return bridge;
|
|
17
|
+
}
|
|
18
|
+
export async function requireMergeOwnershipBridge() {
|
|
19
|
+
const bridge = await import('./local-swarm-mutation.mjs');
|
|
20
|
+
if (typeof bridge.withSwarmMutationBatchLease !== 'function') {
|
|
21
|
+
fail('MERGE_SWARM_BATCH_UNAVAILABLE', 'Real merge requires the original cross-flow ownership guard to protect every batch path under one physical ownership lock');
|
|
22
|
+
}
|
|
23
|
+
return bridge.withSwarmMutationBatchLease;
|
|
24
|
+
}
|
|
25
|
+
export async function requireMergeArchguardBridge() {
|
|
26
|
+
const bridge = await import('./local-merge-archguard.mjs');
|
|
27
|
+
if (typeof bridge.withMergeArchguardFence !== 'function') fail('MERGE_ARCH_CURRENT_UNAVAILABLE', 'Actual merge requires the original architecture contract and native ledger locks throughout its whole batch');
|
|
28
|
+
return bridge.withMergeArchguardFence;
|
|
29
|
+
}
|
|
30
|
+
export async function verifyMergeMutationPass(ctx, input, names, beforeFiles) {
|
|
31
|
+
const { verifyMutationPass } = await import('./local-execution.mjs');
|
|
32
|
+
const { snapshotAuthority14 } = await import('./local-mutation-snapshot.mjs');
|
|
33
|
+
let original;
|
|
34
|
+
for (const targetPath of names) {
|
|
35
|
+
const change = input.changes?.find(item => item.targetPath === targetPath);
|
|
36
|
+
const checked = await verifyMutationPass({ ...ctx.input, gatePassPath: change?.gatePassPath ?? input.gatePassPath, targetPath });
|
|
37
|
+
const pass = checked.pass;
|
|
38
|
+
if (original && canonical(pass) !== canonical(original)) fail('MERGE_PASS_BINDING', 'One atomic batch must use one original mutation pass');
|
|
39
|
+
match(pass, input.contract, ['taskId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch', 'repositoryDigest', 'flowId', 'stepId']);
|
|
40
|
+
if (canonical(pass.paths) !== canonical(names) || pass.executionPlanSha256 !== sha256(canonical(input.contract))) {
|
|
41
|
+
fail('MERGE_PASS_BINDING', 'Actual mutation pass must bind the exact frozen merge contract and changed subset');
|
|
42
|
+
}
|
|
43
|
+
original = pass;
|
|
44
|
+
}
|
|
45
|
+
const snapshot = await snapshotAuthority14(ctx.input, ctx.root, original.snapshotReceipt);
|
|
46
|
+
if (canonical(snapshot.manifest.files) !== canonical(beforeFiles.filter(file => names.includes(file.path)).map(metadata))) {
|
|
47
|
+
fail('MERGE_PASS_BINDING', 'Actual mutation snapshot must retain the exact frozen before subset');
|
|
48
|
+
}
|
|
49
|
+
return { pass: original, mutationPassDigest: sha256(canonical(original)), mutationSnapshotDigest: original.snapshotDigest,
|
|
50
|
+
executionAuthorityDigest: original.executionAuthorityDigest, executionAttemptNonce: original.executionAttemptNonce };
|
|
51
|
+
}
|
|
52
|
+
export function verifyMergeMutationReceipt(receipt, binding, writes, successful, options = {}) {
|
|
53
|
+
inspectSigned(receipt, 'entrorove.mutation-batch-consumption-receipt/1.0', process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY, options.now ?? Date.now());
|
|
54
|
+
match(receipt, { ...binding, passDigest: binding.mutationPassDigest }, ['flowId', 'taskId', 'stepId', 'leaseId', 'generation',
|
|
55
|
+
'passDigest', 'contractDigest', 'transactionId', 'executionAuthorityDigest', 'executionAttemptNonce', 'paths']);
|
|
56
|
+
const common = ['schemaVersion', 'status', 'consumed', 'batchId', 'flowId', 'taskId', 'stepId', 'leaseId', 'generation',
|
|
57
|
+
'passDigest', 'contractDigest', 'transactionId', 'executionAuthorityDigest', 'executionAttemptNonce', 'paths', 'writes',
|
|
58
|
+
'keyId', 'nonce', 'issuedAt', 'expiresAt', 'signature'];
|
|
59
|
+
const extra = successful ? ['afterFiles'] : ['currentFiles', 'code', ...(receipt.reportedRollbackOutcome === undefined ? [] : ['reportedRollbackOutcome'])];
|
|
60
|
+
const files = successful ? receipt.afterFiles : receipt.currentFiles, status = successful ? 'written' : 'failed';
|
|
61
|
+
if (receipt.consumed !== true || receipt.status !== status || Object.keys(receipt).sort().join('|') !== [...common, ...extra].sort().join('|')
|
|
62
|
+
|| !Array.isArray(files) || canonical(paths(files.map(file => file?.path))) !== canonical(receipt.paths)
|
|
63
|
+
|| files.some(file => !file || Object.keys(file).sort().join('|') !== 'exists|mode|path|sha256' || typeof file.exists !== 'boolean'
|
|
64
|
+
|| !/^[0-9a-f]{64}$/.test(file.sha256 ?? '') || (file.exists ? !Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777
|
|
65
|
+
: file.sha256 !== MISSING_SHA256 || file.mode !== null))
|
|
66
|
+
|| canonical(receipt.writes) !== canonical(files.map(file => ({ path: file.path, status, contentDigest: file.sha256 })))
|
|
67
|
+
|| canonical(receipt.writes) !== canonical(writes) || !options.files || canonical(files) !== canonical(options.files)) {
|
|
68
|
+
fail('MERGE_MUTATION_CONSUMPTION', 'Original consumed batch proof must preserve every exact signed actual file and completed or failed write');
|
|
69
|
+
}
|
|
70
|
+
identifier(receipt.batchId);
|
|
71
|
+
if (!successful && (!/^[A-Z][A-Z0-9_]{0,127}$/.test(receipt.code ?? '') || receipt.reportedRollbackOutcome !== undefined
|
|
72
|
+
&& !['retained', 'restored', 'restoration-failed'].includes(receipt.reportedRollbackOutcome))) {
|
|
73
|
+
fail('MERGE_MUTATION_CONSUMPTION', 'Original failed consumption requires its real explicit failure and recovery outcome');
|
|
74
|
+
}
|
|
75
|
+
return receipt;
|
|
76
|
+
}
|
|
77
|
+
export async function inspectMergeMutationHistory(input, originalPass, receipt, beforeFiles, afterFiles) {
|
|
78
|
+
const { mutationPass14 } = await import('./local-mutation-wire.mjs');
|
|
79
|
+
const { readMutationConsumption14 } = await import('./local-mutation-pass-store.mjs');
|
|
80
|
+
const pass = mutationPass14(originalPass, { live: false }), passDigest = sha256(canonical(pass));
|
|
81
|
+
match(pass, { ...receipt, executionPlanSha256: receipt.contractDigest }, ['flowId', 'taskId', 'stepId', 'leaseId', 'generation',
|
|
82
|
+
'executionAuthorityDigest', 'executionAttemptNonce', 'paths', 'executionPlanSha256']);
|
|
83
|
+
if (passDigest !== receipt.passDigest) fail('MERGE_MUTATION_CONSUMPTION', 'Historical proof lost its original cloud-signed pass');
|
|
84
|
+
const file = path.join(process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY, 'mutations', pass.repositoryDigest, pass.flowId, `${identifier(pass.passId)}.json`);
|
|
85
|
+
await noLinks(file);
|
|
86
|
+
const ledger = await readMutationConsumption14(pass, { consumptionPath: file, executionHome: input.home });
|
|
87
|
+
const beforeDigest = sha256(canonical(beforeFiles)), afterDigest = sha256(canonical(afterFiles));
|
|
88
|
+
const batchDigest = sha256(canonical({ passDigest, beforeDigest, afterDigest, batchId: receipt.batchId }));
|
|
89
|
+
for (const after of afterFiles) {
|
|
90
|
+
const entry = ledger.writes[after.path];
|
|
91
|
+
if (!entry || entry.status !== 'written' || entry.contentDigest !== after.sha256 || entry.batchId !== receipt.batchId
|
|
92
|
+
|| entry.batchDigest !== batchDigest || entry.beforeDigest !== beforeDigest || entry.afterDigest !== afterDigest) {
|
|
93
|
+
fail('MERGE_MUTATION_CONSUMPTION', 'Historical result differs from its actual durable completed original batch consumption');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return ledger;
|
|
97
|
+
}
|