@entrorove/skill 0.1.62 → 0.1.67

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 (89) hide show
  1. package/CAPABILITY-MAP.md +30 -2
  2. package/CHANGELOG.md +25 -0
  3. package/DELIVERY-BOUNDARIES.md +2 -0
  4. package/DESIGN-GATES.md +160 -0
  5. package/IDE-EXECUTION.md +32 -3
  6. package/REUSE-DISCIPLINE.md +16 -2
  7. package/SKILL.md +9 -3
  8. package/blueprint-criterion-runner.mjs +1 -1
  9. package/cli-input.mjs +22 -0
  10. package/cli.mjs +13 -12
  11. package/cloud-task-client.mjs +10 -2
  12. package/cloud-task-images.mjs +39 -0
  13. package/design-browser-checks.mjs +86 -0
  14. package/design-browser-identity.mjs +13 -0
  15. package/design-browser-state.mjs +41 -0
  16. package/design-browser.mjs +181 -0
  17. package/design-context.mjs +34 -0
  18. package/design-contracts.d.mts +4 -0
  19. package/design-contracts.mjs +77 -0
  20. package/design-declaration.mjs +17 -0
  21. package/design-frameworks.mjs +4 -0
  22. package/design-gate.mjs +19 -0
  23. package/design-lint.mjs +115 -0
  24. package/design-local-contracts.mjs +15 -0
  25. package/design-operations.mjs +81 -0
  26. package/design-policy.d.mts +5 -0
  27. package/design-policy.mjs +137 -0
  28. package/design-release.mjs +25 -0
  29. package/design-research.mjs +99 -0
  30. package/design-runner.mjs +31 -0
  31. package/design-store.mjs +116 -0
  32. package/design-ui.mjs +24 -0
  33. package/ide-exit.mjs +49 -9
  34. package/ide-hook.mjs +2 -2
  35. package/install-identity.mjs +16 -0
  36. package/local-cli.mjs +3 -1
  37. package/local-contracts.mjs +6 -1
  38. package/local-discipline-actions.mjs +5 -0
  39. package/local-discipline-batch.mjs +12 -3
  40. package/local-discipline-contracts.mjs +15 -3
  41. package/local-discipline-gate.mjs +17 -2
  42. package/local-discipline-repository.mjs +11 -5
  43. package/local-error.mjs +18 -1
  44. package/local-execution.mjs +2 -1
  45. package/local-finder-plist.mjs +87 -0
  46. package/local-finder-store.mjs +115 -0
  47. package/local-mutation-authority.mjs +13 -0
  48. package/local-mutation-completion-evidence.mjs +83 -0
  49. package/local-mutation-consumption.mjs +33 -10
  50. package/local-mutation-environment-observation.mjs +85 -0
  51. package/local-mutation-finder-observation.mjs +58 -0
  52. package/local-mutation-wire.mjs +26 -8
  53. package/local-runner-discipline.mjs +17 -3
  54. package/local-scope-files.mjs +3 -1
  55. package/local-scope-font.mjs +45 -0
  56. package/local-scope-repository.mjs +16 -3
  57. package/local-scope-zip.mjs +114 -0
  58. package/manifest.json +1 -1
  59. package/member-producer-repair-authority.mjs +48 -0
  60. package/model-images.d.mts +9 -0
  61. package/model-images.mjs +87 -0
  62. package/mutation-challenge-refresh.mjs +51 -0
  63. package/mutation-client.mjs +42 -24
  64. package/mutation-connection-state.mjs +17 -0
  65. package/package-archive.mjs +54 -0
  66. package/package-replacement.mjs +48 -0
  67. package/package.json +50 -6
  68. package/release-state.json +87 -45
  69. package/release.mjs +11 -1
  70. package/releases.json +30 -0
  71. package/remote-client.mjs +1 -1
  72. package/remote-transport.mjs +48 -16
  73. package/reuse-discipline-release.mjs +3 -1
  74. package/rollback-history.mjs +51 -0
  75. package/rollback-runner.mjs +77 -0
  76. package/scope-amendment-client.mjs +1 -1
  77. package/scope-amendment-consumption.mjs +32 -0
  78. package/scope-amendment-mutation.mjs +2 -3
  79. package/scope-amendment-observer.mjs +2 -1
  80. package/scope-amendment-remote.mjs +12 -2
  81. package/scope-amendment-retirement.mjs +54 -0
  82. package/scope-amendment-state.mjs +7 -4
  83. package/task-entry-client.mjs +1 -0
  84. package/version.mjs +20 -3
  85. package/workflow-local-runner.mjs +9 -0
  86. package/workflow-observation-client.mjs +11 -0
  87. package/workflow-observation-connection.mjs +2 -1
  88. package/workflow-observation-remote.mjs +26 -1
  89. package/workflow-observation-wire.mjs +6 -3
