@githonllc/avouch 0.1.7 → 0.1.9

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
@@ -39,8 +39,10 @@ Read in this order:
39
39
  | `src/checker.ts` | `check(ontology, facts)` returns the violations |
40
40
  | `src/patch.ts` | JSON-pointer patch that the mutation tests use |
41
41
  | `src/query.ts` | read-only queries over an ontology (`avouch query`) |
42
+ | `src/reconcile.ts` | runtime consistency: `reconcile`, `projectSnapshot` (SPEC section 9) |
43
+ | `src/coverage.ts` | decision-row coverage of reconcile rows, with a baseline (`verifyCoverage`, `avouch gate verify`) |
42
44
  | `src/markdown.ts` | built-in adapter for plain Markdown sources (`avouch.json`); see `docs/adapter-guide.md` section 7 |
43
- | `cli/` | command line: `avouch check` (`check.ts`) and `avouch query` (`query.ts`) |
45
+ | `cli/` | command line: `avouch check` (`check.ts`), `avouch query` (`query.ts`), `avouch reconcile` (`reconcile.ts`) and `avouch gate verify` (`gate.ts`) |
44
46
  | `tsconfig.build.json` | configuration for the JavaScript and type declaration build |
45
47
  | `dist/` | build output; not tracked in version control |
46
48
  | `examples/library/` | toy project: source document, ontology, mutations, adapter, profile, evidence |
@@ -60,6 +62,9 @@ A project supplies an adapter and a profile. The adapter reads the project's sou
60
62
  - `test/cli.test.ts`: command line results, schema errors, checker violations and adapter errors.
61
63
  - `test/query.test.ts`: the query functions on the toy ontology and on patched variants of it.
62
64
  - `test/query-cli.test.ts`: `avouch query`: the JSON envelope, the stamp (git and check), exit codes, text output, and that a query writes no file.
65
+ - `test/coverage.test.ts`: `verifyCoverage` on the toy ontology and on patched variants of it: units, gaps, stale baseline entries, failing reconcile rows, and the shape checks.
66
+ - `test/reconcile-cli.test.ts`: `avouch reconcile`: JSON lines in and out, exit codes, and the line number in each input error.
67
+ - `test/gate-cli.test.ts`: `avouch gate verify`: the pipeline from cases to the gate, text and JSON output, the check with `--adapter`, and exit codes.
63
68
  - `test/markdown.test.ts`: the built-in Markdown adapter: the toy library gives the same findings as with its own adapter (outside the rules the generic adapter cannot check), and each parse convention.
64
69
  - `test/boundary.test.ts`: the runtime (`src/` and `examples/`) imports only relative paths inside this package. It imports no npm package and no file outside the package.
65
70
 
@@ -118,6 +123,71 @@ The stamp tells which specification answered:
118
123
 
119
124
  Exit code 0 means there is a result. 1 means there is no result: the id is not declared, or the list is empty (the envelope is still printed). 2 means invalid arguments, or a file, parsing or adapter error.
120
125
 
