@entrorove/skill 0.1.61 → 0.1.64

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.
Files changed (54) hide show
  1. package/CAPABILITY-MAP.md +7 -2
  2. package/CHANGELOG.md +15 -0
  3. package/IDE-EXECUTION.md +39 -6
  4. package/REUSE-DISCIPLINE.md +1 -1
  5. package/SKILL.md +6 -2
  6. package/cli-input.mjs +22 -0
  7. package/cli.mjs +2 -8
  8. package/cloud-task-client.mjs +10 -2
  9. package/cloud-task-images.mjs +39 -0
  10. package/ide-exit.mjs +49 -9
  11. package/ide-hook.mjs +2 -2
  12. package/local-contracts.mjs +4 -1
  13. package/local-discipline-gate.mjs +14 -3
  14. package/local-discipline-repository.mjs +11 -5
  15. package/local-finder-plist.mjs +87 -0
  16. package/local-finder-store.mjs +115 -0
  17. package/local-mutation-authority.mjs +11 -0
  18. package/local-mutation-completion-evidence.mjs +83 -0
  19. package/local-mutation-consumption.mjs +39 -14
  20. package/local-mutation-environment-observation.mjs +85 -0
  21. package/local-mutation-finder-observation.mjs +58 -0
  22. package/local-mutation-wire.mjs +28 -9
  23. package/local-preserved-mutation-protection.mjs +8 -1
  24. package/local-preserved-mutation-wire.mjs +7 -3
  25. package/local-scope-files.mjs +3 -1
  26. package/local-scope-font.mjs +45 -0
  27. package/local-scope-repository.mjs +16 -3
  28. package/local-scope-zip.mjs +93 -0
  29. package/manifest.json +1 -1
  30. package/member-producer-repair-authority.mjs +48 -0
  31. package/model-images.d.mts +9 -0
  32. package/model-images.mjs +87 -0
  33. package/mutation-challenge-refresh.mjs +51 -0
  34. package/mutation-client.mjs +45 -27
  35. package/mutation-connection-state.mjs +17 -0
  36. package/package.json +18 -2
  37. package/release-state.json +52 -36
  38. package/release.mjs +12 -4
  39. package/releases.json +18 -0
  40. package/remote-client.mjs +1 -1
  41. package/remote-transport.mjs +48 -16
  42. package/scope-amendment-client.mjs +1 -1
  43. package/scope-amendment-consumption.mjs +32 -0
  44. package/scope-amendment-mutation.mjs +3 -4
  45. package/scope-amendment-observer.mjs +7 -3
  46. package/scope-amendment-remote.mjs +12 -2
  47. package/scope-amendment-retirement.mjs +54 -0
  48. package/scope-amendment-state.mjs +7 -4
  49. package/scope-amendment-wire.mjs +3 -2
  50. package/task-entry-client.mjs +1 -0
  51. package/workflow-observation-client.mjs +11 -0
  52. package/workflow-observation-connection.mjs +2 -1
  53. package/workflow-observation-remote.mjs +26 -1
  54. package/workflow-observation-wire.mjs +6 -3