@@ -0,0 +1,87 @@
1
+ // Bounded structural inspection only. No property values leave this reader.
2
+ const invalid = () => { throw new Error('invalid-finder-property-list'); };
3
+ function integer(bytes, offset, size, end = bytes.length) {
4
+ if (![1, 2, 4, 8].includes(size) || offset < 0 || offset + size > end) invalid();
5
+ let value = 0n;
6
+ for (let index = offset; index < offset + size; index++) value = value * 256n + BigInt(bytes[index]);
7
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) invalid();
8
+ return Number(value);
9
+ }
10
+ function length(bytes, start, end, info) {
11
+ if (info !== 15) return { count: info, offset: start };
12
+ const marker = bytes[start];
13
+ if (marker === undefined || marker >> 4 !== 1 || (marker & 15) > 3) invalid();
14
+ const size = 2 ** (marker & 15);
15
+ return { count: integer(bytes, start + 1, size, end), offset: start + 1 + size };
16
+ }
17
+ function string(bytes, start, count, width, end) {
18
+ const size = count * width;
19
+ if (count > 65536 || start + size > end) invalid();
20
+ const content = bytes.subarray(start, start + size);
21
+ if (width === 1 && content.some(value => value > 127)) invalid();
22
+ return new TextDecoder(width === 2 ? 'utf-16be' : 'ascii', { fatal: true }).decode(content);
23
+ }
24
+ function object(state, id, depth) {
25
+ if (!Number.isSafeInteger(id) || id < 0 || id >= state.offsets.length || depth > 64 || state.visiting.has(id)) invalid();
26
+ if (state.read.has(id)) return state.read.get(id);
27
+ state.visiting.add(id);
28
+ const start = state.offsets[id], end = state.ends.get(start), marker = state.bytes[start];
29
+ if (marker === undefined) invalid();
30
+ const type = marker >> 4, info = marker & 15;
31
+ let cursor = start + 1, text = null;
32
+ if (type === 0) { if (![0, 8, 9].includes(info)) invalid(); }
33
+ else if (type === 1) {
34
+ if (info > 4) invalid();
35
+ cursor += 2 ** info;
36
+ } else if (type === 2 || type === 3) {
37
+ const size = type === 3 ? 8 : 2 ** info;
38
+ if (type === 3 && info !== 3 || ![4, 8].includes(size) || cursor + size > end) invalid();
39
+ const value = size === 4 ? state.bytes.readFloatBE(cursor) : state.bytes.readDoubleBE(cursor);
40
+ if (!Number.isFinite(value)) invalid();
41
+ cursor += size;
42
+ } else if ([4, 5, 6, 10, 13].includes(type)) {
43
+ const value = length(state.bytes, cursor, end, info); cursor = value.offset;
44
+ if (value.count > 1_048_576) invalid();
45
+ if (type === 4) cursor += value.count;
46
+ else if (type === 5 || type === 6) {
47
+ text = string(state.bytes, cursor, value.count, type === 6 ? 2 : 1, end);
48
+ cursor += value.count * (type === 6 ? 2 : 1);
49
+ } else {
50
+ if (value.count > 10000) invalid();
51
+ const total = value.count * (type === 13 ? 2 : 1), keys = new Set();
52
+ for (let index = 0; index < total; index++) {
53
+ const reference = integer(state.bytes, cursor, state.referenceSize, end); cursor += state.referenceSize;
54
+ const child = object(state, reference, depth + 1);
55
+ if (type === 13 && index < value.count) {
56
+ if (child.text === null || keys.has(child.text)) invalid();
57
+ keys.add(child.text);
58
+ }
59
+ }
60
+ }
61
+ } else if (type === 8) cursor += info + 1;
62
+ else invalid();
63
+ if (cursor > end) invalid();
64
+ state.visiting.delete(id);
65
+ const result = { text }; state.read.set(id, result); return result;
66
+ }
67
+ export function validateFinderPropertyList(bytes) {
68
+ if (!Buffer.isBuffer(bytes) || bytes.length < 40 || bytes.length > 1_048_576
69
+ || bytes.subarray(0, 8).toString('latin1') !== 'bplist00') invalid();
70
+ const trailer = bytes.length - 32;
71
+ if (bytes.subarray(trailer, trailer + 6).some(value => value !== 0)) invalid();
72
+ const offsetSize = bytes[trailer + 6], referenceSize = bytes[trailer + 7];
73
+ if (![1, 2, 4, 8].includes(offsetSize) || ![1, 2, 4, 8].includes(referenceSize)) invalid();
74
+ const count = integer(bytes, trailer + 8, 8), top = integer(bytes, trailer + 16, 8), table = integer(bytes, trailer + 24, 8);
75
+ if (!count || count > 10000 || top >= count || table < 8 || table + count * offsetSize !== trailer) invalid();
76
+ const offsets = Array.from({ length: count }, (_, index) => integer(bytes, table + index * offsetSize, offsetSize));
77
+ if (new Set(offsets).size !== count || offsets.some(offset => offset < 8 || offset >= table)) invalid();
78
+ const sorted = [...offsets].sort((a, b) => a - b), ends = new Map(sorted.map((offset, index) => [offset, sorted[index + 1] ?? table]));
79
+ const state = { bytes, offsets, ends, referenceSize, read: new Map(), visiting: new Set() };
80
+ if (bytes[offsets[top]] >> 4 !== 13) invalid();
81
+ object(state, top, 0);
82
+ // Finder can retain unreferenced duplicate scalar objects. Inspect those
83
+ // too; an unused object is not an exemption from structural validation.
84
+ for (let id = 0; id < count; id++) object(state, id, 0);
85
+ if (state.read.size !== count) invalid();
86
+ return { objects: count };
87
+ }
@@ -0,0 +1,115 @@
1
+ import { validateFinderPropertyList } from './local-finder-plist.mjs';
2
+
3
+ const invalid = () => { throw new Error('invalid-finder-store'); };
4
+ function reader(bytes, start = 0, end = bytes.length) {
5
+ let cursor = start;
6
+ return {
7
+ take(size) {
8
+ if (!Number.isSafeInteger(size) || size < 0 || cursor + size > end) invalid();
9
+ const result = bytes.subarray(cursor, cursor + size); cursor += size; return result;
10
+ },
11
+ number() { return this.take(4).readUInt32BE(); },
12
+ byte() { return this.take(1)[0]; },
13
+ };
14
+ }
15
+ function allocation(bytes) {
16
+ if (bytes.length < 2048 || bytes.length > 1_048_576 || bytes.subarray(0, 8).toString('hex') !== '0000000142756431') invalid();
17
+ const header = reader(bytes, 8, 36), offset = header.number(), size = header.number();
18
+ if (header.number() !== offset || offset < 32 || size < 1024 || offset + size + 4 > bytes.length) invalid();
19
+ const book = reader(bytes, offset + 4, offset + size + 4), count = book.number();
20
+ if (count < 3 || count > 8192 || book.number() !== 0) invalid();
21
+ const blocks = [], intervals = [[0, 32]];
22
+ for (let index = 0; index < count; index++) {
23
+ const address = book.number();
24
+ if (!address) { blocks.push(null); continue; }
25
+ const power = address & 31, start = (address & 0xffffffe0) >>> 0, length = 2 ** power;
26
+ if (power < 5 || power > 20 || start < 32 || start % length || start + length + 4 > bytes.length) invalid();
27
+ blocks.push({ offset: start, length }); intervals.push([start, start + length]);
28
+ }
29
+ if (book.take((Math.ceil(count / 256) * 256 - count) * 4).some(byte => byte !== 0)) invalid();
30
+ if (blocks[0]?.offset !== offset || blocks[0].length < size || book.number() !== 1 || book.byte() !== 4
31
+ || book.take(4).toString('latin1') !== 'DSDB') invalid();
32
+ const master = book.number();
33
+ if (!master || !blocks[master]) invalid();
34
+ for (let power = 0; power < 32; power++) {
35
+ const entries = book.number();
36
+ if (entries > 8192 || power < 5 && entries) invalid();
37
+ for (let index = 0; index < entries; index++) {
38
+ const start = book.number(), length = 2 ** power;
39
+ if (start < 32 || start % length || start + length > 2 ** 31) invalid();
40
+ intervals.push([start, start + length]);
41
+ }
42
+ }
43
+ intervals.sort((a, b) => a[0] - b[0]);
44
+ if (intervals.some((range, index) => index && range[0] !== intervals[index - 1][1])
45
+ || intervals.at(-1)[1] !== 2 ** 31 || Math.max(...blocks.filter(Boolean).map(block => block.offset + block.length)) + 4 !== bytes.length) invalid();
46
+ return { blocks, master };
47
+ }
48
+ const fixedBlobs = new Map([['Iloc', [16]], ['dilc', [32]], ['fwi0', [16]], ['icgo', [8]], ['icsp', [8]],
49
+ ['icvo', [18, 26]], ['info', [40, 48]], ['lssp', [8]], ['lsvo', [76]], ['BKGD', [12]]]);
50
+ const plistBlobs = new Set(['bwsp', 'icvp', 'lsvp', 'lsvP']);
51
+ const expectedTypes = new Map([
52
+ ...[...fixedBlobs.keys(), ...plistBlobs].map(code => [code, 'blob']),
53
+ ...['ICVO', 'LSVO', 'dscl'].map(code => [code, 'bool']),
54
+ ...['fwsw', 'vSrn'].map(code => [code, 'long']),
55
+ ...['fwvh', 'icvt', 'lsvt'].map(code => [code, 'shor']),
56
+ ...['cmmt', 'extn', 'GRP0'].map(code => [code, 'ustr']),
57
+ ...['logS', 'lg1S', 'phyS', 'ph1S'].map(code => [code, 'comp']),
58
+ ...['modD', 'moDD'].map(code => [code, 'dutc']), ['vstl', 'type'],
59
+ ]);
60
+ function unicode(input) {
61
+ const count = input.number();
62
+ if (count > 65536) invalid();
63
+ const value = new TextDecoder('utf-16be', { fatal: true }).decode(input.take(count * 2));
64
+ if (value.includes('\0')) invalid();
65
+ return value;
66
+ }
67
+ function record(input) {
68
+ const name = unicode(input), code = input.take(4).toString('latin1'), type = input.take(4).toString('latin1');
69
+ if (!name || name.length > 1024 || name.includes('/') || expectedTypes.get(code) !== type) invalid();
70
+ if (type === 'blob') {
71
+ const blob = input.take(input.number());
72
+ if (plistBlobs.has(code)) validateFinderPropertyList(blob);
73
+ else if (!fixedBlobs.get(code).includes(blob.length)) invalid();
74
+ if (code === 'BKGD' && !['DefB', 'ClrB'].includes(blob.subarray(0, 4).toString('latin1'))) invalid();
75
+ } else if (type === 'ustr') unicode(input);
76
+ else if (type === 'bool') { if (input.byte() > 1) invalid(); }
77
+ else if (type === 'comp' || type === 'dutc') input.take(8);
78
+ else if (type === 'shor') { if (input.number() > 65535) invalid(); }
79
+ else if (type === 'long') input.number();
80
+ else if (!['icnv', 'clmv', 'Nlsv', 'Flwv'].includes(input.take(4).toString('latin1'))) invalid();
81
+ return name.normalize('NFD').toLowerCase() + '\0' + code;
82
+ }
83
+ function tree(bytes, blocks, master) {
84
+ const readBlock = id => {
85
+ const block = blocks[id];
86
+ if (!block) invalid();
87
+ return reader(bytes, block.offset + 4, block.offset + block.length + 4);
88
+ };
89
+ const info = readBlock(master), root = info.number(), levels = info.number(), records = info.number(), nodes = info.number(), pageSize = info.number();
90
+ if (levels > 32 || records > 10000 || !nodes || nodes > 8190 || pageSize !== 4096) invalid();
91
+ const visited = new Set([0, master]);
92
+ let actualRecords = 0, previous = null;
93
+ const walk = (id, level) => {
94
+ if (visited.has(id) || !blocks[id] || blocks[id].length > pageSize) invalid();
95
+ visited.add(id);
96
+ const input = readBlock(id), right = input.number(), count = input.number();
97
+ if (Boolean(right) !== Boolean(level) || count > 10000 || actualRecords + count > records) invalid();
98
+ for (let index = 0; index < count; index++) {
99
+ if (right) walk(input.number(), level - 1);
100
+ const key = record(input);
101
+ if (previous !== null && previous >= key) invalid();
102
+ previous = key; actualRecords++;
103
+ }
104
+ if (right) walk(right, level - 1);
105
+ };
106
+ walk(root, levels);
107
+ if (actualRecords !== records || visited.size !== nodes + 2
108
+ || blocks.some((block, id) => block && !visited.has(id))) invalid();
109
+ return { format: 'finder-ds-store/1.0', records, nodes, bytes: bytes.length };
110
+ }
111
+ export function validateFinderStore(bytes) {
112
+ if (!Buffer.isBuffer(bytes)) invalid();
113
+ const { blocks, master } = allocation(bytes);
114
+ return tree(bytes, blocks, master);
115
+ }
@@ -17,6 +17,7 @@ import { assertDisciplineRepository } from './local-discipline-repository.mjs';
17
17
  import { withPreservedMutationFence } from './local-preserved-mutation-state.mjs';
