@dlovans/tenet-core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Declarative logic VM for JSON schemas. Reactive validation, temporal routing, and computed state.
4
4
 
5
+ **Pure TypeScript** — No WASM, no native dependencies. Works in browsers, Node.js, Deno, Bun.
6
+
5
7
  ## Installation
6
8
 
7
9
  ```bash
@@ -13,11 +15,8 @@ npm install @dlovans/tenet-core
13
15
  ### Browser
14
16
 
15
17
  ```html
16
- <script src="https://unpkg.com/@dlovans/tenet-core/wasm/wasm_exec.js"></script>
17
18
  <script type="module">
18
- import { init, run } from '@dlovans/tenet-core';
19
-
20
- await init('/path/to/tenet.wasm');
19
+ import { run } from '@dlovans/tenet-core';
21
20
 
22
21
  const schema = {
23
22
  definitions: {
@@ -33,26 +32,23 @@ const schema = {
33
32
  };
34
33
 
35
34
  const result = run(schema);
36
- console.log(result);
35
+ console.log(result.result.status); // 'READY'
37
36
  </script>
38
37
  ```
39
38
 
40
39
  ### Node.js
41
40
 
42
41
  ```javascript
43
- import { init, run, verify } from '@dlovans/tenet-core';
44
-
45
- // Initialize WASM
46
- await init('./node_modules/@dlovans/tenet-core/wasm/tenet.wasm');
42
+ import { run, verify } from '@dlovans/tenet-core';
47
43
 
48
- // Run schema logic
44
+ // Run schema logic - no initialization needed
49
45
  const result = run(schema, new Date());
50
46
 
51
47
  if (result.error) {
52
48
  console.error(result.error);
53
49
  } else {
54
50
  console.log(result.result.status); // 'READY', 'INCOMPLETE', or 'INVALID'
55
- console.log(result.result.errors); // Validation errors
51
+ console.log(result.result.errors); // Validation errors (if any)
56
52
  }
57
53
 
58
54
  // Verify transformation
@@ -62,51 +58,57 @@ console.log(verification.valid);
62
58
 
63
59
  ## API
64
60
 
65
- ### `init(wasmPath?: string): Promise<void>`
66
- Initialize the WASM module. Must be called before `run()` or `verify()`.
67
-
68
61
  ### `run(schema, date?): TenetResult`
69
62
  Execute the schema logic for the given effective date.
70
63
 
64
+ - `schema` — TenetSchema object or JSON string
65
+ - `date` — Effective date for temporal routing (default: now)
66
+
67
+ Returns `{ result: TenetSchema }` or `{ error: string }`.
68
+
71
69
  ### `verify(newSchema, oldSchema): TenetVerifyResult`
72
70
  Verify that a transformation is legal by replaying the logic.
73
71
 
74
- ### `lint(schema): LintResult` *(No WASM required)*
75
- Static analysis - find issues without executing the schema.
72
+ Returns `{ valid: boolean, error?: string }`.
76
73
 
77
- ```javascript
78
- import { lint } from '@dlovans/tenet-core';
74
+ ### `isReady(): boolean`
75
+ Always returns `true`. Kept for backwards compatibility.
79
76
 
80
- const result = lint(schema);
81
- // No init() needed - pure TypeScript!
77
+ ### `init(): Promise<void>` *(deprecated)*
78
+ No-op. Kept for backwards compatibility with v0.1.x.
82
79
 
83
- if (!result.valid) {
84
- for (const issue of result.issues) {
85
- console.log(`${issue.severity}: ${issue.message}`);
86
- }
87
- }
88
- ```
80
+ ## Runtime Validation
89
81
 
90
- ### `isTenetSchema(obj): boolean`
91
- Check if an object is a Tenet schema.
82
+ The VM automatically detects and reports:
92
83
 
93
- ### `isReady(): boolean`
94
- Check if WASM is initialized.
84
+ - **Undefined variables** — `{"var": "unknown_field"}`
85
+ - **Unknown operators** `{"invalid_op": [...]}`
86
+ - **Temporal conflicts** — Overlapping date ranges, same start/end dates
95
87
 
96
- ## JSON Schema (IDE Support)
88
+ All errors are returned in `result.errors` without failing execution.
97
89
 
98
- Add to your schema files for autocompletion:
90
+ ## TypeScript
99
91
 
100
- ```json
101
- {
102
- "$schema": "https://tenet.dev/schema/v1.json",
103
- "definitions": { ... }
104
- }
92
+ Full type definitions included:
93
+
94
+ ```typescript
95
+ import type { TenetSchema, TenetResult, Definition, Rule } from '@dlovans/tenet-core';
105
96
  ```
106
97
 
107
- ## TypeScript
98
+ ## Migration from v0.1.x
108
99
 
109
- Full type definitions are included. See `TenetSchema`, `Definition`, `Rule`, `LintResult`, etc.
100
+ ```javascript
101
+ // Before (v0.1.x with WASM)
102
+ import { init, run, lint } from '@dlovans/tenet-core';
103
+ await init('./tenet.wasm');
104
+ const result = run(schema);
105
+ const issues = lint(schema);
106
+
107
+ // After (v0.2.x pure TypeScript)
108
+ import { run } from '@dlovans/tenet-core';
109
+ const result = run(schema);
110
+ // Validation errors are now in result.result.errors
111
+ ```
110
112
 
111
113
  ## License
112
114
 
@@ -15,9 +15,12 @@ export declare function run(schema: TenetSchema | string, effectiveDate?: Date |
15
15
  * Verify that a completed document was correctly derived from a base schema.
16
16
  * Simulates the user's journey by iteratively copying visible field values and re-running.
17
17
  *
18
+ * Returns a structured result with all issues found (not just the first).
19
+ * Crash-safe: catches any unexpected error and returns it as an internal_error issue.
20
+ *
18
21
  * @param newSchema - The completed/submitted schema
19
22
  * @param oldSchema - The original base schema
20
23
  * @param maxIterations - Maximum replay iterations (default: 100)
21
- * @returns Whether the transformation was valid
24
+ * @returns Structured verification result
22
25
  */
23
26
  export declare function verify(newSchema: TenetSchema | string, oldSchema: TenetSchema | string, maxIterations?: number): TenetVerifyResult;
@@ -80,7 +80,7 @@ function setDefinitionValue(state, key, value, ruleId) {
80
80
  // Cycle detection
81
81
  const prevRule = state.fieldsSet.get(key);
82
82
  if (prevRule && prevRule !== ruleId) {
83
- addError(state, key, ruleId, `Potential cycle: field '${key}' set by rule '${prevRule}' and again by rule '${ruleId}'`);
83
+ addError(state, key, ruleId, 'cycle_detected', `Potential cycle: field '${key}' set by rule '${prevRule}' and again by rule '${ruleId}'`);
84
84
  }
85
85
  state.fieldsSet.set(key, ruleId);
86
86
  const def = state.schema.definitions[key];
@@ -118,7 +118,7 @@ function applyAction(state, action, ruleId, lawRef) {
118
118
  }
119
119
  // Emit error if specified
120
120
  if (action.error_msg) {
121
- addError(state, '', ruleId, action.error_msg, lawRef);
121
+ addError(state, '', ruleId, 'runtime_warning', action.error_msg, lawRef);
122
122
  }
123
123
  }
124
124
  /**
@@ -152,13 +152,23 @@ function computeDerived(state) {
152
152
  }
153
153
  // Evaluate the expression
154
154
  const value = resolve(derivedDef.eval, state);
155
- // Store the computed value as a definition (readonly)
156
- state.schema.definitions[name] = {
157
- type: inferType(value),
158
- value,
159
- readonly: true,
160
- visible: true,
161
- };
155
+ // Preserve existing definition metadata if present
156
+ const existing = state.schema.definitions[name];
157
+ if (existing) {
158
+ existing.value = value;
159
+ existing.readonly = true;
160
+ if (existing.visible === undefined) {
161
+ existing.visible = true;
162
+ }
163
+ }
164
+ else {
165
+ state.schema.definitions[name] = {
166
+ type: inferType(value),
167
+ value,
168
+ readonly: true,
169
+ visible: true,
170
+ };
171
+ }
162
172
  }
163
173
  }
164
174
  /**
@@ -190,20 +200,23 @@ export function run(schema, effectiveDate = new Date()) {
190
200
  effectiveDate: date,
191
201
  fieldsSet: new Map(),
192
202
  errors: [],
203
+ derivedInProgress: new Set(),
193
204
  };
194
205
  // 1. Select temporal branch and prune inactive rules
195
206
  applyTemporalRouting(state);
196
- // 2. Evaluate logic tree
207
+ // 2. Compute derived state (so logic tree can use derived values)
208
+ computeDerived(state);
209
+ // 3. Evaluate logic tree
197
210
  evaluateLogicTree(state);
198
- // 3. Compute derived state
211
+ // 4. Re-compute derived state (in case logic modified inputs)
199
212
  computeDerived(state);
200
- // 4. Validate definitions
213
+ // 5. Validate definitions
201
214
  validateDefinitions(state);
202
- // 5. Check attestations
215
+ // 6. Check attestations
203
216
  checkAttestations(state, (action, ruleId, lawRef) => {
204
217
  applyAction(state, action, ruleId, lawRef);
205
218
  });
206
- // 6. Determine status and attach errors
219
+ // 7. Determine status and attach errors
207
220
  state.schema.errors = state.errors.length > 0 ? state.errors : undefined;
208
221
  state.schema.status = determineStatus(state);
209
222
  return { result: state.schema };
@@ -225,25 +238,29 @@ function getVisibleEditableFields(schema) {
225
238
  return result;
226
239
  }
227
240
  /**
228
- * Count visible fields in a schema.
241
+ * Get a sorted, comma-joined string of visible field IDs for convergence detection.
229
242
  */
230
- function countVisibleFields(schema) {
231
- let count = 0;
232
- for (const def of Object.values(schema.definitions)) {
233
- if (def?.visible) {
234
- count++;
235
- }
236
- }
237
- return count;
243
+ function getVisibleFieldIds(schema) {
244
+ return Object.entries(schema.definitions)
245
+ .filter(([, def]) => def?.visible)
246
+ .map(([id]) => id)
247
+ .sort()
248
+ .join(',');
238
249
  }
239
250
  /**
240
251
  * Validate that the final state matches expected values.
252
+ * Collects ALL issues instead of bailing on the first — the UI needs the complete picture.
241
253
  */
242
254
  function validateFinalState(newSchema, resultSchema) {
255
+ const issues = [];
243
256
  // Check for unknown/injected fields in newSchema that don't exist in result
244
257
  for (const id of Object.keys(newSchema.definitions)) {
245
258
  if (!(id in resultSchema.definitions)) {
246
- return [false, `Unknown field '${id}' not in schema`];
259
+ issues.push({
260
+ code: 'unknown_field',
261
+ field_id: id,
262
+ message: `Field '${id}' does not exist in the schema`,
263
+ });
247
264
  }
248
265
  }
249
266
  // Compare computed (readonly) values
@@ -253,49 +270,85 @@ function validateFinalState(newSchema, resultSchema) {
253
270
  }
254
271
  const newDef = newSchema.definitions[id];
255
272
  if (!newDef) {
256
- return [false, `Computed field '${id}' missing in submitted document`];
273
+ issues.push({
274
+ code: 'computed_mismatch',
275
+ field_id: id,
276
+ message: `Computed field '${id}' is missing from the submitted document`,
277
+ expected: resultDef.value,
278
+ });
279
+ continue;
257
280
  }
258
281
  if (!compareEqual(newDef.value, resultDef.value)) {
259
- return [false, `Computed field '${id}' mismatch: claimed ${JSON.stringify(newDef.value)}, expected ${JSON.stringify(resultDef.value)}`];
282
+ issues.push({
283
+ code: 'computed_mismatch',
284
+ field_id: id,
285
+ message: `Computed field '${id}' was modified`,
286
+ expected: resultDef.value,
287
+ claimed: newDef.value,
288
+ });
260
289
  }
261
290
  }
262
291
  // Verify attestations are fulfilled
263
292
  if (resultSchema.attestations) {
264
293
  for (const [id, resultAtt] of Object.entries(resultSchema.attestations)) {
265
- if (!resultAtt) {
294
+ if (!resultAtt?.required) {
266
295
  continue;
267
296
  }
268
297
  const newAtt = newSchema.attestations?.[id];
269
298
  if (!newAtt) {
270
299
  continue;
271
300
  }
272
- if (resultAtt.required) {
273
- if (!newAtt.signed) {
274
- return [false, `Required attestation '${id}' not signed`];
275
- }
276
- if (!newAtt.evidence?.provider_audit_id) {
277
- return [false, `Attestation '${id}' missing evidence`];
278
- }
279
- if (!newAtt.evidence?.timestamp) {
280
- return [false, `Attestation '${id}' missing timestamp`];
281
- }
301
+ if (!newAtt.signed) {
302
+ issues.push({
303
+ code: 'attestation_unsigned',
304
+ field_id: id,
305
+ message: `Required attestation '${id}' has not been signed`,
306
+ });
307
+ continue; // No point checking evidence if unsigned
308
+ }
309
+ if (!newAtt.evidence?.provider_audit_id) {
310
+ issues.push({
311
+ code: 'attestation_no_evidence',
312
+ field_id: id,
313
+ message: `Attestation '${id}' is signed but missing proof of signing`,
314
+ });
315
+ }
316
+ if (!newAtt.evidence?.timestamp) {
317
+ issues.push({
318
+ code: 'attestation_no_timestamp',
319
+ field_id: id,
320
+ message: `Attestation '${id}' is signed but missing a timestamp`,
321
+ });
282
322
  }
283
323
  }
284
324
  }
285
325
  // Verify status matches
286
326
  if (newSchema.status !== resultSchema.status) {
287
- return [false, `Status mismatch: claimed ${newSchema.status}, expected ${resultSchema.status}`];
327
+ issues.push({
328
+ code: 'status_mismatch',
329
+ message: 'The document status does not match what was computed',
330
+ expected: resultSchema.status,
331
+ claimed: newSchema.status,
332
+ });
288
333
  }
289
- return [true, undefined];
334
+ return {
335
+ valid: issues.length === 0,
336
+ status: resultSchema.status,
337
+ issues: issues.length > 0 ? issues : undefined,
338
+ schema: resultSchema,
339
+ };
290
340
  }
291
341
  /**
292
342
  * Verify that a completed document was correctly derived from a base schema.
293
343
  * Simulates the user's journey by iteratively copying visible field values and re-running.
294
344
  *
345
+ * Returns a structured result with all issues found (not just the first).
346
+ * Crash-safe: catches any unexpected error and returns it as an internal_error issue.
347
+ *
295
348
  * @param newSchema - The completed/submitted schema
296
349
  * @param oldSchema - The original base schema
297
350
  * @param maxIterations - Maximum replay iterations (default: 100)
298
- * @returns Whether the transformation was valid
351
+ * @returns Structured verification result
299
352
  */
300
353
  export function verify(newSchema, oldSchema, maxIterations = 100) {
301
354
  try {
@@ -316,7 +369,7 @@ export function verify(newSchema, oldSchema, maxIterations = 100) {
316
369
  }
317
370
  // Start with base schema
318
371
  let currentSchema = parsedOldSchema;
319
- let previousVisibleCount = -1;
372
+ let previousVisibleIds = '';
320
373
  for (let iteration = 0; iteration < maxIterations; iteration++) {
321
374
  // Count visible editable fields before copying
322
375
  const visibleEditable = getVisibleEditableFields(currentSchema);
@@ -343,23 +396,42 @@ export function verify(newSchema, oldSchema, maxIterations = 100) {
343
396
  // Run the schema
344
397
  const runResult = run(currentSchema, effectiveDate);
345
398
  if (runResult.error) {
346
- return { valid: false, error: `Run failed (iteration ${iteration}): ${runResult.error}` };
399
+ return {
400
+ valid: false,
401
+ issues: [{
402
+ code: 'internal_error',
403
+ message: `VM run failed at iteration ${iteration}`,
404
+ }],
405
+ error: `Run failed (iteration ${iteration}): ${runResult.error}`,
406
+ };
347
407
  }
348
408
  const resultSchema = runResult.result;
349
- // Count visible fields after run
350
- const currentVisibleCount = countVisibleFields(resultSchema);
351
- // Check for convergence
352
- if (currentVisibleCount === previousVisibleCount) {
353
- // Converged - now validate the final state
354
- const [valid, error] = validateFinalState(parsedNewSchema, resultSchema);
355
- return { valid, error };
409
+ // Get visible field IDs after run
410
+ const currentVisibleIds = getVisibleFieldIds(resultSchema);
411
+ // Check for convergence using set comparison
412
+ if (currentVisibleIds === previousVisibleIds) {
413
+ // Converged - now validate the final state and return full result
414
+ return validateFinalState(parsedNewSchema, resultSchema);
356
415
  }
357
- previousVisibleCount = currentVisibleCount;
416
+ previousVisibleIds = currentVisibleIds;
358
417
  currentSchema = resultSchema;
359
418
  }
360
- return { valid: false, error: `Verification did not converge after ${maxIterations} iterations` };
419
+ return {
420
+ valid: false,
421
+ issues: [{
422
+ code: 'convergence_failed',
423
+ message: `Document did not converge after ${maxIterations} iterations`,
424
+ }],
425
+ };
361
426
  }
362
427
  catch (error) {
363
- return { valid: false, error: String(error) };
428
+ return {
429
+ valid: false,
430
+ issues: [{
431
+ code: 'internal_error',
432
+ message: `Unexpected error during verification`,
433
+ }],
434
+ error: String(error),
435
+ };
364
436
  }
365
437
  }
@@ -2,6 +2,7 @@
2
2
  * JSON-logic operators for the Tenet VM.
3
3
  * All operators are nil-safe: operations on nil/undefined return appropriate defaults.
4
4
  */
5
+ import { addError } from './validate.js';
5
6
  /**
6
7
  * Convert a value to a number if possible.
7
8
  */
@@ -99,17 +100,56 @@ function resolveArgs(args, expected, resolve, state) {
99
100
  }
100
101
  return result;
101
102
  }
103
+ /**
104
+ * Check if a variable name is defined in the schema.
105
+ */
106
+ function isVariableDefined(name, state) {
107
+ // Check definitions
108
+ if (state.schema.definitions[name]) {
109
+ return true;
110
+ }
111
+ // Check derived state
112
+ if (state.schema.state_model?.derived?.[name]) {
113
+ return true;
114
+ }
115
+ return false;
116
+ }
102
117
  /**
103
118
  * Get variable value from schema definitions or current element context.
119
+ * Now accepts an optional resolve function to evaluate derived expressions on-demand.
104
120
  */
105
- function getVar(path, state) {
121
+ function getVar(path, state, resolve) {
106
122
  // Empty path returns current element context (for some/all/none)
107
123
  if (path === '') {
108
124
  return state.currentElement;
109
125
  }
110
126
  const parts = path.split('.');
111
- // Check definitions first
112
- const def = state.schema.definitions[parts[0]];
127
+ const rootVar = parts[0];
128
+ // Check if variable is defined (only for root-level vars, not nested access)
129
+ if (!isVariableDefined(rootVar, state) && state.currentElement === undefined) {
130
+ addError(state, '', '', 'runtime_warning', `Undefined variable '${rootVar}' in logic expression`);
131
+ return null;
132
+ }
133
+ // First, check derived state (derived values take precedence)
134
+ if (state.schema.state_model?.derived && resolve) {
135
+ const derived = state.schema.state_model.derived[rootVar];
136
+ if (derived?.eval) {
137
+ // Cycle detection for derived fields
138
+ if (state.derivedInProgress.has(rootVar)) {
139
+ addError(state, '', '', 'cycle_detected', `Circular dependency detected in derived field '${rootVar}'`);
140
+ return null;
141
+ }
142
+ state.derivedInProgress.add(rootVar);
143
+ const result = resolve(derived.eval, state);
144
+ state.derivedInProgress.delete(rootVar);
145
+ if (parts.length === 1) {
146
+ return result;
147
+ }
148
+ return accessPath(result, parts.slice(1));
149
+ }
150
+ }
151
+ // Then, check definitions
152
+ const def = state.schema.definitions[rootVar];
113
153
  if (def) {
114
154
  if (parts.length === 1) {
115
155
  return def.value;
@@ -117,15 +157,6 @@ function getVar(path, state) {
117
157
  // Nested access into the value
118
158
  return accessPath(def.value, parts.slice(1));
119
159
  }
120
- // Check derived state
121
- if (state.schema.state_model?.derived) {
122
- const derived = state.schema.state_model.derived[parts[0]];
123
- if (derived) {
124
- // Note: derived values should already be computed by this point
125
- // This is a fallback for direct access
126
- return undefined;
127
- }
128
- }
129
160
  return undefined;
130
161
  }
131
162
  /**
@@ -147,9 +178,9 @@ function accessPath(value, parts) {
147
178
  // ============================================================
148
179
  const operators = {
149
180
  // === Variable Access ===
150
- 'var': (args, state) => {
181
+ 'var': (args, state, resolve) => {
151
182
  const path = typeof args === 'string' ? args : '';
152
- return getVar(path, state);
183
+ return getVar(path, state, resolve);
153
184
  },
154
185
  // === Comparison Operators ===
155
186
  '==': (args, state, resolve) => {
@@ -417,7 +448,7 @@ const operators = {
417
448
  export function applyOperator(op, args, state, resolve) {
418
449
  const fn = operators[op];
419
450
  if (!fn) {
420
- // Unknown operator - return null
451
+ addError(state, '', '', 'runtime_warning', `Unknown operator '${op}' in logic expression`);
421
452
  return null;
422
453
  }
423
454
  return fn(args, state, resolve);
@@ -2,6 +2,44 @@
2
2
  * Temporal branch selection and rule pruning.
3
3
  * Routes logic based on effective dates for bitemporal support.
4
4
  */
5
+ import { addError } from './validate.js';
6
+ /**
7
+ * Validate temporal_map for configuration errors.
8
+ * Checks for same start/end dates and overlapping ranges.
9
+ */
10
+ function validateTemporalMap(state) {
11
+ const { schema } = state;
12
+ if (!schema.temporal_map || schema.temporal_map.length === 0) {
13
+ return;
14
+ }
15
+ for (let i = 0; i < schema.temporal_map.length; i++) {
16
+ const branch = schema.temporal_map[i];
17
+ if (!branch) {
18
+ continue;
19
+ }
20
+ const start = branch.valid_range[0];
21
+ const end = branch.valid_range[1];
22
+ // Check for same start/end date (invalid zero-length range)
23
+ if (start && end && start === end) {
24
+ addError(state, '', '', 'runtime_warning', `Temporal branch ${i} has same start and end date '${start}' (invalid range)`);
25
+ }
26
+ // Check for overlapping with previous branch
27
+ if (i > 0) {
28
+ const prev = schema.temporal_map[i - 1];
29
+ if (prev) {
30
+ const prevEnd = prev.valid_range[1]
31
+ ? new Date(prev.valid_range[1]).getTime()
32
+ : Infinity;
33
+ const currStart = start
34
+ ? new Date(start).getTime()
35
+ : -Infinity;
36
+ if (currStart <= prevEnd) {
37
+ addError(state, '', '', 'runtime_warning', `Temporal branch ${i} overlaps with branch ${i - 1} (ranges must not overlap)`);
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
5
43
  /**
6
44
  * Find the active temporal branch for a given effective date.
7
45
  * Returns undefined if no branch matches (uses default/unversioned logic).
@@ -62,6 +100,8 @@ export function pruneRules(state, activeBranch) {
62
100
  * Call this at the start of Run().
63
101
  */
64
102
  export function applyTemporalRouting(state) {
103
+ // Validate temporal_map configuration
104
+ validateTemporalMap(state);
65
105
  const branch = selectBranch(state);
66
106
  if (branch) {
67
107
  pruneRules(state, branch);