@entrorove/skill 0.1.71 → 0.1.78
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/CAPABILITY-MAP.md +5 -0
- package/CHANGELOG.md +47 -0
- package/DESIGN-GATES.md +5 -9
- package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
- package/IDE-EXECUTION.md +25 -4
- package/REUSE-DISCIPLINE.md +3 -3
- package/SKILL.md +15 -4
- package/cli.mjs +8 -4
- package/cloud-mutation-authority.mjs +8 -2
- package/cloud-task-client.mjs +10 -0
- package/cloud-watch-client.mjs +8 -2
- package/design-browser-checks.mjs +18 -5
- package/design-browser-pages.mjs +96 -0
- package/design-browser-state.mjs +1 -0
- package/design-browser.mjs +36 -40
- package/design-contracts.mjs +4 -1
- package/design-declaration.mjs +3 -0
- package/design-frameworks.mjs +2 -1
- package/design-gate.mjs +3 -0
- package/design-operations.mjs +4 -1
- package/design-policy.mjs +12 -4
- package/design-release.mjs +6 -0
- package/design-research-network.mjs +82 -0
- package/design-research.mjs +8 -25
- package/design-runner.mjs +3 -0
- package/design-stage-contracts.mjs +12 -0
- package/design-stage.mjs +78 -0
- package/design-storage.mjs +63 -0
- package/fs-request-trace.mjs +67 -0
- package/local-contracts.mjs +1 -1
- package/local-discipline-repository.mjs +4 -2
- package/local-invocations.mjs +6 -0
- package/local-mutation-files.mjs +13 -5
- package/local-probe.mjs +20 -7
- package/local-recovery-budget.mjs +31 -0
- package/local-scope-repository.mjs +18 -3
- package/local-scope-signature.mjs +2 -2
- package/local-source-execution.mjs +47 -0
- package/manifest.json +1 -1
- package/mutation-challenge-refresh.mjs +3 -0
- package/mutation-client.mjs +17 -5
- package/native-client.mjs +4 -1
- package/package.json +19 -2
- package/release-source-groups.mjs +7 -0
- package/release-state.json +70 -53
- package/release.mjs +9 -3
- package/releases.json +42 -0
- package/remote-capability-cache.mjs +20 -0
- package/remote-client.mjs +25 -5
- package/remote-transport.mjs +14 -0
- package/scope-amendment-client.mjs +90 -22
- package/scope-amendment-observer.mjs +9 -3
- package/scope-amendment-reference.mjs +22 -4
- package/scope-amendment-remote.mjs +20 -6
- package/scope-amendment-retirement.mjs +4 -2
- package/scope-amendment-state.mjs +10 -6
- package/scope-context-client.mjs +24 -8
- package/scope-context-journal.mjs +13 -6
- package/scope-context-remote.mjs +20 -9
- package/scope-context-state.mjs +4 -1
- package/scope-context-wire.mjs +9 -1
- package/scope-evidence-local.mjs +52 -0
- package/scope-evidence-remote.mjs +87 -0
- package/scope-evidence-wire.mjs +120 -0
- package/scope-runner-client.mjs +92 -35
- package/scope-runner-journal.mjs +109 -0
- package/scope-write-observation.d.mts +4 -0
- package/scope-write-observation.mjs +43 -0
- package/service-prerequisite.mjs +87 -0
- package/task-entry-client.mjs +3 -0
- package/workflow-observation-client.mjs +2 -0
- package/workflow-observation-remote.mjs +13 -5
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
@@ -9,17 +10,67 @@ import { scopeAmendmentLocation, readScopeAmendmentState, saveScopeAmendmentStat
|
|
|
9
10
|
import { amendmentRemote, scopeAmendmentStatus } from './scope-amendment-remote.mjs';
|
|
10
11
|
import { observeScopeAmendment } from './scope-amendment-observer.mjs';
|
|
11
12
|
import { retainScopeAmendmentReference } from './scope-amendment-reference.mjs';
|
|
13
|
+
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
14
|
+
import { consumeRecovery, recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
12
15
|
|
|
13
16
|
const schemaVersion = 'entrorove.scope-amendment-connection/1.0';
|
|
17
|
+
const submissionFields = ['evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'evidenceSignature',
|
|
18
|
+
'attestation', 'attestationDigest', 'attestationRef', 'evidenceResult', 'applied', 'rejection'];
|
|
19
|
+
const signingChallenge = state => (state.signingChallenge ?? state.challenged).challenge;
|
|
20
|
+
function sameChallenge(value, original, location, attemptId, options) {
|
|
21
|
+
const fresh = scopeAmendmentChallenge(value, location, options);
|
|
22
|
+
const changing = new Set(['authorityReceipt', 'attemptId', 'challengeId', 'challenge', 'issuedAt', 'expiresAt']);
|
|
23
|
+
const immutable = challenge => Object.fromEntries(Object.entries(challenge).filter(([field]) => !changing.has(field)));
|
|
24
|
+
if (canonical(immutable(fresh)) !== canonical(immutable(original)) || fresh.attemptId !== attemptId
|
|
25
|
+
|| fresh.challenge === original.challenge || fresh.challengeId === original.challengeId) {
|
|
26
|
+
amendmentFail('Fresh amendment challenge changed its original task, signer, step, roles or authority head');
|
|
27
|
+
}
|
|
28
|
+
return fresh;
|
|
29
|
+
}
|
|
30
|
+
async function archiveSigningObservation(location, state) {
|
|
31
|
+
const checksum = sha256(canonical(state)), archive = path.join(location.directory, `${state.attemptId}-${checksum}.json`);
|
|
32
|
+
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) amendmentFail('Original signing observation history changed'); }
|
|
33
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
34
|
+
return checksum;
|
|
35
|
+
}
|
|
36
|
+
export async function renewScopeAmendmentChallenge(location, state, remote) {
|
|
37
|
+
if (submissionFields.some(field => Object.hasOwn(state, field)) || canonical(state.identity) !== canonical(location.identity)
|
|
38
|
+
|| state.snapshotStarted !== true || !state.snapshot || state.snapshot.snapshotId !== state.snapshotId
|
|
39
|
+
|| state.snapshot.flowId !== location.identity.flowId || state.snapshot.contractDigest !== location.identity.proposalDigest) {
|
|
40
|
+
amendmentFail('Fresh amendment challenge requires the same verified snapshot before any signed evidence or application');
|
|
41
|
+
}
|
|
42
|
+
const original = scopeAmendmentChallenge(state.challenged?.challenge, location, { allowExpired: true });
|
|
43
|
+
if (state.snapshot.stepId !== original.stepId) amendmentFail('Amendment snapshot replaced its original challenge step');
|
|
44
|
+
if (state.pending && (state.pending.stage !== 'signingChallenge' || !state.signingAttemptId)) {
|
|
45
|
+
amendmentFail('Recover the original pending amendment request before signing challenge renewal');
|
|
46
|
+
}
|
|
47
|
+
const retained = state.signingChallenge && sameChallenge(state.signingChallenge.challenge, original, location,
|
|
48
|
+
state.signingAttemptId, { allowExpired: true });
|
|
49
|
+
if (!state.pending && retained && Date.parse(retained.expiresAt) > Date.now()) {
|
|
50
|
+
return retained;
|
|
51
|
+
}
|
|
52
|
+
if (!state.pending && (!state.signingAttemptId || state.signingChallenge)) {
|
|
53
|
+
const archivedDigest = await archiveSigningObservation(location, state);
|
|
54
|
+
consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
|
|
55
|
+
delete state.signingChallenge; state.signingAttemptId = randomUUID();
|
|
56
|
+
state.signingRecovery = { archivedDigest, snapshotId: state.snapshotId, requiresSnapshotVerification: true,
|
|
57
|
+
grantsExecutionAuthority: false, recoveredAt: new Date().toISOString() };
|
|
58
|
+
await saveScopeAmendmentState(location, state);
|
|
59
|
+
}
|
|
60
|
+
const issued = await remote('signingChallenge', 'workflow-scope-challenge', { proposalId: location.identity.proposalId,
|
|
61
|
+
proposalDigest: location.identity.proposalDigest, receiverId: location.binding.runnerId, attemptId: state.signingAttemptId });
|
|
62
|
+
return sameChallenge(issued.challenge, original, location, state.signingAttemptId);
|
|
63
|
+
}
|
|
14
64
|
function validateProposal(status, input) {
|
|
15
65
|
const proposal = status.proposal;
|
|
16
66
|
if (!proposal || proposal.proposalId !== input.proposalId || proposal.proposalDigest !== input.proposalDigest
|
|
17
67
|
|| !['pending', 'approved', 'declined'].includes(proposal.approval)) amendmentFail('Current account has no matching original scope proposal');
|
|
18
68
|
return proposal;
|
|
19
69
|
}
|
|
20
|
-
function validateApplied(output, state, location) {
|
|
21
|
-
const effective = output.effectiveAuthority,
|
|
22
|
-
const
|
|
70
|
+
async function validateApplied(output, state, location) {
|
|
71
|
+
const effective = output.effectiveAuthority, version = effective?.record?.schemaVersion === 'entrorove.scope-revision-authority/2.0' ? '2.0' : '1.0';
|
|
72
|
+
const record = scopeRevisionAuthority(effective?.record, `entrorove.scope-revision-authority/${version}`);
|
|
73
|
+
const challenge = scopeAmendmentChallenge(signingChallenge(state), location, { allowExpired: true }), refresh = challenge.purpose === 'scope-revision-refresh';
|
|
23
74
|
if (output.status !== 'succeeded' || output.grantsExecutionAuthority !== false || output.claimsCompletion !== false
|
|
24
75
|
|| output.scopeRevisionDigest !== sha256(canonical(record)) || effective.revisionDigest !== output.scopeRevisionDigest
|
|
25
76
|
|| output.revision !== effective.revision || record.revision !== effective.revision
|
|
@@ -30,21 +81,26 @@ function validateApplied(output, state, location) {
|
|
|
30
81
|
|| canonical(record.files.map(file => ({ ...file, sha256: null }))) !== canonical(challenge.files.map(file => ({ ...file, sha256: null })))) {
|
|
31
82
|
amendmentFail('Applied scope amendment lost its signed original authority or exact roles');
|
|
32
83
|
}
|
|
33
|
-
const
|
|
84
|
+
const factVersion = effective?.fact?.schemaVersion === 'entrorove.scope-revision-facts/2.0' ? '2.0' : '1.0';
|
|
85
|
+
const fact = refresh ? scopeRevisionAuthority(effective.fact, `entrorove.scope-revision-facts/${factVersion}`) : record;
|
|
86
|
+
const compact = fact.schemaVersion.endsWith('/2.0');
|
|
87
|
+
const attestation = compact ? await validateAppliedEvidence(fact, state, location, challenge,
|
|
88
|
+
refresh ? 'scope-refresh' : 'scope-revision') : state.attestation;
|
|
34
89
|
if (refresh && (fact.scopeRevisionDigest !== output.scopeRevisionDigest || fact.factRevision !== challenge.expectedFactRevision
|
|
35
90
|
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest'].some(field => fact[field] !== challenge[field]))
|
|
36
|
-
|| canonical(fact.candidateAttestation) !== canonical(
|
|
37
|
-
|| fact.scope?.attestationDigest !== sha256(canonical(
|
|
91
|
+
|| !compact && canonical(fact.candidateAttestation) !== canonical(attestation)
|
|
92
|
+
|| fact.scope?.attestationDigest !== sha256(canonical(attestation.scopeAttestation))
|
|
93
|
+
|| compact && effective.candidateAttestationDigest !== sha256(canonical(attestation))
|
|
38
94
|
|| effective.factRevision !== (refresh ? fact.factRevision : 0) || output.factRevision !== effective.factRevision) {
|
|
39
95
|
amendmentFail('Applied scope proof differs from this exact actual signed candidate');
|
|
40
96
|
}
|
|
41
97
|
return { schemaVersion, status: 'succeeded', flowId: challenge.flowId, proposalId: challenge.proposalId,
|
|
42
98
|
proposalDigest: challenge.proposalDigest, scopeRevisionDigest: output.scopeRevisionDigest, revision: output.revision,
|
|
43
99
|
factRevision: effective.factRevision, grantsExecutionAuthority: false, claimsCompletion: false,
|
|
44
|
-
...(refresh &&
|
|
100
|
+
...(refresh && attestation.memberMutation ? { localAction: {
|
|
45
101
|
command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>', input: {
|
|
46
|
-
flowId: challenge.flowId, taskId:
|
|
47
|
-
stepId:
|
|
102
|
+
flowId: challenge.flowId, taskId: attestation.memberMutation.consumptionReceipt.taskId,
|
|
103
|
+
stepId: attestation.memberMutation.consumptionReceipt.stepId, gatePassPath: location.identity.gatePassPath } } } : {}),
|
|
48
104
|
nextStep: output.nextStep, ...(state.feedback ? { feedback: state.feedback } : {}) };
|
|
49
105
|
}
|
|
50
106
|
async function archiveAttempt(location, state) {
|
|
@@ -52,27 +108,36 @@ async function archiveAttempt(location, state) {
|
|
|
52
108
|
const archive = path.join(location.directory, `${state.attemptId}.json`);
|
|
53
109
|
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) amendmentFail('Original scope amendment attempt history changed'); }
|
|
54
110
|
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
55
|
-
|
|
111
|
+
if (!state.applied) consumeRecovery(state, 'attempt', state.attemptId);
|
|
112
|
+
const completed = Boolean(state.applied);
|
|
113
|
+
for (const key of Object.keys(state)) if (!['schemaVersion', 'identity', ...(completed ? [] : ['recovery'])].includes(key)) delete state[key];
|
|
56
114
|
await saveScopeAmendmentState(location, state);
|
|
57
115
|
}
|
|
58
116
|
async function finish(location, state, dependencies) {
|
|
59
|
-
if (!state.
|
|
60
|
-
state.attestation
|
|
61
|
-
|
|
117
|
+
if (!state.evidenceDescriptor && !state.applied) {
|
|
118
|
+
const attestation = state.attestation ?? await observeScopeAmendment(location, state, {
|
|
119
|
+
beforeSign: () => renewScopeAmendmentChallenge(location, state,
|
|
120
|
+
(stage, operation, payload) => amendmentRemote(stage, operation, payload, state, location, dependencies)) });
|
|
121
|
+
const challenge = scopeAmendmentChallenge(signingChallenge(state), location, { allowExpired: true });
|
|
122
|
+
await saveLocalScopeEvidence(attestation, challenge.purpose === 'scope-revision-refresh' ? 'scope-refresh' : 'scope-revision',
|
|
123
|
+
challenge, state, location, saveScopeAmendmentState);
|
|
124
|
+
}
|
|
125
|
+
if (!state.applied) {
|
|
126
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies, signingChallenge(state), saveScopeAmendmentState);
|
|
127
|
+
await amendmentRemote('applied', 'workflow-scope-apply', { proposalId: location.identity.proposalId,
|
|
128
|
+
proposalDigest: location.identity.proposalDigest, attestationRef }, state, location, dependencies);
|
|
62
129
|
}
|
|
63
|
-
const applied = state.applied
|
|
64
|
-
proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest,
|
|
65
|
-
attestation: state.attestation }, state, location, dependencies);
|
|
66
|
-
const result = validateApplied(applied, state, location);
|
|
130
|
+
const applied = state.applied, result = await validateApplied(applied, state, location);
|
|
67
131
|
await retainScopeAmendmentReference(location, applied); return result;
|
|
68
132
|
}
|
|
69
133
|
async function amend(location, dependencies) {
|
|
70
134
|
const state = await readScopeAmendmentState(location);
|
|
135
|
+
if (state.evidencePending || state.pending?.operation === 'scope-evidence-prepare') return finish(location, state, dependencies);
|
|
71
136
|
if (state.pending) {
|
|
72
137
|
const pending = state.pending;
|
|
73
138
|
await amendmentRemote(pending.stage, pending.input.operation, pending.input.payload, state, location, dependencies);
|
|
74
139
|
if (pending.stage === 'applied') {
|
|
75
|
-
const result = validateApplied(state.applied, state, location);
|
|
140
|
+
const result = await validateApplied(state.applied, state, location);
|
|
76
141
|
await retainScopeAmendmentReference(location, state.applied); return result;
|
|
77
142
|
}
|
|
78
143
|
return finish(location, state, dependencies);
|
|
@@ -84,7 +149,8 @@ async function amend(location, dependencies) {
|
|
|
84
149
|
requiresOriginalOwnerApproval: true, scopeExpansionAutomaticallyAuthorized: false } };
|
|
85
150
|
if (state.challenged && !state.applied && !state.rejection) {
|
|
86
151
|
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true });
|
|
87
|
-
if (Date.parse(challenge.expiresAt) > Date.now()
|
|
152
|
+
if (Date.parse(challenge.expiresAt) > Date.now() || state.snapshot && state.snapshotStarted === true
|
|
153
|
+
&& !submissionFields.some(field => Object.hasOwn(state, field))) return finish(location, state, dependencies);
|
|
88
154
|
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_CHALLENGE_EXPIRED', effects: 'none',
|
|
89
155
|
reason: 'signed-unused-challenge-expired-before-next-post' }; await saveScopeAmendmentState(location, state);
|
|
90
156
|
}
|
|
@@ -95,7 +161,7 @@ async function amend(location, dependencies) {
|
|
|
95
161
|
const challenge = scopeAmendmentChallenge(challenged.challenge, location);
|
|
96
162
|
if (status.effective ? challenge.purpose !== 'scope-revision-refresh'
|
|
97
163
|
|| challenge.authorityRevisionDigest !== status.effective.revisionDigest || challenge.expectedFactRevision !== status.effective.factRevision + 1
|
|
98
|
-
|| challenge.currentScopeArtifactDigest !== sha256(canonical(status.effective.scope))
|
|
164
|
+
|| challenge.currentScopeArtifactDigest !== (status.effective.scopeArtifactDigest ?? sha256(canonical(status.effective.scope)))
|
|
99
165
|
: challenge.purpose !== 'scope-revision' || challenge.expectedRevision !== proposal.expectedRevision
|
|
100
166
|
|| challenge.previousDigest !== proposal.previousDigest) amendmentFail('Scope amendment challenge has a different original authority head');
|
|
101
167
|
return finish(location, state, dependencies);
|
|
@@ -111,13 +177,15 @@ export async function runScopeAmend(repositoryRoot, input, dependencies) {
|
|
|
111
177
|
} catch (error) {
|
|
112
178
|
const requestId = /^[a-f0-9]{64}$/.test(error?.requestId ?? '') ? error.requestId : undefined;
|
|
113
179
|
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
180
|
+
...prerequisiteFields(error),
|
|
114
181
|
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_AMENDMENT_PREREQUISITE',
|
|
115
182
|
message: ['ENTROROVE_SCOPE_AMENDMENT_BINDING', 'ENTROROVE_SCOPE_AMENDMENT_STATE', 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN',
|
|
116
183
|
'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', 'ENTROROVE_SCOPE_AMENDMENT_MUTATION_PENDING'].includes(error?.code) ? error.message
|
|
117
184
|
: 'Scope amendment stopped; preserve the original proposal, signer, journal and actual evidence.',
|
|
118
|
-
...(requestId ? { requestId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
185
|
+
...(requestId ? { requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
119
186
|
...(error?.feedback ? { feedback: error.feedback } : {}),
|
|
120
|
-
...(error
|
|
187
|
+
...recoveryFailureFields(error),
|
|
188
|
+
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-amend', ...(requestId ? { requestId } : { uploadId: error.uploadId }),
|
|
121
189
|
method: 'GET', reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
122
190
|
}
|
|
123
191
|
}
|
|
@@ -21,12 +21,14 @@ async function inspectFiles(location, challenge) {
|
|
|
21
21
|
physical.add(identity);
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
export async function observeScopeAmendment(location, state) {
|
|
25
|
-
const
|
|
24
|
+
export async function observeScopeAmendment(location, state, { beforeSign } = {}) {
|
|
25
|
+
const original = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: beforeSign !== undefined });
|
|
26
|
+
let challenge = original;
|
|
26
27
|
await inspectFiles(location, challenge);
|
|
27
28
|
if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
|
|
28
29
|
const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
|
|
29
30
|
snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
|
|
31
|
+
scopeWritePaths: challenge.files.filter(file => file.access !== 'read-only').map(file => file.path).sort(),
|
|
30
32
|
contractDigest: challenge.proposalDigest, importContexts: location.contextAuthority?.record.importContexts ?? location.binding.importContexts,
|
|
31
33
|
...(location.contextAuthority ? { contextRevisionDigest: location.contextAuthority.revisionDigest,
|
|
32
34
|
contextEvidence: location.contextAuthority.record.evidence } : {}),
|
|
@@ -56,7 +58,11 @@ export async function observeScopeAmendment(location, state) {
|
|
|
56
58
|
const observed = await attestScopeFacts({ ...input, ownerId: challenge.ownerId, taskDigest: challenge.taskDigest,
|
|
57
59
|
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge,
|
|
58
60
|
signerConfigPath: location.signerConfigPath }, { repositoryRoot: location.root, facts,
|
|
59
|
-
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt)
|
|
61
|
+
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt),
|
|
62
|
+
...(beforeSign === undefined ? {} : { beforeSign: async () => {
|
|
63
|
+
challenge = await beforeSign();
|
|
64
|
+
return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
|
|
65
|
+
} }) });
|
|
60
66
|
const memberMutation = await scopeAmendmentMutation(location, state, challenge, facts);
|
|
61
67
|
const core = { schemaVersion: 'entrorove.scope-revision-attestation/1.0', purpose: challenge.purpose,
|
|
62
68
|
proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest, expectedRevision: challenge.expectedRevision,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
-
import { mutationHome14, privateMutationDirectory14, readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
3
|
+
import { mutationHome14, mutationFile14, privateMutationDirectory14, readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
4
4
|
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
5
|
+
import { readScopeEvidence } from './scope-evidence-local.mjs';
|
|
6
|
+
import { evidenceReference } from './scope-evidence-wire.mjs';
|
|
7
|
+
import { trustedOrigin } from './remote-transport.mjs';
|
|
5
8
|
|
|
6
9
|
async function referenceLocation(root, home, flowId, revisionDigest) {
|
|
7
10
|
const stateRoot = path.join(await mutationHome14(home), '.entrorove');
|
|
@@ -11,21 +14,36 @@ async function referenceLocation(root, home, flowId, revisionDigest) {
|
|
|
11
14
|
export async function retainScopeAmendmentReference(location, output) {
|
|
12
15
|
const effective = output.effectiveAuthority, target = await referenceLocation(location.root, location.home,
|
|
13
16
|
location.identity.flowId, output.scopeRevisionDigest);
|
|
14
|
-
const
|
|
17
|
+
const compact = effective.record.schemaVersion === 'entrorove.scope-revision-authority/2.0';
|
|
18
|
+
if (compact) await readScopeEvidence(location, evidenceReference(effective.record.candidateAttestationRef, undefined, location));
|
|
19
|
+
const value = { schemaVersion: compact ? 'entrorove.scope-amendment-reference/2.0' : 'entrorove.scope-amendment-reference/1.0',
|
|
20
|
+
...(compact ? { apiOrigin: location.apiOrigin } : {}), input: {
|
|
15
21
|
flowId: location.identity.flowId, proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest }, record: effective.record };
|
|
16
22
|
await privateMutationDirectory14(target.directory, { privateFrom: target.stateRoot });
|
|
17
23
|
try { if (canonical(await readMutationJson14(target.file)) !== canonical(value)) amendmentFail('Scope amendment original authority reference changed'); }
|
|
18
24
|
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(target.file, value, { privateFrom: target.stateRoot }); }
|
|
19
25
|
}
|
|
26
|
+
async function readReferenceEvidence(reference, input) {
|
|
27
|
+
const origin = trustedOrigin(reference.apiOrigin), stateRoot = path.join(await mutationHome14(input.home), '.entrorove');
|
|
28
|
+
const base = path.join(stateRoot, 'scope-runners', sha256(`${origin}:${reference.record.flowId}:${input.repositoryRoot}`));
|
|
29
|
+
const file = path.join(base, 'identity', 'binding.json'), item = await mutationFile14(file);
|
|
30
|
+
if (item.stat.size > 1_000_000 || item.stat.nlink !== 1) amendmentFail('Original amendment signer binding is invalid');
|
|
31
|
+
const binding = await readMutationJson14(file);
|
|
32
|
+
if (binding.repositoryRoot !== input.repositoryRoot || binding.apiOrigin !== origin || binding.flowId !== reference.record.flowId) amendmentFail('Original amendment signer binding changed');
|
|
33
|
+
const location = { base, binding };
|
|
34
|
+
await readScopeEvidence(location, evidenceReference(reference.record.candidateAttestationRef, undefined, location));
|
|
35
|
+
}
|
|
20
36
|
export async function scopeAmendmentRefreshAction(input, pass) {
|
|
21
37
|
if (!pass.scopeRevisionDigest) return undefined;
|
|
22
38
|
try {
|
|
23
39
|
const target = await referenceLocation(input.repositoryRoot, input.home, pass.flowId, pass.scopeRevisionDigest);
|
|
24
40
|
const reference = await readMutationJson14(target.file);
|
|
25
|
-
const
|
|
26
|
-
|
|
41
|
+
const compact = reference.schemaVersion === 'entrorove.scope-amendment-reference/2.0';
|
|
42
|
+
const record = scopeRevisionAuthority(reference.record, `entrorove.scope-revision-authority/${compact ? '2.0' : '1.0'}`);
|
|
43
|
+
if (!['entrorove.scope-amendment-reference/1.0', 'entrorove.scope-amendment-reference/2.0'].includes(reference.schemaVersion) || sha256(canonical(record)) !== pass.scopeRevisionDigest
|
|
27
44
|
|| record.flowId !== pass.flowId || record.taskDigest !== pass.taskDigest || record.scope.keyId !== pass.memberScopeKeyId
|
|
28
45
|
|| ['flowId', 'proposalId', 'proposalDigest'].some(field => reference.input[field] !== record[field])) amendmentFail('Stored refresh reference has another original authority');
|
|
46
|
+
if (compact) await readReferenceEvidence(reference, input);
|
|
29
47
|
return { command: 'npx @entrorove/skill scope-amend <absolute repositoryRoot>',
|
|
30
48
|
input: { ...reference.input, gatePassPath: input.gatePassPath }, resumeOriginalFlow: true, requiredBefore: 'mutation-settle' };
|
|
31
49
|
} catch {
|
|
@@ -7,6 +7,8 @@ import { readInvocations } from './local-invocations.mjs';
|
|
|
7
7
|
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
8
8
|
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
9
|
import { retireMissingChallenge } from './scope-amendment-retirement.mjs';
|
|
10
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
11
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
10
12
|
|
|
11
13
|
const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
|
|
12
14
|
{ code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
@@ -16,6 +18,8 @@ async function recover(pending, dependencies) {
|
|
|
16
18
|
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
17
19
|
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
18
20
|
const result = parseTransportResponse(response, 'original scope amendment status');
|
|
21
|
+
const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
|
|
22
|
+
if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
19
23
|
if (response.status === 404 && result?.error === 'request-not-found') {
|
|
20
24
|
throw Object.assign(unknown(pending.requestId), { requestNotFound: true });
|
|
21
25
|
}
|
|
@@ -25,7 +29,8 @@ async function recover(pending, dependencies) {
|
|
|
25
29
|
verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
|
|
26
30
|
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
27
31
|
return { response, result };
|
|
28
|
-
} catch (error) { throw error?.requestNotFound ? error : unknown(pending.requestId)
|
|
32
|
+
} catch (error) { throw error?.requestNotFound ? error : Object.assign(unknown(pending.requestId),
|
|
33
|
+
error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
|
|
29
34
|
}
|
|
30
35
|
async function notSent(pending, home) {
|
|
31
36
|
try {
|
|
@@ -36,10 +41,14 @@ async function notSent(pending, home) {
|
|
|
36
41
|
export async function amendmentRemote(stage, operation, payload, state, location, dependencies) {
|
|
37
42
|
const input = { flowId: location.identity.flowId, operation, payload }, requestId = buildInvocation('continue', input).requestId;
|
|
38
43
|
let response, result;
|
|
39
|
-
|
|
44
|
+
const existing = Boolean(state.pending);
|
|
45
|
+
if (existing) {
|
|
40
46
|
if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
|
|
41
47
|
amendmentFail('Recover the original amendment request before another operation');
|
|
42
48
|
}
|
|
49
|
+
}
|
|
50
|
+
if (existing && !await retryKnownPrerequisite(state, location, saveScopeAmendmentState)) {
|
|
51
|
+
consumeRecovery(state, 'status'); await saveScopeAmendmentState(location, state);
|
|
43
52
|
if (state.challengeRetirement) await retireMissingChallenge(state, location, dependencies, recover, notSent);
|
|
44
53
|
try { ({ response, result } = await recover(state.pending, dependencies)); }
|
|
45
54
|
catch (error) {
|
|
@@ -48,16 +57,18 @@ export async function amendmentRemote(stage, operation, payload, state, location
|
|
|
48
57
|
({ response, result } = await recover(state.pending, dependencies));
|
|
49
58
|
}
|
|
50
59
|
} else {
|
|
51
|
-
state.pending = { stage, operation: 'continue', input, requestId };
|
|
52
|
-
|
|
60
|
+
if (!existing) { state.pending = { stage, operation: 'continue', input, requestId };
|
|
61
|
+
await saveScopeAmendmentState(location, state); }
|
|
53
62
|
try { ({ response, result } = await invokeRemoteResult({ operation: 'continue', input }, dependencies)); }
|
|
54
63
|
catch (error) {
|
|
64
|
+
if (await retainPrerequisite(error, state, location, saveScopeAmendmentState)) throw error;
|
|
55
65
|
if (await notSent(state.pending, dependencies.home)) {
|
|
66
|
+
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', effects: 'none', requestId };
|
|
56
67
|
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
57
68
|
throw Object.assign(new Error('Scope amendment was not transmitted; repair its prerequisite and reuse the original input'),
|
|
58
69
|
{ code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
59
70
|
}
|
|
60
|
-
throw unknown(requestId);
|
|
71
|
+
throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
@@ -80,5 +91,8 @@ export async function scopeAmendmentStatus(flowId, dependencies) {
|
|
|
80
91
|
input: { flowId, operation: 'workflow-scope-status', payload: {} } }, dependencies);
|
|
81
92
|
if (!response.ok || result?.output?.status !== 'succeeded') throw new Error();
|
|
82
93
|
return result.output.result ?? result.output;
|
|
83
|
-
} catch
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.servicePrerequisite) throw error;
|
|
96
|
+
amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent');
|
|
97
|
+
}
|
|
84
98
|
}
|
|
@@ -2,6 +2,7 @@ import { canonical, sha256 } from './local-integrity.mjs';
|
|
|
2
2
|
import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
|
|
3
3
|
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
4
4
|
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
5
|
+
import { retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
5
6
|
|
|
6
7
|
export async function retireMissingChallenge(state, location, dependencies, recover, notSent) {
|
|
7
8
|
const original = state.pending;
|
|
@@ -14,14 +15,15 @@ export async function retireMissingChallenge(state, location, dependencies, reco
|
|
|
14
15
|
if (attempt && (attempt.requestId !== call.requestId || canonical(attempt.input) !== canonical(input))) {
|
|
15
16
|
amendmentFail('Challenge retirement must retain its original preparation envelope');
|
|
16
17
|
}
|
|
17
|
-
if (!attempt) {
|
|
18
|
-
attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
|
|
18
|
+
if (!attempt || await retryKnownPrerequisite(state, location, saveScopeAmendmentState, 'challengeRetirement')) {
|
|
19
|
+
if (!attempt) attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
|
|
19
20
|
await saveScopeAmendmentState(location, state);
|
|
20
21
|
try {
|
|
21
22
|
const { result } = await invokeRemoteResult({ operation: 'scope-challenge-retire', input }, dependencies);
|
|
22
23
|
attempt.output = result.output; attempt.feedback = result.feedback; attempt.state = 'completed';
|
|
23
24
|
await saveScopeAmendmentState(location, state);
|
|
24
25
|
} catch (error) {
|
|
26
|
+
if (await retainPrerequisite(error, state, location, saveScopeAmendmentState, 'challengeRetirement')) throw error;
|
|
25
27
|
if (await notSent(attempt, dependencies.home)) {
|
|
26
28
|
attempt.state = 'not-sent'; await saveScopeAmendmentState(location, state);
|
|
27
29
|
}
|
|
@@ -10,17 +10,21 @@ import { trustedOrigin } from './remote-transport.mjs';
|
|
|
10
10
|
import { boundScopeContexts } from './scope-context-state.mjs';
|
|
11
11
|
|
|
12
12
|
const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
|
|
13
|
-
const
|
|
13
|
+
const maximumControlBytes = 1_000_000;
|
|
14
|
+
// Retained scope combines original facts and verified history, not a single
|
|
15
|
+
// signing control. Keep its aggregate bound separate from key/config files.
|
|
16
|
+
const maximumRetainedScopeBytes = 8_000_000;
|
|
14
17
|
// A journal retains the challenge, S proof, original pending request and full
|
|
15
18
|
// signed response together. Each signed authority retains its own 1 MB bound.
|
|
16
19
|
const maximumJournalBytes = 8_000_000;
|
|
17
|
-
async function privateItem(file, final = 'file', maximumBytes =
|
|
20
|
+
async function privateItem(file, final = 'file', maximumBytes = maximumControlBytes) {
|
|
18
21
|
const value = await mutationFile14(file, { final });
|
|
19
22
|
if (process.getuid && value.stat.uid !== process.getuid()
|
|
20
23
|
|| process.platform !== 'win32' && value.stat.mode & 0o077
|
|
21
|
-
|| final === 'file' &&
|
|
22
|
-
fail('Scope amendment controls must be
|
|
24
|
+
|| final === 'file' && value.stat.nlink !== 1) {
|
|
25
|
+
fail('Scope amendment controls must be private single-link files owned by the current OS account');
|
|
23
26
|
}
|
|
27
|
+
if (final === 'file' && value.stat.size > maximumBytes) fail(`Scope amendment file exceeds the ${maximumBytes} byte limit`);
|
|
24
28
|
return value;
|
|
25
29
|
}
|
|
26
30
|
async function privateText(file) {
|
|
@@ -68,7 +72,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
|
|
|
68
72
|
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey
|
|
69
73
|
|| sha256(der) !== binding.keyId) fail('Original scope signing identity changed');
|
|
70
74
|
const originalStatePath = path.join(stateRoot, 'scope', sha256(root).slice(0, 32), input.flowId, 'state.json');
|
|
71
|
-
await privateItem(originalStatePath);
|
|
75
|
+
await privateItem(originalStatePath, 'file', maximumRetainedScopeBytes);
|
|
72
76
|
const saved = await readMutationJson14(originalStatePath);
|
|
73
77
|
const original = await retainedScope({ repositoryRoot: root, home, flowId: input.flowId,
|
|
74
78
|
stepId: saved.stepId, snapshotId: saved.originalSnapshot?.snapshotId }, root);
|
|
@@ -80,7 +84,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
|
|
|
80
84
|
const directory = path.join(base, 'amendments', sha256(canonical(input)));
|
|
81
85
|
await privateMutationDirectory14(directory, { privateFrom: stateRoot });
|
|
82
86
|
const contextAuthority = await boundScopeContexts(base, binding, dependencies);
|
|
83
|
-
return { root, home, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
|
|
87
|
+
return { root, home, apiOrigin, base, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
|
|
84
88
|
binding, key, signerConfigPath, original, contextAuthority };
|
|
85
89
|
}
|
|
86
90
|
export async function readScopeAmendmentState(location) {
|
package/scope-context-client.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { sign } from 'node:crypto';
|
|
3
4
|
import { canonical } from './local-integrity.mjs';
|
|
@@ -9,6 +10,8 @@ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
|
|
|
9
10
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
10
11
|
import { inspectContextEvidence, observeScopeContext, prepareScopeContext } from './scope-context-observer.mjs';
|
|
11
12
|
import { contextJournal, renewContextChallenge } from './scope-context-journal.mjs';
|
|
13
|
+
import { recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
14
|
+
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
12
15
|
|
|
13
16
|
const schemaVersion = 'entrorove.scope-context-connection/1.0';
|
|
14
17
|
function proposalFor(status, input, location) {
|
|
@@ -22,6 +25,9 @@ function proposalFor(status, input, location) {
|
|
|
22
25
|
}
|
|
23
26
|
function waiting(value, flowId) {
|
|
24
27
|
const status = value.proposal?.approval?.status;
|
|
28
|
+
if (status === 'approved') return { ...value, schemaVersion, status: 'succeeded', flowId,
|
|
29
|
+
grantsExecutionAuthority: false, claimsCompletion: false, nextOperation: 'scope-context',
|
|
30
|
+
nextInput: { action: 'apply', flowId, proposalId: value.proposal.proposalId, proposalDigest: value.proposal.proposalDigest } };
|
|
25
31
|
return { ...value, schemaVersion, status: status === 'declined' ? 'blocked' : 'needs-input', flowId,
|
|
26
32
|
grantsExecutionAuthority: false, claimsCompletion: false,
|
|
27
33
|
nextStep: value.nextStep ?? { operation: 'interaction-answer', requiresOriginalOwnerApproval: true,
|
|
@@ -56,8 +62,10 @@ async function review(location, state, dependencies) {
|
|
|
56
62
|
async function applied(location, state) {
|
|
57
63
|
const effective = state.applied.contextAuthority;
|
|
58
64
|
const challenge = contextChallenge((state.signingChallenge ?? state.challenged).challenge, location, state.proposal, { allowExpired: true });
|
|
65
|
+
const compact = effective?.record?.schemaVersion === 'entrorove.scope-context-authority/2.0';
|
|
66
|
+
if (compact) await validateAppliedEvidence(effective.record, state, location, challenge, 'scope-context');
|
|
59
67
|
if (state.applied.grantsExecutionAuthority !== false || !effective?.record
|
|
60
|
-
|| canonical(effective.record.attestation) !== canonical(state.attestation)
|
|
68
|
+
|| !compact && canonical(effective.record.attestation) !== canonical(state.attestation)
|
|
61
69
|
|| ['proposalId', 'proposalDigest', 'ownerId', 'taskDigest', 'chainDigest'].some(field => effective.record[field] !== challenge[field])
|
|
62
70
|
|| canonical(effective.record.importContexts) !== canonical(challenge.importContexts)
|
|
63
71
|
|| canonical(effective.record.evidence) !== canonical(challenge.evidence)) contextFail('Applied context authority differs from this exact actual candidate');
|
|
@@ -76,14 +84,20 @@ async function apply(location, state, dependencies) {
|
|
|
76
84
|
state.proposal = proposal; await saveContextJournal(location, state);
|
|
77
85
|
if (!state.challenged) await contextRemote('challenged', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
|
|
78
86
|
proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attemptId: state.attemptId }, state, location, dependencies);
|
|
79
|
-
if (!state.
|
|
87
|
+
if (!state.evidenceDescriptor) {
|
|
88
|
+
let attestation = state.attestation;
|
|
89
|
+
if (!attestation) {
|
|
80
90
|
const prepared = await prepareScopeContext(location, state, proposal);
|
|
81
|
-
|
|
91
|
+
attestation = await observeScopeContext(location, state, proposal, prepared,
|
|
82
92
|
() => renewContextChallenge(location, state, proposal, dependencies));
|
|
83
|
-
|
|
93
|
+
}
|
|
94
|
+
await saveLocalScopeEvidence(attestation, 'scope-context', (state.signingChallenge ?? state.challenged).challenge,
|
|
95
|
+
state, location, saveContextJournal);
|
|
84
96
|
}
|
|
97
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies,
|
|
98
|
+
(state.signingChallenge ?? state.challenged).challenge, saveContextJournal);
|
|
85
99
|
await contextRemote('applied', 'scope-runner-context-apply', { flowId: location.binding.flowId,
|
|
86
|
-
proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest,
|
|
100
|
+
proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attestationRef }, state, location, dependencies);
|
|
87
101
|
return applied(location, state);
|
|
88
102
|
}
|
|
89
103
|
async function run(location, input, dependencies) {
|
|
@@ -96,7 +110,7 @@ async function run(location, input, dependencies) {
|
|
|
96
110
|
state.completed = true; await saveContextJournal(location, state);
|
|
97
111
|
return { schemaVersion, ...status, status: 'succeeded', flowId: input.flowId, grantsExecutionAuthority: false, claimsCompletion: false };
|
|
98
112
|
} catch (error) {
|
|
99
|
-
if (!state.pending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
|
|
113
|
+
if (!state.pending && !state.evidencePending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
|
|
100
114
|
throw error;
|
|
101
115
|
}
|
|
102
116
|
}
|
|
@@ -109,10 +123,12 @@ export async function runScopeContext(repositoryRoot, input, dependencies) {
|
|
|
109
123
|
return await withStateLock(path.join(location.base, 'connection'), () => run(location, input, dependencies));
|
|
110
124
|
} catch (error) {
|
|
111
125
|
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
126
|
+
...prerequisiteFields(error),
|
|
112
127
|
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
|
|
113
128
|
message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
|
|
114
|
-
...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
129
|
+
...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
130
|
+
...recoveryFailureFields(error),
|
|
115
131
|
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
|
|
116
|
-
requestId: error.requestId, reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
132
|
+
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
117
133
|
}
|
|
118
134
|
}
|
|
@@ -5,6 +5,7 @@ import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files
|
|
|
5
5
|
import { contextFail } from './scope-context-wire.mjs';
|
|
6
6
|
import { readContextJournal, saveContextJournal } from './scope-context-state.mjs';
|
|
7
7
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
8
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
8
9
|
|
|
9
10
|
export async function archiveContextAttempt(location, state) {
|
|
10
11
|
if (!state.attemptId) return;
|
|
@@ -13,28 +14,34 @@ export async function archiveContextAttempt(location, state) {
|
|
|
13
14
|
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(file, state, { privateFrom: location.stateRoot }); }
|
|
14
15
|
}
|
|
15
16
|
function reusableSnapshot(state, input) {
|
|
16
|
-
return input.action === 'apply' && state.snapshot && state.challenged && !state.pending && !state.completed && !state.applied
|
|
17
|
+
return input.action === 'apply' && state.snapshot && state.challenged && !state.pending && !state.evidencePending && !state.completed && !state.applied
|
|
17
18
|
&& state.rejection && !state.rejection.requestId && !state.rejection.output;
|
|
18
19
|
}
|
|
19
20
|
export async function contextJournal(location, input) {
|
|
20
21
|
let state = await readContextJournal(location, input);
|
|
21
22
|
if (!state.previous && reusableSnapshot(state, input)) {
|
|
22
23
|
await archiveContextAttempt(location, state);
|
|
23
|
-
|
|
24
|
+
consumeRecovery(state, 'attempt', state.signingAttemptId ?? state.attemptId);
|
|
25
|
+
for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId',
|
|
26
|
+
'evidenceDescriptor', 'evidenceSignature', 'evidencePrepared', 'attestationRef', 'evidenceResult']) delete state[field];
|
|
24
27
|
await saveContextJournal(location, state);
|
|
25
|
-
} else if (state.previous || !state.pending && !state.applied && (state.rejection
|
|
28
|
+
} else if (state.previous || !state.pending && !state.evidencePending && !state.applied && (state.rejection
|
|
26
29
|
|| input.action === 'status' && state.completed || input.action === 'apply' && state.completed)) {
|
|
27
|
-
|
|
30
|
+
const previous = state.previous ?? state;
|
|
31
|
+
await archiveContextAttempt(location, previous);
|
|
32
|
+
const recovery = previous.completed ? undefined : consumeRecovery(previous, 'attempt', previous.attemptId);
|
|
33
|
+
state = { ...(recovery ? { recovery } : {}) };
|
|
28
34
|
}
|
|
29
35
|
if (!state.attemptId) {
|
|
30
36
|
state = { schemaVersion: 'entrorove.scope-context-journal/1.0', localBindingDigest: location.localBindingDigest,
|
|
31
|
-
input, attemptId: randomUUID() }; await saveContextJournal(location, state);
|
|
37
|
+
input, attemptId: randomUUID(), ...(state.recovery ? { recovery: state.recovery } : {}) }; await saveContextJournal(location, state);
|
|
32
38
|
}
|
|
33
39
|
return state;
|
|
34
40
|
}
|
|
35
41
|
export async function renewContextChallenge(location, state, proposal, dependencies) {
|
|
36
|
-
if (state.pending) contextFail('Recover the original pending request before any challenge renewal');
|
|
42
|
+
if (state.pending || state.evidencePending) contextFail('Recover the original pending request before any challenge renewal');
|
|
37
43
|
await archiveContextAttempt(location, state);
|
|
44
|
+
consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
|
|
38
45
|
delete state.signingChallenge;
|
|
39
46
|
state.signingAttemptId = randomUUID(); await saveContextJournal(location, state);
|
|
40
47
|
const output = await contextRemote('signingChallenge', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
|