@entrorove/skill 0.1.24 → 0.1.28

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 (50) hide show
  1. package/CAPABILITY-MAP.md +73 -15
  2. package/CHANGELOG.md +20 -0
  3. package/SKILL.md +11 -1
  4. package/calctool-runtime-entry.mjs +2 -2
  5. package/cli.mjs +18 -1
  6. package/local-archguard-checkpoint.mjs +5 -2
  7. package/local-archguard-snapshot.mjs +18 -13
  8. package/local-autocoord17-authority.mjs +36 -0
  9. package/local-autocoord17-controller.mjs +164 -0
  10. package/local-autocoord17-peers.mjs +111 -0
  11. package/local-autocoord17-store.mjs +110 -0
  12. package/local-autocoord17-tasks.mjs +68 -0
  13. package/local-autocoord17-waits.mjs +118 -0
  14. package/local-calctool-cli.mjs +3 -1
  15. package/local-calctool-composition17.mjs +50 -0
  16. package/local-calctool-runtime.mjs +1 -1
  17. package/local-calctool.mjs +1 -1
  18. package/local-chain-bindings.mjs +93 -0
  19. package/local-chain-broker.mjs +61 -0
  20. package/local-chain-contracts.mjs +21 -0
  21. package/local-chain-controller.mjs +321 -0
  22. package/local-chain-human.mjs +48 -0
  23. package/local-chain-lock.mjs +54 -0
  24. package/local-chain-operations.mjs +7 -0
  25. package/local-chain-plan.mjs +232 -0
  26. package/local-chain-release-labels.mjs +12 -0
  27. package/local-chain-results.mjs +75 -0
  28. package/local-cli.mjs +23 -15
  29. package/local-contract-types.mjs +1 -1
  30. package/local-contracts.mjs +2 -0
  31. package/local-error.mjs +23 -0
  32. package/local-execution.mjs +1 -1
  33. package/local-final-gate16-bundle.mjs +2 -2
  34. package/local-formula-ast.mjs +23 -14
  35. package/local-formula-engine.mjs +17 -3
  36. package/local-formula.mjs +1 -1
  37. package/local-invocations.mjs +35 -0
  38. package/local-ledger.mjs +13 -2
  39. package/local-mutation-authority.mjs +13 -6
  40. package/local-mutation-binding.mjs +5 -2
  41. package/local-mutation-files.mjs +29 -4
  42. package/local-task-handoff-observation.mjs +4 -1
  43. package/local-validator-isolation17.mjs +256 -0
  44. package/manifest.json +1 -1
  45. package/package.json +22 -2
  46. package/release-state.json +48 -29
  47. package/release.mjs +22 -1
  48. package/releases.json +24 -0
  49. package/runtime/calctool-runtime-v7.0.43.mjs +40 -17
  50. package/workflow-local-request.mjs +3 -1
