docs(clqms): simplify rule and calculator references

Clarify API contract focused docs for calc, rule, and audit pages to reduce noisy implementation detail.

Escape logical OR table examples as \|\| so Eleventy markdown tables render correctly.
This commit is contained in:
mahdahar 2026-04-02 05:15:21 +07:00
parent bc69ae3570
commit 761ad32c5a
3 changed files with 348 additions and 600 deletions

View File

@ -2,31 +2,47 @@
layout: clqms-post.njk
tags: clqms
title: "Calculator Service Operators Reference"
description: "Operators, functions, constants, and API usage for the calc engine"
description: "Concept, API contracts, and simplified operator reference for the calc engine"
date: 2026-03-17
order: 7
---
# Calculator Service Operators Reference
# Calculator Service: Concept and API Contract
## Overview
## Concept
The `CalculatorService` (`app/Services/CalculatorService.php`) evaluates formulas with Symfony's `ExpressionLanguage`. This document lists the operators, functions, and constants that are available in the current implementation.
`CalculatorService` evaluates formulas for test results using runtime variables.
Use it when you need to:
- transform a raw result into a calculated result,
- apply factors or demographic values (age, gender),
- reuse formula logic from test configuration.
Two API patterns are used in practice:
1. **By Test Site ID** - uses the calculation definition configured for that test site.
2. **By Test Code/Name** - resolves by test code or test name and returns a compact result map.
Responses normally follow `{ status, message, data }`, except the compact test-code endpoint,
which returns a key/value map.
---
## API Endpoints
## Example API Contract
All endpoints live under `/api` and accept JSON. Responses use the standard `{ status, message, data }` envelope unless stated otherwise.
### Calculate By Test Site
Uses the `testdefcal` definition for a test site. The incoming body supplies the variables required by the formula.
### 1) Calculate by Test Site
```http
POST /api/calc/testsite/123
POST /api/calc/testsite/{testSiteID}
Content-Type: application/json
```
Purpose: Evaluate the configured formula for a specific test site.
Request example:
```json
{
"result": 85,
"gender": "female",
@ -34,7 +50,7 @@ Content-Type: application/json
}
```
Response:
Success response example:
```json
{
@ -52,21 +68,35 @@ Response:
}
```
### Calculate By Code Or Name
Error response example:
Evaluates a configured calculation by `TestSiteCode` or `TestSiteName`. Returns a compact map with a single key/value or `{}` on failure.
```json
{
"status": "error",
"message": "Missing variable: factor",
"data": null
}
```
### 2) Calculate by Test Code or Name
```http
POST /api/calc/testcode/GLU
POST /api/calc/testcode/{testCodeOrName}
Content-Type: application/json
```
Purpose: Evaluate a calculation by test code/name and return a compact map.
Request example:
```json
{
"result": 110,
"factor": 1.1
}
```
Response:
Success response example:
```json
{
@ -74,25 +104,31 @@ Response:
}
```
Error response example:
```json
{}
```
---
## Supported Operators
## Formula Reference (Simplified)
### Arithmetic Operators
| Operator | Description | Example | Result |
|----------|-------------|---------|--------|
| `+` | Addition | `5 + 3` | `8` |
| `-` | Subtraction | `10 - 4` | `6` |
| `*` | Multiplication | `6 * 7` | `42` |
| `/` | Division | `20 / 4` | `5` |
| `%` | Modulo | `20 % 6` | `2` |
| `**` | Exponentiation (power) | `2 ** 3` | `8` |
| Operator | Meaning | Example |
|----------|---------|---------|
| `+` | Add | `5 + 3` |
| `-` | Subtract | `10 - 4` |
| `*` | Multiply | `6 * 7` |
| `/` | Divide | `20 / 4` |
| `%` | Modulo | `20 % 6` |
| `**` | Power | `2 ** 3` |
### Comparison Operators
| Operator | Description | Example |
|----------|-------------|---------|
| Operator | Meaning | Example |
|----------|---------|---------|
| `==` | Equal | `{result} == 10` |
| `!=` | Not equal | `{result} != 10` |
| `<` | Less than | `{result} < 10` |
@ -102,245 +138,77 @@ Response:
### Logical Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `and` / `&&` | Logical AND | `{result} > 0 and {factor} > 0` |
| `or` / `||` | Logical OR | `{gender} == 1 or {gender} == 2` |
| `!` / `not` | Logical NOT | `not ({result} > 0)` |
| Operator | Meaning | Example |
|----------|---------|---------|
| `&&` (`and`) | Logical AND | `{result} > 0 && {factor} > 0` |
| `\|\|` (`or`) | Logical OR | `{gender} == 1 \|\| {gender} == 2` |
| `!` (`not`) | Logical NOT | `!({result} > 0)` |
### Conditional Operators
| Operator | Description | Example |
|----------|-------------|---------|
| Operator | Meaning | Example |
|----------|---------|---------|
| `?:` | Ternary | `{result} > 10 ? {result} : 10` |
| `??` | Null coalescing | `{result} ?? 0` |
| `??` | Null fallback | `{result} ?? 0` |
### Parentheses
### Functions
Use parentheses to control operation precedence:
```
(2 + 3) * 4 // Result: 20
2 + 3 * 4 // Result: 14
```
### Notes
- `^` is bitwise XOR (not exponentiation). Use `**` for powers.
- Variables must be numeric after normalization (gender is mapped to 0/1/2).
---
## Functions
Only the default ExpressionLanguage functions are available:
| Function | Description | Example |
|----------|-------------|---------|
| `min(a, b, ...)` | Minimum value | `min({result}, 10)` |
| `max(a, b, ...)` | Maximum value | `max({result}, 10)` |
| Function | Meaning | Example |
|----------|---------|---------|
| `min(a, b, ...)` | Lowest value | `min({result}, 10)` |
| `max(a, b, ...)` | Highest value | `max({result}, 10)` |
| `constant(name)` | PHP constant by name | `constant("PHP_INT_MAX")` |
| `enum(name)` | PHP enum case by name | `enum("App\\Enum\\Status::Active")` |
---
### Constants
## Constants
| Constant | Meaning |
|----------|---------|
| `true` | Boolean true |
| `false` | Boolean false |
| `null` | Null |
ExpressionLanguage recognizes boolean and null literals:
### Variables Commonly Used
| Constant | Value | Description |
|----------|-------|-------------|
| `true` | `true` | Boolean true |
| `false` | `false` | Boolean false |
| `null` | `null` | Null value |
| Variable | Type | Meaning |
|----------|------|---------|
| `{result}` | Float | Input result value |
| `{factor}` | Float | Multiplier (default usually `1`) |
| `{gender}` | Integer | `0` unknown, `1` female, `2` male |
| `{age}` | Float | Patient age |
| `{ref_low}` | Float | Reference low |
| `{ref_high}` | Float | Reference high |
Gender can be passed as either numeric values (`0`, `1`, `2`) or strings
(`"unknown"`, `"female"`, `"male"`) and is normalized.
### Formula Notes
- Use parentheses for precedence: `(2 + 3) * 4`.
- Use `**` for exponentiation; `^` is XOR.
- Implicit multiplication is not supported (`2x` is invalid, use `2 * x`).
---
## Variables in CalculatorService
## Quick Usage Examples
When using `calculateFromDefinition()`, the following variables are automatically available:
| Variable | Description | Type |
|----------|-------------|------|
| `{result}` | The test result value | Float |
| `{factor}` | Calculation factor (default: 1) | Float |
| `{gender}` | Gender value (0=Unknown, 1=Female, 2=Male) | Integer |
| `{age}` | Patient age | Float |
| `{ref_low}` | Reference range low value | Float |
| `{ref_high}` | Reference range high value | Float |
### Gender Mapping
The `gender` variable accepts the following values:
| Value | Description |
|-------|-------------|
| `0` | Unknown |
| `1` | Female |
| `2` | Male |
Or use string values: `'unknown'`, `'female'`, `'male'`
---
## Implicit Multiplication
Implicit multiplication is not supported. Always use `*` between values:
| Expression | Use Instead |
|------------|-------------|
| `2x` | `2 * x` |
| `{result}{factor}` | `{result} * {factor}` |
---
## Usage Examples
### Basic Calculation
```php
use App\Services\CalculatorService;
$calculator = new CalculatorService();
// Simple arithmetic
$result = $calculator->calculate("5 + 3 * 2");
// Result: 11
// Using min/max
$result = $calculator->calculate("max({result}, 10)", ['result' => 7]);
// Result: 10
```
### With Variables
```php
$formula = "{result} * {factor} + 10";
$variables = [
'result' => 5.2,
'factor' => 2
];
$result = $calculator->calculate($formula, $variables);
// Result: 20.4
```
### BMI Calculation
```php
$formula = "{weight} / ({height} ** 2)";
$variables = [
'weight' => 70, // kg
'height' => 1.75 // meters
];
$result = $calculator->calculate($formula, $variables);
// Result: 22.86
```
### Gender-Based Calculation
```php
// Apply different multipliers based on gender
$formula = "{result} * (1 + 0.1 * {gender})";
$variables = [
'result' => 100,
'gender' => 1 // Female = 1
];
$result = $calculator->calculate($formula, $variables);
// Result: 110
```
### Complex Formula
```php
// Pythagorean theorem
$formula = "(({a} ** 2 + {b} ** 2) ** 0.5)";
$variables = [
'a' => 3,
'b' => 4
];
$result = $calculator->calculate($formula, $variables);
// Result: 5
```
### Using calculateFromDefinition
```php
$calcDef = [
'FormulaCode' => '{result} * {factor} + {gender}',
'Factor' => 2
];
$testValues = [
'result' => 10,
'gender' => 1 // Female
];
$result = $calculator->calculateFromDefinition($calcDef, $testValues);
// Result: 21 (10 * 2 + 1)
```txt
{result} * {factor} + 10
{weight} / ({height} ** 2)
{result} * (1 + 0.1 * {gender})
```
---
## Formula Validation
## Validation and Errors
Validate formulas before storing them:
Validate formulas before saving.
```php
$validation = $calculator->validate("{result} / {factor}");
// Returns: ['valid' => true, 'error' => null]
Typical error cases:
$validation = $calculator->validate("{result} /");
// Returns: ['valid' => false, 'error' => 'Error message']
```
- invalid syntax,
- missing variable,
- non-numeric value after normalization,
- division by zero.
### Extract Variables
Get a list of variables used in a formula:
```php
$variables = $calculator->extractVariables("{result} * {factor} + {age}");
// Returns: ['result', 'factor', 'age']
```
---
## Error Handling
The service throws exceptions for invalid formulas or missing variables:
```php
try {
$result = $calculator->calculate("{result} / 0");
} catch (\Exception $e) {
// Handle division by zero or other errors
log_message('error', $e->getMessage());
}
```
Common errors:
- **Invalid formula syntax**: Malformed expressions
- **Missing variable**: Variable placeholder not provided in data array
- **Non-numeric value**: Variables must be numeric
- **Division by zero**: Mathematical error
---
## Best Practices
1. **Always validate formulas** before storing in database
2. **Use placeholder syntax** `{variable_name}` for clarity
3. **Handle exceptions** in production code
4. **Test edge cases** like zero values and boundary conditions
5. **Document formulas** with comments in your code
---
## References
- [Symfony ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html)
- `app/Services/CalculatorService.php`
Recommended flow: validate formula -> save formula -> evaluate with guarded error handling.

View File

@ -2,180 +2,153 @@
layout: clqms-post.njk
tags: clqms
title: "Test Rule Engine Documentation"
description: "Comprehensive guide to the CLQMS Rule Engine DSL, syntax, and action definitions."
description: "Concept, catalog, and API contracts for the CLQMS Rule Engine"
date: 2026-03-16
order: 8
---
# Test Rule Engine Documentation
# Test Rule Engine: Concept and API Contract
## Overview
## Concept
The CLQMS Rule Engine evaluates business rules that inspect orders, patients, and tests, then executes actions when the compiled condition matches.
The CLQMS Rule Engine executes business rules when specific events occur.
Rules are authored using a domain specific language stored in `ruledef.ConditionExpr`. Before the platform executes any rule, the DSL must be compiled into JSON and stored in `ConditionExprCompiled`, and each rule must be linked to the tests it should influence via `testrule`.
Each rule follows this lifecycle:
### Execution Flow
1. Author DSL in `ConditionExpr`.
2. Compile DSL using `POST /api/rule/compile`.
3. Save compiled JSON in `ConditionExprCompiled`.
4. Link the rule to tests via `testrule.TestSiteID`.
5. On trigger (`test_created`, `result_updated`), execute `then` or `else` actions.
1. Write or edit the DSL in `ConditionExpr`.
2. POST the expression to `POST /api/rule/compile` to validate syntax and produce compiled JSON.
3. Save the compiled payload into `ConditionExprCompiled` and persist the rule in `ruledef`.
4. Link the rule to one or more tests through `testrule.TestSiteID` (rules only run for linked tests).
5. When the configured event fires (`test_created` or `result_updated`), the engine evaluates `ConditionExprCompiled` and runs the resulting `then` or `else` actions.
Rules without compiled JSON or without test links are not executed.
> **Note:** The rule engine currently fires only for `test_created` and `result_updated`. Other event codes can exist in the database but are not triggered by the application unless additional `RuleEngineService::run(...)` calls are added.
---
## Event Triggers
| Event Code | Status | Trigger Point |
|------------|--------|----------------|
| `test_created` | Active | Fired after a new test row is persisted; the handler calls `RuleEngineService::run('test_created', ...)` to evaluate test-scoped rules |
| `result_updated` | Active | Fired whenever a test result is saved or updated so result-dependent rules run immediately |
| `test_created` | Active | Runs after a new test row is saved |
| `result_updated` | Active | Runs when a result is saved or edited |
Other event codes remain in the database for future workflows, but only `test_created` and `result_updated` are executed by the current application flow.
Other event codes may exist in data, but these are the active runtime triggers.
## Rule Structure
---
```
Rule
├── Event Trigger (when to run)
├── Conditions (when to match)
└── Actions (what to do)
## Rule Shape
```txt
Rule = Event + Condition + Actions
```
The DSL expression lives in `ConditionExpr`. The compile endpoint (`/api/rule/compile`) renders the lifeblood of execution, producing `conditionExpr`, `valueExpr`, `then`, and `else` nodes that the engine consumes at runtime.
DSL format:
## Syntax Guide
### Basic Format
```
```txt
if(condition; then-action; else-action)
```
### Logical Operators
Multi-action branches use `:` and execute left to right:
- Use `&&` for AND (all sub-conditions must match).
- Use `||` for OR (any matching branch satisfies the rule).
- Surround mixed logic with parentheses for clarity and precedence.
### Multi-Action Syntax
Actions within any branch are separated by `:` and evaluated in order. Every `then` and `else` branch must end with an action; use `nothing` when no further work is required.
```
```txt
if(sex('M'); result_set(0.5):test_insert('HBA1C'); nothing)
```
### Multiple Rules
---
Create each rule as its own `ruledef` row; do not chain expressions with commas. The `testrule` table manages rule-to-test mappings, so multiple rules can attach to the same test. Example:
1. Insert `RULE_MALE_RESULT` and `RULE_SENIOR_COMMENT` in `ruledef`.
2. Add two `testrule` rows linking each rule to the appropriate `TestSiteID`.
Each rule compiles and runs independently when its trigger fires and the test is linked.
## Available Functions
## Catalog (Simplified)
### Conditions
| Function | Description | Example |
|----------|-------------|---------|
| Function / Pattern | Meaning | Example |
|--------------------|---------|---------|
| `sex('M'|'F')` | Match patient sex | `sex('M')` |
| `priority('R'|'S'|'U')` | Match order priority | `priority('S')` |
| `age > 18` | Numeric age comparisons (`>`, `<`, `>=`, `<=`) | `age >= 18 && age <= 65` |
| `requested('CODE')` | Check whether the order already requested a test (queries `patres`) | `requested('GLU')` |
| `age` comparisons | Numeric age checks | `age >= 18 && age <= 65` |
| `requested('CODE')` | Checks if test code exists in order | `requested('GLU')` |
### Logical Operators
| Operator | Meaning | Example |
|----------|---------|---------|
| `&&` | AND (all truthy) | `sex('M') && age > 40` |
| `||` | OR (any truthy) | `sex('M') || age > 65` |
| `()` | Group expressions | `(sex('M') && age > 40) || priority('S')` |
| `&&` | AND | `sex('M') && age > 40` |
| `\|\|` | OR | `sex('M') \|\| age > 65` |
| `!` | NOT | `!(sex('M'))` |
| `()` | Grouping | `(sex('M') && age > 40) \|\| priority('S')` |
## Actions
### Actions
| Action | Description | Example |
|--------|-------------|---------|
| `result_set(value)` | (deprecated) Write to `patres.Result` for the current context test | `result_set(0.5)` |
| `result_set('CODE', value)` | Target a specific test by `TestSiteCode`, allowing multiple tests to be updated in one rule | `result_set('tesA', 0.5)` |
| `test_insert('CODE')` | Insert a test row by `TestSiteCode` if it doesnt already exist for the order | `test_insert('HBA1C')` |
| `test_delete('CODE')` | Remove a previously requested test from the current order when the rule deems it unnecessary | `test_delete('INS')` |
| `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` |
| `nothing` | Explicit no-op to terminate an action chain | `nothing` |
| Action | Meaning | Example |
|--------|---------|---------|
| `result_set(value)` | Set current-context result (deprecated style) | `result_set(0.5)` |
| `result_set('CODE', value)` | Set result for a specific test code | `result_set('tesA', 0.5)` |
| `test_insert('CODE')` | Insert test if missing | `test_insert('HBA1C')` |
| `test_delete('CODE')` | Remove test from order | `test_delete('INS')` |
| `comment_insert('text')` | Insert order comment | `comment_insert('Review required')` |
| `nothing` | No operation | `nothing` |
> **Note:** `set_priority()` was removed. Use `comment_insert()` for priority notes without altering billing.
`set_priority()` is removed; use `comment_insert()` when you need to record priority notes.
## Runtime Requirements
---
1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`).
2. **Order context:** `context.order.InternalOID` must exist for any action that writes to `patres` or `ordercom`.
3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). When you provide a `TestSiteCode` as the first argument (`result_set('tesA', value)`), the engine resolves that code before writing the result. `test_insert()` resolves a `TestSiteID` via the `TestSiteCode` in `TestDefSiteModel`, and `test_delete()` removes the matching `TestSiteID` rows when needed.
4. **Requested check:** `requested('CODE')` inspects `patres` rows for the same `OrderID` and `TestSiteCode`.
## Example API Contract
## Examples
```
if(sex('M'); result_set('tesA', 0.5):result_set('tesB', 1.2); result_set('tesA', 0.6):result_set('tesB', 1.0))
```
Sets both `tesA`/`tesB` results together per branch.
```
if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)
```
Adds new tests when glucose is already requested.
```
if(sex('M') && age > 40; result_set(1.2); result_set(1.0))
```
```
if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))
```
```
if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))
```
```
if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)
```
```
if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))
```
```
if(requested('GLU'); test_delete('INS'):comment_insert('Duplicate insulin request removed'); nothing)
```
## API Usage
### Compile DSL
Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`.
### 1) Compile DSL
```http
POST /api/rule/compile
Content-Type: application/json
```
Purpose: Validate DSL and return compiled JSON for `ConditionExprCompiled`.
Request example:
```json
{
"expr": "if(sex('M'); result_set(0.5); result_set(0.6))"
}
```
The response contains `raw`, `compiled`, and `conditionExprCompiled` fields; store the JSON payload in `ConditionExprCompiled` before saving the rule.
Success response example:
### Evaluate Expression (Validation)
```json
{
"status": "success",
"data": {
"raw": "if(sex('M'); result_set(0.5); result_set(0.6))",
"compiled": {
"conditionExpr": "sex('M')",
"then": [{ "type": "result_set", "args": [0.5] }],
"else": [{ "type": "result_set", "args": [0.6] }]
},
"conditionExprCompiled": "{...json string...}"
}
}
```
This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result.
Error response example:
```json
{
"status": "error",
"message": "Invalid expression near ';'",
"data": null
}
```
### 2) Validate Expression Against Context
```http
POST /api/rule/validate
Content-Type: application/json
```
Purpose: Evaluate an expression with context only (no compile, no persistence).
Request example:
```json
{
"expr": "order[\"Age\"] > 18",
"context": {
@ -186,12 +159,29 @@ Content-Type: application/json
}
```
### Create Rule (example)
Success response example:
```json
{
"status": "success",
"data": {
"result": true
}
}
```
### 3) Create Rule
```http
POST /api/rule
Content-Type: application/json
```
Purpose: Save rule metadata, DSL, compiled payload, and linked tests.
Request example:
```json
{
"RuleCode": "RULE_001",
"RuleName": "Sex-based result",
@ -202,89 +192,32 @@ Content-Type: application/json
}
```
## Database Schema
Success response example:
### Tables
- **ruledef** stores rule metadata, raw DSL, and compiled JSON.
- **testrule** mapping table that links rules to tests via `TestSiteID`.
- **ruleaction** deprecated. Actions are now embedded in `ConditionExprCompiled`.
### Key Columns
| Column | Table | Description |
|--------|-------|-------------|
| `EventCode` | ruledef | The trigger event (typically `test_created` or `result_updated`). |
| `ConditionExpr` | ruledef | Raw DSL expression (semicolon syntax). |
| `ConditionExprCompiled` | ruledef | JSON payload consumed at runtime (`then`, `else`, etc.). |
| `ActionType` / `ActionParams` | ruleaction | Deprecated; actions live in compiled JSON now. |
## Best Practices
1. Always run `POST /api/rule/compile` before persisting a rule so `ConditionExprCompiled` exists.
2. Link each rule to the relevant tests via `testrule.TestSiteID`—rules are scoped to linked tests.
3. Use multi-action (`:`) to bundle several actions in a single branch; finish the branch with `nothing` if no further work is needed.
4. Prefer `comment_insert()` over the removed `set_priority()` action when documenting priority decisions.
5. Group complex boolean logic with parentheses for clarity when mixing `&&` and `||`.
6. Use `requested('CODE')` responsibly; it performs a database lookup on `patres` so avoid invoking it in high-frequency loops without reason.
## Migration Guide
### Syntax Changes (v2.0)
The DSL moved from ternary (`condition ? action : action`) to semicolon syntax. Existing rules must be migrated via the provided script.
| Old Syntax | New Syntax |
|------------|------------|
| `if(condition ? action : action)` | `if(condition; action; action)` |
#### Migration Examples
```
# BEFORE
if(sex('M') ? result_set(0.5) : result_set(0.6))
# AFTER
if(sex('M'); result_set(0.5); result_set(0.6))
```json
{
"status": "success",
"message": "Rule created",
"data": {
"RuleCode": "RULE_001"
}
}
```
```
# BEFORE
if(sex('F') ? set_priority('S') : nothing)
---
# AFTER
if(sex('F'); comment_insert('Female patient - review priority'); nothing)
```
## Minimal End-to-End Flow
#### Migration Process
1. Compile DSL with `/api/rule/compile`.
2. Store returned `conditionExprCompiled` in `ruledef.ConditionExprCompiled`.
3. Create rule and link `TestSiteIDs` via `/api/rule`.
4. Trigger event (`test_created` or `result_updated`) and verify actions ran.
Run the migration which:
---
1. Converts ternary syntax to semicolon syntax.
2. Recompiles every expression into JSON so the engine consumes `ConditionExprCompiled` directly.
3. Eliminates reliance on the `ruleaction` table.
## Runtime Requirements (Quick Check)
```bash
php spark migrate
```
## Troubleshooting
### Rule Not Executing
1. Ensure the rule has a compiled payload (`ConditionExprCompiled`).
2. Confirm the rule is linked to the relevant `TestSiteID` in `testrule`.
3. Verify the `EventCode` matches the currently triggered event (`test_created` or `result_updated`).
4. Check that `EndDate IS NULL` for both `ruledef` and `testrule` (soft deletes disable execution).
5. Use `/api/rule/compile` to validate the DSL and view errors.
### Invalid Expression
1. POST the expression to `/api/rule/compile` to get a detailed compilation error.
2. If using `/api/rule/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it.
### Runtime Errors
- `RESULT_SET requires context.order.InternalOID` or `testSiteID`: include those fields in the context passed to `RuleEngineService::run()`.
- `TEST_INSERT` failures mean the provided `TestSiteCode` does not exist or the rule attempted to insert a duplicate test; check `testdefsite` and existing `patres` rows.
- `COMMENT_INSERT requires comment`: ensure the action provides text.
1. `ConditionExprCompiled` must be present.
2. Rule must be linked to the target `TestSiteID`.
3. Trigger event must match `EventCode`.
4. Context must include required identifiers for write actions.

