@entrorove/skill 0.1.22 → 0.1.24

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 (155) hide show
  1. package/AGREEMENT.md +6 -6
  2. package/CHANGELOG.md +10 -0
  3. package/SKILL.md +12 -4
  4. package/archguard-runtime-build.mjs +15 -0
  5. package/blueprint-criterion-runner.mjs +110 -0
  6. package/blueprint-criterion-wire.mjs +78 -0
  7. package/calctool-runtime-build.mjs +59 -0
  8. package/calctool-runtime-entry.mjs +92 -0
  9. package/cli.mjs +18 -83
  10. package/local-approved-plan.mjs +74 -15
  11. package/local-archguard-ast.mjs +371 -0
  12. package/local-archguard-checkpoint.mjs +123 -0
  13. package/local-archguard-components.mjs +177 -0
  14. package/local-archguard-contract.mjs +109 -0
  15. package/local-archguard-contracts.mjs +42 -0
  16. package/local-archguard-evaluate.mjs +120 -0
  17. package/local-archguard-language-ast.mjs +165 -0
  18. package/local-archguard-mutation-fence.mjs +28 -0
  19. package/local-archguard-operations.mjs +25 -0
  20. package/local-archguard-scope.mjs +64 -0
  21. package/local-archguard-signing.mjs +52 -0
  22. package/local-archguard-snapshot.mjs +126 -0
  23. package/local-archguard-state.mjs +200 -0
  24. package/local-archguard.mjs +74 -0
  25. package/local-budget-git-attributes.mjs +49 -0
  26. package/local-budget-git-loader.mjs +61 -0
  27. package/local-budget-git-native-attributes.mjs +81 -0
  28. package/local-budget-git-native-darwin.mjs +104 -0
  29. package/local-budget-git-native-linux.mjs +93 -0
  30. package/local-budget-git-native-reads.mjs +88 -0
  31. package/local-budget-git-native-request.mjs +53 -0
  32. package/local-budget-git-native-runtime.mjs +18 -0
  33. package/local-budget-git-native.c +313 -0
  34. package/local-budget-git-native.mjs +109 -0
  35. package/local-budget-git-outcome.mjs +33 -0
  36. package/local-budget-git-scratch-loader.mjs +158 -0
  37. package/local-budget-git-scratch.mjs +112 -0
  38. package/local-budget-git-system-attributes.mjs +44 -0
  39. package/local-budget-git.mjs +92 -0
  40. package/local-budget-grants.mjs +82 -0
  41. package/local-budget-handoff-completion.mjs +70 -0
  42. package/local-budget-handoff-contracts.mjs +53 -0
  43. package/local-budget-handoff.mjs +229 -0
  44. package/local-budget-lock.mjs +55 -0
  45. package/local-budget-reads.mjs +135 -0
  46. package/local-budget-renewal.mjs +86 -34
  47. package/local-budget-source-bindings.mjs +51 -0
  48. package/local-budget-store.mjs +131 -0
  49. package/local-budget-transition.mjs +46 -0
  50. package/local-budget.mjs +185 -0
  51. package/local-calctool-cli.mjs +22 -0
  52. package/local-calctool-contracts.mjs +42 -0
  53. package/local-calctool-runtime.mjs +10 -0
  54. package/local-calctool-storage.mjs +101 -0
  55. package/local-calctool.mjs +103 -0
  56. package/local-cli.mjs +49 -16
  57. package/local-contract-types.mjs +46 -0
  58. package/local-contracts.mjs +124 -0
  59. package/local-credential.mjs +101 -0
  60. package/local-decimal.mjs +85 -0
  61. package/local-evidence-verification.mjs +199 -0
  62. package/local-execution.mjs +115 -304
  63. package/local-final-gate16-bundle.mjs +21 -0
  64. package/local-final-gate16-checks.mjs +88 -0
  65. package/local-final-gate16-contract.mjs +15 -0
  66. package/local-final-gate16-plan.mjs +66 -0
  67. package/local-final-gate16-proof.mjs +75 -0
  68. package/local-final-gate16-release.mjs +17 -0
  69. package/local-final-gate16-state.mjs +70 -0
  70. package/local-final-gate16-worker.mjs +19 -0
  71. package/local-final-gate16.mjs +137 -0
  72. package/local-formula-ast.mjs +97 -0
  73. package/local-formula-engine.mjs +111 -0
  74. package/local-formula-tests.mjs +61 -0
  75. package/local-formula.mjs +25 -125
  76. package/local-integrity.mjs +22 -41
  77. package/local-invocations.mjs +107 -0
  78. package/local-ledger.mjs +2 -7
  79. package/local-merge-archguard-verification.d.mts +44 -0
  80. package/local-merge-archguard-verification.mjs +240 -0
  81. package/local-merge-archguard.mjs +138 -0
  82. package/local-merge-artifacts.mjs +86 -0
  83. package/local-merge-authority.mjs +89 -0
  84. package/local-merge-contracts.mjs +56 -0
  85. package/local-merge-controller.mjs +170 -0
  86. package/local-merge-head.mjs +56 -0
  87. package/local-merge-journal.mjs +77 -0
  88. package/local-merge-mutation.mjs +97 -0
  89. package/local-merge-rollback.mjs +126 -0
  90. package/local-merge-snapshot-rollback.mjs +26 -0
  91. package/local-merge-storage.mjs +103 -0
  92. package/local-merge-targets.mjs +47 -0
  93. package/local-merge-transaction.mjs +133 -0
  94. package/local-mutation-audit.mjs +34 -0
  95. package/local-mutation-authority.mjs +96 -0
  96. package/local-mutation-batch-receipt.mjs +84 -0
  97. package/local-mutation-batch.mjs +143 -0
  98. package/local-mutation-binding.mjs +86 -0
  99. package/local-mutation-files.mjs +245 -0
  100. package/local-mutation-pass-store.mjs +63 -0
  101. package/local-mutation-snapshot.mjs +200 -0
  102. package/local-mutation-wire.mjs +89 -0
  103. package/local-probe.mjs +133 -11
  104. package/local-scope-candidate.mjs +28 -0
  105. package/local-scope-context.mjs +28 -0
  106. package/local-scope-files.mjs +106 -0
  107. package/local-scope-git-attributes.mjs +200 -0
  108. package/local-scope-git-comparison.mjs +8 -0
  109. package/local-scope-git-history.mjs +72 -0
  110. package/local-scope-git-reads.mjs +66 -0
  111. package/local-scope-git.mjs +124 -0
  112. package/local-scope-repository.mjs +249 -0
  113. package/local-scope-signature.mjs +70 -0
  114. package/local-scope-snapshot.mjs +39 -0
  115. package/local-scope-state.mjs +204 -0
  116. package/local-scope-workspaces.mjs +83 -0
  117. package/local-swarm-authority.mjs +143 -0
  118. package/local-swarm-baseline.mjs +32 -0
  119. package/local-swarm-completion.mjs +102 -0
  120. package/local-swarm-contracts.mjs +47 -0
  121. package/local-swarm-mutation.mjs +142 -0
  122. package/local-swarm-priority.mjs +237 -0
  123. package/local-swarm-state.mjs +102 -0
  124. package/local-swarm-storage.mjs +135 -0
  125. package/local-swarm.mjs +179 -0
  126. package/local-task-handoff-completion-wire.mjs +71 -0
  127. package/local-task-handoff-contracts.mjs +66 -0
  128. package/local-task-handoff-fence.mjs +478 -0
  129. package/local-task-handoff-observation.mjs +38 -0
  130. package/local-task-handoff-overlay-wire.mjs +94 -0
  131. package/local-task-handoff-read-authority.mjs +236 -0
  132. package/local-task-handoff-storage.mjs +73 -0
  133. package/local-task-handoff-write-archive.mjs +80 -0
  134. package/local-task-handoff-write-constraints.mjs +66 -0
  135. package/local-task-handoff-write-receipts.mjs +99 -0
  136. package/local-task-handoff-write-wire.mjs +99 -0
  137. package/local-task-routing.mjs +202 -22
  138. package/manifest.json +1 -1
  139. package/package.json +153 -3
  140. package/release-state.json +153 -17
  141. package/release.mjs +238 -4
  142. package/releases.json +12 -0
  143. package/remote-client.mjs +127 -36
  144. package/remote-transport.mjs +80 -0
  145. package/review-client.mjs +38 -0
  146. package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
  147. package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
  148. package/runtime/git-native-darwin-v1.mjs +5 -0
  149. package/runtime/git-native-linux-v1.mjs +5 -0
  150. package/task-message-cli.mjs +47 -0
  151. package/task-message-entry.mjs +159 -0
  152. package/workflow-local-client.mjs +35 -0
  153. package/workflow-local-contracts.mjs +58 -0
  154. package/workflow-local-request.mjs +53 -0
  155. package/workflow-local-runner.mjs +350 -0
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Decimal, DECIMAL_POLICY, formulaError } from './local-decimal.mjs';
3
+ import { compileFormulaGraph, evaluateFormulaGraph, formulaKey, formulaObject } from './local-formula-ast.mjs';
4
+
5
+ export const FORMULA_SCHEMA = 'entrorove.formula-engine/1.0';
6
+ export const FORMULA_RUNTIME_VERSION = 'decimal28-ast/1.0';
7
+ const TYPES = ['number', 'integer', 'money', 'percent', 'text', 'date', 'enum'];
8
+ const NUMERIC = TYPES.slice(0, 4);
9
+ const ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
10
+ const VERSION = /^\d+\.\d+\.\d+$/;
11
+
12
+ export function formulaCanonical(value) {
13
+ if (Array.isArray(value)) return `[${value.map(formulaCanonical).join(',')}]`;
14
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${formulaCanonical(value[key])}`).join(',')}}`;
15
+ const json = JSON.stringify(value);
16
+ if (json === undefined || (typeof value === 'number' && !Number.isFinite(value))) throw formulaError('FORMULA_JSON_INVALID', 'formula artifacts must contain finite JSON values');
17
+ return json;
18
+ }
19
+ export const formulaDigest = value => createHash('sha256').update(typeof value === 'string' || ArrayBuffer.isView(value) ? value : formulaCanonical(value)).digest('hex');
20
+ function cloned(value) { return JSON.parse(formulaCanonical(value)); }
21
+ function requiredText(value, field) {
22
+ if (typeof value !== 'string' || !value.trim()) throw formulaError('ENGINE_REQUIRED_FIELD', `${field} is required`);
23
+ }
24
+ export function normalizeEngine(engine) {
25
+ formulaObject(engine, 'engine');
26
+ if (engine.schemaVersion !== 'engine.spec/1') throw formulaError('ENGINE_SCHEMA_VERSION', 'Expected engine.spec/1');
27
+ if (typeof engine.engineId !== 'string' || !ID.test(engine.engineId)) throw formulaError('ENGINE_ID_INVALID', 'engineId must be an explicit stable lowercase identifier');
28
+ requiredText(engine.name, 'name');
29
+ if (typeof engine.semanticVersion !== 'string' || !VERSION.test(engine.semanticVersion)) throw formulaError('ENGINE_VERSION_INVALID', 'semanticVersion must be major.minor.patch');
30
+ if (engine.decimalPolicy !== 'decimal-string' && formulaCanonical(engine.decimalPolicy ?? null) !== formulaCanonical(DECIMAL_POLICY)) throw formulaError('DECIMAL_POLICY_INVALID', 'engine requires Decimal28 decimal-string with half-away-from-zero rounding');
31
+ for (const field of ['fields', 'formulas', 'rules', 'views', 'testSuites', 'importProfiles']) {
32
+ if (!Array.isArray(engine[field])) throw formulaError('ENGINE_REQUIRED_ARRAY', `${field} must be an array`);
33
+ }
34
+ if (!engine.fields.length || !engine.formulas.length || engine.fields.length > 1000) throw formulaError('ENGINE_EMPTY', 'engine requires 1..1000 fields and 1..1000 formulas');
35
+ const keys = new Set();
36
+ for (const field of engine.fields) {
37
+ formulaObject(field, 'field'); const key = formulaKey(field.key);
38
+ if (keys.has(key)) throw formulaError('FIELD_DUPLICATE_KEY', `duplicate field key ${key}`);
39
+ keys.add(key); requiredText(field.label, 'field label');
40
+ if (!TYPES.includes(field.type)) throw formulaError('FIELD_TYPE_INVALID', 'unsupported field type');
41
+ if (NUMERIC.includes(field.type) && typeof field.unit !== 'string') throw formulaError('FIELD_UNIT_MISSING', 'numeric fields require an explicit unit');
42
+ if (field.required !== undefined && typeof field.required !== 'boolean') throw formulaError('FIELD_REQUIRED_INVALID', 'field required must be boolean');
43
+ }
44
+ const graph = compileFormulaGraph(engine.formulas, [...keys]);
45
+ for (const item of graph.formulas) for (const ref of graph.dependencies[item.key]) {
46
+ const field = engine.fields.find(entry => entry.key === ref);
47
+ if (field && !NUMERIC.includes(field.type)) throw formulaError('FORMULA_REF_TYPE', `formula reference ${ref} must be numeric`);
48
+ }
49
+ const normalized = cloned(engine);
50
+ normalized.formulas = engine.formulas.map(item => ({ ...cloned(item), expression: graph.formulas.find(entry => entry.key === item.key).expression }));
51
+ return { engine: normalized, graph };
52
+ }
53
+ export function validateFormulaEngine(engine) {
54
+ try { normalizeEngine(engine); return []; }
55
+ catch (cause) { return [{ severity: 'P0', ruleId: cause.code ?? 'ENGINE_INVALID', entityRef: 'engine', message: cause.message }]; }
56
+ }
57
+ export function buildFormulaEngine(requirements) {
58
+ formulaObject(requirements, 'requirements'); requiredText(requirements.goal, 'goal');
59
+ if (!Array.isArray(requirements.inputs) || !Array.isArray(requirements.formulas)) throw formulaError('COMPILE_REQUIREMENTS', 'inputs and formulas are required arrays');
60
+ const engine = {
61
+ schemaVersion: 'engine.spec/1', engineId: requirements.engineId, name: requirements.name ?? requirements.goal,
62
+ semanticVersion: requirements.semanticVersion ?? '1.0.0', status: 'draft', compatibilityProfile: 'legacy-compatible',
63
+ decimalPolicy: requirements.decimalPolicy ?? 'decimal-string', defaultLocale: requirements.defaultLocale ?? 'zh-CN',
64
+ category: requirements.category ?? 'general', inputMethod: requirements.inputMethod ?? 'manual', output: requirements.output ?? ['metric-cards'],
65
+ constraints: requirements.constraints ?? '', fields: requirements.inputs, formulas: requirements.formulas,
66
+ rules: requirements.rules ?? [], views: requirements.views ?? [], importProfiles: requirements.importProfiles ?? [],
67
+ reports: requirements.reports ?? [], testSuites: requirements.testSuites ?? [], acceptance: requirements.acceptance ?? '',
68
+ };
69
+ return normalizeEngine(engine).engine;
70
+ }
71
+ export function compileFormulaEngine(engine) {
72
+ const normalized = normalizeEngine(engine);
73
+ const program = { schemaVersion: FORMULA_SCHEMA, runtimeVersion: FORMULA_RUNTIME_VERSION, decimalPolicy: DECIMAL_POLICY,
74
+ engineId: normalized.engine.engineId, engineVersion: normalized.engine.semanticVersion,
75
+ engineDigest: formulaDigest(normalized.engine), engine: normalized.engine,
76
+ formulas: normalized.graph.formulas, dependencies: normalized.graph.dependencies, order: normalized.graph.order };
77
+ return { ...program, programDigest: formulaDigest(program) };
78
+ }
79
+ export function verifyFormulaProgram(program, expected = {}) {
80
+ formulaObject(program, 'compiled formula program');
81
+ if (program.runtimeVersion !== FORMULA_RUNTIME_VERSION || program.schemaVersion !== FORMULA_SCHEMA) throw formulaError('FORMULA_RUNTIME_VERSION', 'compiled formula runtime version is unsupported');
82
+ const compiled = compileFormulaEngine(program.engine);
83
+ if (formulaCanonical(compiled) !== formulaCanonical(program)) throw formulaError('FORMULA_ARTIFACT_BINDING', 'compiled formula artifact or digest does not match its engine');
84
+ for (const field of ['engineDigest', 'programDigest', 'engineId', 'engineVersion']) {
85
+ if (expected[field] !== undefined && expected[field] !== compiled[field]) throw formulaError('FORMULA_ARTIFACT_BINDING', `${field} does not match the required engine binding`);
86
+ }
87
+ return compiled;
88
+ }
89
+ export function evaluateFormulaProgram(program, inputs = {}, expected = {}) {
90
+ const verified = verifyFormulaProgram(program, expected); formulaObject(inputs, 'engine inputs');
91
+ const fields = new Map(verified.engine.fields.map(item => [item.key, item]));
92
+ for (const [key, value] of Object.entries(inputs)) {
93
+ const field = fields.get(key);
94
+ if (!field) throw formulaError('ENGINE_INPUT_UNKNOWN', `unknown input field: ${key}`);
95
+ if (NUMERIC.includes(field.type)) {
96
+ const decimal = Decimal.from(value);
97
+ if (field.type === 'integer' && decimal.scale !== 0) throw formulaError('ENGINE_INPUT_INTEGER', `${key} must be an integer`);
98
+ } else if (typeof value !== 'string') throw formulaError('ENGINE_INPUT_TYPE', `${key} must be text`);
99
+ }
100
+ for (const field of fields.values()) if (field.required && (!Object.hasOwn(inputs, field.key) || inputs[field.key] === '' || inputs[field.key] === null)) throw formulaError('MISSING_INPUT', `missing input: ${field.key}`, { fieldId: field.key });
101
+ return evaluateFormulaGraph(verified.formulas, Object.fromEntries(Object.entries(inputs).filter(([key]) => NUMERIC.includes(fields.get(key).type))));
102
+ }
103
+ export function formulaImpact(program, changedKeys) {
104
+ const verified = verifyFormulaProgram(program);
105
+ if (!Array.isArray(changedKeys) || !changedKeys.length) throw formulaError('FORMULA_IMPACT_INVALID', 'changedKeys must be a non-empty array');
106
+ const known = new Set([...verified.engine.fields.map(item => item.key), ...verified.formulas.map(item => item.key)]);
107
+ const affected = new Set(changedKeys.map(formulaKey));
108
+ for (const key of affected) if (!known.has(key)) throw formulaError('FORMULA_REF_UNKNOWN', `unknown changed key: ${key}`);
109
+ for (const key of verified.order) if (verified.dependencies[key].some(ref => affected.has(ref))) affected.add(key);
110
+ return verified.order.filter(key => affected.has(key));
111
+ }
@@ -0,0 +1,61 @@
1
+ import { Decimal, formulaError } from './local-decimal.mjs';
2
+ import { formulaObject } from './local-formula-ast.mjs';
3
+ import { compileFormulaEngine, verifyFormulaProgram, evaluateFormulaProgram, formulaCanonical, formulaDigest } from './local-formula-engine.mjs';
4
+ export const FORMULA_GATE_SCHEMA = 'entrorove.calctool-final-gate/1.0';
5
+
6
+ function suiteValue(suite, primary, alias) {
7
+ if (Object.hasOwn(suite, primary) && Object.hasOwn(suite, alias) && formulaCanonical(suite[primary]) !== formulaCanonical(suite[alias])) throw formulaError('FORMULA_SUITE_ALIAS', `conflicting ${primary}/${alias} test values`);
8
+ const value = suite[primary] ?? suite[alias]; formulaObject(value, `test ${primary}`); return value;
9
+ }
10
+ function runSuite(program, suite, index) {
11
+ formulaObject(suite, 'test suite');
12
+ const expected = suiteValue(suite, 'expected', 'expect');
13
+ const inputs = suiteValue(suite, 'input', 'inputs');
14
+ const keys = Object.keys(expected);
15
+ if (!keys.length) throw formulaError('FORMULA_TEST_EMPTY', 'test suites must assert at least one formula result');
16
+ const known = new Set(program.formulas.map(item => item.key));
17
+ for (const key of keys) if (!known.has(key)) throw formulaError('FORMULA_TEST_KEY_UNKNOWN', `test expectation references unknown formula: ${key}`);
18
+ const actual = evaluateFormulaProgram(program, inputs);
19
+ const mismatches = keys.flatMap(key => {
20
+ const want = Decimal.from(expected[key]).toString();
21
+ return actual[key] === want ? [] : [{ key, expected: want, actual: actual[key] }];
22
+ });
23
+ return { name: suite.name ?? `suite-${index + 1}`, passed: mismatches.length === 0, keys, actual, mismatches };
24
+ }
25
+ export function formulaFinalGate(engine, options = {}) {
26
+ const started = Date.now(); const findings = []; const tests = []; let program;
27
+ try { program = compileFormulaEngine(engine); }
28
+ catch (cause) { findings.push({ severity: 'P0', ruleId: cause.code ?? 'ENGINE_INVALID', entityRef: 'engine', message: cause.message }); }
29
+ if (program) {
30
+ const suites = program.engine.testSuites;
31
+ if (!suites.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_TESTS_EMPTY', entityRef: 'testSuites', message: 'no runnable benchmark samples' });
32
+ for (const [index, suite] of suites.entries()) {
33
+ try {
34
+ const result = runSuite(program, suite, index); tests.push(result);
35
+ if (!result.passed) findings.push({ severity: 'P1', ruleId: 'FORMULA_TEST_FAIL', entityRef: `testSuites[${index}]`, message: 'formula test expectation mismatch', mismatches: result.mismatches });
36
+ } catch (cause) {
37
+ tests.push({ name: suite?.name ?? `suite-${index + 1}`, passed: false, code: cause.code ?? 'FORMULA_TEST_ERROR', message: cause.message });
38
+ findings.push({ severity: 'P1', ruleId: cause.code ?? 'FORMULA_TEST_ERROR', entityRef: `testSuites[${index}]`, message: cause.message });
39
+ }
40
+ }
41
+ const coverage = new Set(tests.filter(item => item.passed).flatMap(item => item.keys));
42
+ for (const formula of program.formulas) if (!coverage.has(formula.key)) findings.push({ severity: 'P1', ruleId: 'FORMULA_TEST_UNCOVERED', entityRef: formula.key, message: 'formula has no passing expectation' });
43
+ }
44
+ const passed = findings.length === 0;
45
+ const artifact = { schemaVersion: 'entrorove.formula-test-result/1.0', programDigest: program?.programDigest ?? null, findings, tests };
46
+ const artifactSha256 = formulaDigest(artifact);
47
+ const subject = { schemaVersion: 'entrorove.formula-execution-subject/1.0', engineId: engine?.engineId ?? null,
48
+ engineVersion: program?.engineVersion ?? null, runtimeVersion: program?.runtimeVersion ?? null,
49
+ engineDigest: program?.engineDigest ?? null, programDigest: program?.programDigest ?? null, artifactSha256,
50
+ flowId: options.flowId ?? null, stepId: options.stepId ?? null };
51
+ const evidence = { schemaVersion: 'entrorove.test-evidence/2.0', evidenceId: `formula-${artifactSha256}`,
52
+ kind: 'test', runner: 'local', command: 'entrorove local formula-final-gate', exitCode: passed ? 0 : 2,
53
+ durationMs: Math.max(0, Date.now() - started), summary: `${tests.filter(item => item.passed).length}/${tests.length} formula suites passed`,
54
+ artifactSha256, subject, subjectDigest: formulaDigest(subject),
55
+ ...(options.flowId ? { flowId: options.flowId } : {}), ...(options.stepId ? { stepId: options.stepId } : {}) };
56
+ return { schemaVersion: FORMULA_GATE_SCHEMA, status: passed ? 'incomplete' : 'blocked', authority: 'local',
57
+ computationsPassed: passed, findings, tests, engineId: engine?.engineId ?? null, artifact, evidence: [evidence],
58
+ ...(program ? { programDigest: program.programDigest, engineDigest: program.engineDigest, engineVersion: program.engineVersion, runtimeVersion: program.runtimeVersion } : {}),
59
+ guarantee: passed ? 'formula-tests-passed-awaiting-independent-validator' : 'formula-tests-blocked' };
60
+ }
61
+ export function verifyFormulaTests(program, options = {}) { return formulaFinalGate(verifyFormulaProgram(program).engine, options); }
package/local-formula.mjs CHANGED
@@ -1,130 +1,30 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
-
4
- export const FORMULA_SCHEMA = 'entrorove.formula-engine/1.0';
5
- export const FORMULA_GATE_SCHEMA = 'entrorove.calctool-final-gate/1.0';
6
- const MAX_SCALE = 28;
7
- const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
8
- const SHA256 = /^[0-9a-f]{64}$/;
9
-
10
- function error(code, message, details = {}) {
11
- const value = new Error(message); value.code = code; Object.assign(value, details); return value;
12
- }
13
- function ten(power) { return 10n ** BigInt(power); }
14
- function normalize(coefficient, scale) {
15
- while (scale > 0 && coefficient % 10n === 0n) { coefficient /= 10n; scale -= 1; }
16
- return { coefficient, scale };
3
+ import { readBudgetedProjectFile } from './local-budget-reads.mjs';
4
+ import { formulaError } from './local-decimal.mjs';
5
+ import { formulaDigest } from './local-formula-engine.mjs';
6
+ import { formulaFinalGate } from './local-formula-tests.mjs';
7
+
8
+ export { Decimal, Decimal28, DECIMAL_POLICY } from './local-decimal.mjs';
9
+ export { evaluateFormula, evaluateFormulaGraph, compileFormulaGraph, FORMULA_OPERATORS } from './local-formula-ast.mjs';
10
+ export { FORMULA_SCHEMA, FORMULA_RUNTIME_VERSION, compileFormulaEngine, verifyFormulaProgram,
11
+ evaluateFormulaProgram, buildFormulaEngine, validateFormulaEngine, formulaImpact } from './local-formula-engine.mjs';
12
+ export { FORMULA_GATE_SCHEMA, formulaFinalGate, verifyFormulaTests } from './local-formula-tests.mjs';
13
+
14
+ export async function readFormulaFile(input, relativePath = input.enginePath) {
15
+ if (typeof input?.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) throw formulaError('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
16
+ if (typeof relativePath !== 'string' || relativePath.normalize('NFC') !== relativePath || path.isAbsolute(relativePath)
17
+ || /[\u0000-\u001f\u007f]/.test(relativePath) || /^[A-Za-z]:/.test(relativePath)
18
+ || relativePath.split('/').some(part => !part || part === '.' || part === '..' || part.includes('\\'))) throw formulaError('FORMULA_PATH_INVALID', 'enginePath must be a safe relative file path');
19
+ const root = await fs.realpath(input.repositoryRoot); const target = path.resolve(root, ...relativePath.split('/'));
20
+ const stat = await fs.lstat(target);
21
+ if (!stat.isFile() || stat.isSymbolicLink() || await fs.realpath(target) !== target) throw formulaError('FORMULA_FILE_INVALID', 'enginePath must be a regular file without symlinked ancestors');
22
+ const source = await readBudgetedProjectFile(input, target, { encoding: 'utf8', sourcePath: relativePath });
23
+ let engine;
24
+ try { engine = JSON.parse(source); } catch { throw formulaError('FORMULA_JSON_INVALID', 'enginePath is not valid JSON'); }
25
+ return { engine, sourceSha256: formulaDigest(source), path: relativePath };
17
26
  }
18
- function round(coefficient, fromScale, toScale) {
19
- if (fromScale <= toScale) return coefficient * ten(toScale - fromScale);
20
- const divisor = ten(fromScale - toScale); let quotient = coefficient / divisor; const remainder = coefficient % divisor;
21
- if ((remainder < 0n ? -remainder : remainder) * 2n >= divisor) quotient += coefficient < 0n ? -1n : 1n;
22
- return quotient;
23
- }
24
-
25
- export class Decimal {
26
- constructor(value) {
27
- if (value instanceof Decimal) { this.coefficient = value.coefficient; this.scale = value.scale; this.value = value.value; return; }
28
- const source = String(value ?? '').trim();
29
- if (!DECIMAL.test(source) || source === '' || /^(?:[+-]?)(?:nan|infinity)$/i.test(source)) throw error('INVALID_DECIMAL', `Invalid decimal: ${source}`);
30
- const negative = source[0] === '-'; const unsigned = source.replace(/^[+-]/, ''); const [integer = '0', fraction = ''] = unsigned.split('.');
31
- const digits = `${integer || '0'}${fraction}`.replace(/^0+(?=\d)/, '') || '0';
32
- const raw = BigInt(digits) * (negative ? -1n : 1n); const boundedScale = Math.min(fraction.length, MAX_SCALE);
33
- const normalized = normalize(round(raw, fraction.length, boundedScale), boundedScale); this.coefficient = normalized.coefficient; this.scale = normalized.scale;
34
- this.value = Decimal.format(this.coefficient, this.scale);
35
- }
36
- static from(value) { return value instanceof Decimal ? value : new Decimal(value); }
37
- static fromParts(coefficient, scale) { return new Decimal(Decimal.format(...Object.values(normalize(round(coefficient, scale, Math.min(scale, MAX_SCALE)), Math.min(scale, MAX_SCALE))))); }
38
- static format(coefficient, scale) {
39
- const negative = coefficient < 0n; const digits = (negative ? -coefficient : coefficient).toString().padStart(scale + 1, '0');
40
- const text = scale === 0 ? digits : `${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
41
- return negative && coefficient !== 0n ? `-${text}` : text;
42
- }
43
- plus(other) { const right = Decimal.from(other); const scale = Math.max(this.scale, right.scale); return Decimal.fromParts(this.coefficient * ten(scale - this.scale) + right.coefficient * ten(scale - right.scale), scale); }
44
- minus(other) { return this.plus(Decimal.from(other).negate()); }
45
- negate() { return Decimal.fromParts(-this.coefficient, this.scale); }
46
- times(other) { const right = Decimal.from(other); return Decimal.fromParts(this.coefficient * right.coefficient, this.scale + right.scale); }
47
- div(other) {
48
- const right = Decimal.from(other); if (right.coefficient === 0n) throw error('DIV_ZERO', 'division by zero');
49
- const numerator = this.coefficient * ten(right.scale + MAX_SCALE); const denominator = right.coefficient * ten(this.scale); let quotient = numerator / denominator; const remainder = numerator % denominator;
50
- if ((remainder < 0n ? -remainder : remainder) * 2n >= (denominator < 0n ? -denominator : denominator)) quotient += (numerator < 0n) !== (denominator < 0n) ? -1n : 1n;
51
- return Decimal.fromParts(quotient, MAX_SCALE);
52
- }
53
- rounded(dp) { if (!Number.isInteger(dp) || dp < 0 || dp > MAX_SCALE) throw error('DECIMAL_SCALE_INVALID', 'decimal places must be 0..28'); return this.scale <= dp ? this : Decimal.fromParts(round(this.coefficient, this.scale, dp), dp); }
54
- isZero() { return this.coefficient === 0n; }
55
- toString() { return this.value; }
56
- }
57
-
58
- function requireNode(node) { if (!node || typeof node !== 'object' || Array.isArray(node)) throw error('FORMULA_NODE_INVALID', 'formula nodes must be objects'); }
59
- function requireArgs(node, count, exact = false) {
60
- if (!Array.isArray(node.args) || (exact ? node.args.length !== count : node.args.length < count)) throw error('FORMULA_ARGS_INVALID', `${node.op} requires ${exact ? `exactly ${count}` : `at least ${count}`} arguments`);
61
- }
62
-
63
- export function evaluateFormula(node, values = {}) {
64
- requireNode(node);
65
- if (Object.hasOwn(node, 'ref')) {
66
- if (typeof node.ref !== 'string' || !node.ref || values[node.ref] === undefined || values[node.ref] === null || values[node.ref] === '') throw error('MISSING_INPUT', `missing input: ${node.ref}`, { fieldId: node.ref });
67
- return Decimal.from(values[node.ref]);
68
- }
69
- if (Object.hasOwn(node, 'lit')) return Decimal.from(node.lit);
70
- const op = node.op; const args = node.args;
71
- switch (op) {
72
- case 'add': requireArgs(node, 1); return args.reduce((total, item) => total.plus(evaluateFormula(item, values)), Decimal.from('0'));
73
- case 'sub': requireArgs(node, 2, true); return evaluateFormula(args[0], values).minus(evaluateFormula(args[1], values));
74
- case 'mul': requireArgs(node, 1); return args.reduce((total, item) => total.times(evaluateFormula(item, values)), Decimal.from('1'));
75
- case 'div': requireArgs(node, 2, true); return evaluateFormula(args[0], values).div(evaluateFormula(args[1], values));
76
- case 'safeDivide': requireArgs(node, 2, true); { const denominator = evaluateFormula(args[1], values); return denominator.isZero() ? Decimal.from('0') : evaluateFormula(args[0], values).div(denominator); }
77
- case 'percentOf': requireArgs(node, 2, true); return evaluateFormula(args[0], values).div(evaluateFormula(args[1], values)).times('100');
78
- case 'round': requireArgs(node, 1, true); return evaluateFormula(args[0], values).rounded(2);
79
- case 'if': requireArgs(node, 3, true); return evaluateFormula(args[0], values).isZero() ? evaluateFormula(args[2], values) : evaluateFormula(args[1], values);
80
- default: throw error('UNSUPPORTED_OPERATOR', `Unsupported operator: ${String(op)}`);
81
- }
82
- }
83
-
84
- function refs(node, output = []) {
85
- requireNode(node); if (Object.hasOwn(node, 'ref')) output.push(node.ref); if (Array.isArray(node.args)) node.args.forEach(item => refs(item, output)); return output;
86
- }
87
- export function evaluateFormulaGraph(formulas, inputs = {}) {
88
- if (!Array.isArray(formulas)) throw error('FORMULA_GRAPH_INVALID', 'formulas must be an array');
89
- const values = Object.fromEntries(Object.entries(inputs).map(([key, value]) => [key, Decimal.from(value)])); const definitions = new Map();
90
- for (const formula of formulas) { if (!formula || typeof formula.key !== 'string' || !formula.key || definitions.has(formula.key)) throw error('FORMULA_KEY_INVALID', 'formula keys must be unique non-empty strings'); definitions.set(formula.key, formula); }
91
- const visiting = new Set(); const done = new Set();
92
- const compute = key => {
93
- if (done.has(key)) return values[key]; if (visiting.has(key)) throw error('FORMULA_CYCLE', `formula cycle at ${key}`); const formula = definitions.get(key); if (!formula) throw error('FORMULA_REF_UNKNOWN', `unknown formula: ${key}`);
94
- visiting.add(key); for (const ref of refs(formula.expression)) if (definitions.has(ref)) compute(ref); const result = evaluateFormula(formula.expression, values); visiting.delete(key); done.add(key); values[key] = result; return result;
95
- };
96
- for (const key of definitions.keys()) compute(key);
97
- return Object.fromEntries([...definitions.keys()].map(key => [key, values[key].toString()]));
98
- }
99
-
100
- function digest(value) { return value; }
101
- function engineFindings(engine) {
102
- const findings = []; const formulas = Array.isArray(engine?.formulas) ? engine.formulas : [];
103
- if (!formulas.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_EMPTY', entityRef: 'formulas', message: 'no formulas declared' });
104
- const known = new Set([...(engine?.fields ?? []).map(item => item?.key), ...formulas.map(item => item?.key)]);
105
- for (const [index, formula] of formulas.entries()) {
106
- if (!formula?.key || !formula?.expression) findings.push({ severity: 'P0', ruleId: 'FORMULA_DECLARATION', entityRef: `formulas[${index}]`, message: 'formula key and expression are required' });
107
- try { for (const ref of refs(formula.expression)) if (!known.has(ref)) findings.push({ severity: 'P0', ruleId: 'FORMULA_REF_UNKNOWN', entityRef: `formulas[${index}]`, message: `unknown reference: ${ref}` }); } catch (cause) { findings.push({ severity: 'P0', ruleId: 'FORMULA_NODE_INVALID', entityRef: `formulas[${index}]`, message: cause.message }); }
108
- }
109
- return findings;
110
- }
111
- export function formulaFinalGate(engine, options = {}) {
112
- const findings = engineFindings(engine); const formulas = Array.isArray(engine?.formulas) ? engine.formulas : []; const suites = Array.isArray(engine?.testSuites) ? engine.testSuites : [];
113
- const tests = []; if (!suites.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_TESTS_EMPTY', entityRef: 'testSuites', message: 'no runnable benchmark samples' });
114
- for (const [index, suite] of suites.entries()) {
115
- const expected = suite?.expected ?? suite?.expect ?? {}; const inputs = suite?.input ?? suite?.inputs ?? {}; let actual;
116
- try { actual = evaluateFormulaGraph(formulas, inputs); } catch (cause) { findings.push({ severity: 'P1', ruleId: 'FORMULA_TEST_ERROR', entityRef: `testSuites[${index}]`, message: cause.message }); continue; }
117
- const mismatches = []; for (const [key, expectedValue] of Object.entries(expected)) { try { if (Decimal.from(actual[key]).toString() !== Decimal.from(expectedValue).toString()) mismatches.push({ key, expected: String(expectedValue), actual: actual[key] }); } catch (cause) { mismatches.push({ key, expected: String(expectedValue), actual: actual[key], error: cause.message }); } }
118
- tests.push({ name: suite.name ?? `suite-${index + 1}`, passed: mismatches.length === 0, mismatches }); if (mismatches.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_TEST_FAIL', entityRef: `testSuites[${index}]`, message: 'formula test expectation mismatch', mismatches });
119
- }
120
- return { schemaVersion: FORMULA_GATE_SCHEMA, status: findings.some(item => item.severity === 'P0') ? 'blocked' : findings.length ? 'incomplete' : 'complete', authority: 'trusted-local-runner', findings, tests, engineId: engine?.engineId ?? null, guarantee: findings.length ? 'local-completion-gate-blocked' : 'local-completion-gate-green' };
121
- }
122
-
123
27
  export async function formulaFinalGateFile(input) {
124
- if (typeof input?.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) throw error('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
125
- if (typeof input.enginePath !== 'string' || path.isAbsolute(input.enginePath) || input.enginePath.split('/').some(part => !part || part === '.' || part === '..' || part.includes('\\'))) throw error('FORMULA_PATH_INVALID', 'enginePath must be a safe relative file path');
126
- const root = await fs.realpath(input.repositoryRoot); const target = path.resolve(root, ...input.enginePath.split('/')); const relative = path.relative(root, target); if (relative.startsWith('..') || path.isAbsolute(relative)) throw error('FORMULA_PATH_ESCAPE', 'enginePath escapes repositoryRoot');
127
- const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) throw error('FORMULA_FILE_INVALID', 'enginePath must be a regular file'); const source = await fs.readFile(target, 'utf8');
128
- let engine; try { engine = JSON.parse(source); } catch (cause) { throw error('FORMULA_JSON_INVALID', `enginePath is not valid JSON: ${cause.message}`); }
129
- return { ...formulaFinalGate(engine, input), enginePath: input.enginePath, engineSha256: (await import('node:crypto')).createHash('sha256').update(source).digest('hex') };
28
+ const loaded = await readFormulaFile(input);
29
+ return { ...formulaFinalGate(loaded.engine, input), enginePath: loaded.path, engineSha256: loaded.sourceSha256 };
130
30
  }
@@ -1,10 +1,10 @@
1
+ import { inspectLegacyArchguardPreflight } from './local-merge-archguard-verification.mjs';
1
2
  import { createHash, createPublicKey, verify } from 'node:crypto';
3
+ import { verifyTestEvidence } from './local-evidence-verification.mjs';
2
4
 
3
5
  export const ARCHGUARD_PREFLIGHT_SCHEMA = 'entrorove.archguard-preflight/1.0';
4
6
  export const RUNNER_RECEIPT_SCHEMA = 'entrorove.runner-receipt/1.0';
5
- const EXECUTION_RECEIPT_SCHEMA = 'validator.execution-receipt/1.0';
6
7
  const SNAPSHOT_SCHEMA = 'entrorove.snapshot/1.0';
7
- const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
8
8
  const SHA256 = /^[0-9a-f]{64}$/;
9
9
  const REPOSITORY_DIGEST = /^[0-9a-f]{32}$/;
10
10
  const SAFE_PATH = /^(?!.*[\\\u0000-\u001f\u007f])(?!.*(?:^|\/)(?:\.|\.\.)(?:\/|$))[^/]+(?:\/[^/]+)*$/;
@@ -21,6 +21,17 @@ export function sha256(value) {
21
21
  return createHash('sha256').update(value).digest('hex');
22
22
  }
23
23
 
24
+ export function snapshotHandoffBinding(value) {
25
+ if (!Object.hasOwn(value, 'handoffEpoch') && !Object.hasOwn(value, 'handoffBaselineReceiptDigest')) return {};
26
+ if (!Object.hasOwn(value, 'handoffEpoch') || !Object.hasOwn(value, 'handoffBaselineReceiptDigest')
27
+ || !Number.isSafeInteger(value.handoffEpoch) || value.handoffEpoch < 0
28
+ || (value.handoffBaselineReceiptDigest !== null && (typeof value.handoffBaselineReceiptDigest !== 'string'
29
+ || !SHA256.test(value.handoffBaselineReceiptDigest)))) {
30
+ throw new Error('Snapshot handoff epoch and baseline receipt binding are invalid');
31
+ }
32
+ return { handoffEpoch: value.handoffEpoch, handoffBaselineReceiptDigest: value.handoffBaselineReceiptDigest };
33
+ }
34
+
24
35
  export function verifySnapshotManifest(manifest, binding) {
25
36
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
26
37
  || manifest.schemaVersion !== SNAPSHOT_SCHEMA || manifest.snapshotId !== binding.snapshotId
@@ -37,6 +48,8 @@ export function verifySnapshotManifest(manifest, binding) {
37
48
  }
38
49
  const core = { snapshotId: manifest.snapshotId, flowId: manifest.flowId, stepId: manifest.stepId,
39
50
  paths: manifest.paths, files: manifest.files, contractDigest: manifest.contractDigest };
51
+ try { Object.assign(core, snapshotHandoffBinding(manifest)); }
52
+ catch { return { verified: false, reason: 'snapshot handoff binding is invalid' }; }
40
53
  return manifest.snapshotDigest === sha256(canonical(core))
41
54
  ? { verified: true } : { verified: false, reason: 'snapshot manifest digest is invalid' };
42
55
  }
@@ -67,49 +80,17 @@ function bindingMismatch(attestation, binding) {
67
80
  || attestation.snapshotDigest !== binding.snapshotDigest || attestation.contractDigest !== binding.contractDigest;
68
81
  }
69
82
 
70
- function verifyExecutionReceipt(receipt, evidence, binding) {
71
- if (!receipt || receipt.schemaVersion !== EXECUTION_RECEIPT_SCHEMA || receipt.subjectDigest !== evidence.subjectDigest
72
- || !ID.test(String(receipt.nonce ?? '')) || !receipt.result || receipt.result.runner !== 'trusted-runner' || typeof receipt.result.passed !== 'boolean'
73
- || receipt.result.exitCode !== evidence.exitCode || receipt.result.durationMs !== evidence.durationMs || receipt.result.summary !== evidence.summary) throw new Error('Trusted execution receipt is not bound');
74
- const issuedAt = Date.parse(String(receipt.issuedAt ?? '')); const expiresAt = Date.parse(String(receipt.expiresAt ?? ''));
75
- if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > Date.now() + 60_000 || expiresAt <= Date.now() || expiresAt <= issuedAt || expiresAt - issuedAt > 600_000) throw new Error('Trusted execution receipt lifetime is invalid');
76
- const unsigned = Object.fromEntries(Object.entries(receipt).filter(([key]) => key !== 'signature'));
77
- if (typeof binding.receiptPublicKey !== 'string') throw new Error('Trusted execution receipt public key is required');
78
- return verifyReceipt(receipt, unsigned, { schemaVersion: EXECUTION_RECEIPT_SCHEMA, publicKey: binding.receiptPublicKey });
79
- }
80
-
81
83
  export function verifyArchguardPreflight(attestation, binding) {
82
- if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)
83
- || attestation.schemaVersion !== ARCHGUARD_PREFLIGHT_SCHEMA || attestation.status !== 'passed'
84
- || bindingMismatch(attestation, binding) || !attestation.receipt) {
85
- return { verified: false, reason: 'ArchGuard preflight is missing or not bound to this flow, step, snapshot, and contract' };
86
- }
87
- const unsigned = Object.fromEntries(Object.entries(attestation).filter(([key]) => key !== 'receipt'));
88
- try {
89
- const receipt = verifyReceipt(attestation.receipt, unsigned);
90
- return { verified: true, attestation, keyId: receipt.keyId };
91
- } catch (error) {
92
- return { verified: false, reason: error instanceof Error ? error.message : String(error) };
93
- }
84
+ return inspectLegacyArchguardPreflight(attestation, binding);
94
85
  }
95
86
 
96
- export function verifyTrustedEvidence(evidence, binding) {
97
- if (!evidence || evidence.runner !== 'trusted-runner' || !evidence.subject
98
- || evidence.subject.schemaVersion !== 'validator.validation-subject/1.0'
99
- || evidence.subject.chainId !== binding.flowId || evidence.subject.validationRunId !== binding.stepId
100
- || evidence.subjectDigest !== sha256(canonical(evidence.subject))
101
- || evidence.subject.snapshotDigest !== binding.snapshotDigest
102
- || evidence.subject.contractDigest !== binding.contractDigest) {
103
- return { verified: false, reason: 'trusted evidence subject is not bound to this flow, step, snapshot, and contract' };
104
- }
105
- const unsigned = Object.fromEntries(Object.entries(evidence).filter(([key]) => key !== 'receipt'));
106
- try {
107
- const receipt = evidence.receipt?.schemaVersion === EXECUTION_RECEIPT_SCHEMA
108
- ? verifyExecutionReceipt(evidence.receipt, evidence, binding) : verifyReceipt(evidence.receipt, unsigned);
109
- return { verified: true, evidence, keyId: receipt.keyId };
110
- } catch (error) {
111
- return { verified: false, reason: error instanceof Error ? error.message : String(error) };
87
+ export function verifyTrustedEvidence(evidence, binding = {}) {
88
+ const publicKey = process.env.ENTROROVE_VALIDATOR_RECEIPT_PUBLIC_KEY?.trim();
89
+ if (publicKey && binding.receiptPublicKey !== undefined && binding.receiptPublicKey !== publicKey) {
90
+ return { verified: false, authentic: false, successful: false, status: 'blocked', evidenceState: 'blocked',
91
+ code: 'ENTROROVE_EVIDENCE_KEY', reason: 'Supplied receipt key does not match independent verifier configuration' };
112
92
  }
93
+ return verifyTestEvidence(evidence, { publicKey, binding, requireBinding: true });
113
94
  }
114
95
 
115
96
  export function assertGreen(value, label) {
@@ -0,0 +1,107 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+
7
+ const SHA = /^[a-f0-9]{64}$/;
8
+ const UUID = /^[a-f0-9-]{36}$/;
9
+ const ID = /^[a-z][a-z0-9-]{0,63}$/;
10
+ const COUNTS = ['inputTokens', 'outputTokens', 'durationMs'];
11
+ const events = new Set(['started', 'completed', 'rejected', 'outcome-unknown', 'not-sent', 'status']);
12
+
13
+ function receipt(value, requestId) {
14
+ if (!value || !UUID.test(value.usageId) || value.requestId !== requestId
15
+ || !['pending', 'recorded', 'unknown'].includes(value.state)
16
+ || !['runtime', 'model'].includes(value.meterKind)
17
+ || !['not-applicable', 'reported', 'unknown'].includes(value.tokenStatus)) throw new Error('Invalid original request usage receipt');
18
+ const clean = { usageId: value.usageId, requestId, meterKind: value.meterKind,
19
+ state: value.state, tokenStatus: value.tokenStatus };
20
+ for (const key of COUNTS) {
21
+ if (value[key] !== null && (!Number.isSafeInteger(value[key]) || value[key] < 0)) throw new Error('Invalid original request usage measurement');
22
+ clean[key] = value[key];
23
+ }
24
+ if (value.tokenStatus === 'unknown' && (value.inputTokens !== null || value.outputTokens !== null)) throw new Error('Unknown token usage must not report zero');
25
+ for (const key of ['providerId', 'modelId']) {
26
+ if (value[key] !== null && (typeof value[key] !== 'string' || !/^[A-Za-z0-9._:/-]{1,128}$/.test(value[key]))) {
27
+ throw new Error('Invalid original request model identity');
28
+ }
29
+ clean[key] = value[key];
30
+ }
31
+ return clean;
32
+ }
33
+
34
+ async function ledgerFile(home) {
35
+ if (typeof home !== 'string' || !path.isAbsolute(home)) throw new Error('Invocation ledger home must be absolute');
36
+ const root = await fs.realpath(home);
37
+ const directory = path.join(root, '.entrorove');
38
+ await fs.mkdir(directory, { mode: 0o700 }).catch(cause => { if (cause.code !== 'EEXIST') throw cause; });
39
+ const stat = await fs.lstat(directory);
40
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && stat.uid !== process.getuid()) {
41
+ throw new Error('Invocation ledger requires an owned real directory');
42
+ }
43
+ if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
44
+ return path.join(directory, 'invocations.jsonl');
45
+ }
46
+
47
+ export async function appendInvocation(value, home = os.homedir()) {
48
+ if (!value || !SHA.test(value.requestId) || !ID.test(value.operation) || !events.has(value.event)
49
+ || !['remote', 'local'].includes(value.kind)) throw new Error('Invalid invocation ledger metadata');
50
+ const record = { schemaVersion: 'entrorove.invocation/1.0', eventId: randomUUID(), at: new Date().toISOString(),
51
+ kind: value.kind, operation: value.operation, requestId: value.requestId, event: value.event };
52
+ if (value.durationMs !== undefined) {
53
+ if (!Number.isSafeInteger(value.durationMs) || value.durationMs < 0) throw new Error('Invalid local invocation duration');
54
+ record.durationMs = value.durationMs;
55
+ }
56
+ if (value.usage !== undefined) {
57
+ if (!Array.isArray(value.usage) || value.usage.length > 100) throw new Error('Invalid invocation usage receipt list');
58
+ record.usage = value.usage.map(item => receipt(item, value.requestId));
59
+ }
60
+ const file = await ledgerFile(home);
61
+ await withStateLock(file, async () => {
62
+ const handle = await fs.open(file, constants.O_APPEND | constants.O_WRONLY | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0), 0o600);
63
+ try {
64
+ const stat = await handle.stat();
65
+ if (!stat.isFile() || stat.nlink !== 1 || process.platform !== 'win32'
66
+ && (stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600)) throw new Error('Invocation ledger must be a private regular file with permissions 0600');
67
+ await handle.writeFile(`${JSON.stringify(record)}\n`); await handle.sync();
68
+ } finally { await handle.close(); }
69
+ });
70
+ return record;
71
+ }
72
+
73
+ function canonical(value) {
74
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
75
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
76
+ return JSON.stringify(value);
77
+ }
78
+
79
+ export async function withLocalInvocation(operation, input, work) {
80
+ const requestId = createHash('sha256').update(operation + canonical(input)).digest('hex');
81
+ const metadata = { kind: 'local', operation, requestId };
82
+ await appendInvocation({ ...metadata, event: 'started' }, input.home);
83
+ const startedAt = performance.now();
84
+ try {
85
+ const output = await work();
86
+ const rejected = ['blocked', 'failed', 'incomplete'].includes(output?.status);
87
+ await appendInvocation({ ...metadata, event: rejected ? 'rejected' : 'completed',
88
+ durationMs: Math.max(0, Math.round(performance.now() - startedAt)) }, input.home);
89
+ return output;
90
+ } catch (cause) {
91
+ await appendInvocation({ ...metadata, event: 'outcome-unknown',
92
+ durationMs: Math.max(0, Math.round(performance.now() - startedAt)) }, input.home);
93
+ throw cause;
94
+ }
95
+ }
96
+
97
+ export async function readInvocations(home = os.homedir()) {
98
+ const file = await ledgerFile(home);
99
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
100
+ try {
101
+ const stat = await handle.stat();
102
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 16_777_216
103
+ || process.platform !== 'win32' && (stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600)) throw new Error('Invocation ledger is unavailable or invalid');
104
+ try { return (await handle.readFile('utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); }
105
+ catch { throw new Error('Invocation ledger contains invalid metadata'); }
106
+ } finally { await handle.close(); }
107
+ }
package/local-ledger.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { operationSchemasFor } from './local-contracts.mjs';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { promises as fs } from 'node:fs';
3
4
  import os from 'node:os';
@@ -49,10 +50,4 @@ export const LOCAL_LEDGER_OPERATIONS = Object.freeze({
49
50
  'chain-init': chainInit, 'chain-resume': chainResume, 'chain-status': chainStatus, 'chain-answer': chainAnswer,
50
51
  'branch-create-virtual': branchCreate, 'chain-ledger-init': chainInit, 'chain-ledger-status': chainStatus, 'chain-ledger-query': ledgerQuery,
51
52
  });
52
- const LEDGER_REQUIRED = {
53
- 'branch-create': ['repositoryRoot', 'name', 'fromRef'], 'branch-list': ['repositoryRoot'], 'branch-switch': ['repositoryRoot', 'branchName'],
54
- 'diff-report': ['repositoryRoot'], preflight: ['repositoryRoot'], 'ledger-query': ['repositoryRoot'], 'chain-init': ['repositoryRoot', 'plan'],
55
- 'chain-resume': ['repositoryRoot', 'chainId'], 'chain-status': ['repositoryRoot', 'chainId'], 'chain-answer': ['repositoryRoot', 'chainId', 'answer'],
56
- 'branch-create-virtual': ['repositoryRoot', 'name', 'fromRef'], 'chain-ledger-init': ['repositoryRoot', 'plan'], 'chain-ledger-status': ['repositoryRoot', 'chainId'], 'chain-ledger-query': ['repositoryRoot'],
57
- };
58
- export const LOCAL_LEDGER_SCHEMAS = Object.freeze(Object.fromEntries(Object.keys(LOCAL_LEDGER_OPERATIONS).map(operation => [operation, { type: 'object', additionalProperties: false, required: LEDGER_REQUIRED[operation] ?? [] }])));
53
+ export const LOCAL_LEDGER_SCHEMAS = operationSchemasFor(LOCAL_LEDGER_OPERATIONS);
@@ -0,0 +1,44 @@
1
+ import type { KeyObject } from 'node:crypto';
2
+
3
+ export interface MergeInspectionOptions {
4
+ publicKey?: string;
5
+ now?: number;
6
+ consumedNonces?: ReadonlySet<string>;
7
+ }
8
+ export interface MergePreflightInspectionOptions extends MergeInspectionOptions {
9
+ preparation?: unknown;
10
+ mergeLease?: unknown;
11
+ preparationPublicKey?: string;
12
+ authorityPublicKey?: string;
13
+ consumedPreparationNonces?: ReadonlySet<string>;
14
+ consumedLeaseNonces?: ReadonlySet<string>;
15
+ }
16
+ export interface MergeReceiptDetails {
17
+ authentic?: boolean;
18
+ status?: 'passed' | 'blocked';
19
+ reason?: string;
20
+ preparation?: Record<string, unknown>;
21
+ preflight?: Record<string, unknown>;
22
+ attestation?: Record<string, unknown>;
23
+ }
24
+ export type MergeReceiptInspection = MergeReceiptDetails & (
25
+ { verified: true; keyId: string; nonce: string; receiptDigest: string }
26
+ | { verified: false; keyId?: string; nonce?: string; receiptDigest?: string }
27
+ );
28
+ export const MERGE_ARCHGUARD_SCHEMA: 'entrorove.archguard-merge-preflight/1.0';
29
+ export const MERGE_PREPARATION_SCHEMA: 'entrorove.merge-preparation-receipt/1.0';
30
+ export const MERGE_PIN_ENV: 'ENTROROVE_ARCHGUARD_RUNNER_PUBLIC_KEY';
31
+ export const MERGE_ARCHGUARD_RUNTIME_SHA256: string;
32
+ export const SHA256: RegExp;
33
+ export const ID: RegExp;
34
+ export function mergeSha256(bytes: string | Uint8Array): string;
35
+ export function mergeCanonical(value: unknown): string;
36
+ export function configuredMergePin(publicKey: string | undefined): { key: KeyObject; der: Buffer; keyId: string; publicKey: string };
37
+ export function verifyMergeSignature(value: unknown, signature: unknown, keyId: unknown, publicKey: string | undefined): string;
38
+ export function verifyMergeLifetime(value: Record<string, unknown>, now?: number, consumedNonces?: ReadonlySet<string>): void;
39
+ export function inspectMergeSignedEnvelope(value: unknown, options: MergeInspectionOptions & { schemaVersion: string }): MergeReceiptInspection;
40
+ export function inspectLegacyArchguardPreflight(value: unknown, binding?: Record<string, unknown>, options?: MergeInspectionOptions): MergeReceiptInspection;
41
+ export function mergeArtifactSha256(files: ReadonlyArray<{ path: string; sha256: string }>): string;
42
+ export function mergePreflightBinding(preparation: Record<string, unknown>): Record<string, unknown>;
43
+ export function inspectMergePreparation(preparation: unknown, binding?: Record<string, unknown>, options?: MergeInspectionOptions): MergeReceiptInspection;
44
+ export function inspectMergeArchguardPreflight(preflight: unknown, binding?: Record<string, unknown>, options?: MergePreflightInspectionOptions): MergeReceiptInspection;