@@ -0,0 +1,232 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ export const LOCAL_CHAIN_PLAN_SCHEMA = 'entrorove.execution-plan/1.0';
5
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
+ const KINDS = ['skill', 'coordinator', 'command'];
7
+ const EVIDENCE_KINDS = ['test', 'build', 'lint', 'security', 'benchmark'];
8
+ const RESERVED_ENV = /(?:TOKEN|SECRET|PASSWORD|PRIVATE.?KEY|AUTHORIZATION|CREDENTIAL)/i;
9
+ const UNSAFE_TARGET = new Set(['__proto__', 'prototype', 'constructor']);
10
+ function fail(code, message) { throw Object.assign(new Error(message), { code }); }
11
+ function object(value, label) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)
13
+ || ![null, Object.prototype].includes(Object.getPrototypeOf(value))) fail('ENTROROVE_CHAIN_JSON', `${label} must be a JSON object`);
14
+ return value;
15
+ }
16
+ function fields(value, required, optional, label) {
17
+ object(value, label);
18
+ if (required.some(key => !Object.hasOwn(value, key))
19
+ || Object.keys(value).some(key => !required.includes(key) && !optional.includes(key))) {
20
+ fail('ENTROROVE_CHAIN_FIELDS', `${label} has missing or unknown fields`);
21
+ }
22
+ }
23
+ function identifier(value, label) {
24
+ if (typeof value !== 'string' || !ID.test(value)) fail('ENTROROVE_CHAIN_ID', `${label} must be a safe identifier`);
25
+ }
26
+ function jsonCopy(value) {
27
+ const seen = new WeakSet(); let entries = 0;
28
+ function visit(item, depth = 0) {
29
+ if (depth > 128) fail('ENTROROVE_CHAIN_JSON', 'plan JSON exceeds its nesting bound');
30
+ if (++entries > 100000) fail('ENTROROVE_CHAIN_JSON', 'plan JSON exceeds its control bound');
31
+ if (item === null || typeof item === 'string' || typeof item === 'boolean') return;
32
+ if (typeof item === 'number' && Number.isFinite(item)) return;
33
+ if (typeof item !== 'object' || seen.has(item)) fail('ENTROROVE_CHAIN_JSON', 'values must be finite acyclic JSON');
34
+ if (!Array.isArray(item)) object(item, 'value');
35
+ seen.add(item);
36
+ for (const child of Object.values(item)) visit(child, depth + 1);
37
+ seen.delete(item);
38
+ }
39
+ visit(value);
40
+ const bytes = JSON.stringify(value);
41
+ if (Buffer.byteLength(bytes) > 1024 * 1024) fail('ENTROROVE_CHAIN_JSON', 'plan JSON exceeds its control byte bound');
42
+ return JSON.parse(bytes);
43
+ }
44
+ function freeze(value) {
45
+ if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
46
+ return value;
47
+ }
48
+ function pointer(value, target = false) {
49
+ if (typeof value !== 'string' || (value !== '' && !value.startsWith('/')) || /~(?![01])/u.test(value)) {
50
+ fail('ENTROROVE_CHAIN_POINTER', 'binding must use a valid RFC 6901 JSON Pointer');
51
+ }
52
+ const parts = value === '' ? [] : value.slice(1).split('/').map(part => part.replace(/~1/g, '/').replace(/~0/g, '~'));
53
+ if (target && (!parts.length || parts.some(part => UNSAFE_TARGET.has(part)))) {
54
+ fail('ENTROROVE_CHAIN_POINTER', 'binding target must name a declared safe input property');
55
+ }
56
+ return parts;
57
+ }
58
+ function position(value, part) {
59
+ if (Array.isArray(value)) {
60
+ if (!/^(0|[1-9][0-9]*)$/.test(part) || !Number.isSafeInteger(Number(part)) || Number(part) >= value.length) {
61
+ fail('ENTROROVE_CHAIN_POINTER', 'binding array index must identify an existing element');
62
+ }
63
+ } else object(value, 'pointer parent');
64
+ if (!Object.hasOwn(value, part)) fail('ENTROROVE_CHAIN_POINTER', 'binding pointer value is missing');
65
+ return part;
66
+ }
67
+ function readPointer(value, parts) {
68
+ let current = value;
69
+ for (const part of parts) current = current[position(current, part)];
70
+ return current;
71
+ }
72
+ function setPointer(value, parts, replacement) {
73
+ const parent = readPointer(value, parts.slice(0, -1));
74
+ const key = position(parent, parts.at(-1));
75
+ Object.defineProperty(parent, key, { value: jsonCopy(replacement), enumerable: true, configurable: true, writable: true });
76
+ }
77
+ function commandInput(input) {
78
+ fields(input, ['executable', 'args', 'workingDirectory', 'timeoutMs', 'evidenceKind', 'environment'], [], 'command input');
79
+ if (typeof input.executable !== 'string' || !input.executable || input.executable.includes('\0')
80
+ || !Array.isArray(input.args) || input.args.some(arg => typeof arg !== 'string' || arg.includes('\0'))
81
+ || typeof input.workingDirectory !== 'string' || !input.workingDirectory || input.workingDirectory.includes('\0')
82
+ || !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 3600000
83
+ || !EVIDENCE_KINDS.includes(input.evidenceKind)) fail('ENTROROVE_CHAIN_COMMAND', 'command policy is invalid');
84
+ object(input.environment, 'command environment');
85
+ for (const [key, value] of Object.entries(input.environment)) {
86
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || RESERVED_ENV.test(key) || typeof value !== 'string' || value.includes('\0')) {
87
+ fail('ENTROROVE_CHAIN_ENVIRONMENT', 'command environment must contain explicit non-secret strings');
88
+ }
89
+ }
90
+ if (!path.isAbsolute(input.executable) && !input.environment.PATH) fail('ENTROROVE_CHAIN_COMMAND', 'relative executable requires an explicit PATH');
91
+ const directory = path.normalize(input.workingDirectory);
92
+ if (!path.isAbsolute(directory) && (directory === '..' || directory.startsWith(`..${path.sep}`))) {
93
+ fail('ENTROROVE_CHAIN_COMMAND', 'working directory cannot escape the original repository');
94
+ }
95
+ }
96
+ function validateBindings(step, previous) {
97
+ if (!Array.isArray(step.bindings)) fail('ENTROROVE_CHAIN_BINDING', 'step bindings must be an array');
98
+ if (step.kind === 'command' && step.bindings.length) fail('ENTROROVE_CHAIN_BINDING', 'command input cannot receive dynamic bindings');
99
+ const targets = [];
100
+ for (const binding of step.bindings) {
101
+ fields(binding, ['stepId', 'source', 'target'], [], 'binding');
102
+ if (!previous.has(binding.stepId)) fail('ENTROROVE_CHAIN_BINDING', 'binding source must name an earlier declared step');
103
+ pointer(binding.source);
104
+ const target = pointer(binding.target, true);
105
+ readPointer(step.input, target);
106
+ if (targets.some(parts => parts.every((part, index) => target[index] === part)
107
+ || target.every((part, index) => parts[index] === part))) fail('ENTROROVE_CHAIN_BINDING', 'binding targets cannot overlap');
108
+ targets.push(target);
109
+ }
110
+ }
111
+ function validateStep(step, previous, skills) {
112
+ fields(step, ['stepId', 'kind', 'skillId', 'operation', 'input', 'dependsOn', 'bindings'], ['continueWhen'], 'step');
113
+ identifier(step.stepId, 'stepId'); identifier(step.operation, 'operation'); object(step.input, 'step input');
114
+ if (previous.has(step.stepId) || !KINDS.includes(step.kind) || !Array.isArray(step.dependsOn)
115
+ || new Set(step.dependsOn).size !== step.dependsOn.length || step.dependsOn.some(id => !previous.has(id))) {
116
+ fail('ENTROROVE_CHAIN_TOPOLOGY', 'steps must have unique IDs and only earlier dependencies');
117
+ }
118
+ if (step.kind === 'skill' ? !skills.has(step.skillId) : step.skillId !== null) fail('ENTROROVE_CHAIN_SKILL', 'step skillId must match its declared kind and installed package declaration');
119
+ if (step.kind === 'command') {
120
+ if (step.operation !== 'exec') fail('ENTROROVE_CHAIN_COMMAND', 'command operation must be exec');
121
+ commandInput(step.input);
122
+ }
123
+ if (step.kind === 'coordinator' && step.operation === 'resolve-human') fail('ENTROROVE_CHAIN_DECISION', 'human resolution cannot be injected by a plan');
124
+ if (step.continueWhen !== undefined) {
125
+ if (step.kind !== 'skill' || step.skillId !== 'confirm-protocol' || step.operation !== 'interaction-request') {
126
+ fail('ENTROROVE_CHAIN_DECISION', 'continueWhen applies only to a declared confirmation request');
127
+ }
128
+ fields(step.continueWhen, ['answer'], [], 'continueWhen');
129
+ const answer = step.continueWhen.answer;
130
+ if (!(typeof answer === 'string' && answer.trim()) && !(Array.isArray(answer) && answer.length
131
+ && answer.every(item => typeof item === 'string' && item.trim()) && new Set(answer).size === answer.length)) {
132
+ fail('ENTROROVE_CHAIN_DECISION', 'continueWhen must declare an explicit string or unique choice IDs');
133
+ }
134
+ }
135
+ validateBindings(step, previous);
136
+ }
137
+
138
+ // Metadata validation never verifies installed packages, approves commands or
139
+ // confers execution authority. The managed dispatcher retains those checks.
140
+ export function validateLocalChainPlan(value) {
141
+ const plan = jsonCopy(value);
142
+ fields(plan, ['schemaVersion', 'chainId', 'skills', 'steps'], [], 'plan');
143
+ identifier(plan.chainId, 'chainId');
144
+ if (plan.schemaVersion !== LOCAL_CHAIN_PLAN_SCHEMA || !Array.isArray(plan.skills)
145
+ || !Array.isArray(plan.steps) || !plan.steps.length) fail('ENTROROVE_CHAIN_PLAN_INVALID', 'chain plan schema, skills and steps are required');
146
+ const skills = new Set();
147
+ for (const skill of plan.skills) {
148
+ fields(skill, ['skillId', 'packageRoot'], [], 'skill'); identifier(skill.skillId, 'skillId');
149
+ if (skills.has(skill.skillId) || typeof skill.packageRoot !== 'string' || !path.isAbsolute(skill.packageRoot)
150
+ || skill.packageRoot.includes('\0')) fail('ENTROROVE_CHAIN_SKILL', 'skills require unique IDs and absolute package declarations');
151
+ skills.add(skill.skillId);
152
+ }
153
+ const previous = new Set();
154
+ for (const step of plan.steps) { validateStep(step, previous, skills); previous.add(step.stepId); }
155
+ return freeze(plan);
156
+ }
157
+
158
+ // completed must come from the dispatcher's original durable outcomes. This
159
+ // resolver does not authenticate caller-supplied results or advance a journal.
160
+ export function bindLocalChainStepInput(value, stepId, completed) {
161
+ const plan = validateLocalChainPlan(value), index = plan.steps.findIndex(step => step.stepId === stepId);
162
+ if (index < 0) fail('ENTROROVE_CHAIN_STEP_UNKNOWN', 'binding target is outside the immutable plan');
163
+ if (!Array.isArray(completed)) fail('ENTROROVE_CHAIN_RESULT', 'completed outcomes must be an array');
164
+ const earlier = new Set(plan.steps.slice(0, index).map(step => step.stepId)), results = new Map();
165
+ for (const result of completed) {
166
+ fields(result, ['stepId', 'status', 'output'], [], 'completed outcome');
167
+ if (!earlier.has(result.stepId) || results.has(result.stepId)) fail('ENTROROVE_CHAIN_RESULT', 'outcomes must belong to unique earlier steps');
168
+ results.set(result.stepId, jsonCopy(result));
169
+ }
170
+ const step = plan.steps[index];
171
+ for (const dependency of new Set([...step.dependsOn, ...step.bindings.map(binding => binding.stepId)])) {
172
+ if (results.get(dependency)?.status !== 'succeeded') fail('ENTROROVE_CHAIN_DEPENDENCY', 'binding requires the original successful dependency outcome');
173
+ }
174
+ const input = jsonCopy(step.input);
175
+ for (const binding of step.bindings) setPointer(input, pointer(binding.target, true), readPointer(results.get(binding.stepId).output, pointer(binding.source)));
176
+ return input;
177
+ }
178
+
179
+ function statuses(value, found = new Set()) {
180
+ if (value && typeof value === 'object') {
181
+ if (typeof value.status === 'string') found.add(value.status);
182
+ for (const child of Object.values(value)) statuses(child, found);
183
+ }
184
+ return found;
185
+ }
186
+ function equal(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
187
+ function gate(state, code) { return Object.freeze({ state, code, eligible: state === 'eligible', executionVerified: false, authority: 'protocol-shape-only' }); }
188
+
189
+ // A protocol-success envelope is necessary, never proof of actual execution.
190
+ // The dispatcher must authenticate its broker/runner result before committing.
191
+ export function inspectLocalChainResult(step, observed) {
192
+ object(step, 'step'); const response = jsonCopy(observed);
193
+ if (!KINDS.includes(step.kind)) fail('ENTROROVE_CHAIN_STEP_INVALID', 'result gate requires a declared step kind');
194
+ object(response, 'response');
195
+ const output = response.output ?? response.result ?? response;
196
+ object(output, 'protocol output');
197
+ const states = new Set([response.status, output.status]);
198
+ if (states.has('uncertain') || output.process?.status === 'uncertain') return gate('uncertain', 'ENTROROVE_CHAIN_OUTCOME_UNCERTAIN');
199
+ if (states.has('failed')) return gate('failed', 'ENTROROVE_CHAIN_RESULT_FAILED');
200
+ if (states.has('blocked')) return gate('blocked', 'ENTROROVE_CHAIN_RESULT_BLOCKED');
201
+ if (['waiting', 'pending', 'pending-execution', 'incomplete'].some(state => states.has(state))
202
+ || statuses(output).has('pending-execution')) return gate('waiting', 'ENTROROVE_CHAIN_RESULT_PENDING');
203
+ if (response.status !== 'succeeded') return gate('waiting', 'ENTROROVE_CHAIN_RESULT_UNVERIFIED');
204
+ if (step.kind === 'command') {
205
+ const process = output.process ?? output;
206
+ if (!Number.isInteger(process.exitCode)) return gate('uncertain', 'ENTROROVE_CHAIN_PROCESS_UNKNOWN');
207
+ if (process.exitCode !== 0 || process.signal || process.timedOut || process.outputLimitExceeded) return gate('failed', 'ENTROROVE_CHAIN_PROCESS_FAILED');
208
+ }
209
+ if (step.kind === 'skill' && step.skillId === 'validator') {
210
+ const verdict = output.report?.verdict ?? output.verdict;
211
+ if (step.operation === 'verdict' && !['pass', 'pass-with-risk'].includes(verdict)) return gate(verdict === 'blocked' ? 'blocked' : 'waiting', 'ENTROROVE_CHAIN_VALIDATOR_VERDICT');
212
+ if (step.operation === 'functional-verify') {
213
+ if (output.summary?.failed > 0 || output.evidence?.some(item => item.exitCode !== 0)) return gate('blocked', 'ENTROROVE_CHAIN_VALIDATOR_FAILED');
214
+ if (!Array.isArray(output.evidence) || !output.evidence.length || output.evidence.some(item => item.runner !== 'trusted-runner' || !item.receipt)) return gate('waiting', 'ENTROROVE_CHAIN_VALIDATOR_INCOMPLETE');
215
+ }
216
+ }
217
+ if (step.kind === 'skill' && step.skillId === 'confirm-protocol' && step.operation === 'interaction-request') {
218
+ if (!step.continueWhen || !Object.hasOwn(output, 'answer')) return gate('waiting', 'ENTROROVE_CHAIN_DECISION_REQUIRED');
219
+ if (!equal(output.answer, step.continueWhen.answer)) return gate('blocked', 'ENTROROVE_CHAIN_DECISION_DENIED');
220
+ }
221
+ return gate('eligible', 'ENTROROVE_CHAIN_AUTHENTICATION_REQUIRED');
222
+ }
223
+
224
+ export function localChainPlanDigest(plan) {
225
+ const value = validateLocalChainPlan(plan);
226
+ function canonical(item) {
227
+ if (Array.isArray(item)) return `[${item.map(canonical).join(',')}]`;
228
+ if (item && typeof item === 'object') return `{${Object.keys(item).sort().map(key => `${JSON.stringify(key)}:${canonical(item[key])}`).join(',')}}`;
229
+ return JSON.stringify(item);
230
+ }
231
+ return createHash('sha256').update(canonical(value)).digest('hex');
232
+ }
@@ -0,0 +1,12 @@
1
+ export const chainReleaseLabels = Object.freeze({
2
+ 'local-chain-plan.mjs': { zh: '原链严格计划与结果门禁', en: 'strict original-chain plan and result gates' },
3
+ 'local-chain-bindings.mjs': { zh: '原链安装元数据与执行映射', en: 'original-chain installed metadata and execution bindings' },
4
+ 'local-chain-lock.mjs': { zh: '原链宿主锁与中断恢复', en: 'original-chain host locks and interruption recovery' },
5
+ 'local-chain-broker.mjs': { zh: '原链同流程技能调用', en: 'original-chain skill calls on the same workflow' },
6
+ 'local-chain-human.mjs': { zh: '原链终端确认与真实回调', en: 'original-chain terminal confirmation and original callbacks' },
7
+ 'local-chain-controller.mjs': { zh: '原链持久受控执行器', en: 'durable original-chain controlled executor' },
8
+ 'local-chain-contracts.mjs': { zh: '原链本地严格操作契约', en: 'strict original-chain local operation contracts' },
9
+ 'local-chain-operations.mjs': { zh: '原链受控操作入口', en: 'original-chain controlled operation entry points' },
10
+ 'local-chain-release-labels.mjs': { zh: '原链独立更新说明', en: 'independent original-chain release descriptions' },
11
+ 'local-chain-results.mjs': { zh: '原链结果与退出状态', en: 'original-chain results and exit states' },
12
+ });
@@ -0,0 +1,75 @@
1
+ import { observeWorkflowLocalStep } from './workflow-local-runner.mjs';
2
+ import { buildInvocation } from './remote-client.mjs';
3
+ import { inspectChainSkillResponse } from './local-chain-broker.mjs';
4
+ import { inspectChainHumanResponse } from './local-chain-human.mjs';
5
+ import { bindLocalChainStepInput } from './local-chain-plan.mjs';
6
+ import { chainCanonical as canonical, chainFail as fail, chainSha as sha } from './local-chain-bindings.mjs';
7
+
8
+ function denied(message) { fail('ENTROROVE_CHAIN_RESULT_BINDING', message); }
9
+ async function commandResult(state, original, step) {
10
+ const binding = state.commandBindings.find(value => value.stepId === original.stepId);
11
+ if (!binding || step.phase !== 'done' || !step.localResult?.receipt || !step.output || step.error !== null) {
12
+ denied('verified command has no original process receipt and result');
13
+ }
14
+ let actual;
15
+ try { actual = await observeWorkflowLocalStep(binding.workflowInput); }
16
+ catch { denied('verified command has no independently valid original native cache'); }
17
+ if (!actual?.receipt || actual.status !== 'reported' || actual.receipt.result?.passed !== true
18
+ || ['process', 'evidence', 'receipt', 'subject', 'statePath'].some(key => canonical(actual[key]) !== canonical(step.localResult[key]))) {
19
+ denied('verified command differs from its actual signed native process');
20
+ }
21
+ const output = step.output, lease = binding.workflowInput.lease;
22
+ if (output.status !== 'verified' || output.flowId !== state.identity.flowId || output.stepId !== step.stepId
23
+ || output.generation !== lease.generation || canonical(output.lease) !== canonical(lease)
24
+ || output.evidenceDigest !== sha(canonical(actual.evidence[0])) || canonical(output.evidence) !== canonical(actual.evidence[0])
25
+ || output.verification?.summary?.passed !== 1 || output.verification.summary.failed !== 0
26
+ || output.verification.findings?.length !== 0) denied('verified command has no matching independent verification of its exact original receipt');
27
+ }
28
+ function skillRequest(state, original, step) {
29
+ if (step.phase !== 'done' || !step.response || !step.request || step.error !== null) denied('verified skill has no original durable request result');
30
+ const preceding = state.steps.filter(value => value.status === 'verified' && value.stepId !== original.stepId)
31
+ .map(value => ({ stepId: value.stepId, status: 'succeeded', output: value.output }));
32
+ const bound = bindLocalChainStepInput(state.plan, original.stepId, preceding);
33
+ const answer = original.skillId === 'confirm-protocol' && original.operation === 'interaction-request' && step.response.result?.auditEntry;
34
+ const expected = answer ? buildInvocation('continue', step.request.input)
35
+ : buildInvocation('continue', { flowId: state.identity.flowId, operation: original.operation, payload: bound });
36
+ if (step.request.operation !== 'continue' || step.request.requestId !== expected.requestId || step.response.requestId !== expected.requestId
37
+ || canonical(step.request.input) !== canonical(expected.body.input)) denied('verified skill lost its exact original canonical request');
38
+ if (answer) {
39
+ const saved = { ...step, output: step.originalInteraction };
40
+ if (!saved.output?.interaction || saved.output.interactionDigest !== sha(canonical(saved.output.interaction))
41
+ || step.request.input.flowId !== state.identity.flowId || step.request.input.operation !== 'interaction-answer'
42
+ || step.request.input.payload.interactionId !== saved.output.interactionId
43
+ || step.request.input.payload.interactionDigest !== saved.output.interactionDigest
44
+ || !inspectChainHumanResponse(original, state, saved, step.response).eligible) denied('verified answer lacks its original audited callback');
45
+ if (canonical(step.output) !== canonical({ ...step.response.result, answer: step.response.result.auditEntry.answer })) denied('verified answer changed its original audited output');
46
+ } else if (!inspectChainSkillResponse(original, state, step.response).eligible || canonical(step.output) !== canonical(step.response.result)) {
47
+ denied('verified skill changed its actual original protocol result');
48
+ }
49
+ return expected.requestId;
50
+ }
51
+ export async function validateArchivedChainResults(state) {
52
+ for (const original of state.plan.steps) {
53
+ const step = state.steps.find(value => value.stepId === original.stepId);
54
+ if (step.status !== 'verified') continue;
55
+ if (original.kind === 'command') await commandResult(state, original, step);
56
+ else if (original.kind === 'skill') skillRequest(state, original, step);
57
+ else denied('local ledger cannot manufacture a verified coordinator result');
58
+ }
59
+ }
60
+ export async function revalidateArchivedChainSkill(state, original, step, client) {
61
+ const requestId = skillRequest(state, original, step);
62
+ let observed;
63
+ try { observed = await client.status(requestId); } catch { denied('original verified skill result is unavailable; never replay its operation'); }
64
+ if (canonical(observed) !== canonical(step.response)) denied('archived skill differs from the actual durable original request result');
65
+ }
66
+ export async function revalidateArchivedChainCommand(state, original, step, client) {
67
+ await commandResult(state, original, step);
68
+ const request = buildInvocation('continue', { flowId: state.identity.flowId,
69
+ operation: 'execution-verify', payload: { stepId: original.stepId } });
70
+ let observed;
71
+ try { observed = await client.status(request.requestId); }
72
+ catch { denied('the original command has no actual durable independent verification; never manufacture or replay it'); }
73
+ if (observed.requestId !== request.requestId || !['succeeded', 'reported'].includes(observed.status)
74
+ || canonical(observed.result) !== canonical(step.output)) denied('archived command differs from the actual independently verified original request');
75
+ }
package/local-cli.mjs CHANGED
@@ -21,7 +21,9 @@ import { withBudgetReadContext } from './local-budget-reads.mjs';
21
21
  import { LOCAL_BUDGET_TRANSITION_OPERATIONS } from './local-budget-transition.mjs';
22
22
  import { ARCHGUARD_LOCAL_OPERATIONS } from './local-archguard-operations.mjs';
23
23
  import { LOCAL_BUDGET_HANDOFF_OPERATIONS } from './local-budget-handoff.mjs';
24
+ import { LOCAL_CHAIN_OPERATIONS } from './local-chain-operations.mjs';
24
25
  import { runManagedWorkflowLocalStep } from './workflow-local-client.mjs';
26
+ import { localErrorEnvelope } from './local-error.mjs';
25
27
 
26
28
  async function stdinJson() {
27
29
  let raw = '';
@@ -48,7 +50,7 @@ const operations = {
48
50
  'formula-final-gate': input => formulaFinalGate(input.engine, input), 'calculation-final-gate': formulaFinalGateFile,
49
51
  'run-approved-plan': runApprovedPlan,
50
52
  'workflow-run-step': runManagedWorkflowLocalStep,
51
- ...LOCAL_LEDGER_OPERATIONS, ...LOCAL_BUDGET_TRANSITION_OPERATIONS,
53
+ ...LOCAL_LEDGER_OPERATIONS, ...LOCAL_CHAIN_OPERATIONS, ...LOCAL_BUDGET_TRANSITION_OPERATIONS,
52
54
  ...LOCAL_RENEWAL_OPERATIONS, ...LOCAL_PROBE_OPERATIONS,
53
55
  ...LOCAL_TASK_ROUTING_OPERATIONS,
54
56
  ...ARCHGUARD_LOCAL_OPERATIONS,
@@ -66,20 +68,25 @@ async function checkedLocalInput(operation, input) {
66
68
  }
67
69
  }
68
70
 
69
- export async function runLocal(args = []) {
70
- const operation = args[0];
71
- if (operation === 'capabilities' || operation === 'help') {
72
- validateLocalInput(operation, process.stdin.isTTY ? {} : await stdinJson());
73
- process.stdout.write(`${JSON.stringify(LOCAL_CAPABILITIES)}\n`); return;
71
+ export async function runLocal(args = [], dependencies) {
72
+ try {
73
+ const operation = args[0];
74
+ if (operation === 'capabilities' || operation === 'help') {
75
+ validateLocalInput(operation, process.stdin.isTTY ? {} : await stdinJson());
76
+ process.stdout.write(`${JSON.stringify(LOCAL_CAPABILITIES)}\n`); return;
77
+ }
78
+ const handler = operations[operation];
79
+ if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
80
+ const input = await stdinJson();
81
+ if (args[1] && input.repositoryRoot === undefined) input.repositoryRoot = args[1];
82
+ const validated = await checkedLocalInput(operation, input);
83
+ const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
84
+ const output = await withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated, supplied)));
85
+ process.stdout.write(`${JSON.stringify(output)}\n`);
86
+ if (['pending', 'blocked', 'failed', 'incomplete'].includes(output?.status)) process.exitCode = 2;
87
+ } catch (cause) {
88
+ const error = localErrorEnvelope(cause); process.stderr.write(`${JSON.stringify(error)}\n`); process.exitCode = error.status === 'blocked' ? 2 : 1;
74
89
  }
75
- const handler = operations[operation];
76
- if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
77
- const input = await stdinJson();
78
- if (args[1] && input.repositoryRoot === undefined) input.repositoryRoot = args[1];
79
- const validated = await checkedLocalInput(operation, input);
80
- const output = await withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated)));
81
- process.stdout.write(`${JSON.stringify(output)}\n`);
82
- if (output?.status === 'blocked' || output?.status === 'failed' || output?.status === 'incomplete') process.exitCode = 2;
83
90
  }