18
18
  import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
19
19
  import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
20
+ import { rememberMutationBaselines } from './rollback-history.mjs';
20
21
  export { beginMutationBatch14, finishMutationBatch14, failMutationBatch14 } from './local-mutation-batch.mjs';
21
22
 
22
23
  const SCHEMA = 'entrorove.local-execution/1.0';
@@ -92,6 +93,16 @@ function contentFor(input, baseline) {
92
93
  if (input.expectedSha256 !== undefined && input.expectedSha256 !== null && input.expectedSha256 !== baseline.sha256) {
93
94
  mutationFail14('ENTROROVE_WRITE_BASELINE', 'Target differs from the expected snapshot hash');
94
95
  }
96
+ if (Object.hasOwn(input, 'contentBase64')) {
97
+ if (Object.hasOwn(input, 'content') || typeof input.contentBase64 !== 'string'
98
+ || input.contentBase64.length > 16_000_000 || input.contentBase64.length % 4
99
+ || !/^[A-Za-z0-9+/]*={0,2}$/.test(input.contentBase64)) {
100
+ mutationFail14('ENTROROVE_CONTENT_INVALID', 'Use exactly one text/JSON content or canonical bounded contentBase64');
101
+ }
102
+ const bytes = Buffer.from(input.contentBase64, 'base64');
103
+ if (bytes.toString('base64') !== input.contentBase64) mutationFail14('ENTROROVE_CONTENT_INVALID', 'Binary content must use canonical base64');
104
+ return bytes;
105
+ }
95
106
  let content; try { content = typeof input.content === 'string' ? input.content : JSON.stringify(input.content); }
96
107
  catch { mutationFail14('ENTROROVE_CONTENT_INVALID', 'Guarded-write content must be finite JSON or text'); }
97
108
  if (typeof content !== 'string') mutationFail14('ENTROROVE_CONTENT_INVALID', 'Guarded-write content must be JSON or text');
@@ -103,6 +114,8 @@ async function performWrite(input, state, value) {
103
114
  return withSwarmMutationLease(input, pass, async () => {
104
115
  const baseline = snapshot.manifest.files.find(file => file.path === target), content = contentFor(input, baseline);
105
116
  const discipline = await disciplineWritePreflight(input, current, content);
117
+ mutationLifetime14(pass);
118
+ await rememberMutationBaselines(input, current, [baseline]);
106
119
  const entry = { status: 'pending', contentDigest: sha256(content), mode: baseline.mode ?? 0o644, at: new Date().toISOString() };
107
120
  await saveMutationConsumption14(locations, { ...ledger, writes: { ...ledger.writes, [target]: entry } });
108
121
  await auditMutation14(input, 'guarded-write-authorized', { passId: pass.passId, sha256: entry.contentDigest,
@@ -0,0 +1,83 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { mutationFile14, mutationStateRoot14, readMutationJson14, mutationFail14 } from './local-mutation-files.mjs';
6
+
7
+ const fail = () => mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT',
8
+ 'Post-write recovery requires the original successful write audit and intact execution ledger; no baseline or permission was changed');
9
+ const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
10
+
11
+ function checkedLedger(value, repositoryDigest) {
12
+ if (value.schemaVersion !== 'entrorove.execution-ledger/1.0' || value.repositoryDigest !== repositoryDigest
13
+ || !Array.isArray(value.records) || value.records.length > 100_000 || value.sequence !== value.records.length) fail();
14
+ let previous = null;
15
+ for (const [index, record] of value.records.entries()) {
16
+ const { digest, previousDigest, ...unsigned } = record;
17
+ if (record.sequence !== index + 1 || previousDigest !== previous || !iso(record.at)
18
+ || digest !== sha256(canonical(unsigned) + (previous ?? ''))) fail();
19
+ previous = digest;
20
+ }
21
+ if (value.head !== previous) fail();
22
+ return value.records;
23
+ }
24
+
25
+ async function matchingAudit(file, pass, candidates) {
26
+ const item = await mutationFile14(file);
27
+ if (item.stat.nlink !== 1 || item.stat.size > 256 * 1024 * 1024
28
+ || process.platform !== 'win32' && (item.stat.mode & 0o077)
29
+ || process.getuid && item.stat.uid !== process.getuid()) fail();
30
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
31
+ const matched = new Map();
32
+ try {
33
+ const before = await handle.stat();
34
+ if (before.dev !== item.stat.dev || before.ino !== item.stat.ino || before.size < item.stat.size) fail();
35
+ if (item.stat.size) {
36
+ const lines = createInterface({ input: handle.createReadStream({ start: 0, end: item.stat.size - 1, autoClose: false }), crlfDelay: Infinity });
37
+ try {
38
+ for await (const line of lines) {
39
+ if (Buffer.byteLength(line) > 1_048_576) fail();
40
+ let record; try { record = JSON.parse(line); } catch { fail(); }
41
+ if (record.event !== 'guarded-write' || record.passId !== pass.passId) continue;
42
+ if (record.schemaVersion !== 'entrorove.local-execution/1.0' || record.flowId !== pass.flowId
43
+ || record.stepId !== pass.stepId || record.repositoryDigest !== pass.repositoryDigest || !iso(record.at)) fail();
44
+ const candidate = candidates.get(record.targetPathDigest);
45
+ if (!candidate || matched.has(candidate.targetPath) || record.sha256 !== candidate.sha256
46
+ || Date.parse(record.at) < Date.parse(candidate.writtenAt) || Date.parse(record.at) > Date.parse(candidate.at)) fail();
47
+ matched.set(candidate.targetPath, record);
48
+ }
49
+ } finally { lines.close(); }
50
+ }
51
+ const after = await handle.stat(), current = await mutationFile14(file);
52
+ if (after.dev !== item.stat.dev || after.ino !== item.stat.ino || after.nlink !== 1
53
+ || after.size < item.stat.size || current.stat.dev !== after.dev || current.stat.ino !== after.ino) fail();
54
+ return matched;
55
+ } finally { await handle.close(); }
56
+ }
57
+
58
+ export async function mutationCompletionEvidence(input, state) {
59
+ const { pass, ledger } = state, stateRoot = await mutationStateRoot14(input);
60
+ let records;
61
+ try { records = checkedLedger(await readMutationJson14(path.join(stateRoot, 'ledgers', pass.repositoryDigest, 'state.json')), pass.repositoryDigest); }
62
+ catch { fail(); }
63
+ const candidates = new Map();
64
+ for (const [targetPath, written] of Object.entries(ledger.writes)) {
65
+ if (written.status !== 'written') continue;
66
+ const matches = records.filter(record => record.event === 'guarded-write' && record.passId === pass.passId && record.targetPath === targetPath);
67
+ const record = matches[0];
68
+ if (matches.length !== 1 || record.flowId !== pass.flowId || record.stepId !== pass.stepId
69
+ || record.sha256 !== written.contentDigest || !iso(written.writtenAt)
70
+ || Date.parse(record.at) < Date.parse(written.writtenAt) || Date.parse(record.at) > Date.now()) fail();
71
+ candidates.set(sha256(targetPath), { ...record, writtenAt: written.writtenAt });
72
+ }
73
+ if (!candidates.size) fail();
74
+ let audits;
75
+ try { audits = await matchingAudit(path.join(stateRoot, 'invocations.jsonl'), pass, candidates); } catch { fail(); }
76
+ return [...candidates.values()].sort((a, b) => a.targetPath < b.targetPath ? -1 : 1).map(record => {
77
+ const audit = audits.get(record.targetPath);
78
+ if (!audit) fail();
79
+ return { path: record.targetPath, sha256: record.sha256, writtenAt: record.writtenAt, completedAt: record.at,
80
+ ledgerSequence: record.sequence, ledgerRecordDigest: record.digest, auditRecordDigest: sha256(canonical(audit)),
81
+ source: 'original-successful-write-audit-and-ledger' };
82
+ });
83
+ }
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { randomUUID, sign, verify } from 'node:crypto';
3
3
  import { canonical, sha256 } from './local-integrity.mjs';
4
4
  import { withStateLock } from './local-state-lock.mjs';
5
- import { mutationFail14, mutationRead14, readMutationJson14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
5
+ import { mutationFail14, mutationRead14, mutationTarget14, readMutationJson14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
6
6
  import { readStoredMutationPass14, readMutationConsumption14, mutationPassLocations14 } from './local-mutation-pass-store.mjs';
7
7
  import { assertPreservedMutationIssued } from './local-preserved-mutation-state.mjs';
8
8
  import { loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
@@ -10,10 +10,11 @@ import { preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs'
10
10
  import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
11
11
  import { snapshotAuthority14, readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
12
12
  import { assertDelegatedSourceModification } from './local-delegated-modify-analysis.mjs';
13
- import { assertDisciplineRepository } from './local-discipline-repository.mjs';
13
+ import { observeCompletedMutationEnvironment, readMutationEnvironmentObservation } from './local-mutation-environment-observation.mjs';
14
14
  import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
15
+ import { assertDisciplineRepository } from './local-discipline-repository.mjs';
15
16
 
16
- const fail = () => mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_INVALID', 'Mutation consumption requires its actual completed original writes, unchanged protection and separate M signer');
17
+ const fail = () => mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_INVALID', 'Mutation consumption requires actual original writes or expired unchanged unwritten targets, intact protection and the separate M signer');
17
18
  const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
18
19
  export async function preservedMutationConsumptionState(input) {
19
20
  const pass = await readStoredMutationPass14(input, { live: false });
@@ -24,13 +25,25 @@ export async function preservedMutationConsumptionState(input) {
24
25
  }
25
26
  async function actualWrites(input, state) {
26
27
  const { pass, binding, ledger } = state;
27
- if (!state.complete || Object.keys(ledger.writes).length !== pass.paths.length) fail();
28
+ if (!state.complete && Date.now() < Date.parse(pass.expiresAt)) fail();
28
29
  const snapshot = await snapshotAuthority14(input, binding.root, pass.snapshotReceipt);
29
30
  if (sha256(canonical(snapshot.manifest)) !== pass.snapshotManifestDigest || snapshot.manifest.snapshotDigest !== pass.snapshotDigest) fail();
30
- const writes = [];
31
+ const writes = [], unwritten = [];
31
32
  const create = pass.writeDelegation.purpose === 'planned-file-create';
32
33
  for (const target of pass.paths) {
33
34
  const completed = ledger.writes[target], before = snapshot.manifest.files.find(file => file.path === target);
35
+ if (completed?.status !== 'written') {
36
+ if (!before || completed && (completed.status !== 'pending' || !iso(completed.at)
37
+ || Date.parse(completed.at) < Date.parse(pass.expiresAt) || Date.parse(completed.at) > Date.now())) fail();
38
+ const item = await mutationTarget14(binding.root, target, true);
39
+ const current = item.exists ? await mutationRead14(input, binding.root, target)
40
+ : { sha256: sha256('entrorove.missing-file/1.0'), mode: null };
41
+ if (item.exists !== before.exists || current.sha256 !== before.sha256 || current.mode !== before.mode) fail();
42
+ unwritten.push({ path: target, beforeSha256: before.sha256, beforeMode: before.mode,
43
+ observedSha256: current.sha256, observedMode: current.mode, ledgerStatus: completed ? 'expired-before-write' : 'untouched',
44
+ attemptedAt: completed?.at ?? null, attemptedContentDigest: completed?.contentDigest ?? null });
45
+ continue;
46
+ }
34
47
  if (!iso(completed.writtenAt)) mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_WRITE_TIME', 'Original write has no verifiable completion timestamp; its old receipt cannot authorize another mutation');
35
48
  if (!before || (create ? before.exists !== false || before.mode !== null : !before.exists)
36
49
  || completed.kind && completed.kind !== 'modify' || !iso(completed.at)
@@ -42,20 +55,28 @@ async function actualWrites(input, state) {
42
55
  writes.push({ path: target, beforeSha256: before.sha256, beforeMode: before.mode,
43
56
  afterSha256: current.sha256, afterMode: current.mode, writtenAt: completed.writtenAt });
44
57
  }
45
- await assertDisciplineRepository(input, binding.root, await readDisciplineSnapshot(snapshot), ledger);
46
58
  await assertPreservedMutationProtection(input, binding.root, pass);
47
- return writes;
59
+ const review = await readDisciplineSnapshot(snapshot);
60
+ const writtenLedger = { ...ledger, writes: Object.fromEntries(Object.entries(ledger.writes).filter(([, value]) => value.status === 'written')) };
61
+ if (!writes.length) await assertDisciplineRepository(input, binding.root, review, writtenLedger);
62
+ else await observeCompletedMutationEnvironment(input, state, review, writtenLedger);
63
+ return { writes, ...(unwritten.length ? { unwritten, reconciledAt: new Date(Date.now()).toISOString() } : {}) };
48
64
  }
49
- function receiptCore(pass, writes) {
65
+ function receiptCore(pass, physical) {
50
66
  return { schemaVersion: 'entrorove.preserved-mutation-consumption/1.0', purpose: `${pass.writeDelegation.purpose}-consumption`,
51
67
  keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId, flowId: pass.flowId, stepId: pass.stepId,
52
68
  leaseId: pass.leaseId, generation: pass.generation, epoch: pass.epoch, passId: pass.passId, passDigest: sha256(canonical(pass)),
53
69
  challengeId: pass.challengeId, challengeNonce: pass.challengeNonce, passNonce: pass.nonce,
54
70
  snapshotId: pass.snapshotId, snapshotDigest: pass.snapshotDigest,
55
- protectedFilesDigest: sha256(canonical(pass.writeDelegation.protectedFiles)), writes };
71
+ protectedFilesDigest: sha256(canonical(pass.writeDelegation.protectedFiles)), ...physical };
56
72
  }
57
73
  function cachedReceipt(value, pass, core) {
58
74
  const { signature, issuedAt, expiresAt, nonce, ...actual } = value;
75
+ if (core.reconciledAt) {
76
+ if (!iso(actual.reconciledAt) || Date.parse(actual.reconciledAt) < Date.parse(pass.expiresAt)
77
+ || Date.parse(actual.reconciledAt) > Date.parse(issuedAt)) fail();
78
+ core = { ...core, reconciledAt: actual.reconciledAt };
79
+ }
59
80
  const unsigned = { ...actual, issuedAt, expiresAt, nonce }, bytes = typeof signature === 'string' ? Buffer.from(signature, 'base64url') : Buffer.alloc(0);
60
81
  if (canonical(actual) !== canonical(core) || !iso(issuedAt) || !iso(expiresAt) || Date.parse(issuedAt) > Date.now()
61
82
  || Date.parse(expiresAt) <= Date.parse(issuedAt) || Date.parse(expiresAt) - Date.parse(issuedAt) > 300_000
@@ -64,7 +85,7 @@ function cachedReceipt(value, pass, core) {
64
85
  return value;
65
86
  }
66
87
  async function consumptionReceipt(input, state, { renewExpired = false, allowExpiredReplay = false } = {}) {
67
- const writes = await actualWrites(input, state), core = receiptCore(state.pass, writes);
88
+ const physical = await actualWrites(input, state), core = receiptCore(state.pass, physical);
68
89
  const directory = path.join(state.binding.directory, 'consumption-receipts'), file = path.join(directory, `${state.pass.passId}.json`);
69
90
  await privateMutationDirectory14(directory, { privateFrom: state.binding.stateRoot });
70
91
  let previous;
@@ -88,7 +109,9 @@ export async function attestMutationConsumption14(input, options = {}) {
88
109
  const original = await preservedMutationConsumptionState(input);
89
110
  return withStateLock(original.locations.consumptionPath, async () => {
90
111
  const state = await preservedMutationConsumptionState(input), receipt = await consumptionReceipt(input, state, options);
112
+ const environmentObservation = await readMutationEnvironmentObservation(input, state.pass);
91
113
  return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', consumptionReceipt: receipt,
114
+ ...(environmentObservation ? { environmentObservation, environmentObservationAcknowledgedByServer: false } : {}),
92
115
  claimsVerifiedExecution: false, claimsCompletion: false,
93
116
  nextAction: { operation: 'mutation-pass-settle', input: { passId: state.pass.passId, consumptionReceipt: receipt } } };
94
117
  });
@@ -0,0 +1,85 @@
1
+ import path from 'node:path';
2
+ import { randomUUID, sign, verify } from 'node:crypto';
3
+ import { canonical, sha256 } from './local-integrity.mjs';
4
+ import { mutationFail14, mutationStateRoot14, atomicMutationJson14, readMutationJson14 } from './local-mutation-files.mjs';
5
+ import { observeDisciplineRepositoryState, repositoryIntegrityFile } from './local-discipline-repository.mjs';
6
+ import { mutationCompletionEvidence } from './local-mutation-completion-evidence.mjs';
7
+ import { loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
8
+ import { preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
9
+ import { finderMetadataPath, observeFinderMetadataChanges } from './local-mutation-finder-observation.mjs';
10
+
11
+ const schemaVersion = 'entrorove.post-write-environment-observation/1.0';
12
+ const fail = () => mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT',
13
+ 'Environment drift is not a proven new regular file created after the expired pass and its original successful write checks; original source protection remains enforced');
14
+ const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
15
+ const attribution = changes => changes.some(change => change?.kind === 'validated-finder-metadata')
16
+ ? changes.every(change => change?.kind === 'validated-finder-metadata')
17
+ ? 'unattributed-post-write-finder-metadata' : 'unattributed-post-write-environment'
18
+ : 'unattributed-post-write-additions';
19
+ function location(stateRoot, pass) {
20
+ return path.join(stateRoot, 'post-write-environment', pass.repositoryDigest, pass.passId + '.json');
21
+ }
22
+ function verified(value, pass) {
23
+ const { signature, ...unsigned } = value ?? {}, key = preservedMutationPublicKey(pass.writeDelegation.mutationPublicKey);
24
+ const expected = { schemaVersion, keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId,
25
+ flowId: pass.flowId, stepId: pass.stepId, passId: pass.passId, passDigest: sha256(canonical(pass)),
26
+ originalReviewDigest: pass.snapshotReceipt.disciplineReviewDigest,
27
+ attribution: attribution(Array.isArray(value?.changes) ? value.changes : []), grantsWriteAuthority: false,
28
+ claimsRepositoryUnchanged: false, claimsAuthorizedEnvironmentChange: false };
29
+ if (Object.entries(expected).some(([field, expected]) => canonical(value?.[field]) !== canonical(expected))
30
+ || !iso(value.observedAt) || Date.parse(value.observedAt) > Date.now()
31
+ || !Array.isArray(value.changes) || !value.changes.length || !Array.isArray(value.completionEvidence)
32
+ || !verify(null, Buffer.from(canonical(unsigned)), key.key, Buffer.from(signature ?? '', 'base64url'))) fail();
33
+ return value;
34
+ }
35
+ export async function readMutationEnvironmentObservation(input, pass) {
36
+ try { return verified(await readMutationJson14(location(await mutationStateRoot14(input), pass)), pass); }
37
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
38
+ }
39
+ async function additions(root, review, comparison, changed, state, completionEvidence) {
40
+ const { pass } = state, before = new Set(review.comparison.files.map(file => file.path));
41
+ const protectedPaths = new Set([...pass.paths, ...pass.writeDelegation.protectedFiles.map(file => file.path)]);
42
+ const completedAt = Math.max(Date.parse(pass.expiresAt), ...completionEvidence.map(record => Date.parse(record.completedAt)));
43
+ if (Date.now() < Date.parse(pass.expiresAt) || changed.length > 512) fail();
44
+ const changes = [];
45
+ for (const relative of changed) {
46
+ const observed = comparison.files.find(file => file.path === relative);
47
+ if (before.has(relative) || protectedPaths.has(relative) || observed?.kind !== 'file') fail();
48
+ const current = await repositoryIntegrityFile(root, relative), identity = current.identity;
49
+ if (current.sha256 !== observed.sha256 || current.mode !== observed.mode || current.mode & 0o111
50
+ || identity.nlink !== 1 || !Number.isFinite(identity.birthtimeMs) || identity.birthtimeMs <= completedAt
51
+ || identity.birthtimeMs > Date.now() || identity.ctimeMs < identity.birthtimeMs) fail();
52
+ changes.push({ path: relative, before: null, after: { sha256: current.sha256, mode: current.mode },
53
+ physicalIdentity: identity, createdAt: new Date(identity.birthtimeMs).toISOString() });
54
+ }
55
+ return changes;
56
+ }
57
+ export async function observeCompletedMutationEnvironment(input, state, review, writtenLedger) {
58
+ const { comparison, changed } = await observeDisciplineRepositoryState(input, state.binding.root, review, writtenLedger);
59
+ if (!changed.length) return null;
60
+ if (changed.length > 512) fail();
61
+ const completionEvidence = await mutationCompletionEvidence(input, state);
62
+ const finder = changed.filter(finderMetadataPath), other = changed.filter(relative => !finderMetadataPath(relative));
63
+ const changes = [...(finder.length ? await observeFinderMetadataChanges(state.binding.root, review, comparison, finder, state, completionEvidence) : []),
64
+ ...await additions(state.binding.root, review, comparison, other, state, completionEvidence)].sort((a, b) => a.path < b.path ? -1 : 1);
65
+ const pass = state.pass;
66
+ const core = { schemaVersion, keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId,
67
+ flowId: pass.flowId, stepId: pass.stepId, passId: pass.passId, passDigest: sha256(canonical(pass)),
68
+ originalReviewDigest: pass.snapshotReceipt.disciplineReviewDigest,
69
+ originalComparisonDigest: sha256(canonical(review.comparison)), observedComparisonDigest: sha256(canonical(comparison)),
70
+ attribution: attribution(changes), grantsWriteAuthority: false,
71
+ claimsRepositoryUnchanged: false, claimsAuthorizedEnvironmentChange: false, completionEvidence, changes };
72
+ const stateRoot = await mutationStateRoot14(input), file = location(stateRoot, pass);
73
+ let previous;
74
+ try { previous = verified(await readMutationJson14(file), pass); } catch (error) { if (error.code !== 'ENOENT') throw error; }
75
+ if (previous) {
76
+ const { signature: _, observedAt: __, nonce: ___, ...original } = previous;
77
+ if (canonical(original) !== canonical(core)) fail();
78
+ return previous;
79
+ }
80
+ const signer = await loadPreservedMutationSigner(input, state.binding.signerConfigPath, core.keyId);
81
+ const unsigned = { ...core, observedAt: new Date().toISOString(), nonce: randomUUID() };
82
+ const result = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), signer.privateKey).toString('base64url') };
83
+ await atomicMutationJson14(file, result, { privateFrom: stateRoot });
84
+ return result;
85
+ }
@@ -0,0 +1,58 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { sha256 } from './local-integrity.mjs';
4
+ import { mutationTarget14, mutationFail14 } from './local-mutation-files.mjs';
5
+ import { validateFinderStore } from './local-finder-store.mjs';
6
+
7
+ const fail = () => mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT',
8
+ 'Finder metadata recovery requires bounded valid allocator/tree/property-list bytes, original successful write evidence, unchanged protected sources, and single-link non-executable files changed only after completion');
9
+ const same = (left, right) => ['dev', 'ino', 'nlink', 'size', 'mtimeMs', 'ctimeMs', 'mode'].every(key => left[key] === right[key]);
10
+ export const finderMetadataPath = relative => path.posix.basename(relative) === '.DS_Store';
11
+
12
+ async function metadataFile(root, relative) {
13
+ const item = await mutationTarget14(root, relative), handle = await fs.open(item.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
14
+ try {
15
+ const before = await handle.stat();
16
+ if (!same(before, item.stat) || !before.isFile() || before.nlink !== 1 || before.mode & 0o7111
17
+ || before.size > 1_048_576) fail();
18
+ const bytes = Buffer.alloc(before.size);
19
+ let offset = 0;
20
+ while (offset < bytes.length) {
21
+ const read = await handle.read(bytes, offset, bytes.length - offset, offset);
22
+ if (!read.bytesRead) fail();
23
+ offset += read.bytesRead;
24
+ }
25
+ let format;
26
+ try { format = validateFinderStore(bytes); } catch { fail(); }
27
+ const after = await handle.stat(), current = await mutationTarget14(root, relative);
28
+ if (!same(before, after) || !same(after, current.stat)) fail();
29
+ return { sha256: sha256(bytes), mode: after.mode & 0o777, format,
30
+ identity: { dev: after.dev, ino: after.ino, nlink: after.nlink, birthtimeMs: after.birthtimeMs,
31
+ ctimeMs: after.ctimeMs, mtimeMs: after.mtimeMs, size: after.size } };
32
+ } finally { await handle.close(); }
33
+ }
34
+ export async function observeFinderMetadataChanges(root, review, comparison, changed, state, completionEvidence) {
35
+ const pass = state.pass, protectedPaths = new Set([...pass.paths, ...pass.writeDelegation.protectedFiles.map(file => file.path)]);
36
+ const completedAt = Math.max(...completionEvidence.map(record => Date.parse(record.completedAt)));
37
+ if (!completionEvidence.length || !Number.isFinite(completedAt) || Date.now() < Date.parse(pass.expiresAt)
38
+ || !changed.length || changed.length > 512) fail();
39
+ const changes = [];
40
+ let bytes = 0;
41
+ for (const relative of changed) {
42
+ const before = review.comparison.files.find(file => file.path === relative) ?? null;
43
+ const observed = comparison.files.find(file => file.path === relative);
44
+ if (!finderMetadataPath(relative) || protectedPaths.has(relative) || observed?.kind !== 'file'
45
+ || before && (before.kind !== 'file' || before.mode !== observed.mode || before.mode & 0o111)) fail();
46
+ const current = await metadataFile(root, relative), identity = current.identity;
47
+ if (current.sha256 !== observed.sha256 || current.mode !== observed.mode
48
+ || ![identity.birthtimeMs, identity.mtimeMs, identity.ctimeMs].every(value => Number.isFinite(value) && value <= Date.now())
49
+ || identity.mtimeMs <= completedAt || identity.ctimeMs <= completedAt
50
+ || !before && identity.birthtimeMs <= completedAt || identity.birthtimeMs > identity.ctimeMs) fail();
51
+ bytes += identity.size;
52
+ if (bytes > 8_388_608) fail();
53
+ changes.push({ path: relative, kind: 'validated-finder-metadata', before,
54
+ after: { sha256: current.sha256, mode: current.mode }, physicalIdentity: identity, format: current.format,
55
+ originalContentAvailable: false, claimsOriginalContentFormatValidated: false });
56
+ }
57
+ return changes;
58
+ }
@@ -39,6 +39,21 @@ function pinnedAuthority() {
39
39
  return { key, keyId: sha256(der) };
40
40
  } catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
41
41
  }
42
+ function originalReceiver(payload) {
43
+ const receiver = payload.receiver;
44
+ const original = receiver?.schemaVersion === 'entrorove.original-task-mutation-receiver/1.0';
45
+ exact(receiver, original ? ['schemaVersion', 'taskId', 'ownerId', 'flowId', 'originalRequestId', 'taskDigest',
46
+ 'routeDigest', 'acceptanceReceiptId', 'acceptanceDigest']
47
+ : ['taskId', 'ownerId', 'projectId', 'hostId', 'threadId', 'descriptionDigest', 'taskDigest']);
48
+ if (receiver.taskId !== payload.taskId || receiver.ownerId !== payload.ownerId || receiver.taskDigest !== payload.taskDigest
49
+ || sha256(canonical(receiver)) !== payload.receiverDigest || (original
50
+ ? typeof receiver.originalRequestId !== 'string' || !ID.test(receiver.originalRequestId)
51
+ || ['flowId', 'routeDigest', 'acceptanceReceiptId', 'acceptanceDigest']
52
+ .some(field => receiver[field] !== payload[field])
53
+ : !SHA.test(receiver.descriptionDigest) || ['projectId', 'hostId', 'threadId'].some(field => !ID.test(String(receiver[field]))))) {
54
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority lost its original accepted receiver');
55
+ }
56
+ }
42
57
  function identity(payload) {
43
58
  if (Object.hasOwn(payload, 'scopeRevisionDigest') && !SHA.test(payload.scopeRevisionDigest)) {
44
59
  mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority scope revision is invalid');
@@ -74,13 +89,7 @@ function identity(payload) {
74
89
  || canonical([...payload.newFilePaths].sort()) !== canonical(payload.newFilePaths)) {
75
90
  mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority path set is invalid');
76
91
  }
77
- exact(payload.receiver, ['taskId', 'ownerId', 'projectId', 'hostId', 'threadId', 'descriptionDigest', 'taskDigest']);
78
- if (payload.receiver.taskId !== payload.taskId || payload.receiver.ownerId !== payload.ownerId
79
- || payload.receiver.taskDigest !== payload.taskDigest || !SHA.test(payload.receiver.descriptionDigest)
80
- || sha256(canonical(payload.receiver)) !== payload.receiverDigest
81
- || ['projectId', 'hostId', 'threadId'].some(field => !ID.test(String(payload.receiver[field])))) {
82
- mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority lost its original cloud receiver');
83
- }
92
+ originalReceiver(payload);
84
93
  if (delegated) preservedMutationDelegation(payload);
85
94
  }
86
95
  function authority(value, fields, schema, options) {
@@ -101,7 +110,16 @@ function authority(value, fields, schema, options) {
101
110
  identity(payload); mutationLifetime14(payload, options); return value;
102
111
  }
103
112
  export function mutationChallenge14(value, options) {
104
- return authority(value, authorityFields(value, CHALLENGE_FIELDS), 'entrorove.mutation-snapshot-challenge/1.0', options);
113
+ const fields = authorityFields(value, CHALLENGE_FIELDS);
114
+ if (Object.hasOwn(value ?? {}, 'previousChallenge')) {
115
+ exact(value.previousChallenge, ['challengeId', 'challengeDigest']);
116
+ if (!ID.test(value.previousChallenge.challengeId) || !SHA.test(value.previousChallenge.challengeDigest)
117
+ || value.verificationMode !== 'member-local' || !value.writeDelegation) {
118
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Expired challenge predecessor requires its exact member-local binding');
119
+ }
120
+ fields.push('previousChallenge');
121
+ }
122
+ return authority(value, fields, 'entrorove.mutation-snapshot-challenge/1.0', options);
105
123
  }
106
124
  function authorityFields(value, fields) {
107
125
  const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>