View File

@ -2,201 +2,148 @@
layout: clqms-post.njk
tags: clqms
title: "Audit Logging"
description: "Unified audit logging model, event catalog, and capture rules for CLQMS."
description: "Audit logging concept and example API contracts for CLQMS"
date: 2026-03-17
order: 9
---
# Audit Logging Strategy
## Overview
# Audit Logging: Concept and Example API Contract
This document defines how CLQMS should capture audit and operational logs across four tables:
## Concept
- `logpatient` — patient, visit, and ADT activity
- `logorder` — orders, tests, specimens, results, and QC
- `logmaster` — master data and configuration changes
- `logsystem` — sessions, security, import/export, and system operations
CLQMS uses four audit tables so domain activity is separated but consistent:
The intent is to audit all domains, including master data changes, and to standardize event capture so reporting and compliance are consistent.
- `logpatient` - patient, identity, consent, insurance, and visit/ADT activity
- `logorder` - orders, specimens, results, and QC activity
- `logmaster` - configuration and master data changes
- `logsystem` - sessions, security, integration jobs, and system operations
## Table Ownership
The goal is traceability: who did what, when, where, and why.
| Event | Table |
| --- | --- |
| Patient registered/updated/merged | `logpatient` |
| Insurance/consent changed | `logpatient` |
| Patient visit (admit/transfer/discharge) | `logpatient` |
| Order created/cancelled | `logorder` |
| Sample received/rejected | `logorder` |
| Result entered/verified/amended | `logorder` |
| Result released/retracted/corrected | `logorder` |
| QC result recorded | `logorder` |
| Test panel added/removed | `logmaster` |
| Reference range changed | `logmaster` |
| Analyzer config updated | `logmaster` |
| User role changed | `logmaster` |
| User login/logout | `logsystem` |
| Import/export job start/end | `logsystem` |
### Shared Audit Fields
## Standard Log Schema (Shared Columns)
All log records should carry the same core metadata:
Use a shared schema for all four tables to keep instrumentation and reporting consistent. The legacy names below match existing patterns and can be reused.
- identity: `UserID`, `SessionID`, `SiteID`
- event classification: `EventID`, `ActivityID`
- change details: `TblName`, `RecID`, `FldName`, `FldValuePrev`, `FldValueNew`
- time/context: `LogDate`, optional `Context` JSON, optional `IpAddress`
| Column | Description |
| --- | --- |
| `LogID` (PK) | Auto increment primary key per table (e.g., `LogPatientID`) |
| `TblName` | Source table name |
| `RecID` | Record ID of the entity |
| `FldName` | Field name that changed (nullable for bulk events) |
| `FldValuePrev` | Previous value (string or JSON) |
| `FldValueNew` | New value (string or JSON) |
| `UserID` | Acting user ID (nullable for system actions) |
| `SiteID` | Site context |
| `DIDType` | Device identifier type |
| `DID` | Device identifier |
| `MachineID` | Workstation or host identifier |
| `SessionID` | Session identifier |
| `AppID` | Client application ID |
| `ProcessID` | Process/workflow identifier |
| `WebPageID` | UI page/context (nullable) |
| `EventID` | Event code (see catalog) |
| `ActivityID` | Action code (create/update/delete/read/etc.) |
| `Reason` | User/system reason |
| `LogDate` | Timestamp of event |
| `Context` | JSON metadata (optional but recommended) |
| `IpAddress` | Remote IP (optional but recommended) |
### Category Reference (Simplified)
Recommended: keep a JSON string in `Context` for extra details (e.g., route, request id, batch id, error message). Use size limits to avoid oversized rows.
- **Patient (`logpatient`)**: register/update/merge, consent/insurance changes, ADT events
- **Order (`logorder`)**: create/cancel/reopen, specimen lifecycle, result verify/amend/release,
QC record/override
- **Master (`logmaster`)**: test definitions, reference ranges, analyzer/integration config,
user/role/permission changes
- **System (`logsystem`)**: login/logout/failures, token lifecycle, import/export jobs,
background process events
## Event Catalog
Recommended `ActivityID` values:
`CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`,
`AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`.
### logpatient
---
**Patient core**
## Example API Contract (Reference Pattern)
- Register patient
- Update demographics
- Merge/unmerge/split
- Identity changes (MRN, external identifiers)
- Consent grant/revoke/update
- Insurance add/update/remove
- Patient record view (if required by compliance)
These contracts are a reference pattern for standardizing audit capture and reporting.
**Visit/ADT**
### 1) Write Audit Event
- Admit, transfer, discharge
- Bed/ward/unit changes
- Visit status updates
```http
POST /api/audit/log
Content-Type: application/json
```
**Other**
Purpose: write one normalized audit event, then route it to the correct log table.
- Patient notes/attachments added/removed
- Patient alerts/flags changes
Request example:
### logorder
```json
{
"domain": "order",
"TblName": "patres",
"RecID": "12345",
"FldName": "Result",
"FldValuePrev": "1.0",
"FldValueNew": "1.2",
"EventID": "RESULT_VERIFIED",
"ActivityID": "VERIFY",
"Reason": "Auto verification rule passed",
"UserID": "u001",
"SiteID": "s01",
"SessionID": "sess-8f31",
"LogDate": "2026-03-17T10:21:33Z",
"Context": {
"order_id": "OID-7788",
"test_code": "GLU",
"request_id": "req-2a9"
},
"IpAddress": "10.2.4.8"
}
```
**Orders/tests**
Success response example:
- Create/cancel/reopen order
- Add/remove tests
- Priority changes
- Order comments added/removed
```json
{
"status": "success",
"message": "Audit event stored",
"data": {
"table": "logorder",
"logId": 556901
}
}
```
**Specimen lifecycle**
Error response example:
- Collected, labeled, received, rejected
- Centrifuged, aliquoted, stored
- Disposed/expired
```json
{
"status": "error",
"message": "Missing required field: EventID",
"data": null
}
```
**Results**
### 2) Query Audit Logs
- Result entered/updated
- Verified/amended
- Released/retracted/corrected
- Result comments/interpretation changes
- Auto-verification override
```http
GET /api/audit/logs?domain=order&recId=12345&eventId=RESULT_VERIFIED&from=2026-03-01&to=2026-03-31
```
**QC**
Purpose: retrieve normalized audit history with filters for investigation and compliance.
- QC result recorded
- QC failure/override
Success response example:
### logmaster
```json
{
"status": "success",
"data": [
{
"logId": 556901,
"table": "logorder",
"EventID": "RESULT_VERIFIED",
"ActivityID": "VERIFY",
"TblName": "patres",
"RecID": "12345",
"FldName": "Result",
"FldValuePrev": "1.0",
"FldValueNew": "1.2",
"UserID": "u001",
"SiteID": "s01",
"LogDate": "2026-03-17T10:21:33Z"
}
]
}
```
**Value sets**
---
- Create/update/retire value set items
## Capture Rules (Short)
**Test definitions**
- Test definition updates (units, methods, ranges)
- Reference range changes
- Formula/delta check changes
- Test panel membership add/remove
**Infrastructure**
- Analyzer/instrument config changes
- Host app integration config
- Coding system changes
**Users/roles**
- User create/disable/reset
- Role changes
- Permission changes
**Sites/workstations**
- Site/location/workstation CRUD
### logsystem
**Sessions & security**
- Login/logout
- Failed login attempts
- Lockouts/password resets
- Token issue/refresh/revoke
- Authorization failures
**Import/export**
- Import/export job start/end
- Batch ID, source, record counts, status
**System operations**
- Background jobs start/end
- Integration sync runs
- System config changes
- Service errors that affect data integrity
## Activity & Event Codes
Use consistent `ActivityID` and `EventID` values. Recommended defaults:
- `ActivityID`: `CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`
- `EventID`: domain-specific codes (e.g., `PATIENT_REGISTERED`, `ORDER_CREATED`, `RESULT_VERIFIED`, `QC_RECORDED`)
## Capture Guidelines
- Always capture `UserID`, `SessionID`, `SiteID`, and `LogDate` when available.
- If the action is system-driven, set `UserID` to `SYSTEM` (or null) and add context in `Context`.
- Store payload diffs in `FldValuePrev` and `FldValueNew` for single-field changes; for multi-field changes, put a JSON diff in `Context` and leave `FldName` null.
- For bulk operations, store batch metadata in `Context` (`batch_id`, `record_count`, `source`).
- Do not log secrets, tokens, or full PHI when not required. Mask or omit sensitive fields.
## Retention & Governance
- Define retention policy per table (e.g., 7 years for patient/order, 2 years for system).
- Archive before purge; record purge activity in `logsystem`.
- Restrict write/delete permissions to service accounts only.
## Implementation Checklist
1. Create the four tables with shared schema (or migrate existing log tables to match).
2. Add a single audit service with helpers to build a normalized payload.
3. Instrument controllers/services for each event category above.
4. Add automated tests for representative audit writes.
5. Document `EventID` codes used by each endpoint/service.
- Always include `UserID`, `SessionID`, `SiteID`, `EventID`, `ActivityID`, and `LogDate`.
- For multi-field updates, keep `FldName` null and store the diff in `Context`.
- Do not log secrets/tokens; mask sensitive values in payloads.
- For system-driven actions, use `UserID = SYSTEM` (or null) and explain in `Context`.