84
91
 
85
92
  export async function runLocalInput(operation, input, dependencies) {
@@ -87,5 +94,6 @@ export async function runLocalInput(operation, input, dependencies) {
87
94
  const handler = operations[operation];
88
95
  if (!handler) throw new Error(`Unsupported local operation: ${operation}`);
89
96
  const validated = await checkedLocalInput(operation, input);
90
- return withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated, operation === 'workflow-run-step' ? dependencies : undefined)));
97
+ const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
98
+ return withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated, supplied)));
91
99
  }
@@ -35,7 +35,7 @@ function expression(operators, minItems, maxItems) {
35
35
  }
36
36
  export const formulaDefinitions = { formula: { oneOf: [
37
37
  object({ ref: text }, ['ref']), object({ lit: decimal }, ['lit']),
38
- expression(['add', 'mul'], 0), expression(['sub', 'div', 'safeDivide', 'percentOf'], 2, 2),
38
+ expression(['add', 'mul'], 1), expression(['sub', 'div', 'safeDivide', 'percentOf'], 2, 2),
39
39
  expression(['round'], 1, 1), expression(['if'], 3, 3),
40
40
  ] } };
41
41
  export const decimalValues = { type: 'object', additionalProperties: decimal };
@@ -7,6 +7,7 @@ import { ARCHGUARD_LOCAL_OPERATION_SCHEMAS } from './local-archguard-contracts.m
7
7
  import { MERGE_LOCAL_OPERATION_SCHEMAS } from './local-merge-contracts.mjs';