@@ -113,7 +113,7 @@ export async function runScopeAmend(repositoryRoot, input, dependencies) {
113
113
  return { schemaVersion, status: 'blocked', flowId: input?.flowId,
114
114
  code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_AMENDMENT_PREREQUISITE',
115
115
  message: ['ENTROROVE_SCOPE_AMENDMENT_BINDING', 'ENTROROVE_SCOPE_AMENDMENT_STATE', 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN',
116
- 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT'].includes(error?.code) ? error.message
116
+ 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', 'ENTROROVE_SCOPE_AMENDMENT_MUTATION_PENDING'].includes(error?.code) ? error.message
117
117
  : 'Scope amendment stopped; preserve the original proposal, signer, journal and actual evidence.',
118
118
  ...(requestId ? { requestId } : {}), ...(error?.output ? { output: error.output } : {}),
119
119
  ...(error?.feedback ? { feedback: error.feedback } : {}),
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { withStateLock } from './local-state-lock.mjs';
4
+ import { mutationFail14, privateMutationDirectory14 } from './local-mutation-files.mjs';
5
+ import { preservedMutationConsumptionState, attestMutationConsumption14 } from './local-mutation-consumption.mjs';
6
+ import { readMutationConnection } from './mutation-connection-state.mjs';
7
+
8
+ export async function observeScopeMutationConsumption(location, state, pass) {
9
+ // A pending S keeps its exact old attestation and is recovered with GET. A
10
+ // new signed S challenge can observe the same physical writes afresh, but
11
+ // must not fork an unresolved settlement of that original M receipt.
12
+ if (state.pending || state.attestation) mutationFail14('ENTROROVE_SCOPE_AMENDMENT_MUTATION_PENDING',
13
+ 'Recover the original pending scope request before observing its M consumption again');
14
+ const input = { repositoryRoot: location.root, home: location.home,
15
+ flowId: pass.flowId, taskId: pass.taskId, stepId: pass.stepId, gatePassPath: location.identity.gatePassPath };
16
+ const original = await preservedMutationConsumptionState(input), { binding } = original;
17
+ if (canonical(original.pass) !== canonical(pass)) mutationFail14('ENTROROVE_SCOPE_AMENDMENT_BINDING', 'Original mutation pass changed');
18
+ const directory = path.join(binding.directory, 'settlements');
19
+ await privateMutationDirectory14(directory, { privateFrom: binding.stateRoot });
20
+ const journal = { ...binding, file: path.join(directory, `${pass.passId}.json`) };
21
+ const identity = { flowId: pass.flowId, stepId: pass.stepId, passDigest: sha256(canonical(pass)),
22
+ repositoryRoot: binding.root, apiOrigin: location.identity.apiOrigin, mutationKeyId: binding.binding.keyId };
23
+ return withStateLock(journal.file, async () => {
24
+ const settlement = await readMutationConnection(journal, identity);
25
+ if (settlement.pending || settlement.settled) mutationFail14('ENTROROVE_SCOPE_AMENDMENT_MUTATION_PENDING',
26
+ 'Recover the original mutation-settle request before a fresh scope observation; do not replace its receipt');
27
+ // Renewal re-reads every original target and protected file, verifies the
28
+ // old signature and write times, and archives the expired observation.
29
+ // The original pass, write ledger and their expiry remain unchanged.
30
+ return attestMutationConsumption14(input, { renewExpired: true });
31
+ });
32
+ }
@@ -1,12 +1,12 @@
1
1
  import { mutationPass14 } from './local-mutation-wire.mjs';
2
2
  import { readMutationJson14 } from './local-mutation-files.mjs';
3
- import { attestMutationConsumption14 } from './local-mutation-consumption.mjs';
3
+ import { observeScopeMutationConsumption } from './scope-amendment-consumption.mjs';
4
4
  import { amendmentFail } from './scope-amendment-wire.mjs';
5
5
  import path from 'node:path';
6
6
  import { sha256 } from './local-integrity.mjs';
7
7
 
8
8
  export async function scopeAmendmentMutation(location, state, challenge, facts) {
9
- const changed = challenge.files.filter(file => file.access === 'write'
9
+ const changed = challenge.files.filter(file => file.access !== 'read-only'
10
10
  && facts.files.find(actual => actual.path === file.path)?.sha256 !== challenge.currentFiles.find(current => current.path === file.path)?.sha256);
11
11
  if (!changed.length || challenge.purpose !== 'scope-revision-refresh') return undefined;
12
12
  const gatePassPath = location.identity.gatePassPath;
@@ -20,7 +20,6 @@ export async function scopeAmendmentMutation(location, state, challenge, facts)
20
20
  || pass.memberScopeKeyId !== challenge.keyId || pass.memberScopeBindingDigest !== challenge.originalBindingDigest
21
21
  || pass.scopeRevisionDigest !== challenge.authorityRevisionDigest
22
22
  || changed.some(file => !pass.paths.includes(file.path))) amendmentFail('Scope refresh cannot replace its actual original mutation pass or approved write paths');
23
- const observed = await attestMutationConsumption14({ repositoryRoot: location.root, home: location.home,
24
- flowId: pass.flowId, taskId: pass.taskId, stepId: pass.stepId, gatePassPath }, { allowExpiredReplay: true });
23
+ const observed = await observeScopeMutationConsumption(location, state, pass);
25
24
  return { passId: pass.passId, consumptionReceipt: observed.consumptionReceipt };
26
25
  }
@@ -12,7 +12,9 @@ import { scopeAmendmentMutation } from './scope-amendment-mutation.mjs';
12
12
  async function inspectFiles(location, challenge) {
13
13
  const physical = new Set();
14
14
  for (const file of challenge.files) {
15
- const target = await scopeTarget(location.root, file.path);
15
+ const target = await scopeTarget(location.root, file.path, file.access === 'create');
16
+ if (!target.exists && file.access === 'create') continue;
17
+ if (file.access === 'create' && challenge.purpose === 'scope-revision') amendmentFail('A create role requires an actually absent target before approval is applied');
16
18
  if (!target.stat.isFile()) amendmentFail('Scope amendment permits only exact existing ordinary files');
17
19
  const identity = `${target.stat.dev}:${target.stat.ino}`;
18
20
  if (physical.has(identity)) amendmentFail('Scope amendment roles cannot alias the same physical file');
@@ -25,7 +27,8 @@ export async function observeScopeAmendment(location, state) {
25
27
  if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
26
28
  const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
27
29
  snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
28
- contractDigest: challenge.proposalDigest, importContexts: location.binding.importContexts };
30
+ contractDigest: challenge.proposalDigest, importContexts: location.binding.importContexts,
31
+ scopeCreatePaths: challenge.files.filter(file => file.access === 'create').map(file => file.path) };
29
32
  if (!state.snapshot) {
30
33
  if (!state.snapshotStarted) {
31
34
  state.snapshotStarted = true; await saveScopeAmendmentState(location, state);
@@ -43,7 +46,8 @@ export async function observeScopeAmendment(location, state) {
43
46
  facts.snapshotEvidence = await verifyFreshScopeSnapshot(input, location.root, facts);
44
47
  for (const file of challenge.files) {
45
48
  const actual = facts.files.find(item => item.path === file.path);
46
- if (!actual?.exists || (challenge.purpose === 'scope-revision' || file.access === 'read-only') && actual.sha256 !== file.sha256) {
49
+ if (!actual || file.access !== 'create' && !actual.exists
50
+ || (challenge.purpose === 'scope-revision' || file.access === 'read-only') && actual.sha256 !== file.sha256) {
47
51
  amendmentFail('Scope amendment bytes differ from the exact original approved roles');
48
52
  }
49
53
  }
@@ -6,6 +6,7 @@ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFe
6
6
  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
+ import { retireMissingChallenge } from './scope-amendment-retirement.mjs';
9
10
 
10
11
  const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
11
12
  { code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
@@ -15,13 +16,16 @@ async function recover(pending, dependencies) {
15
16
  const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
16
17
  headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
17
18
  const result = parseTransportResponse(response, 'original scope amendment status');
19
+ if (response.status === 404 && result?.error === 'request-not-found') {
20
+ throw Object.assign(unknown(pending.requestId), { requestNotFound: true });
21
+ }
18
22
  if (result?.output?.requestId !== pending.requestId || response.status >= 500
19
23
  || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
20
24
  result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
21
25
  verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
22
26
  result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
23
27
  return { response, result };
24
- } catch { throw unknown(pending.requestId); }
28
+ } catch (error) { throw error?.requestNotFound ? error : unknown(pending.requestId); }
25
29
  }
26
30
  async function notSent(pending, home) {
27
31
  try {
@@ -36,7 +40,13 @@ export async function amendmentRemote(stage, operation, payload, state, location
36
40
  if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
37
41
  amendmentFail('Recover the original amendment request before another operation');
38
42
  }
39
- ({ response, result } = await recover(state.pending, dependencies));
43
+ if (state.challengeRetirement) await retireMissingChallenge(state, location, dependencies, recover, notSent);
44
+ try { ({ response, result } = await recover(state.pending, dependencies)); }
45
+ catch (error) {
46
+ if (!error?.requestNotFound || stage !== 'challenged') throw error;
47
+ await retireMissingChallenge(state, location, dependencies, recover, notSent);
48
+ ({ response, result } = await recover(state.pending, dependencies));
49
+ }
40
50
  } else {
41
51
  state.pending = { stage, operation: 'continue', input, requestId };
42
52
  await saveScopeAmendmentState(location, state);
@@ -0,0 +1,54 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
3
+ import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
4
+ import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
5
+
6
+ export async function retireMissingChallenge(state, location, dependencies, recover, notSent) {
7
+ const original = state.pending;
8
+ if (original.stage !== 'challenged' || original.input.operation !== 'workflow-scope-challenge') {
9
+ amendmentFail('Only the original missing challenge preparation has a retirement path');
10
+ }
11
+ const input = { requestId: original.requestId, flowId: original.input.flowId, payload: original.input.payload };
12
+ const call = buildInvocation('scope-challenge-retire', input);
13
+ let attempt = state.challengeRetirement;
14
+ if (attempt && (attempt.requestId !== call.requestId || canonical(attempt.input) !== canonical(input))) {
15
+ amendmentFail('Challenge retirement must retain its original preparation envelope');
16
+ }
17
+ if (!attempt) {
18
+ attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
19
+ await saveScopeAmendmentState(location, state);
20
+ try {
21
+ const { result } = await invokeRemoteResult({ operation: 'scope-challenge-retire', input }, dependencies);
22
+ attempt.output = result.output; attempt.feedback = result.feedback; attempt.state = 'completed';
23
+ await saveScopeAmendmentState(location, state);
24
+ } catch (error) {
25
+ if (await notSent(attempt, dependencies.home)) {
26
+ attempt.state = 'not-sent'; await saveScopeAmendmentState(location, state);
27
+ }
28
+ throw error;
29
+ }
30
+ } else if (attempt.state !== 'completed') {
31
+ // A retirement with an uncertain outcome has its own original GET. Neither
32
+ // the original challenge nor the retirement is retransmitted here.
33
+ if (attempt.state === 'not-sent') {
34
+ delete state.challengeRetirement; await saveScopeAmendmentState(location, state);
35
+ return retireMissingChallenge(state, location, dependencies, recover, notSent);
36
+ }
37
+ const { result } = await recover(attempt, dependencies);
38
+ attempt.output = result.output; attempt.feedback = result.feedback; attempt.state = 'completed';
39
+ await saveScopeAmendmentState(location, state);
40
+ }
41
+ const output = attempt.output;
42
+ if (output?.requestId !== call.requestId) amendmentFail('Retirement lost its original request identity');
43
+ if (output.status === 'blocked' && output.result?.reason === 'original-admission-already-recorded') return;
44
+ if (output.status !== 'succeeded' || output.result?.retired !== true) amendmentFail('The server did not retire this original missing preparation');
45
+ const receipt = scopeRevisionAuthority(output.result.retirement, 'entrorove.scope-challenge-retirement/1.0');
46
+ if (receipt.purpose !== 'scope-challenge-retirement' || receipt.requestId !== original.requestId
47
+ || receipt.retirementRequestId !== call.requestId || receipt.flowId !== input.flowId
48
+ || receipt.proposalId !== input.payload.proposalId || receipt.proposalDigest !== input.payload.proposalDigest
49
+ || receipt.receiverId !== input.payload.receiverId || receipt.attemptId !== input.payload.attemptId
50
+ || receipt.originalInputDigest !== sha256(canonical(original.input)) || receipt.outcome !== 'retired-before-admission'
51
+ || ['claimsOriginalExecution', 'grantsExecutionAuthority', 'grantsWriteAuthority', 'claimsCompletion'].some(key => receipt[key] !== false)) {
52
+ amendmentFail('Challenge retirement changed its exact original preparation or authority boundary');
53
+ }
54
+ }
@@ -10,11 +10,14 @@ import { trustedOrigin } from './remote-transport.mjs';
10
10
 
11
11
  const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
12
12
  const maximumStateBytes = 1_000_000;
13
- async function privateItem(file, final = 'file') {
13
+ // A journal retains the challenge, S proof, original pending request and full
14
+ // signed response together. Each signed authority retains its own 1 MB bound.
15
+ const maximumJournalBytes = 8_000_000;
16
+ async function privateItem(file, final = 'file', maximumBytes = maximumStateBytes) {
14
17
  const value = await mutationFile14(file, { final });
15
18
  if (process.getuid && value.stat.uid !== process.getuid()
16
19
  || process.platform !== 'win32' && value.stat.mode & 0o077
17
- || final === 'file' && (value.stat.nlink !== 1 || value.stat.size > maximumStateBytes)) {
20
+ || final === 'file' && (value.stat.nlink !== 1 || value.stat.size > maximumBytes)) {
18
21
  fail('Scope amendment controls must be bounded private files owned by the current OS account');
19
22
  }
20
23
  return value;
@@ -80,7 +83,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
80
83
  }
81
84
  export async function readScopeAmendmentState(location) {
82
85
  let value;
83
- try { await privateItem(location.file); value = await readMutationJson14(location.file); }
86
+ try { await privateItem(location.file, 'file', maximumJournalBytes); value = await readMutationJson14(location.file); }
84
87
  catch (error) { if (error.code !== 'ENOENT') throw error; }
85
88
  if (!value) return { schemaVersion: 'entrorove.scope-amendment-state/1.0', identity: location.identity };
86
89
  if (value.schemaVersion !== 'entrorove.scope-amendment-state/1.0'
@@ -89,6 +92,6 @@ export async function readScopeAmendmentState(location) {
89
92
  return value;
90
93
  }
91
94
  export async function saveScopeAmendmentState(location, value) {
92
- if (Buffer.byteLength(canonical(value)) > maximumStateBytes) fail('Scope amendment state exceeds its bounded private file limit');
95
+ if (Buffer.byteLength(canonical(value)) + 1 > maximumJournalBytes) fail('Scope amendment state exceeds its bounded private file limit');
93
96
  return atomicMutationJson14(location.file, value, { privateFrom: location.stateRoot, replace: true });
94
97
  }
@@ -22,8 +22,9 @@ export function amendmentFiles(files, original) {
22
22
  if (!Array.isArray(files) || !files.length || files.length > 512) amendmentFail('Scope amendment requires a bounded complete exact role list');
23
23
  const requirements = new Set(original?.requirements?.length ? original.requirements.map(item => item.id) : ['task-goal']);
24
24
  for (const file of files) {
25
- amendmentExact(file, ['path', 'sha256', 'access', 'requirementRefs', 'reason']); scopePath(file.path);
26
- if (!hash.test(file.sha256) || !['write', 'read-only'].includes(file.access)
25
+ amendmentExact(file, ['path', 'sha256', 'access', 'requirementRefs', 'reason', ...(file.access === 'create' ? ['producerNodeRef'] : [])]); scopePath(file.path);
26
+ if (!hash.test(file.sha256) || !['write', 'read-only', 'create'].includes(file.access)
27
+ || file.access === 'create' && (!identifier.test(file.producerNodeRef) || file.sha256 !== sha256('entrorove.missing-file/1.0'))
27
28
  || typeof file.reason !== 'string' || !file.reason.trim() || file.reason.length > 2000
28
29
  || !Array.isArray(file.requirementRefs) || !file.requirementRefs.length || file.requirementRefs.length > 512
29
30
  || new Set(file.requirementRefs).size !== file.requirementRefs.length
@@ -28,5 +28,6 @@ export async function routeTaskEntry(data, token, dependencies) {
28
28
  if (!task || typeof task.goal !== 'string') throw new Error('Task entry requires the original task goal');
29
29
  const ideTaskId = ideTaskIdentity(data.ideTaskId);
30
30
  return runCloudTask({ goal: task.goal, task,
31
+ ...(data.images === undefined ? {} : { images: data.images }),
31
32
  ...(ideTaskId ? { ideTaskId } : {}),...(choice ? { sandboxChoice: choice } : {}) },dependencies);
32
33
  }
@@ -5,6 +5,7 @@ const safeId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-
5
5
  const safeRequest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
6
6
  const messages = {
7
7
  ENTROROVE_LOCAL_OBSERVATION_UNKNOWN: 'Original observation outcome is unknown; recover its original GET without repeating execution',
8
+ ENTROROVE_LOCAL_OBSERVATION_REJECTED: 'Original observation request failed and was acknowledged; repair its cause and read the original live task guidance',
8
9
  ENTROROVE_LOCAL_OBSERVATION_NOT_SENT: 'Observation was not transmitted; repair its local or discovery prerequisite and reuse the original input',
9
10
  ENTROROVE_LOCAL_OBSERVATION_PREREQUISITE: 'Local observation requires cloud sandbox disabled and its original live execution lease',
10
11
  ENTROROVE_SCOPE_RECEIPT_EXPIRED: 'Refresh the original scope evidence and reuse the original observation input',
@@ -24,6 +25,11 @@ function blocked(error, input) {
24
25
  'ENTROROVE_SCOPE_RUNNER_REQUIRED', 'ENTROROVE_SCOPE_SNAPSHOT_REQUIRED', 'ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED'].includes(code));
25
26
  const reason = ['sandbox-enabled', 'cloud-sandbox-enabled', 'lease-expired', 'receipt-expired', 'after-write-observation-required'].includes(output?.reason)
26
27
  ? output.reason : undefined;
28
+ const rejected = code === 'ENTROROVE_LOCAL_OBSERVATION_REJECTED' && cause.knownTerminalFailure === true
29
+ && requestId && cause.rejection?.status === 'failed' && cause.rejection.acknowledged === true
30
+ && /^[a-f0-9-]{36}$/.test(cause.rejection.receiptId ?? '')
31
+ && Number.isSafeInteger(cause.rejection.generation) && cause.rejection.generation > 0
32
+ && ['member-observation-admit', 'member-observation-report'].includes(cause.rejection.operation);
27
33
  return { schemaVersion: 'entrorove.local-observation-connection/1.0', status: 'blocked',
28
34
  ...(safeId(input?.flowId) ? { flowId: input.flowId } : {}), independentValidation: 'pending', code,
29
35
  message: messages[code] ?? 'Local observation could not continue; its original command and result remain preserved',
@@ -33,6 +39,11 @@ function blocked(error, input) {
33
39
  ...(safeRequest(output.requestId) ? { resumeRequestId: output.requestId } : {}) } } } : {}),
34
40
  ...(cause.outcomeUnknown === true && requestId ? { outcomeUnknown: true, requestId, repeatPost: false,
35
41
  nextAction: { operation: 'workflow-observe-step', reuseOriginalInput: true, method: 'GET' } } : {}),
42
+ ...(rejected ? { outcomeUnknown: false, requestId, repeatPost: false, grantsExecutionAuthority: false,
43
+ rejection: { operation: cause.rejection.operation, status: 'failed', receiptId: cause.rejection.receiptId,
44
+ generation: cause.rejection.generation, acknowledged: true, effects: 'not-inferred' },
45
+ nextAction: { operation: 'task-resume', input: { flowId: input.flowId }, preserveOriginalFlow: true,
46
+ localProcessMayStart: false, originalRequestRetained: true } } : {}),
36
47
  ...(cause.knownBeforeTransmission === true && requestId ? { requestId, knownBeforeTransmission: true } : {}) };
37
48
  }
38
49
  export async function runWorkflowObserveStep(repositoryRoot, input, dependencies) {
@@ -9,7 +9,8 @@ import { verifyWorkflowLocalAcceptance } from './workflow-local-acceptance.mjs';
9
9
  import { buildInvocation } from './remote-client.mjs';
10
10
 
11
11
  async function connect(input, state, location, dependencies) {
12
- const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', ...(Object.hasOwn(input, 'settlementId') ? ['settlementId'] : [])];
12
+ const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', ...(Object.hasOwn(input, 'settlementId') ? ['settlementId'] : []),
13
+ ...(Object.hasOwn(input, 'dependencyRepairDigest') ? ['dependencyRepairDigest'] : [])];
13
14
  const original = Object.fromEntries(fields.map(key => [key, input[key]]));
14
15
  const admitted = state.admitted ?? await observationRemote('admitted', 'member-observation-admit', original, state, location, dependencies);
15
16
  const admission = observationAdmission(admitted.admission, input, { allowExpired: Boolean(state.result) });
@@ -51,6 +51,18 @@ function cachedReportPrerequisite(result, pending, state, location) {
51
51
  && next.reuseOriginalInput === true && next.resumeRequestId === pending.requestId && next.preserveOriginalFlow === true
52
52
  && result.feedback?.acknowledgement?.stored === true && result.feedback.resultDigest === sha256(canonical(output));
53
53
  }
54
+ function terminalRejection(result, pending) {
55
+ const output = result?.output, feedback = result?.feedback, ack = feedback?.acknowledgement;
56
+ try { verifyAutomaticFeedback(feedback, pending.requestId, pending.requestId); } catch { return null; }
57
+ if (output?.schemaVersion !== 'entrorove.skill.response/1.0' || output.requestId !== pending.requestId
58
+ || output.status !== 'failed' || feedback.resultDigest !== sha256(canonical(output))
59
+ || feedback.evaluation.operation !== pending.operation || feedback.evaluation.status !== 'failed'
60
+ || ack?.stored !== true || ack.requestId !== pending.requestId || ack.receiptId !== feedback.receiptId
61
+ || ack.generation !== (feedback.generation ?? 1)) return null;
62
+ return { operation: pending.operation, status: 'failed', receiptId: feedback.receiptId,
63
+ generation: feedback.generation ?? 1, acknowledged: true, effects: 'not-inferred',
64
+ ...(/^[a-z][a-z0-9-]{0,63}$/.test(output.error ?? '') ? { error: output.error } : {}) };
65
+ }
54
66
  export async function observationRemote(stage, operation, input, state, location, dependencies) {
55
67
  const requestId = buildInvocation(operation, input).requestId;
56
68
  let response, result;
@@ -79,6 +91,17 @@ export async function observationRemote(stage, operation, input, state, location
79
91
  throw Object.assign(new Error(result.output.message ?? 'Original local observation prerequisite is unresolved'),
80
92
  { code: result.output.code, output: result.output });
81
93
  }
94
+ const rejection = terminalRejection(result, state.pending);
95
+ if (rejection) {
96
+ const failure = { ...state.pending, output: result.output, feedback: result.feedback };
97
+ if (canonical(state.failures?.at(-1) ?? null) !== canonical(failure)) state.failures = [...(state.failures ?? []), failure];
98
+ state.rejection = failure;
99
+ // Keep the original pending binding: a known failure grants no retry,
100
+ // no replacement admission and no second launch of a cached process.
101
+ await saveObservationState(location, state);
102
+ throw Object.assign(new Error('The original observation request failed and its receipt was acknowledged'),
103
+ { code: 'ENTROROVE_LOCAL_OBSERVATION_REJECTED', knownTerminalFailure: true, requestId, rejection });
104
+ }
82
105
  throw unknown(requestId);
83
106
  }
84
107
  state[stage] = result.output; delete state.pending; await saveObservationState(location, state);
@@ -91,9 +114,11 @@ export async function authorizeObservation(admission, dependencies, launching) {
91
114
  const status = parseTransportResponse(response, 'original local observation account');
92
115
  if (!response.ok || status?.schemaVersion !== 'entrorove.flow-status/1.0'
93
116
  || status.flow?.flowId !== admission.flowId || status.flow.ownerId !== admission.ownerId
94
- || launching && (status.flow.stepId !== admission.stepId || status.flow.status !== 'running'
117
+ || launching && (status.flow.stepId !== admission.stepId && !memberProducerRepairStepMatches(status, admission)
118
+ || status.flow.status !== 'running'
95
119
  || status.workflow?.phase !== 'execute' || status.workflow.terminalAt
96
120
  || ['pending', 'declined'].includes(status.workflow.authorization))) {
97
121
  observationFail('Current account cannot confirm its original local observation task and execution step');
98
122
  }
99
123
  }
124
+ import { memberProducerRepairStepMatches } from './member-producer-repair-authority.mjs';
@@ -10,9 +10,11 @@ export function observationExact(value, fields) {
10
10
  || canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) observationFail('Local observation fields are invalid');
11
11
  }
12
12
  export function observationInput(input) {
13
- const fields = Object.hasOwn(input ?? {}, 'settlementId') ? observationIdentityFields : observationReadOnlyIdentityFields;
13
+ const fields = [...(Object.hasOwn(input ?? {}, 'settlementId') ? observationIdentityFields : observationReadOnlyIdentityFields),
14
+ ...(Object.hasOwn(input ?? {}, 'dependencyRepairDigest') ? ['dependencyRepairDigest'] : [])];
14
15
  observationExact(input, fields);
15
- for (const field of fields.filter(field => field !== 'generation')) mutationId14(input[field], field);
16
+ for (const field of fields.filter(field => !['generation', 'dependencyRepairDigest'].includes(field))) mutationId14(input[field], field);
17
+ if (Object.hasOwn(input, 'dependencyRepairDigest') && !/^[a-f0-9]{64}$/.test(input.dependencyRepairDigest)) observationFail('Dependency repair reference must be its exact accepted server digest');
16
18
  if (!Number.isSafeInteger(input.generation) || input.generation < 1) observationFail('Local observation generation is invalid');
17
19
  return input;
18
20
  }
@@ -53,7 +55,8 @@ export function observationAuthoritySignature(value) {
53
55
  }
54
56
  export function observationAdmission(value, input, { allowExpired = false } = {}) {
55
57
  const readOnly = !Object.hasOwn(input, 'settlementId');
56
- const fields = readOnly ? observationReadOnlyIdentityFields : observationIdentityFields;
58
+ const fields = [...(readOnly ? observationReadOnlyIdentityFields : observationIdentityFields),
59
+ ...(Object.hasOwn(input, 'dependencyRepairDigest') ? ['dependencyRepairDigest'] : [])];
57
60
  observationExact(value, [...fields, 'schemaVersion', 'purpose', 'observationId', 'ownerId', 'taskDigest',
58
61
  'chainDigest', 'epoch', 'planId', 'planDigest', 'executionPlanSha256', 'repositoryDigest',
59
62
  ...(readOnly ? ['sourceKind'] : ['settlementDigest']), 'command',