126
+ ## Runtime consistency on the command line
127
+
128
+ `avouch reconcile` compares the result that the runtime reported with the result of the evaluator and with the scenario expectation (SPEC section 9, Runtime consistency). `avouch gate verify` checks that the reconciled cases cover each decision row. Both commands read files and write no file. They do not run project code: the project's test harness writes the cases.
129
+
130
+ ```sh
131
+ avouch reconcile <ontology.yaml> --cases <cases.jsonl>
132
+ avouch gate verify <ontology.yaml> --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--adapter <module|config.json>]
133
+ ```
134
+
135
+ ### reconcile
136
+
137
+ The cases file has one `RuntimeCase` (SPEC section 9) per line, as JSON. Empty lines are skipped. A case can have more keys than `RuntimeCase`; they are ignored. The command prints one `ReconcileRow` per case, as one JSON line, in input order. For example, `avouch reconcile library.ontology.yaml --cases cases.jsonl > rows.jsonl` writes the input of `gate verify`.
138
+
139
+ Exit code 0 means that each case gave a row, also a `mismatch` or a `cannot_reconcile` row: the command does not judge the rows. 2 means invalid arguments, a file or parse error, or a line that is not a `RuntimeCase`; the message gives the file and the line number. The command does not use exit code 1.
140
+
141
+ ### gate verify
142
+
143
+ The unit of coverage is the decision row. Each row of a decision table and its `otherwise` is one unit. A unit has the name `<action>:rows.<i>` (`i` from 0) or `<action>:otherwise`, as in the R12 keys. A unit is verified when a `match` row has its action, its `row` and the result of that row. A `mismatch` row does not verify its unit. An action without a decision table has no units; it is printed as `no_table` and does not change the result. The command checks every action of the ontology.
144
+
145
+ Verified means: for one case, `decide` reached this row, and the runtime result and the expected result are the same as the result of `decide`. The runtime does not report which row it ran. A case without an expected result never verifies a unit: `reconcile` gives `expectation_missing` for it, and the gate prints it as `unasserted`. An action with `permission: unknown` never reaches its decision table in `decide`, so all its units stay as `uncovered` entries until the permission is written.
146
+
147
+ A gap is a condition that a decision row names and that is declared without an expression (`unspecified`). `decide` stops at the first row that names a gap, so the units from that row to `otherwise` are blocked: they are printed as `blocked` and are not reported as uncovered. Each gap needs an exemption with a reason in the baseline. When a gap gets an expression, the units it blocked become ordinary units. The change that adds the expression must also add cases or `uncovered` entries for them.
148
+
149
+ The baseline is a JSON file. In this example, the condition `loan_active` has no expression:
150
+
151
+ ```json
152
+ {
153
+ "avouchCoverage": 1,
154
+ "uncovered": [
155
+ { "action": "BORROW", "row": 3, "when": { "fails": "returns_in_good_condition" }, "result": "REFERRED" },
156
+ { "action": "BORROW", "row": "otherwise", "when": null, "result": "BORROWED" }
157
+ ],
158
+ "exempt": [
159
+ { "action": "RETURN", "condition": "loan_active", "kind": "unspecified", "reason": "the source does not state when a loan is active" }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ - `uncovered` lists units that have no `match` yet, each with the `when` and `result` of its row. An entry for a unit that is now verified fails the gate. The tool does not stop new entries; review the baseline diff for that.
165
+ - `exempt` lists gaps, each with a non-empty `reason`.
166
+ - Both keys are required. A key that is not shown above is an error (exit code 2).
167
+ - Without `--baseline`, both lists are empty.
168
+ - Units are named by position. When rows are added, removed or moved, the positions change; an `uncovered` entry whose `when` or `result` is not the same as in the ontology is stale (`moved`).
169
+
170
+ The canonical form of the file is `JSON.stringify(baseline, null, 2)` with a final line break, the keys in the order shown above, `uncovered` in unit order (actions in code-unit order, then rows from 0, then `otherwise`) and `exempt` in action order, then condition order. The gate does not check the form. `--json` prints `uncovered` and `unexempted` in the shape and order of the baseline entries; to make a first baseline, copy them and add a reason to each `unexempted` entry.
171
+
172
+ The gate fails (exit code 1) when:
173
+
174
+ | Output | Cause |
175
+ |---|---|
176
+ | `uncovered` | a unit has no `match`, is not blocked, and is not in `uncovered` of the baseline |
177
+ | `unexempted` | a gap has no entry in `exempt` |
178
+ | `unreasoned` | an `exempt` entry has no reason, or only white space |
179
+ | `stale` | an `uncovered` entry names a unit that does not exist, that has another `when` or `result` (`moved`), that is verified or that is blocked, or repeats an entry; an `exempt` entry names a condition that is not a gap, or repeats an entry |
180
+ | `failed` | a reconcile row names an action that the ontology does not have (`action_unknown`); a reconcile row is a `mismatch`; a `cannot_reconcile` row has the reason `runtime_missing`, `runtime_unmapped`, `expectation_unmapped`, `snapshot_missing` or `evaluator_error`, or the reason `indeterminate` with the detail `no_row_matched`; a `match` row names a row that the action does not have, or a result that is not the result of that row (`row_unknown`) |
181
+ | `check` | with `--adapter`, the same check as `avouch check` fails |
182
+
183
+ The reasons `envelope`, `expectation_missing` and `indeterminate` with another detail do not fail the gate. A replay is not a decision; a case without an expectation states nothing to compare. An `indeterminate` result does not fail the gate, except `no_row_matched`; the units that it leaves unverified stay `uncovered` or `blocked`, so the gate still sees them. `no_row_matched` means that no row applies and the table has no `otherwise`.
184
+
185
+ Known limit: when a case has no expected result, `reconcile` gives `expectation_missing` before it checks the runtime result. So the gate does not see a runtime result that the action cannot give (`runtime_unmapped`) in such a case.
186
+
187
+ Exit code 0 means the gate passes. 1 means it fails. 2 means invalid arguments (also a repeated option), a file or parse error, an ontology without an `actionTypes` mapping, or a rows or baseline file with a wrong shape. Without `--json`, the command prints one `FAIL` line for each failure, `INFO` lines (`check`, `blocked`, `no_table`, `unasserted`), and a summary line that starts with `PASS` or `FAIL`. `--json` prints `{avouchGate: 1, gate: "verify", check, result}`.
188
+
189
+ The same gate is a function in the package root, for tests that run without a file system or child processes: `verifyCoverage(ontology, rows, baseline)` returns the `result` object (`src/coverage.ts`). The command is a thin wrapper around it. The function does not run the check.
190
+
121
191
  ## Claude Code plugin
122
192
 
123
193
  This repository is also a Claude Code plugin marketplace. Install the plugin:
package/SPEC.md CHANGED
@@ -28,6 +28,17 @@ Only generic needs go into the format. A special-case need stays in the project
28
28
 
29
29
  Example: objects shared by several contexts (a shared kernel) are a cross-project pattern. The format already expresses them: the shared objects get their own context, and actions that write them declare `crossContext`. An exemption such as "writes to shared objects need no declaration" fails criteria 2 and 3, so it is not in the format.
30
30
 
31
+ ### Limit of the expression language
32
+
33
+ The expression language (section 9) is a restricted predicate language that can be checked statically. It reads domain facts and decides the conditions of decision tables. It is not a language in which a project implements its rules, for two reasons: the ontology states the rules independently of the runtime, so that agreement in `reconcile` means something; and the static checks of section 9 (types, the UNKNOWN analysis) must stay decidable.
34
+
35
+ So a new expression node must meet the three criteria above and do one of these two things:
36
+
37
+ - read a fact that no node reads now: of the snapshot, the invocation or the caller, including a field of a parameter or of a stored value;
38
+ - decide a bounded claim over facts: a comparison, a quantifier over a finite collection, or a count or sum over a finite collection inside a comparison.
39
+
40
+ Value nodes such as `{lit}`, `plus` and `dateIn` only give a comparison its operands. Not admitted: user-defined functions, recursion, unbounded iteration, nodes that build new collections, records or strings, and other arithmetic than `plus`, `dateIn` and the count or sum above.
41
+
31
42
  ## 2. Concepts
32
43
 
33
44
  A key is foundation when Palantir Foundry has the same concept (names follow https://www.palantir.com/docs/foundry/ontology/overview/ and the pages for each type). A key is extension when Palantir has no analog; the rule that uses it is named.
@@ -46,7 +57,7 @@ The definitions quoted in the "Palantir analog" column are short quotations from
46
57
  | `derivedProperties.<d>.expr` / `cite` / `type` | foundation | derived property definition | the definition as an expression over `self` (instead of `reads`), its source text and its declared type; a predicate expression has type `boolean`; without `expr` the derived property is undefined for evaluation; R1, R2, R4, R12 |
47
58
  | `linkTypes` | foundation | link type: "the schema definition of a relationship between two object types" | `via` / `table` map to a foreign key or a many-to-many datasource; `create_rule` is an extension (R5) |
48
59
  | `actionTypes` | foundation | action type: "the schema definition of a set of changes or edits to objects, property values, and links that a user can take at once" | |
49
- | `actionTypes.<A>.parameters` | foundation | action parameter: "Parameters are the inputs of an action type." | name → `{cite, type?, optional?, values?}`; read by a condition as `{param}` or, in an expression, `{ref}`; `type` is needed when an expression reads it; `optional: true` lets the caller omit it; `values` (only with `type: enum`) is the value domain of the parameter; R1, R2 |
60
+ | `actionTypes.<A>.parameters` | foundation | action parameter: "Parameters are the inputs of an action type." | name → `{cite, type?, optional?, values?, projectedFrom?}`; read by a condition as `{param}` or, in an expression, `{ref}`; `type` is needed when an expression reads it; `optional: true` lets the caller omit it; `values` (only with `type: enum`) is the value domain of the parameter; `projectedFrom` lists the request fields that the project resolves to this ref; it takes effect only on a `type: {ref: <Object>}` parameter with at least one field, and such a projected parameter is always given, also when optional (section 9); on any other parameter R1 reports it and `decide` ignores it; R1, R2 |
50
61
  | `conditions` | foundation + constraint | submission criteria: "the conditions that determine whether an action can be submitted"; Palantir submission criteria ≈ the rows whose disposition class is `rejected` | named Boolean claims `{id, expr \| unspecified, reads?, reads_unspecified?, cites[{cite}], scenarios?}`: `expr` is the claim as an expression (section 9); `unspecified` says it is not written as one, and `reads` may then list what it reads (properties, parameters `{param, values?}`, the caller `{actor: id}` / `{actor: keys}`); R1, R4, R12 |
51
62
  | `decision` | extension | none | decision table `{hitPolicy: first, order?: {cite}, rows: [{when: {passes \| fails: <condition>}, result, cites, next?}], otherwise?: {result, cites, next?}}`; `next` is `{actions}`, `{external, cite}` or `{unspecified}`; R1, R12 |
52
63
  | `edits` / `creates` / `deletes` | foundation | action rules: modify / create / delete object | `deletes` is a hard delete of datasource rows; a soft delete is an `edits` |
@@ -104,8 +115,8 @@ The adapter may read the ontology instance (for example to list action ids in a
104
115
 
105
116
  | Rule | Checks | Facts used | Guarantee | Not guaranteed |
106
117
  |---|---|---|---|---|
107
- | R1 | shape, references resolve (a `{param}` read names a parameter of its action; `actor` is `id` or `keys`), ids unique, `formatVersion`, evidence ids exist; expressions type-check (section 9; the properties, parameters and derived properties they read declare `type`; `self` only in derived expressions; quantifier variables do not reuse reserved or bound names; set literals are non-empty with one element type), derived properties have exactly one of `expr` / `reads` and form no cycle, a derived `expr` has `type` and `cite`; a condition has exactly one of `expr` / `unspecified`, `reads` only with `unspecified`; duplicate condition ids are R1 (implementation decision: each entry with an `expr` is type-checked, but every entry with that id takes its type error, R4 reads and literals, and the R12 derived-property part from the last entry with that id that has an `expr`; the static UNKNOWN analysis also uses that last entry, while `decide` evaluates the first entry with that id); `scope` types are declared object types, each listed once; disposition ids are not invocation results; decision rows name a condition of the action and a disposition, only `pending` results have `next` (and must), `next.actions` are declared actions; `principals` is `{kinds: [distinct], cite}`; `idempotencyKey` is `{required: boolean, cite}`; a parameter with `values` has `type: enum` and a non-empty list of distinct non-empty strings, and a string literal compared with that parameter (`eq` / `neq` with `{ref: <name>}` on the other side, or every element of the set literal in `in [{ref: <name>}, {lit: [..]}]`) must be one of its `values` (else a type error); with `config.idempotencyDeclarationRequired: true` (default `false`) an action without `idempotencyKey` fails `idempotency_undeclared` (key `<ID>`) | `config.actionIdPattern`, `config.stateProperty`, `evidence` keys, `config.idempotencyDeclarationRequired` | structure is well-formed | that an expression means what its cite says |
108
- | R2 | anchors unique, quotes verbatim (also under `derivedProperties`, `scope` and `dispositions`), properties are fields, names in the store catalog, parameter names appear in their quote (`parameter_not_in_quote`), and so does each of a parameter's `values` (`parameter_value_not_in_quote`, key `<ID>:<name>:<value>`); with `scope`: each tenant type's field list has `<by>` (`scope_field_missing`, key `<Object>.<by>`), no global type's field list has it (`scope_field_present`), `<by>` is in the scope quote (`scope_by_not_in_quote`, key `scope`); each disposition id is in its own quote (`disposition_not_in_quote`, key `dispositions.<ID>`); a declared `type` has a cite (`type_without_provenance`, key `<Object>.<prop>` or `derivedProperties.<id>`; a derived property with `expr` and no cite is R1 only) | `section`, `containsTerm`, `fieldList`, `storeCatalog` | quotes exist in the source | that a quote supports the claim |
118
+ | R1 | shape, references resolve (a `{param}` read names a parameter of its action; `actor` is `id` or `keys`), ids unique, `formatVersion`, evidence ids exist; expressions type-check (section 9; the properties, parameters and derived properties they read declare `type`; `self` only in derived expressions; quantifier variables do not reuse reserved or bound names; set literals are non-empty with one element type), derived properties have exactly one of `expr` / `reads` and form no cycle, a derived `expr` has `type` and `cite`; a condition has exactly one of `expr` / `unspecified`, `reads` only with `unspecified`; duplicate condition ids are R1 (implementation decision: each entry with an `expr` is type-checked, but every entry with that id takes its type error, R4 reads and literals, and the R12 derived-property part from the last entry with that id that has an `expr`; the static UNKNOWN analysis also uses that last entry, while `decide` evaluates the first entry with that id); `scope` types are declared object types, each listed once; disposition ids are not invocation results; decision rows name a condition of the action and a disposition, only `pending` results have `next` (and must), `next.actions` are declared actions; `principals` is `{kinds: [distinct], cite}`; `idempotencyKey` is `{required: boolean, cite}`; a parameter with `values` has `type: enum` and a non-empty list of distinct non-empty strings, and a string literal compared with that parameter (`eq` / `neq` with `{ref: <name>}` on the other side, or every element of the set literal in `in [{ref: <name>}, {lit: [..]}]`) must be one of its `values` (else a type error); a parameter with `projectedFrom` has `type: {ref: <Object>}` and a non-empty list of distinct non-empty field names; with `config.idempotencyDeclarationRequired: true` (default `false`) an action without `idempotencyKey` fails `idempotency_undeclared` (key `<ID>`) | `config.actionIdPattern`, `config.stateProperty`, `evidence` keys, `config.idempotencyDeclarationRequired` | structure is well-formed | that an expression means what its cite says |
119
+ | R2 | anchors unique, quotes verbatim (also under `derivedProperties`, `scope` and `dispositions`), properties are fields, names in the store catalog, parameter names appear in their quote (`parameter_not_in_quote`), or, for a projected parameter, each of its fields (`projection_field_not_in_quote`, key `<ID>:<name>:<field>`), and so does each of a parameter's `values` (`parameter_value_not_in_quote`, key `<ID>:<name>:<value>`); with `scope`: each tenant type's field list has `<by>` (`scope_field_missing`, key `<Object>.<by>`), no global type's field list has it (`scope_field_present`), `<by>` is in the scope quote (`scope_by_not_in_quote`, key `scope`); each disposition id is in its own quote (`disposition_not_in_quote`, key `dispositions.<ID>`); a declared `type` has a cite (`type_without_provenance`, key `<Object>.<prop>` or `derivedProperties.<id>`; a derived property with `expr` and no cite is R1 only) | `section`, `containsTerm`, `fieldList`, `storeCatalog` | quotes exist in the source | that a quote supports the claim |
109
120
  | R3 | graph equality, terminal states, reachability, `by` = triggers, `by` iff the action edits the state property | `stateMachines`, `transitionTriggers`, `config.stateProperty` | ontology matches the source machine | trigger-cell conditions; objects without a machine in the source |
110
121
  | R4 | conditions read only canonical and policy properties, for an expression also through the derived properties it reaches (parameter and actor reads are request inputs, not state, and are not classified; a derived property without `expr` is not expanded; a hand-written `reads` entry that names a derived property is expanded through its `reads`, or, when it has an `expr`, through the properties that expression reads, as for an expression condition); a non-canonical property passes when every read compares it by `eq` / `neq` with a literal in its `canonical_values`; canonical values and the values of a parameter read appear in their quote (`parameter_value_unsupported`); each literal of an expression appears in one of its condition's quotes, or in the derived property's quote (`literal_unsupported`, key `<ID>:<condition id>:<value>` or `derivedProperties.<id>:<value>`) | none | the property reads of conditions are canonical or policy facts; literals are not invented | that `reads` is complete; that an expression's logic matches its cite |
111
122
  | R5 | object membership, cross-context declaration (`via` or `unspecified`), `create_rule`, entity coverage | `contextMembers`, `config.crossContextMechanisms` | object ownership matches the source | the content of the mechanism |
@@ -146,7 +157,7 @@ The table lists only the optional facts. Every rule may also read the required f
146
157
 
147
158
  ## 8. Versions
148
159
 
149
- The version is in two places: `v1` in the schema `$id`, and `formatVersion: 1` in the ontology file (R1 fails on any other value). A change that breaks existing ontologies raises both. A change that rejects only files without meaning (for example an empty name) is not a break. Adding the `permission.any_of` form is additive: existing files stay valid, so `formatVersion` stays 1. Adding `parameters` and the parameter and actor reads of the submission criteria (since renamed `conditions`) is additive: existing files stay valid, so `formatVersion` stays 1. Adding `values` on enum parameters is additive; `formatVersion` stays 1. Adding `deletes` is additive; `formatVersion` stays 1.
160
+ The version is in two places: `v1` in the schema `$id`, and `formatVersion: 1` in the ontology file (R1 fails on any other value). A change that breaks existing ontologies raises both. A change that rejects only files without meaning (for example an empty name) is not a break. Adding the `permission.any_of` form is additive: existing files stay valid, so `formatVersion` stays 1. Adding `parameters` and the parameter and actor reads of the submission criteria (since renamed `conditions`) is additive: existing files stay valid, so `formatVersion` stays 1. Adding `values` on enum parameters is additive; `formatVersion` stays 1. Adding `deletes` is additive; `formatVersion` stays 1. Adding `projectedFrom` on ref parameters is additive; `formatVersion` stays 1.
150
161
  During 0.x the v1 draft changes in place (`formatVersion` 1, `$id` v1); the rule above applies from the 1.0 semantic freeze.
151
162
 
152
163
  ## 9. Decision semantics
@@ -155,7 +166,7 @@ Stage 1A; runtime consistency is Stage 1B. Code: `src/expr.ts` (AST, types, stat
155
166
 
156
167
  ### Expressions and two-level evaluation
157
168
 
158
- Every node is a single-key map. The reserved names `now`, `actor` and `self` are exported as `RESERVED_NAMES`. The node set is fixed (`EXPR_NODES`):
169
+ Every node is a single-key map. The reserved names `now`, `actor` and `self` are exported as `RESERVED_NAMES`. The node set is fixed (`EXPR_NODES`); section 1, "Limit of the expression language", says which new nodes can be admitted:
159
170
 
160
171
  | Level | Nodes | Result |
161
172
  |---|---|---|
@@ -220,7 +231,7 @@ Nullability comes from the facts and the parameters; only this analysis treats u
220
231
 
221
232
  - `fieldList` gives each field with `nullable` (a trailing `?` in the source). A field is non-null only when its object's field list says `nullable: false`. Unknown nullability is analysed as may-be-null: a field that is not listed, or an object without a field list (R2 reports both).
222
233
  - `now` and `actor.id` / `actor.keys` are never null. A quantifier variable or `self` is not null; its property `x.prop` may be null unless the field is non-null.
223
- - An optional parameter may be null, and so may every property read through it. A required parameter is not null (a missing one is `INVALID_BINDING`); its property may be null unless the field is non-null.
234
+ - An optional parameter may be null, and so may every property read through it. A required parameter is not null (a missing one, or a null projected one, is `INVALID_BINDING`); its property may be null unless the field is non-null.
224
235
 
225
236
  Counting: the analysis collects the distinct normalized paths that may be null and that the outer evaluation reads (`self` is replaced by the `of` of the derived property that is inlined). Reads only inside a quantifier are not counted, because a quantifier is T or F outside; a read that also occurs outside a quantifier is counted there. The `from` of a navigation is never counted: a null ref navigates to the empty set, not to U. With k paths, all 2^k combinations are evaluated; for k > 8 (`MAX_NULLABLE_REFS`) the condition is taken as may-be-U without enumeration. A quantifier body is analysed on its own with the same evaluator and the same counting, its variable added to the visible names; a quantifier nested in the body is again T or F there and is analysed on its own.
226
237
 
@@ -264,14 +275,24 @@ A request-shape check before `decide` (so before its shape step), permission and
264
275
 
265
276
  | Step | Result |
266
277
  |---|---|
267
- | shape (no snapshot read): undeclared parameter (first by sorted name), missing required parameter (absent or null, in declaration order), value not fitting its type, or an `enum` value not among the parameter's `values` | `INVALID_BINDING` with `unknown_param`, `missing`, `type` |
278
+ | shape (no snapshot read): undeclared parameter (first by sorted name), missing parameter (in declaration order: a required one that is absent or null, except a null projected parameter; a projected parameter that is absent, also when optional), value not fitting its type, or an `enum` value not among the parameter's `values` | `INVALID_BINDING` with `unknown_param`, `missing`, `type` |
268
279
  | permission: each alternative is denied (a key missing, or `principals: {kinds, cite}` whose `kinds` lacks `actor.kind`; the cite is not read), passed (all keys and conditional keys), or undecided (only conditional keys missing); the `{none}` form passes, or is denied when its `principals` `kinds` lacks `actor.kind` (never undecided) | `PERMISSION_DENIED`; INDETERMINATE `permission_condition` / `permission_unknown` |
269
280
  | envelope (idempotency) | `REPLAYED`, `IDEMPOTENCY_CONFLICT`; **not implemented by `decide`**, which has no idempotency key |
270
- | binding and scope: parameters are checked in declaration order, across all parameters (scalar ref and `{set: {ref: T}}`), and the first failing parameter gives the result; each ref parameter is looked up in its own type only; each element of a `{set: {ref: T}}` parameter is bound the same way, in array order, and the first failing element gives the result | `SCOPE_DENIED` (the id exists only outside the scope), `INVALID_BINDING` `not_found` (absent from the type, including ids of other types) |
281
+ | binding and scope: parameters are checked in declaration order, across all parameters (scalar ref and `{set: {ref: T}}`), and the first failing parameter gives the result; each ref parameter is looked up in its own type only; each element of a `{set: {ref: T}}` parameter is bound the same way, in array order, and the first failing element gives the result; a required projected parameter with the value null fails as `not_found` | `SCOPE_DENIED` (the id exists only outside the scope), `INVALID_BINDING` `not_found` (absent from the type, including ids of other types) |
271
282
  | decision: first-hit table | a domain result `{result, class, row}` or INDETERMINATE |
272
283
 
273
284
  The invocation results are a fixed set (`INVOCATION_RESULTS`), not declared by actions. A null optional ref binds; reading its properties gives null. Invalid input (unknown action, scope argument against the declaration, an ill-typed condition, an untyped parameter, a parameter named `now`, `actor` or `self`, a decision row naming an unknown condition, a decision row or `otherwise` whose `result` has no disposition, a type both global and root, a corrupt or incomplete snapshot) throws; it is never a result. A row without a field that evaluation reads (such as the scope field) is incomplete; a field with the value null is not.
274
285
 
286
+ Projected parameters. A `{ref: T}` parameter with `projectedFrom: [<field>, …]` (at least one field) is projected: it is not a field of the request. The project's projection reads those request fields (an alternate key, for example `source` and `external_ref`) and looks up the object of the parameter's ref type as the runtime looks it up: with the same scope rules (only in the caller's scope for a tenant or root type; everywhere for a global type) and the same soft delete filter. It never passes the id of an object outside the scope. When a key field is missing from the request, the projection omits the binding; when no object has the key, it passes null; else it passes the id. Null is not the same as an absent binding:
287
+
288
+ | Binding | Required parameter | Optional parameter |
289
+ |---|---|---|
290
+ | absent | `INVALID_BINDING` `missing` (shape) | `INVALID_BINDING` `missing` (shape) |
291
+ | null | `INVALID_BINDING` `not_found` (binding and scope), as for an id that does not resolve | an ordinary value, for example "no other object has this key", which a condition tests with `isNull` |
292
+ | id | bound as any ref parameter | bound as any ref parameter |
293
+
294
+ An absent binding is a valid input (the request lacks a key field); when the runtime gives another result, `reconcile` shows the difference as a mismatch. On any other parameter (another type, for example `{set: {ref: T}}`, or no field) R1 reports `projectedFrom` and `decide` ignores it: the parameter behaves as without `projectedFrom`. The format does not read the fields or their values; it has no alternate-key model.
295
+
275
296
  ### Decision table and INDETERMINATE
276
297
 
277
298
  `passes(c) := eval(c) = T`; `fails(c) := eval(c) != T`. Rows are tried in order; the first hit gives `{layer: domain, result, class, row, next?}`, with `class` from `dispositions`. Only the rows actually reached matter. INDETERMINATE is neither an invocation result nor a domain result, and is never filled in as rejected or applied. Its reasons:
@@ -332,6 +353,8 @@ The physical type mapping is checked here: the profile supplies `decode`, which
332
353
 
333
354
  `Invocation.disposition` stays the optional Stage 0 field of the fact contract. `reconcile` does not read it.
334
355
 
356
+ The command line reads and writes these types as JSON lines, one `RuntimeCase` or one `ReconcileRow` per line (`avouch reconcile`, `avouch gate verify`; README, "Runtime consistency on the command line"). A `Value` is a JSON string, number, boolean, null or array. An optional field that is absent (`reason`, `detail`, `row`, a binding that is not given) is an absent key; a binding with the value null is `null`. The coverage rule of `avouch gate verify` is a rule of the tool, not of this format.
357
+
335
358
  ### Existence is not revealed
336
359
 
337
360
  `SCOPE_DENIED` is internal. `externalResult` maps it and `INVALID_BINDING` `not_found` to one value, `{result: INVALID_BINDING, param, reason: not_found}`, so a caller cannot learn that an id exists in another tenant. Other reasons, such as `type`, are reported as they are. A runtime must not report `SCOPE_DENIED`; `reconcile` gives `runtime_unmapped` for it. A scenario must not expect it either; `reconcile` gives `expectation_unmapped` for it (step 7).
@@ -339,3 +362,5 @@ The physical type mapping is checked here: the profile supplies `decode`, which
339
362
  ### Known limit
340
363
 
341
364
  `dateIn` uses the time zone data of the runtime. Near a time zone rule change the evaluator and another runtime with a different tzdata version may give different dates.
365
+
366
+ An optional projected parameter carries a fact that the project computes from the same database as the runtime, for example the object that already holds a unique key. Avouch checks the decision given that fact, not whether the fact is right. When the projection and the runtime are wrong in the same way (for example the projection passes null and the runtime has lost its unique index), they agree and `reconcile` gives `match`.
@@ -2,7 +2,9 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { CONFIG_FILE, runCheck } from "./check.js";
5
+ import { GATE_USAGE, runGate } from "./gate.js";
5
6
  import { QUERY_USAGE, runQuery } from "./query.js";
7
+ import { RECONCILE_USAGE, runReconcile } from "./reconcile.js";
6
8
  const CHECK_USAGE = "usage: avouch check <ontology.yaml> [--adapter <module|config.json>]";
7
9
  async function check(args) {
8
10
  let adapter;
@@ -38,8 +40,14 @@ async function main() {
38
40
  return check(args.slice(1));
39
41
  if (args[0] === "query")
40
42
  return runQuery(args.slice(1));
43
+ if (args[0] === "reconcile")
44
+ return runReconcile(args.slice(1));
45
+ if (args[0] === "gate")
46
+ return runGate(args.slice(1));
41
47
  console.error(CHECK_USAGE);
42
48
  console.error(QUERY_USAGE);
49
+ console.error(RECONCILE_USAGE);
50
+ console.error(GATE_USAGE);
43
51
  return 2;
44
52
  }
45
53
  try {
@@ -0,0 +1,2 @@
1
+ export declare const GATE_USAGE = "usage: avouch gate verify <ontology.yaml> --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--adapter <module|config.json>]";
2
+ export declare function runGate(args: string[]): Promise<number>;
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { verifyCoverage } from "../src/coverage.js";
4
+ import { runCheck } from "./check.js";
5
+ import { readJsonLines } from "./jsonl.js";
6
+ import { parseOptions, readOntology } from "./reconcile.js";
7
+ export const GATE_USAGE = "usage: avouch gate verify <ontology.yaml> --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--adapter <module|config.json>]";
8
+ const unitText = (row) => (row === "otherwise" ? "otherwise" : `rows.${row}`);
9
+ function text(check, r, pass) {
10
+ const lines = [];
11
+ if (check.status === "fail")
12
+ lines.push(`FAIL check: ${check.schemaErrors} schema error(s), ${check.violations} violation(s)`);
13
+ for (const u of r.uncovered)
14
+ lines.push(`FAIL uncovered ${u.action}:${unitText(u.row)}`);
15
+ for (const g of r.unexempted)
16
+ lines.push(`FAIL unexempted ${g.action}:${g.condition}`);
17
+ for (const g of r.unreasoned)
18
+ lines.push(`FAIL unreasoned ${g.action}:${g.condition}`);
19
+ for (const s of r.stale)
20
+ lines.push(s.list === "uncovered" ? `FAIL stale uncovered ${s.action}:${unitText(s.row)} ${s.why}` : `FAIL stale exempt ${s.action}:${s.condition} ${s.why}`);
21
+ for (const f of r.failed)
22
+ lines.push(`FAIL case ${f.id} ${f.action} ${f.why}`);
23
+ lines.push(`INFO check ${check.status}`);
24
+ for (const b of r.blocked)
25
+ lines.push(`INFO blocked ${b.action}:${unitText(b.row)} by ${b.condition}`);
26
+ for (const a of r.noTable)
27
+ lines.push(`INFO no_table ${a}`);
28
+ for (const c of r.unasserted)
29
+ lines.push(`INFO unasserted ${c.id} ${c.action}`);
30
+ lines.push(`${pass ? "PASS" : "FAIL"}: ${r.units} unit(s), ${r.verified.length} verified, ${r.baselined.length} baselined, ${r.blocked.length} blocked, ` +
31
+ `${r.uncovered.length} uncovered; ${r.unexempted.length} unexempted, ${r.unreasoned.length} unreasoned, ${r.stale.length} stale, ` +
32
+ `${r.failed.length} failed case(s), ${r.unasserted.length} unasserted`);
33
+ return lines.join("\n") + "\n";
34
+ }
35
+ async function verify(args) {
36
+ const { pos, opts } = parseOptions(args, ["--rows", "--baseline", "--adapter"], ["--json"]);
37
+ if (pos.length !== 1)
38
+ throw new Error(`expected 1 argument, found ${pos.length}`);
39
+ const rowsFile = opts.get("--rows");
40
+ if (typeof rowsFile !== "string")
41
+ throw new Error("--rows is required");
42
+ const baselineFile = opts.get("--baseline");
43
+ const adapter = opts.get("--adapter");
44
+ const ont = readOntology(pos[0]);
45
+ const rows = readJsonLines(rowsFile);
46
+ let baseline = null;
47
+ if (baselineFile !== undefined) {
48
+ const raw = readFileSync(resolve(baselineFile), "utf8");
49
+ try {
50
+ baseline = JSON.parse(raw);
51
+ }
52
+ catch (error) {
53
+ throw new Error(`${baselineFile}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ }
56
+ let result;
57
+ try {
58
+ result = verifyCoverage(ont, rows, baseline);
59
+ }
60
+ catch (error) {
61
+ const msg = error instanceof Error ? error.message : String(error);
62
+ throw new Error(`${msg.startsWith("baseline:") ? baselineFile : msg.startsWith("ontology:") ? pos[0] : rowsFile}: ${msg}`);
63
+ }
64
+ let check = { status: "not_run" };
65
+ if (adapter !== undefined) {
66
+ const { schemaErrors, report } = await runCheck(pos[0], adapter);
67
+ const pass = schemaErrors.length === 0 && report.violations.length === 0;
68
+ check = { status: pass ? "pass" : "fail", adapter, schemaErrors: schemaErrors.length, violations: report.violations.length, waived: report.waived.length };
69
+ }
70
+ const pass = result.ok && check.status !== "fail";
71
+ process.stdout.write(opts.get("--json") === true ? `${JSON.stringify({ avouchGate: 1, gate: "verify", check, result }, null, 2)}\n` : text(check, result, pass));
72
+ return pass ? 0 : 1;
73
+ }
74
+ // Exit 0: the gate passes; 1: it fails; 2: a usage, file, parse or shape error, or an adapter error (one stderr line).
75
+ export async function runGate(args) {
76
+ try {
77
+ if (args.length === 0)
78
+ throw new Error("missing gate");
79
+ if (args[0] !== "verify")
80
+ throw new Error(`unknown gate ${args[0]}`);
81
+ return await verify(args.slice(1));
82
+ }
83
+ catch (error) {
84
+ const msg = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ");
85
+ console.error(`${msg}; ${GATE_USAGE}`);
86
+ return 2;
87
+ }
88
+ }
@@ -0,0 +1,5 @@
1
+ export declare function jsonLines(file: string): {
2
+ line: number;
3
+ value: unknown;
4
+ }[];
5
+ export declare function readJsonLines(file: string): unknown[];
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ // One JSON value per line, with its line number (from 1). Lines that are empty or only white space are skipped; the
3
+ // line numbers count them.
4
+ export function jsonLines(file) {
5
+ return readFileSync(file, "utf8")
6
+ .split("\n")
7
+ .flatMap((text, i) => {
8
+ if (text.trim() === "")
9
+ return [];
10
+ try {
11
+ return [{ line: i + 1, value: JSON.parse(text) }];
12
+ }
13
+ catch (error) {
14
+ throw new Error(`${file}:${i + 1}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
15
+ }
16
+ });
17
+ }
18
+ export function readJsonLines(file) {
19
+ return jsonLines(file).map((x) => x.value);
20
+ }
@@ -0,0 +1,7 @@
1
+ export declare const RECONCILE_USAGE = "usage: avouch reconcile <ontology.yaml> --cases <cases.jsonl>";
2
+ export declare function parseOptions(args: string[], values: readonly string[], flags: readonly string[]): {
3
+ pos: string[];
4
+ opts: Map<string, string | true>;
5
+ };
6
+ export declare function readOntology(file: string): Record<string, unknown>;
7
+ export declare function runReconcile(args: string[]): Promise<number>;
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { isObj } from "../src/expr.js";
5
+ import { reconcile } from "../src/reconcile.js";
6
+ import { jsonLines } from "./jsonl.js";
7
+ export const RECONCILE_USAGE = "usage: avouch reconcile <ontology.yaml> --cases <cases.jsonl>";
8
+ const REASONS = ["missing", "unknown_param", "type", "not_found"];
9
+ // Options in any position. Returns the positional arguments and the value of each option.
10
+ export function parseOptions(args, values, flags) {
11
+ const pos = [];
12
+ const opts = new Map();
13
+ for (let i = 0; i < args.length; i++) {
14
+ const a = args[i];
15
+ if (opts.has(a))
16
+ throw new Error(`repeated option ${a}`);
17
+ if (flags.includes(a))
18
+ opts.set(a, true);
19
+ else if (values.includes(a)) {
20
+ if (i + 1 >= args.length)
21
+ throw new Error(`${a} needs a file`);
22
+ opts.set(a, args[++i]);
23
+ }
24
+ else if (a.startsWith("--"))
25
+ throw new Error(`unknown option ${a}`);
26
+ else
27
+ pos.push(a);
28
+ }
29
+ return { pos, opts };
30
+ }
31
+ export function readOntology(file) {
32
+ const ont = parse(readFileSync(resolve(file), "utf8"));
33
+ if (!isObj(ont))
34
+ throw new Error(`${file}: the root is not a mapping`);
35
+ return ont;
36
+ }
37
+ // The shape of a RuntimeCase (SPEC section 9). Extra keys are allowed; the snapshot content is not checked.
38
+ function checkCase(x) {
39
+ if (!isObj(x))
40
+ return "case is not an object";
41
+ const c = x;
42
+ if (typeof c.id !== "string" || c.id === "")
43
+ return "id is not a non-empty string";
44
+ if (typeof c.action !== "string" || c.action === "")
45
+ return "action is not a non-empty string";
46
+ if (!Object.hasOwn(c, "snapshot"))
47
+ return "snapshot is missing";
48
+ if (c.snapshot !== null && !isObj(c.snapshot))
49
+ return "snapshot is not null or an object";
50
+ const i = c.inputs;
51
+ if (!isObj(i))
52
+ return "inputs is not an object";
53
+ if (!isObj(i.bindings))
54
+ return "inputs.bindings is not an object";
55
+ if (!isObj(i.actor))
56
+ return "inputs.actor is not an object";
57
+ if (typeof i.actor.id !== "string")
58
+ return "inputs.actor.id is not a string";
59
+ if (typeof i.actor.kind !== "string")
60
+ return "inputs.actor.kind is not a string";
61
+ if (!Array.isArray(i.actor.keys) || !i.actor.keys.every((k) => typeof k === "string"))
62
+ return "inputs.actor.keys is not a list of strings";
63
+ if (i.scope !== null && typeof i.scope !== "string")
64
+ return "inputs.scope is not a string or null";
65
+ if (typeof i.now !== "number" || !Number.isFinite(i.now))
66
+ return "inputs.now is not a finite number";
67
+ for (const k of ["reported", "expected"]) {
68
+ if (!Object.hasOwn(c, k))
69
+ return `${k} is missing`;
70
+ const r = c[k];
71
+ if (r === null)
72
+ continue;
73
+ if (!isObj(r))
74
+ return `${k} is not null or an object`;
75
+ if (typeof r.result !== "string" || r.result === "")
76
+ return `${k}.result is not a non-empty string`;
77
+ if (r.reason !== undefined && !REASONS.includes(r.reason))
78
+ return `${k}.reason is not one of ${REASONS.join(", ")}`;
79
+ }
80
+ return null;
81
+ }
82
+ async function main(args) {
83
+ const { pos, opts } = parseOptions(args, ["--cases"], []);
84
+ if (pos.length !== 1)
85
+ throw new Error(`expected 1 argument, found ${pos.length}`);
86
+ const cases = opts.get("--cases");
87
+ if (typeof cases !== "string")
88
+ throw new Error("--cases is required");
89
+ const ont = readOntology(pos[0]);
90
+ const lines = jsonLines(cases);
91
+ for (const { line, value } of lines) {
92
+ const problem = checkCase(value);
93
+ if (problem !== null)
94
+ throw new Error(`${cases}:${line}: ${problem}`);
95
+ }
96
+ const rows = reconcile(ont, lines.map((x) => x.value));
97
+ process.stdout.write(rows.map((r) => `${JSON.stringify(r)}\n`).join(""));
98
+ return 0;
99
+ }
100
+ // Exit 0: one row per case (the command does not judge the rows); 2: a usage, file, parse or shape error (one stderr line).
101
+ export async function runReconcile(args) {
102
+ try {
103
+ return await main(args);
104
+ }
105
+ catch (error) {
106
+ const msg = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ");
107
+ console.error(`${msg}; ${RECONCILE_USAGE}`);
108
+ return 2;
109
+ }
110
+ }
@@ -1,7 +1,7 @@
1
1
  // Format-level ontology checker (rules R1–R12). Pure functions: no I/O. Project facts arrive through SourceFacts (contract.ts).
2
2
  // Fail closed: a missing anchor, a parse failure or a missing field is a violation, never a skip.
3
3
  import { IDENT, RULES } from "./contract.js";
4
- import { derivedPropertyType, isObj, newReads } from "./expr.js";
4
+ import { derivedPropertyType, isObj, newReads, projectedFields } from "./expr.js";
5
5
  import { analyzeConditions, asReads, parseScope, quotesOf, stage1Violations } from "./stage1-check.js";
6
6
  export { IDENT };
7
7
  const CLASSES = ["canonical", "derived", "policy", "unknown"];
@@ -284,6 +284,9 @@ export function check(ontology, facts) {
284
284
  const vs = isObj(pv) ? pv.values : undefined;
285
285
  if (vs !== undefined && !(pv.type === "enum" && Array.isArray(vs) && vs.length > 0 && vs.every(isStr) && new Set(vs).size === vs.length))
286
286
  v("R1", `${p}.parameters.${n}`, "values needs type enum and a non-empty list of distinct non-empty strings");
287
+ const pf = isObj(pv) ? pv.projectedFrom : undefined;
288
+ if (pf !== undefined && !(isObj(pv.type) && isStr(pv.type.ref) && Array.isArray(pf) && pf.length > 0 && pf.every(isStr) && new Set(pf).size === pf.length))
289
+ v("R1", `${p}.parameters.${n}`, "projectedFrom needs type {ref: <Object>} and a non-empty list of distinct non-empty field names");
287
290
  }
288
291
  const pids = new Set();
289
292
  for (const [i, pc] of arr(c.conditions).entries()) {
@@ -429,7 +432,14 @@ export function check(ontology, facts) {
429
432
  for (const [n, pv] of Object.entries(isObj(c.parameters) ? c.parameters : {})) {
430
433
  if (!isObj(pv) || !isStr(pv.cite?.quote))
431
434
  continue;
432
- if (!IDENT(n).test(pv.cite.quote))
435
+ // A projected parameter is not a request field: its quote names the fields it is projected from.
436
+ const fields = projectedFields(pv);
437
+ if (fields !== null) {
438
+ for (const f of fields)
439
+ if (!IDENT(f).test(pv.cite.quote))
440
+ v("R2", `${cid}:${n}:${f}`, `parameter ${n} is projected from ${f}, which does not appear in its cite quote`, "projection_field_not_in_quote");
441
+ }
442
+ else if (!IDENT(n).test(pv.cite.quote))
433
443
  v("R2", `${cid}:${n}`, `parameter ${n} does not appear in its cite quote`, "parameter_not_in_quote");
434
444
  for (const x of Array.isArray(pv.values) ? pv.values.filter(isStr) : [])
435
445
  if (!IDENT(x).test(pv.cite.quote))
@@ -0,0 +1,65 @@
1
+ import type { ReconcileRow } from "./reconcile.js";
2
+ /** One coverage unit: a row of a decision table (index from 0) or its otherwise. Key text: `<action>:rows.<i>` / `<action>:otherwise`. */
3
+ export type CoverageUnit = {
4
+ action: string;
5
+ row: number | "otherwise";
6
+ };
7
+ /** A unit with the `when` and `result` of its row, so that an entry for a row that moved is stale. */
8
+ export type CoverageDebt = CoverageUnit & {
9
+ when: {
10
+ passes: string;
11
+ } | {
12
+ fails: string;
13
+ } | null;
14
+ result: string;
15
+ };
16
+ /** A condition that a decision row names and that is declared without an expression. */
17
+ export type CoverageGap = {
18
+ action: string;
19
+ condition: string;
20
+ kind: "unspecified";
21
+ };
22
+ export type CoverageExemption = CoverageGap & {
23
+ reason?: string;
24
+ };
25
+ export type CoverageBaseline = {
26
+ avouchCoverage: 1;
27
+ uncovered: CoverageDebt[];
28
+ exempt: CoverageExemption[];
29
+ };
30
+ export type CoverageStale = {
31
+ list: "uncovered";
32
+ action: string;
33
+ row: number | "otherwise";
34
+ why: "duplicate" | "no_such_row" | "moved" | "verified" | "blocked";
35
+ } | {
36
+ list: "exempt";
37
+ action: string;
38
+ condition: string;
39
+ why: "duplicate" | "not_a_gap";
40
+ };
41
+ export type CoverageFailure = {
42
+ id: string;
43
+ action: string;
44
+ why: "mismatch" | "action_unknown" | "row_unknown" | "no_row_matched" | "runtime_missing" | "runtime_unmapped" | "expectation_unmapped" | "snapshot_missing" | "evaluator_error";
45
+ };
46
+ export interface CoverageResult {
47
+ ok: boolean;
48
+ units: number;
49
+ verified: CoverageUnit[];
50
+ baselined: CoverageUnit[];
51
+ blocked: (CoverageUnit & {
52
+ condition: string;
53
+ })[];
54
+ noTable: string[];
55
+ unasserted: {
56
+ id: string;
57
+ action: string;
58
+ }[];
59
+ uncovered: CoverageDebt[];
60
+ unexempted: CoverageGap[];
61
+ unreasoned: CoverageGap[];
62
+ stale: CoverageStale[];
63
+ failed: CoverageFailure[];
64
+ }
65
+ export declare function verifyCoverage(ont: unknown, rows: readonly ReconcileRow[], baseline: CoverageBaseline | null): CoverageResult;
@@ -0,0 +1,229 @@
1
+ // Decision-row coverage of reconcile rows (`avouch gate verify`). A rule of the tool, not of the format. A pure function:
2
+ // it reads only its arguments, and the order of each output list is fixed.
3
+ import { isObj } from "./expr.js";
4
+ import { queryAction } from "./query.js";
5
+ // Which reasons of cannot_reconcile fail the gate. A replay is not a decision; a case without an expectation states
6
+ // nothing to compare; INDETERMINATE does not fail (except no_row_matched: see below), and the units that it leaves
7
+ // unverified stay uncovered or blocked, so the gate still sees them.
8
+ const FAILS = {
9
+ envelope: false,
10
+ expectation_missing: false,
11
+ indeterminate: false,
12
+ runtime_missing: true,
13
+ runtime_unmapped: true,
14
+ expectation_unmapped: true,
15
+ snapshot_missing: true,
16
+ evaluator_error: true,
17
+ };
18
+ const sortStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
19
+ const show = (x) => JSON.stringify(x) ?? String(x);
20
+ const isRow = (x) => x === "otherwise" || (typeof x === "number" && Number.isSafeInteger(x) && x >= 0);
21
+ const sameWhen = (a, b) => JSON.stringify(a) === JSON.stringify(b);
22
+ function checkKeys(o, path, required, allowed) {
23
+ for (const k of required)
24
+ if (!Object.hasOwn(o, k))
25
+ throw new Error(`baseline: ${path}${k}: missing`);
26
+ for (const k of Object.keys(o))
27
+ if (!allowed.includes(k))
28
+ throw new Error(`baseline: ${path}${k}: unknown key`);
29
+ }
30
+ function checkRows(rows) {
31
+ if (!Array.isArray(rows))
32
+ throw new Error("rows: not an array");
33
+ rows.forEach((x, i) => {
34
+ const at = `rows[${i}]:`;
35
+ if (!isObj(x))
36
+ throw new Error(`${at} not an object`);
37
+ if (typeof x.id !== "string")
38
+ throw new Error(`${at} id is not a string`);
39
+ if (typeof x.action !== "string")
40
+ throw new Error(`${at} action is not a string`);
41
+ if (x.status === "match" || x.status === "mismatch") {
42
+ if (x.row !== undefined && !isRow(x.row))
43
+ throw new Error(`${at} row ${show(x.row)} is not a row index or "otherwise"`);
44
+ }
45
+ else if (x.status === "cannot_reconcile") {
46
+ if (typeof x.reason !== "string" || !Object.hasOwn(FAILS, x.reason))
47
+ throw new Error(`${at} reason ${show(x.reason)} is not a reason of cannot_reconcile`);
48
+ }
49
+ else
50
+ throw new Error(`${at} status ${show(x.status)} is not match, mismatch or cannot_reconcile`);
51
+ });
52
+ }
53
+ function checkBaseline(b) {
54
+ if (!isObj(b))
55
+ throw new Error("baseline: not an object");
56
+ checkKeys(b, "", ["avouchCoverage", "uncovered", "exempt"], ["avouchCoverage", "uncovered", "exempt"]);
57
+ if (b.avouchCoverage !== 1)
58
+ throw new Error(`baseline: avouchCoverage: ${show(b.avouchCoverage)} is not 1`);
59
+ if (!Array.isArray(b.uncovered))
60
+ throw new Error("baseline: uncovered: not an array");
61
+ if (!Array.isArray(b.exempt))
62
+ throw new Error("baseline: exempt: not an array");
63
+ b.uncovered.forEach((x, i) => {
64
+ const at = `uncovered[${i}]`;
65
+ if (!isObj(x))
66
+ throw new Error(`baseline: ${at}: not an object`);
67
+ checkKeys(x, `${at}.`, ["action", "row", "when", "result"], ["action", "row", "when", "result"]);
68
+ if (typeof x.action !== "string")
69
+ throw new Error(`baseline: ${at}.action: not a string`);
70
+ if (!isRow(x.row))
71
+ throw new Error(`baseline: ${at}.row: ${show(x.row)} is not a row index or "otherwise"`);
72
+ const w = x.when;
73
+ const when = w === null || (isObj(w) && Object.keys(w).length === 1 && (typeof w.passes === "string" || typeof w.fails === "string"));
74
+ if (!when)
75
+ throw new Error(`baseline: ${at}.when: not null, {passes: <condition>} or {fails: <condition>}`);
76
+ if (typeof x.result !== "string")
77
+ throw new Error(`baseline: ${at}.result: not a string`);
78
+ });
79
+ b.exempt.forEach((x, i) => {
80
+ const at = `exempt[${i}]`;
81
+ if (!isObj(x))
82
+ throw new Error(`baseline: ${at}: not an object`);
83
+ checkKeys(x, `${at}.`, ["action", "condition", "kind"], ["action", "condition", "kind", "reason"]);
84
+ if (typeof x.action !== "string")
85
+ throw new Error(`baseline: ${at}.action: not a string`);
86
+ if (typeof x.condition !== "string")
87
+ throw new Error(`baseline: ${at}.condition: not a string`);
88
+ if (x.kind !== "unspecified")
89
+ throw new Error(`baseline: ${at}.kind: ${show(x.kind)} is not supported by this version`);
90
+ if (x.reason !== undefined && typeof x.reason !== "string")
91
+ throw new Error(`baseline: ${at}.reason: not a string`);
92
+ });
93
+ }
94
+ // Coverage of the decision rows of every action of the ontology by the match rows. baseline null = both lists empty.
95
+ // Throws for an ontology without an actionTypes object, or rows or a baseline with a wrong shape (the message starts
96
+ // with "ontology:", "rows" or "baseline:").
97
+ export function verifyCoverage(ont, rows, baseline) {
98
+ if (!isObj(ont) || !isObj(ont.actionTypes))
99
+ throw new Error("ontology: actionTypes is not an object");
100
+ const actionTypes = ont.actionTypes;
101
+ checkRows(rows);
102
+ if (baseline !== null)
103
+ checkBaseline(baseline);
104
+ const base = baseline ?? { avouchCoverage: 1, uncovered: [], exempt: [] };
105
+ // 1. Units and gaps. A gap: a condition that a row names (as its own or as an earlier row's) and that has no expression.
106
+ const units = [];
107
+ const unitOf = new Map();
108
+ const gaps = [];
109
+ const noTable = [];
110
+ const key = (action, x) => JSON.stringify([action, x]);
111
+ for (const action of Object.keys(actionTypes).sort(sortStr)) {
112
+ const q = queryAction(ont, action);
113
+ if (q === null)
114
+ continue;
115
+ if (q.decision === null) {
116
+ noTable.push(action);
117
+ continue;
118
+ }
119
+ const isGap = (c) => q.conditions.find((x) => x.id === c)?.expr === null;
120
+ const named = new Set();
121
+ for (const r of q.decision.rows) {
122
+ const u = { action, q: r, gap: r.requires.find((x) => isGap(x.condition))?.condition ?? null };
123
+ units.push(u);
124
+ unitOf.set(key(action, r.row), u);
125
+ for (const x of r.requires)
126
+ if (isGap(x.condition))
127
+ named.add(x.condition);
128
+ }
129
+ for (const condition of [...named].sort(sortStr))
130
+ gaps.push({ action, condition, kind: "unspecified" });
131
+ }
132
+ const gapKeys = new Set(gaps.map((g) => key(g.action, g.condition)));
133
+ // 2. Case rows, in input order.
134
+ const verified = new Set();
135
+ const failed = [];
136
+ const unasserted = [];
137
+ for (const r of rows) {
138
+ const head = { id: r.id, action: r.action };
139
+ if (!Object.hasOwn(actionTypes, r.action))
140
+ failed.push({ ...head, why: "action_unknown" });
141
+ else if (r.status === "mismatch")
142
+ failed.push({ ...head, why: "mismatch" });
143
+ else if (r.status === "cannot_reconcile") {
144
+ if (r.reason === "indeterminate" && r.detail === "no_row_matched")
145
+ failed.push({ ...head, why: "no_row_matched" });
146
+ else if (FAILS[r.reason])
147
+ failed.push({ ...head, why: r.reason });
148
+ else if (r.reason === "expectation_missing")
149
+ unasserted.push(head);
150
+ }
151
+ else if (r.row !== undefined) {
152
+ // a match on a row: the unit must exist and give the same result
153
+ const u = unitOf.get(key(r.action, r.row));
154
+ const result = isObj(r.result) ? r.result.result : undefined;
155
+ if (u === undefined || u.q.result !== result)
156
+ failed.push({ ...head, why: "row_unknown" });
157
+ else
158
+ verified.add(u);
159
+ }
160
+ }
161
+ // 3. Exemptions, in baseline order.
162
+ const stale = [];
163
+ const exempted = new Set();
164
+ const unreasonedKeys = new Set();
165
+ const seenExempt = new Set();
166
+ for (const e of base.exempt) {
167
+ const k = key(e.action, e.condition);
168
+ const out = { list: "exempt", action: e.action, condition: e.condition };
169
+ if (seenExempt.has(k))
170
+ stale.push({ ...out, why: "duplicate" });
171
+ else if (!gapKeys.has(k))
172
+ stale.push({ ...out, why: "not_a_gap" });
173
+ else {
174
+ exempted.add(k);
175
+ if (e.reason === undefined || e.reason.trim() === "")
176
+ unreasonedKeys.add(k);
177
+ }
178
+ seenExempt.add(k);
179
+ }
180
+ // 4. Uncovered entries, in baseline order. The baseline must have no dead entries.
181
+ const owed = new Set();
182
+ const seenDebt = new Set();
183
+ for (const d of base.uncovered) {
184
+ const k = key(d.action, d.row);
185
+ const u = unitOf.get(k);
186
+ const out = { list: "uncovered", action: d.action, row: d.row };
187
+ if (seenDebt.has(k))
188
+ stale.push({ ...out, why: "duplicate" });
189
+ else if (u === undefined)
190
+ stale.push({ ...out, why: "no_such_row" });
191
+ else if (!sameWhen(d.when, u.q.when) || d.result !== u.q.result)
192
+ stale.push({ ...out, why: "moved" });
193
+ else if (verified.has(u))
194
+ stale.push({ ...out, why: "verified" });
195
+ else if (u.gap !== null)
196
+ stale.push({ ...out, why: "blocked" });
197
+ else
198
+ owed.add(u);
199
+ seenDebt.add(k);
200
+ }
201
+ // 5. Each unit, in unit order.
202
+ const result = {
203
+ ok: false,
204
+ units: units.length,
205
+ verified: [],
206
+ baselined: [],
207
+ blocked: [],
208
+ noTable,
209
+ unasserted,
210
+ uncovered: [],
211
+ unexempted: gaps.filter((g) => !exempted.has(key(g.action, g.condition))),
212
+ unreasoned: gaps.filter((g) => unreasonedKeys.has(key(g.action, g.condition))),
213
+ stale,
214
+ failed,
215
+ };
216
+ for (const u of units) {
217
+ const id = { action: u.action, row: u.q.row };
218
+ if (verified.has(u))
219
+ result.verified.push(id);
220
+ else if (u.gap !== null)
221
+ result.blocked.push({ ...id, condition: u.gap });
222
+ else if (owed.has(u))
223
+ result.baselined.push(id);
224
+ else
225
+ result.uncovered.push({ ...id, when: u.q.when, result: u.q.result });
226
+ }
227
+ result.ok = [result.uncovered, result.unexempted, result.unreasoned, result.stale, result.failed].every((x) => x.length === 0);
228
+ return result;
229
+ }
@@ -1,7 +1,7 @@
1
1
  // Decision evaluation: evalValue / evalPredicate (two levels, Kleene logic) and decide (invocation layer, then decision table).
2
2
  // Deterministic: no wall clock, no randomness, no global mutable state; `now` comes from the caller.
3
3
  // Invalid input (ontology, snapshot, scope argument) throws; it is never a result.
4
- import { checkPredicate, fitsType, own, RESERVED_NAMES, TIMESTAMP_MAX, TIMESTAMP_MIN } from "./expr.js";
4
+ import { checkPredicate, fitsType, own, projectedFields, RESERVED_NAMES, TIMESTAMP_MAX, TIMESTAMP_MIN } from "./expr.js";
5
5
  // The fixed invocation result set of the format (defined in ./expr). decide never returns the first two (the idempotency
6
6
  // envelope is not implemented here: it has no idempotency key).
7
7
  export { INVOCATION_RESULTS } from "./expr.js";
@@ -347,7 +347,9 @@ function admit(ont, actionId, bindings, actor) {
347
347
  for (const [name, p] of Object.entries(params)) {
348
348
  const v = own(bindings, name);
349
349
  if (v === null || v === undefined) {
350
- if (p.optional === true)
350
+ // A projected ref parameter is always given: null means the projection found nothing (not_found at the binding step).
351
+ const projected = projectedFields(p) !== null;
352
+ if (v === null ? p.optional === true || projected : p.optional === true && !projected)
351
353
  continue;
352
354
  return { layer: "invocation", result: "INVALID_BINDING", param: name, reason: "missing" };
353
355
  }
@@ -380,6 +382,8 @@ function resolve(s, actionId) {
380
382
  if (typeof elem === "string" || !("ref" in elem))
381
383
  continue;
382
384
  const v = own(s.env.bindings, name);
385
+ if (v === null && p.optional !== true && projectedFields(p) !== null)
386
+ return { layer: "invocation", result: "INVALID_BINDING", param: name, reason: "not_found" };
383
387
  if (v === null || v === undefined)
384
388
  continue;
385
389
  for (const id of isSet ? v : [v]) {
@@ -116,11 +116,16 @@ export type ActionType = {
116
116
  type?: Type;
117
117
  optional?: true;
118
118
  values?: string[];
119
+ projectedFrom?: string[];
119
120
  }>;
120
121
  permission: Permission;
121
122
  conditions: Condition[];
122
123
  decision?: Decision;
123
124
  };
125
+ export declare function projectedFields(p: {
126
+ type?: unknown;
127
+ projectedFrom?: unknown;
128
+ }): string[] | null;
124
129
  export interface Stage1Ontology {
125
130
  scope?: {
126
131
  by: string;
package/dist/src/expr.js CHANGED
@@ -11,6 +11,12 @@ export const DISPOSITION_CLASSES = ["rejected", "recorded", "pending", "applied"
11
11
  // not be one of these names.
12
12
  // Every invocation-layer result, SCOPE_DENIED included. SCOPE_DENIED is internal and never reported (SPEC section 9).
13
13
  export const INVOCATION_RESULTS = ["REPLAYED", "IDEMPOTENCY_CONFLICT", "INVALID_BINDING", "SCOPE_DENIED", "PERMISSION_DENIED"];
14
+ // The distinct request fields of a projected parameter, or null: only a scalar {ref: T} parameter with at least one field is
15
+ // projected; on any other parameter projectedFrom has no effect.
16
+ export function projectedFields(p) {
17
+ const fs = Array.isArray(p.projectedFrom) ? [...new Set(p.projectedFrom.filter((f) => typeof f === "string"))] : [];
18
+ return isObj(p.type) && typeof p.type.ref === "string" && fs.length > 0 ? fs : null;
19
+ }
14
20
  export class ExprError extends Error {
15
21
  path;
16
22
  constructor(path, msg) {
@@ -7,3 +7,4 @@ export * from "./patch.js";
7
7
  export * from "./query.js";
8
8
  export * from "./markdown.js";
9
9
  export * from "./unknown.js";
10
+ export * from "./coverage.js";
package/dist/src/index.js CHANGED
@@ -7,3 +7,4 @@ export * from "./patch.js";
7
7
  export * from "./query.js";
8
8
  export * from "./markdown.js";
9
9
  export * from "./unknown.js";
10
+ export * from "./coverage.js";
@@ -142,6 +142,7 @@ export type QueryAction = {
142
142
  type: Type | null;
143
143
  optional: boolean;
144
144
  values: string[] | null;
145
+ projectedFrom: string[] | null;
145
146
  cite: QueryCite | null;
146
147
  }[];
147
148
  conditions: {
package/dist/src/query.js CHANGED
@@ -330,6 +330,7 @@ export function queryAction(ont, id) {
330
330
  type: typeOf(map(p).type),
331
331
  optional: map(p).optional === true,
332
332
  values: Array.isArray(map(p).values) ? map(p).values.filter((x) => typeof x === "string") : null,
333
+ projectedFrom: Array.isArray(map(p).projectedFrom) ? map(p).projectedFrom.filter((x) => typeof x === "string") : null,
333
334
  cite: citeOf(map(p).cite),
334
335
  })),
335
336
  conditions: conditionsOf(a).map((c) => ({
@@ -673,7 +673,7 @@
673
673
  "type": "object",
674
674
  "propertyNames": { "minLength": 1 },
675
675
  "additionalProperties": { "$ref": "#/$defs/parameter" },
676
- "description": "Optional action parameters: parameter name → {cite}. Foundation: Palantir action parameters (\"Parameters are the inputs of an action type.\"). A condition reads a parameter as `{param: <name>}` or, in an expression, as `{ref: <name>}`. R1: must be a mapping; each entry carries a cite. R2: the cite quote must occur verbatim, and the parameter name must appear in it (R2 `parameter_not_in_quote`, key `<ID>:<name>`), and so must each of its `values` (R2 `parameter_value_not_in_quote`, key `<ID>:<name>:<value>`). A declared parameter that no condition reads is not a failure."
676
+ "description": "Optional action parameters: parameter name → {cite}. Foundation: Palantir action parameters (\"Parameters are the inputs of an action type.\"). A condition reads a parameter as `{param: <name>}` or, in an expression, as `{ref: <name>}`. R1: must be a mapping; each entry carries a cite. R2: the cite quote must occur verbatim, and the parameter name must appear in it (R2 `parameter_not_in_quote`, key `<ID>:<name>`), or, for a projected parameter (`type: {ref: <Object>}` with at least one `projectedFrom` field), each of its fields (R2 `projection_field_not_in_quote`, key `<ID>:<name>:<field>`), and so must each of its `values` (R2 `parameter_value_not_in_quote`, key `<ID>:<name>:<value>`). A declared parameter that no condition reads is not a failure."
677
677
  },
678
678
  "edits": {
679
679
  "type": "array",
@@ -1009,17 +1009,22 @@
1009
1009
  "type": "object",
1010
1010
  "additionalProperties": false,
1011
1011
  "required": ["cite"],
1012
- "description": "One action parameter. Foundation: Palantir action parameter. The name is the map key; the cite is the source text that names it. `type` is required when an expression reads the parameter; `optional: true` lets the caller omit it (it is then null). `values` is the value domain of an `enum` parameter (only with `type: enum`): a value outside it is `INVALID_BINDING` `type`; R1 checks its shape and that literals compared with the parameter are among them, R2 that each value appears in the cite quote.",
1012
+ "description": "One action parameter. Foundation: Palantir action parameter. The name is the map key; the cite is the source text that names it. `type` is required when an expression reads the parameter; `optional: true` lets the caller omit it (it is then null). `values` is the value domain of an `enum` parameter (only with `type: enum`): a value outside it is `INVALID_BINDING` `type`; R1 checks its shape and that literals compared with the parameter are among them, R2 that each value appears in the cite quote. `projectedFrom` (only with `type: {ref: <Object>}`) lists the request fields, an alternate key, that the project's projection resolves to this ref; on any other parameter R1 reports it and `decide` ignores it; the cite quote must name each field instead of the parameter name (R2 `projection_field_not_in_quote`); a null value of a required projected parameter is `INVALID_BINDING` `not_found`, and an absent one is `missing`, also with `optional: true` (SPEC section 9).",
1013
1013
  "properties": {
1014
1014
  "cite": { "$ref": "#/$defs/cite" },
1015
1015
  "type": { "$ref": "#/$defs/valueType" },
1016
1016
  "optional": { "const": true },
1017
- "values": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }
1017
+ "values": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
1018
+ "projectedFrom": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }
1018
1019
  },
1019
1020
  "allOf": [
1020
1021
  {
1021
1022
  "if": { "required": ["values"] },
1022
1023
  "then": { "required": ["type"], "properties": { "type": { "const": "enum" } } }
1024
+ },
1025
+ {
1026
+ "if": { "required": ["projectedFrom"] },
1027
+ "then": { "required": ["type"], "properties": { "type": { "type": "object", "required": ["ref"] } } }
1023
1028
  }
1024
1029
  ]
1025
1030
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githonllc/avouch",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/vocab.ttl CHANGED
@@ -475,7 +475,12 @@ ofv:set a rdf:Property ;
475
475
 
476
476
  ofv:optional a rdf:Property ;
477
477
  rdfs:label "optional" ;
478
- rdfs:comment "The caller may leave the parameter out; it is then null. YAML: actionTypes.<ID>.parameters.<n>.optional" ;
478
+ rdfs:comment "The caller may leave the parameter out; it is then null. Not for a projected parameter: it is always given, and null is a value. YAML: actionTypes.<ID>.parameters.<n>.optional" ;
479
+ rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
480
+
481
+ ofv:projectedFrom a rdf:Property ;
482
+ rdfs:label "projectedFrom" ;
483
+ rdfs:comment "Request fields (an alternate key) that the project's projection resolves to this ref parameter. A projected parameter is always given; null means the projection found nothing. YAML: actionTypes.<ID>.parameters.<n>.projectedFrom[]" ;
479
484
  rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
480
485
 
481
486
  ofv:principals a rdf:Property ;