8
8
  import { WORKFLOW_LOCAL_OPERATION_SCHEMA } from './workflow-local-contracts.mjs';
9
9
  import { HANDOFF_WRITE_PASS_SCHEMA, HANDOFF_PROVISIONAL_LEASE_SCHEMA, HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA } from './local-task-handoff-contracts.mjs';
10
+ import { LOCAL_CHAIN_OPERATION_SCHEMAS } from './local-chain-contracts.mjs';
10
11
  import { array, binding, change, confirmation, enumeration, evidence, evidenceArray, executionContext, executionPlan,
11
12
  formula, formulaDefinitions, formulaEntry, decimalValues, identifier, integer, ledgerIdentifier, modes, nullable, object, paths, record, relativePath, sha256, text } from './local-contract-types.mjs';
12
13
 
@@ -72,6 +73,7 @@ const chain = { ...base, chainId: ledgerIdentifier };
72
73
  add('chain-resume', { ...chain, stepId: ledgerIdentifier, status: enumeration(['running', 'waiting', 'reported', 'verified', 'blocked', 'failed']), output: {}, error: {} }, ['repositoryRoot', 'chainId']);
73
74
  add('chain-status|chain-ledger-status', chain, ['repositoryRoot', 'chainId']);
74
75
  add('chain-answer', { ...chain, stepId: ledgerIdentifier, answer: { anyOf: [text, object({ id: text }, ['id'])] }, accepted: { type: 'boolean' }, actorId: nullable(text) }, ['repositoryRoot', 'chainId', 'answer']);
76
+ Object.assign(schemas, LOCAL_CHAIN_OPERATION_SCHEMAS);
75
77
  const renewal = { ...flow, requestId: identifier, scope: object({ goal: text, allowedPaths: paths, forbiddenPaths: paths }, ['goal', 'allowedPaths']), policy: object({ intervalMs: integer(1), maxRenewals: integer(1, 100) }, ['intervalMs', 'maxRenewals']) };
76
78
  add('budget-auto-renew-request', renewal, ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy']);
77
79
  add('budget-auto-renew', { ...renewal, confirmation, grant: record }, ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy'], ['grant', 'confirmation']);
@@ -0,0 +1,23 @@
1
+ export const LOCAL_ERROR_SCHEMA = 'entrorove.local-error/1.0';
2
+
3
+ const BLOCKED_CODES = new Set([
4
+ 'ENTROROVE_MUTATE_NOT_ACCEPTED', 'ENTROROVE_MUTATION_AUTHORITY_REQUIRED',
5
+ 'ENTROROVE_MUTATION_PASS_INVALID', 'ENTROROVE_MUTATION_PASS_EXPIRED',
6
+ 'ENTROROVE_BUDGET_LEASE_EXPIRED', 'ENTROROVE_BUDGET_AUTHORITY_REQUIRED',
7
+ 'ENTROROVE_SWARM_AUTHORITY_INVALID', 'ENTROROVE_SWARM_CLOUD_AUTHORITY_REQUIRED',
8
+ 'ENTROROVE_HANDOFF_FENCE', 'ENTROROVE_HANDOFF_WRITE_REVOKED',
9
+ 'ENTROROVE_HANDOFF_WRITE_BINDING', 'ENTROROVE_HANDOFF_WRITE_SNAPSHOT',
10
+ 'ENTROROVE_SNAPSHOT_CORRUPT', 'ENTROROVE_SNAPSHOT_INVALID',
11
+ ]);
12
+
13
+ export function localErrorStatus(error) {
14
+ const code = typeof error?.code === 'string' ? error.code : '';
15
+ return BLOCKED_CODES.has(code) || /(?:AUTHORITY_REQUIRED|LEASE_EXPIRED|NOT_ACCEPTED|REVOKED)$/.test(code)
16
+ ? 'blocked' : 'failed';
17
+ }
18
+
19
+ export function localErrorEnvelope(error, fallbackCode = 'ENTROROVE_LOCAL_FAILURE') {
20
+ const code = typeof error?.code === 'string' && error.code ? error.code : fallbackCode;
21
+ const message = typeof error?.message === 'string' && error.message ? error.message : 'Local operation failed';
22
+ return { schemaVersion: LOCAL_ERROR_SCHEMA, status: localErrorStatus({ ...error, code }), code, message };
23
+ }
@@ -178,7 +178,7 @@ async function loadReceipt(input) { return loadSnapshotReceipt14(input.receipt);
178
178
  async function snapshotAuthority(input, root, receipt) { return snapshotAuthority14(input, root, receipt); }
179
179
  export async function mutateGate(input) { return mutateGate14(input); }
180
180
  export async function issueMutationPass(input) { return issueMutationPass14(input); }
181
- export async function verifyMutationPass(input) { return verifyMutationPass14(input); }
181
+ export async function verifyMutationPass(input, options) { return verifyMutationPass14(input, options); }
182
182
  async function writeSnapshotFile(input, root, targetPath, baseline) {
183
183
  if (!baseline || input.expectedSha256 && input.expectedSha256 !== baseline.sha256) fail('ENTROROVE_WRITE_BASELINE', 'target does not match its exact expected snapshot baseline');
184
184
  const content = typeof input.content === 'string' ? input.content : JSON.stringify(input.content);
@@ -6,8 +6,8 @@ export const FINAL_GATE16_CHECKER_PINS = Object.freeze({
6
6
  'local-final-gate16-worker.mjs': '344d64b6c1c5552b96960e0d8514a876a3dbfa9762ab9eb749038e8351727121',
7
7
  'local-final-gate16-checks.mjs': '1dd30e9666d458d8b12ca6e4975f6f0ac8450c3ea9cbecc7401ae037f66b589a',
8
8
  'local-decimal.mjs': '93580b0cbdac2e881942658c1cd10394b432219f8657afaaaf4cb1c35c91d644',
9
- 'local-formula-ast.mjs': 'e7475176409f146f46eb8565be8df4108c88f12b46c9b600915834f9843089d7',
10
- 'local-formula-engine.mjs': '3b6ed599575b17d3da738e0c798804264540cbff87c58459e2ef3fa6072cd003',
9
+ 'local-formula-ast.mjs': '768ec4aa88a127588035d5b94aa2721df00e3e0576dc7e341baaead44795f314',
10
+ 'local-formula-engine.mjs': 'cfc44f03bb8dc4797f9f9e859a64a2dd5e4dd316db45624c03f7f8fad3202ede',
11
11
  'local-formula-tests.mjs': 'd6d2809572c46955e6b556023ed6a12362c8acd0ad0d5be3b1b2dd64439215c0',
12
12
  });
13
13
  export async function finalGate16Bundle() {
@@ -1,7 +1,8 @@
1
1
  import { Decimal, formulaError } from './local-decimal.mjs';
2
2
 
3
3
  export const FORMULA_OPERATORS = Object.freeze(['add', 'sub', 'mul', 'div', 'safeDivide', 'percentOf', 'round', 'if']);
4
- const ARITY = { add: [0, 1000], mul: [0, 1000], sub: [2, 2], div: [2, 2], safeDivide: [2, 2], percentOf: [2, 2], round: [1, 1], if: [3, 3] };
4
+ export const FORMULA_ROUNDING_POLICY = Object.freeze({ decimalPlaces: 2, mode: 'half-away-from-zero' });
5
+ const ARITY = { add: [1, 1000], mul: [1, 1000], sub: [2, 2], div: [2, 2], safeDivide: [2, 2], percentOf: [2, 2], round: [1, 1], if: [3, 3] };
5
6
  export function formulaKey(value) {
6
7
  if (typeof value !== 'string' || !value.trim() || value.length > 128 || /[\u0000-\u001f\u007f]/.test(value)) throw formulaError('FORMULA_KEY_INVALID', 'formula and field keys must be non-empty strings of at most 128 characters');
7
8
  return value;
@@ -30,28 +31,35 @@ export function formulaRefs(node, output = new Set()) {
30
31
  if (node.args) for (const item of node.args) formulaRefs(item, output);
31
32
  return [...output].sort();
32
33
  }
33
- function evaluateNode(node, resolve) {
34
+ function roundPlaces(policy) {
35
+ const value = policy?.decimalPlaces;
36
+ if (!Number.isInteger(value) || value < 0 || value > 28) throw formulaError('ROUND_POLICY_INVALID', 'rounding policy decimalPlaces must be an integer from 0 to 28');
37
+ if (policy.mode !== 'half-away-from-zero') throw formulaError('ROUND_POLICY_INVALID', 'rounding policy mode must be half-away-from-zero');
38
+ return value;
39
+ }
40
+ function evaluateNode(node, resolve, policy) {
34
41
  if (Object.hasOwn(node, 'lit')) return Decimal.from(node.lit);
35
42
  if (Object.hasOwn(node, 'ref')) return Decimal.from(resolve(node.ref));
36
43
  const [left, right, alternate] = node.args;
37
44
  switch (node.op) {
38
- case 'add': return node.args.reduce((total, item) => total.plus(evaluateNode(item, resolve)), Decimal.from('0'));
39
- case 'mul': return node.args.reduce((total, item) => total.times(evaluateNode(item, resolve)), Decimal.from('1'));
40
- case 'sub': return evaluateNode(left, resolve).minus(evaluateNode(right, resolve));
41
- case 'div': return evaluateNode(left, resolve).div(evaluateNode(right, resolve));
42
- case 'safeDivide': { const denominator = evaluateNode(right, resolve); return denominator.isZero() ? Decimal.from('0') : evaluateNode(left, resolve).div(denominator); }
43
- case 'percentOf': return evaluateNode(left, resolve).div(evaluateNode(right, resolve)).times('100');
44
- case 'round': return evaluateNode(left, resolve).rounded(2);
45
- case 'if': return evaluateNode(left, resolve).isZero() ? evaluateNode(alternate, resolve) : evaluateNode(right, resolve);
45
+ case 'add': return node.args.reduce((total, item) => total.plus(evaluateNode(item, resolve, policy)), Decimal.from('0'));
46
+ case 'mul': return node.args.reduce((total, item) => total.times(evaluateNode(item, resolve, policy)), Decimal.from('1'));
47
+ case 'sub': return evaluateNode(left, resolve, policy).minus(evaluateNode(right, resolve, policy));
48
+ case 'div': return evaluateNode(left, resolve, policy).div(evaluateNode(right, resolve, policy));
49
+ case 'safeDivide': { const denominator = evaluateNode(right, resolve, policy); if (denominator.isZero()) throw formulaError('DIV_ZERO', 'division by zero', { nodeId: 'safeDivide' }); return evaluateNode(left, resolve, policy).div(denominator); }
50
+ case 'percentOf': return evaluateNode(left, resolve, policy).div(evaluateNode(right, resolve, policy)).times('100');
51
+ case 'round': return evaluateNode(left, resolve, policy).rounded(roundPlaces(policy));
52
+ case 'if': return evaluateNode(left, resolve, policy).isZero() ? evaluateNode(alternate, resolve, policy) : evaluateNode(right, resolve, policy);
46
53
  default: throw formulaError('UNSUPPORTED_OPERATOR', 'unsupported normalized formula operator');
47
54
  }
48
55
  }
49
- export function evaluateFormula(node, values = {}) {
56
+ export function evaluateFormula(node, values = {}, options = {}) {
50
57
  formulaObject(values, 'formula values');
58
+ const policy = options.roundingPolicy ?? FORMULA_ROUNDING_POLICY;
51
59
  return evaluateNode(normalizeFormula(node), key => {
52
60
  if (!Object.hasOwn(values, key) || values[key] === null || values[key] === undefined || values[key] === '') throw formulaError('MISSING_INPUT', `missing input: ${key}`, { fieldId: key });
53
61
  return values[key];
54
- });
62
+ }, policy);
55
63
  }
56
64
  export function compileFormulaGraph(formulas, fieldKeys) {
57
65
  if (!Array.isArray(formulas) || formulas.length > 1000) throw formulaError('FORMULA_GRAPH_INVALID', 'formulas must be an array of at most 1000 definitions');
@@ -78,8 +86,9 @@ export function compileFormulaGraph(formulas, fieldKeys) {
78
86
  for (const key of [...definitions.keys()].sort()) visit(key);
79
87
  return { formulas: [...definitions.values()].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0), dependencies, order };
80
88
  }
81
- export function evaluateFormulaGraph(formulas, inputs = {}) {
89
+ export function evaluateFormulaGraph(formulas, inputs = {}, options = {}) {
82
90
  formulaObject(inputs, 'formula inputs');
91
+ const policy = options.roundingPolicy ?? FORMULA_ROUNDING_POLICY;
83
92
  const values = new Map(Object.entries(inputs).map(([key, value]) => [key, Decimal.from(value)]));
84
93
  const graph = compileFormulaGraph(formulas);
85
94
  const definitions = new Map(graph.formulas.map(item => [item.key, item.expression]));
@@ -91,7 +100,7 @@ export function evaluateFormulaGraph(formulas, inputs = {}) {
91
100
  return values.get(key);
92
101
  }
93
102
  if (visiting.has(key)) throw formulaError('FORMULA_CYCLE', `formula cycle at ${key}`);
94
- visiting.add(key); const value = evaluateNode(definitions.get(key), resolve); visiting.delete(key); results.set(key, value); return value;
103
+ visiting.add(key); const value = evaluateNode(definitions.get(key), resolve, policy); visiting.delete(key); results.set(key, value); return value;
95
104
  };
96
105
  return Object.fromEntries(graph.formulas.map(item => [item.key, resolve(item.key).toString()]));
97
106
  }
@@ -1,9 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { Decimal, DECIMAL_POLICY, formulaError } from './local-decimal.mjs';
3
- import { compileFormulaGraph, evaluateFormulaGraph, formulaKey, formulaObject } from './local-formula-ast.mjs';
3
+ import { compileFormulaGraph, evaluateFormulaGraph, formulaKey, formulaObject, FORMULA_ROUNDING_POLICY } from './local-formula-ast.mjs';
4
4
 
5
5
  export const FORMULA_SCHEMA = 'entrorove.formula-engine/1.0';
6
6
  export const FORMULA_RUNTIME_VERSION = 'decimal28-ast/1.0';
7
+ export { FORMULA_ROUNDING_POLICY };
7
8
  const TYPES = ['number', 'integer', 'money', 'percent', 'text', 'date', 'enum'];
8
9
  const NUMERIC = TYPES.slice(0, 4);
9
10
  const ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
@@ -21,6 +22,14 @@ function cloned(value) { return JSON.parse(formulaCanonical(value)); }
21
22
  function requiredText(value, field) {
22
23
  if (typeof value !== 'string' || !value.trim()) throw formulaError('ENGINE_REQUIRED_FIELD', `${field} is required`);
23
24
  }
25
+ function normalizeRoundingPolicy(value) {
26
+ const policy = value ?? FORMULA_ROUNDING_POLICY;
27
+ formulaObject(policy, 'roundingPolicy');
28
+ if (Object.keys(policy).sort().join(',') !== 'decimalPlaces,mode'
29
+ || !Number.isInteger(policy.decimalPlaces) || policy.decimalPlaces < 0 || policy.decimalPlaces > 28
30
+ || policy.mode !== 'half-away-from-zero') throw formulaError('ROUND_POLICY_INVALID', 'roundingPolicy must declare decimalPlaces 0..28 and half-away-from-zero mode');
31
+ return { decimalPlaces: policy.decimalPlaces, mode: policy.mode };
32
+ }
24
33
  export function normalizeEngine(engine) {
25
34
  formulaObject(engine, 'engine');
26
35
  if (engine.schemaVersion !== 'engine.spec/1') throw formulaError('ENGINE_SCHEMA_VERSION', 'Expected engine.spec/1');
@@ -41,12 +50,14 @@ export function normalizeEngine(engine) {
41
50
  if (NUMERIC.includes(field.type) && typeof field.unit !== 'string') throw formulaError('FIELD_UNIT_MISSING', 'numeric fields require an explicit unit');
42
51
  if (field.required !== undefined && typeof field.required !== 'boolean') throw formulaError('FIELD_REQUIRED_INVALID', 'field required must be boolean');
43
52
  }
53
+ const roundingPolicy = normalizeRoundingPolicy(engine.roundingPolicy);
44
54
  const graph = compileFormulaGraph(engine.formulas, [...keys]);
45
55
  for (const item of graph.formulas) for (const ref of graph.dependencies[item.key]) {
46
56
  const field = engine.fields.find(entry => entry.key === ref);
47
57
  if (field && !NUMERIC.includes(field.type)) throw formulaError('FORMULA_REF_TYPE', `formula reference ${ref} must be numeric`);
48
58
  }
49
59
  const normalized = cloned(engine);
60
+ normalized.roundingPolicy = roundingPolicy;
50
61
  normalized.formulas = engine.formulas.map(item => ({ ...cloned(item), expression: graph.formulas.find(entry => entry.key === item.key).expression }));
51
62
  return { engine: normalized, graph };
52
63
  }
@@ -62,7 +73,8 @@ export function buildFormulaEngine(requirements) {
62
73
  semanticVersion: requirements.semanticVersion ?? '1.0.0', status: 'draft', compatibilityProfile: 'legacy-compatible',
63
74
  decimalPolicy: requirements.decimalPolicy ?? 'decimal-string', defaultLocale: requirements.defaultLocale ?? 'zh-CN',
64
75
  category: requirements.category ?? 'general', inputMethod: requirements.inputMethod ?? 'manual', output: requirements.output ?? ['metric-cards'],
65
- constraints: requirements.constraints ?? '', fields: requirements.inputs, formulas: requirements.formulas,
76
+ constraints: requirements.constraints ?? '', roundingPolicy: requirements.roundingPolicy ?? FORMULA_ROUNDING_POLICY,
77
+ fields: requirements.inputs, formulas: requirements.formulas,
66
78
  rules: requirements.rules ?? [], views: requirements.views ?? [], importProfiles: requirements.importProfiles ?? [],
67
79
  reports: requirements.reports ?? [], testSuites: requirements.testSuites ?? [], acceptance: requirements.acceptance ?? '',
68
80
  };
@@ -71,6 +83,7 @@ export function buildFormulaEngine(requirements) {
71
83
  export function compileFormulaEngine(engine) {
72
84
  const normalized = normalizeEngine(engine);
73
85
  const program = { schemaVersion: FORMULA_SCHEMA, runtimeVersion: FORMULA_RUNTIME_VERSION, decimalPolicy: DECIMAL_POLICY,
86
+ roundingPolicy: normalized.engine.roundingPolicy,
74
87
  engineId: normalized.engine.engineId, engineVersion: normalized.engine.semanticVersion,
75
88
  engineDigest: formulaDigest(normalized.engine), engine: normalized.engine,
76
89
  formulas: normalized.graph.formulas, dependencies: normalized.graph.dependencies, order: normalized.graph.order };
@@ -98,7 +111,8 @@ export function evaluateFormulaProgram(program, inputs = {}, expected = {}) {
98
111
  } else if (typeof value !== 'string') throw formulaError('ENGINE_INPUT_TYPE', `${key} must be text`);
99
112
  }
100
113
  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))));
114
+ return evaluateFormulaGraph(verified.formulas, Object.fromEntries(Object.entries(inputs).filter(([key]) => NUMERIC.includes(fields.get(key).type))),
115
+ { roundingPolicy: verified.roundingPolicy });
102
116
  }
103
117
  export function formulaImpact(program, changedKeys) {
104
118
  const verified = verifyFormulaProgram(program);
package/local-formula.mjs CHANGED
@@ -6,7 +6,7 @@ import { formulaDigest } from './local-formula-engine.mjs';
6
6
  import { formulaFinalGate } from './local-formula-tests.mjs';
7
7
 
8
8
  export { Decimal, Decimal28, DECIMAL_POLICY } from './local-decimal.mjs';
9
- export { evaluateFormula, evaluateFormulaGraph, compileFormulaGraph, FORMULA_OPERATORS } from './local-formula-ast.mjs';
9
+ export { evaluateFormula, evaluateFormulaGraph, compileFormulaGraph, FORMULA_OPERATORS, FORMULA_ROUNDING_POLICY } from './local-formula-ast.mjs';
10
10
  export { FORMULA_SCHEMA, FORMULA_RUNTIME_VERSION, compileFormulaEngine, verifyFormulaProgram,
11
11
  evaluateFormulaProgram, buildFormulaEngine, validateFormulaEngine, formulaImpact } from './local-formula-engine.mjs';
12
12
  export { FORMULA_GATE_SCHEMA, formulaFinalGate, verifyFormulaTests } from './local-formula-tests.mjs';