@githonllc/avouch 0.1.10 → 0.1.12

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
@@ -142,7 +142,7 @@ Exit code 0 means that each case gave a row, also a `mismatch` or a `cannot_reco
142
142
 
143
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
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.
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`. When the runtime result of such a case is a result that the action cannot give, `reconcile` gives `runtime_unmapped` instead, and the case fails the gate. 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
146
 
147
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
148
 
@@ -182,8 +182,6 @@ The gate fails (exit code 1) when:
182
182
 
183
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
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
185
  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
186
 
189
187
  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.
package/SPEC.md CHANGED
@@ -16,7 +16,7 @@ Identifiers: every Avouch identifier is under `https://w3id.org/avouch/`, a perm
16
16
 
17
17
  The format layer never reads a source document. The package also ships a generic adapter for plain Markdown sources, `src/markdown.ts`; it belongs to layer (2), takes document texts rather than file paths, and the checker does not import it (conventions: `docs/adapter-guide.md` section 7). The adapter reads the sources and returns `SourceFacts` (section 4). The checker compares the ontology (3) with those facts. Parse conventions (headings, table headers, anchor syntax) stay in adapter code, not in configuration.
18
18
 
19
- An ontology file conforms to the format when both are true: it validates against `ontology.schema.json`, and the checker reports no violation that is not waived. The two checks do not overlap: the schema rejects unknown keys (`additionalProperties: false`), empty names (`propertyNames: {minLength: 1}` on every map keyed by a name) and wrong value types; R1 checks required shape, references and ids, and does not look for unknown keys.
19
+ An ontology file conforms to the format when both are true: it validates against `ontology.schema.json`, and the checker reports no violation that is not waived. Apart from one rule, the two checks do not overlap: the schema rejects unknown keys (`additionalProperties: false`), empty names (`propertyNames: {minLength: 1}` on every map keyed by a name) and wrong value types; R1 checks required shape, references and ids, and does not look for unknown keys. The one rule in both is the name `__proto__` of an object type, a link type or a property, because `check` is also called without the schema.
20
20
 
21
21
  ### Admission criteria for the format layer
22
22
 
@@ -57,7 +57,7 @@ The definitions quoted in the "Palantir analog" column are short quotations from
57
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 |
58
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) |
59
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" | |
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, each a declared parameter of the same action, that the project resolves to this ref; it takes effect only on a `type: {ref: <Object>}` parameter with at least one field, and `decide` requires the binding of such a projected parameter, also when optional: an absent binding is `INVALID_BINDING` `missing` (section 9); on any other parameter R1 reports it and `decide` ignores it; R1, R2 |
60
+ | `actionTypes.<A>.parameters` | foundation | action parameter: "Parameters are the inputs of an action type." | name → `{cite, type?, optional?, values?, projectedFrom?, unresolved?}`; 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, each a declared parameter of the same action, that the project resolves to this ref; it takes effect only on a `type: {ref: <Object>}` parameter with at least one field, and `decide` requires the binding of such a projected parameter, also when optional: an absent binding is `INVALID_BINDING` `missing` (section 9); on any other parameter R1 reports it and `decide` ignores it; `unresolved: domain` (only on a required scalar `{ref: <Object>}` parameter without `projectedFrom`) makes resolution a domain precondition: an id that does not resolve binds to null, and a decision row tests it (section 9, Domain resolution); on any other parameter R1 reports it and `decide` ignores it; R1, R2 |
61
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}` / `{actor: kind}`); R1, R4, R12 |
62
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 |
63
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` |
@@ -67,7 +67,7 @@ The definitions quoted in the "Palantir analog" column are short quotations from
67
67
  | `permission` … `principals` | extension | none | optional on a keys-form, an `any_of` alternative or the `{none}` form: `{kinds: [<actor kind>, …], cite}`; an actor of another kind is denied; the cite is checked by R2, not by R10; R1, R2 |
68
68
  | `scope` | extension | none | optional tenant scope `{by, global?: [{type, cite}], root?: {type, cite}, cite}`; unlisted object types are tenant types; `cite` supports `by` only; R1, R2, R6 |
69
69
  | `dispositions` | extension | none | optional result vocabulary `{<ID>: {class: rejected \| recorded \| pending \| applied, cite}}`; decision rows name these ids; R1, R2 |
70
- | `stateMachines` | extension | none | R3 |
70
+ | `stateMachines` | extension | none | R1, R3; `stateMachines.<O>.property` (optional) names the property of `<O>` that holds its state; absent, it is `config.stateProperty` |
71
71
  | `idempotencyKey` | extension | none | `{required, cite}`: whether the caller must supply an idempotency key. Absent = unspecified (not `false`). A missing key on a `required: true` action is rejected at request shape, before `decide` (so before its shape step), permission and the envelope; it is not an invocation result of `decide`, gives no receipt and is not reconciled. Never derived from effects. R1, R2 |
72
72
  | `evidence` | extension | none | id of the executed evidence, resolved by the evidence adapter; R1, R6, R7 |
73
73
  | `cite` / `knownSourceGaps` | extension | none | provenance and waivers; R2 |
@@ -115,9 +115,9 @@ The adapter may read the ontology instance (for example to list action ids in a
115
115
 
116
116
  | Rule | Checks | Facts used | Guarantee | Not guaranteed |
117
117
  |---|---|---|---|---|
118
- | R1 | shape, references resolve (a `{param}` read names a parameter of its action; `actor` is `id`, `keys` or `kind`), 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, and then each field names a declared parameter of the same action that is not projected (a request input); 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 |
118
+ | R1 | shape, references resolve (a `{param}` read names a parameter of its action; `actor` is `id`, `keys` or `kind`), ids unique, no object type, link type or property is named `__proto__`, `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, and then each field names a declared parameter of the same action that is not projected (a request input); a parameter with `unresolved` has the value `domain` and is a `type: {ref: <Object>}` parameter with no `optional` key and no `projectedFrom` (key `actionTypes.<ID>.parameters.<name>`); `stateMachines.<O>.property`, when present, is a non-empty string that names a declared property of `<O>` (key `stateMachines.<O>.property`); `canonical_values` on the state property of an object with a machine (`stateMachines.<O>.property`, default `config.stateProperty`) are states of that machine; 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
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 |
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 |
120
+ | R3 | graph equality, terminal states, reachability, `by` = triggers, `by` iff the action edits the state property of the object (`stateMachines.<O>.property`, default `config.stateProperty`) | `stateMachines`, `transitionTriggers`, `config.stateProperty` | ontology matches the source machine | trigger-cell conditions; objects without a machine in the source |
121
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 (an empty string never appears), 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 |
122
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 |
123
123
  | R6 | link sweep, link catalog complete (with `scope`, the `<by>` field of a tenant type counts as declared and must not be in `non_link_fields`: `non_link_field_stray`), `none_but_written` | `fieldList`, `config.linkFieldPattern`, `evidence` | link effects are declared completely | that an effect text is right |
@@ -140,7 +140,7 @@ The adapter may read the ontology instance (for example to list action ids in a
140
140
  |---|---|---|
141
141
  | R1, R4, R11, R12 | none | none |
142
142
  | R2 | `storeCatalog` | store catalog membership |
143
- | R3 | `stateMachines` | authoritative anchor check; graph comparison |
143
+ | R3 | `stateMachines` | all except the `doc` anchor check (`stateMachines.<O>.doc` must give exactly one source section) |
144
144
  | R3 | `transitionTriggers` | trigger binding checks |
145
145
  | R5 | `contextMembers` | membership and entity coverage |
146
146
  | R6 | `evidence` | `none_but_written` |
@@ -157,7 +157,7 @@ The table lists only the optional facts. Every rule may also read the required f
157
157
 
158
158
  ## 8. Versions
159
159
 
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. Adding the ref `actor.kind` (and the read `{actor: kind}`) is additive; `formatVersion` stays 1. Requiring each `projectedFrom` field to be a declared parameter rejects only files that fail at run time (the field reaches `decide` as an undeclared binding, `INVALID_BINDING` `unknown_param`); `formatVersion` stays 1. For an optional projected parameter whose field is missing or null, the projection now passes null (it omitted the binding before); `decide` is unchanged, and `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. Adding the ref `actor.kind` (and the read `{actor: kind}`) is additive; `formatVersion` stays 1. Requiring each `projectedFrom` field to be a declared parameter rejects only files that fail at run time (the field reaches `decide` as an undeclared binding, `INVALID_BINDING` `unknown_param`); `formatVersion` stays 1. For an optional projected parameter whose field is missing or null, the projection now passes null (it omitted the binding before); `decide` is unchanged, and `formatVersion` stays 1. Adding `unresolved: domain` on ref parameters is additive: a parameter without it binds as before; `formatVersion` stays 1. Checking the runtime result for `runtime_unmapped` before the expectation, the snapshot and `decide` (section 9, Runtime consistency, step 3) changes only the reason that `reconcile` gives: a case that was `expectation_missing`, `snapshot_missing` or `evaluator_error` and whose runtime result the action cannot give is now `runtime_unmapped`; ontology files are unchanged, and `formatVersion` stays 1.
161
161
  During 0.x the v1 draft changes in place (`formatVersion` 1, `$id` v1); the rule above applies from the 1.0 semantic freeze.
162
162
 
163
163
  ## 9. Decision semantics
@@ -214,9 +214,9 @@ Host language comparisons (`null != "A"` is true in JavaScript) are not used. `i
214
214
 
215
215
  Quantifiers are two-valued. `exists` is T when some element gives T; `all` is T when every element gives T (an element with F or U makes it F); `none = not exists`. An empty collection gives `exists` F, `all` T, `none` T: navigation results are a closed world, not U. Their bodies should be provably two-valued (R12 quantifier_absorbs_unknown; see Static UNKNOWN analysis).
216
216
 
217
- Navigation: a foreign-key link (no `table`, or `table` is an object's `datasource`) follows `via`; a pure link (`table` is no object's `datasource`) reads `snapshot.links[link.id]` rows `{from, to}`. A pure link that is navigated from a row and is not a key of `snapshot.links` is an incomplete snapshot: `decide` throws `snapshot lacks link <id>`. An empty link table is passed as `[]`. A null `via`, a dangling foreign key (missing or out of scope) and a null optional ref give the empty set (navigation from a null optional ref does not read the link); orphan data is a data-integrity question, not U.
217
+ Navigation: a foreign-key link (no `table`, or `table` is an object's `datasource`) follows `via`; a pure link (`table` is no object's `datasource`) reads `snapshot.links[link.id]` rows `{from, to}`. A pure link that is navigated from a row and is not a key of `snapshot.links` is an incomplete snapshot: `decide` throws `snapshot lacks link <id>`. An empty link table is passed as `[]`. When evaluation reads a pure link, each row of `snapshot.links[link.id]` must have a `from` and a `to` that are strings. A row with another value (a number, null) or without `from` or `to` is a corrupt snapshot, and `decide` throws. This applies to every row of the link, also to a row outside the scope. A link row is the link itself, so a null end is not the same as a null `via`: it does not give the empty set. A null `via`, a dangling foreign key (missing or out of scope) and a null optional ref give the empty set (navigation from a null optional ref does not read the link); orphan data is a data-integrity question, not U.
218
218
 
219
- Known risk (vacuous truth): from an optional ref that is null, `all` and `none` are always T. This is not an UNKNOWN risk and is not reported. Guard first: `and(isNotNull({ref: x}), all(...))`.
219
+ Known risk (vacuous truth): from an optional ref that is null, or from a ref with `unresolved: domain` whose id did not resolve, `all` and `none` are always T. This is not an UNKNOWN risk and is not reported. Guard first: `and(isNotNull({ref: x}), all(...))`.
220
220
 
221
221
  ### Static UNKNOWN analysis
222
222
 
@@ -231,7 +231,7 @@ Nullability comes from the facts and the parameters; only this analysis treats u
231
231
 
232
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).
233
233
  - `now` and `actor.id` / `actor.keys` / `actor.kind` are never null. A quantifier variable or `self` is not null; its property `x.prop` 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.
234
+ - An optional parameter may be null, and so may every property read through it. A ref parameter with `unresolved: domain` (where it takes effect) is the same: it is null when its id does not resolve. Any other 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.
235
235
 
236
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.
237
237
 
@@ -278,10 +278,10 @@ A request-shape check before `decide` (so before its shape step), permission and
278
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` |
279
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` |
280
280
  | envelope (idempotency) | `REPLAYED`, `IDEMPOTENCY_CONFLICT`; **not implemented by `decide`**, which has no idempotency key |
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) |
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`; a scalar ref parameter with `unresolved: domain` never fails this step: when its id is not visible (absent from the type, including ids of other types, or only outside the scope), it binds to null, and the step goes on with the next parameter | `SCOPE_DENIED` (the id exists only outside the scope), `INVALID_BINDING` `not_found` (absent from the type, including ids of other types) |
282
282
  | decision: first-hit table | a domain result `{result, class, row}` or INDETERMINATE |
283
283
 
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.
284
+ The invocation results are a fixed set (`INVOCATION_RESULTS`), not declared by actions. A null optional ref binds, and so does a ref with `unresolved: domain` whose id does not resolve (to null); reading its properties gives null, and `{ref: <name>}` gives null, not the id that the caller gave. 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 `undefined` is the same as an absent field; a field with the value null is not incomplete. An object type that evaluation reads (to look up a ref parameter or as the target of a navigation) and that is not a key of `snapshot.objects` is an incomplete snapshot: `decide` throws `snapshot lacks object type <T>`. An empty table is passed as `[]`. An object type that evaluation does not read can be absent.
285
285
 
286
286
  Projected parameters. A `{ref: T}` parameter with `projectedFrom: [<field>, …]` (at least one field) is projected: it is not a field of the request. Each of its fields is a declared parameter of the same action that is not itself projected (R1): a request input, given and checked as any other parameter. The projection only adds the projected binding; it does not remove the fields from the bindings. 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 any of the fields is missing from the request or is null, the projection does not look up: for an optional parameter it passes null, and for a required parameter it omits the binding. Else, when no object has the key, it passes null; else it passes the id. Null is not the same as an absent binding:
287
287
 
@@ -293,6 +293,8 @@ Projected parameters. A `{ref: T}` parameter with `projectedFrom: [<field>, …]
293
293
 
294
294
  For a required parameter, an absent binding is the expected input when a field is missing (`decide` gives `missing`); when the runtime gives another result, `reconcile` shows the difference as a mismatch. For an optional parameter, an absent binding is never a valid input: `decide` gives `missing`, so a projection that did not run shows as a mismatch in `reconcile` and never passes as "no object has this key". When a field is a required parameter and is missing or null, the result is `INVALID_BINDING` `missing` whatever the projection passes; the rule for an optional parameter matters only when its fields are optional. 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
295
 
296
+ Domain resolution. A ref parameter with `unresolved: domain` has a resolution that is a domain precondition. The shape step does not change: an absent or null binding is `missing`, and a value that does not fit its type is `type`. In the binding step, an id that is not visible binds to null. An absent id and an id that exists only outside the scope give the same null, so the result does not reveal that the id exists in another tenant. A decision row tests the null with `isNull` or `isNotNull` and gives a domain result. No rule checks that a row tests it: a condition that does not read the parameter, or a quantifier over an empty navigation from it, lets an id that does not resolve reach any row (a known risk, as for a null optional ref). The key takes effect only on a required scalar `{ref: T}` parameter without `projectedFrom`. On any other parameter R1 reports it and `decide` ignores it: on an optional parameter, because null would then mean both "omitted" and "did not resolve", and a row cannot tell them apart; on a projected parameter, because its null already has a meaning (the table above); on a `{set: {ref: T}}` parameter, because a condition cannot see that an element did not resolve, so the format does not define it.
297
+
296
298
  ### Decision table and INDETERMINATE
297
299
 
298
300
  `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:
@@ -327,10 +329,10 @@ Types:
327
329
  |---|---|---|
328
330
  | 1 | `reported.result` is `REPLAYED` or `IDEMPOTENCY_CONFLICT` | `cannot_reconcile`, `envelope`; `decide` is not called |
329
331
  | 2 | `reported` is `null` | `cannot_reconcile`, `runtime_missing` |
330
- | 3 | `expected` is `null` | `cannot_reconcile`, `expectation_missing` |
331
- | 4 | `snapshot` is `null` | `cannot_reconcile`, `snapshot_missing` |
332
- | 5 | `decide(ont, action, snapshot, scope, bindings, actor, now)` throws | `cannot_reconcile`, `evaluator_error`; `detail` is the error message |
333
- | 6 | `reported.result` is `SCOPE_DENIED`, or is neither an invocation result (`INVOCATION_RESULTS`) nor the `result` of a decision row or of `otherwise` of this action | `cannot_reconcile`, `runtime_unmapped`; `detail` is `reported.result` |
332
+ | 3 | `action` is a key of `actionTypes`, and `reported.result` is a result that this action cannot give (`SCOPE_DENIED`, or neither an invocation result (`INVOCATION_RESULTS`) nor the `result` of a decision row or of `otherwise` of this action) | `cannot_reconcile`, `runtime_unmapped`; `detail` is `reported.result` |
333
+ | 4 | `expected` is `null` | `cannot_reconcile`, `expectation_missing` |
334
+ | 5 | `snapshot` is `null` | `cannot_reconcile`, `snapshot_missing` |
335
+ | 6 | `decide(ont, action, snapshot, scope, bindings, actor, now)` throws | `cannot_reconcile`, `evaluator_error`; `detail` is the error message |
334
336
  | 7 | `expected.result` is `SCOPE_DENIED`, or is neither an invocation result (`INVOCATION_RESULTS`) nor the `result` of a decision row or of `otherwise` of this action | `cannot_reconcile`, `expectation_unmapped`; `detail` is `expected.result` |
335
337
  | 8 | the result is INDETERMINATE | `cannot_reconcile`, `indeterminate`; `detail` is its reason |
336
338
  | 9 | otherwise: `evaluator` is `externalResult` of the result without `param` (a domain result is `{result}`; `INVALID_BINDING` keeps `reason`) | `match` with `result = evaluator` when the three results are pairwise equal; else `mismatch`. When the result is a domain result, the row also has `row`: the `row` that `decide` gave (the index of the decision row, from 0, or `otherwise`). The runtime does not report a row, so `row` is always the row that `decide` chose |
@@ -341,12 +343,12 @@ A missing runtime result is never filled in from the decision table; neither is
341
343
 
342
344
  Once an invocation is inside the domain path of an action with a decision table, the runtime must report the domain result explicitly. A coarse signal, such as the status of a receipt, must not stand in for it: a coarse value can share a name with the expected result and give a false match. `runtime_missing` (nothing reported) and `runtime_unmapped` (a value the action cannot give) record the two ways this evidence fails.
343
345
 
344
- `expectation_unmapped` (an expected result the action cannot give) is a defect of the scenario, not of the runtime or the ontology. Defects of the input (steps 6 and 7) come before the evaluator's own gap (INDETERMINATE, step 8).
346
+ `expectation_unmapped` (an expected result the action cannot give) is a defect of the scenario, not of the runtime or the ontology. Defects of the input (steps 3 and 7) come before the evaluator's own gap (INDETERMINATE, step 8). Step 3 reads only the ontology and the action, so a runtime result that the action cannot give is `runtime_unmapped` also when the case has no expectation, no snapshot, or a `decide` call that throws. Step 3 does not apply to an action that the ontology does not have: for such an action, step 4 or 5 applies, or `decide` throws (step 6).
345
347
 
346
348
  `projectSnapshot(ont, stores, decode, linkColumns?)` turns stored rows (keyed by table name) into a typed `Snapshot` (objects keyed by object type, pure links keyed by link id) and returns `{snapshot, defects}`:
347
349
 
348
- 1. For each link type with a `table` that is not the `datasource` of any object type (a pure link), in array order: when `linkColumns` has no entry for its id, the function throws `pure link <id> is not projected`; a missing store throws `snapshot lacks store <table>`. Otherwise each row is copied with all its columns except `from` and `to`; then `from` gets the value of the column `linkColumns[id].from` and `to` gets the value of the column `linkColumns[id].to`. A mapped column absent from the row is not projected (`decide` throws when it reads it). Link columns are not decoded and give no defects. An entry of `linkColumns` that is not a pure link id is ignored.
349
- 2. For each object type, in key order: a missing store throws `snapshot lacks store <datasource>`. Otherwise each row is copied with all its columns, and then each property `p` with a declared `type` is projected: a column absent from the row is not projected and is not a defect (`decide` throws when it reads it, as for any incomplete snapshot); a null value stays null and is never passed to `decode`; any other value goes through `decode(raw, type)`, and a result that is `undefined` or does not fit the type (`fitsType`) is a defect `{object, prop: p, row: String(row.id), value: raw}`, and the column keeps its raw value; else the column gets the decoded value. Columns without a declared type are kept as they are.
350
+ 1. For each link type with a `table` that is not the `datasource` of any object type (a pure link), in array order: when `linkColumns` has no entry for its id, the function throws `pure link <id> is not projected`; a missing store throws `snapshot lacks store <table>`. Otherwise each row is copied with all its columns except `from` and `to`; then `from` gets the value of the column `linkColumns[id].from` and `to` gets the value of the column `linkColumns[id].to`. A mapped column absent from the row, or with the value `undefined`, is not projected (`decide` throws when it reads it). Link columns are not decoded and give no defects. An entry of `linkColumns` that is not a pure link id is ignored.
351
+ 2. For each object type, in key order: a missing store throws `snapshot lacks store <datasource>`. Otherwise each row is copied with all its columns, and then each property `p` with a declared `type` is projected: a column absent from the row, or with the value `undefined`, is not decoded and is not a defect (`decide` throws when it reads it, as for any incomplete snapshot); a null value stays null and is never passed to `decode`; any other value goes through `decode(raw, type)`, and a result that is `undefined` or does not fit the type (`fitsType`) is a defect `{object, prop: p, row: String(row.id), value: raw}`, and the column keeps its raw value; else the column gets the decoded value. Columns without a declared type are kept as they are.
350
352
  3. The result is `{snapshot: {objects, links}, defects}`, with one `links` entry per pure link; when the ontology has no pure link, `snapshot.links` is not set. When `defects` is not empty the snapshot holds raw values for those columns; do not reconcile with it.
351
353
 
352
354
  The physical type mapping is checked here: the profile supplies `decode`, which turns a stored value (for example the integers 0 and 1 of SQLite for a boolean) into a value of the declared type, and `fitsType` checks the result.
@@ -357,7 +359,7 @@ The command line reads and writes these types as JSON lines, one `RuntimeCase` o
357
359
 
358
360
  ### Existence is not revealed
359
361
 
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).
362
+ `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. A ref parameter with `unresolved: domain` gives null in both cases (section 9, Domain resolution), so the domain result is the same too. 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).
361
363
 
362
364
  ### Known limit
363
365
 
@@ -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, projectedFields } from "./expr.js";
4
+ import { derivedPropertyType, isObj, newReads, projectedFields, resolvesInDomain } 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"];
@@ -100,11 +100,15 @@ export function check(ontology, facts) {
100
100
  for (const n of Object.keys(stateMachines))
101
101
  if (!objType(n))
102
102
  v("R1", `stateMachines.${n}`, "not a declared object type");
103
+ // the state property of an object: its machine's `property` when that is a non-empty string, else the profile default
104
+ const stateProp = (o) => (own(stateMachines, o) && isObj(stateMachines[o]) && isStr(stateMachines[o].property) ? stateMachines[o].property : config.stateProperty);
103
105
  const gaps = arr(ont.knownSourceGaps);
104
106
  if (!Array.isArray(ont.knownSourceGaps))
105
107
  v("R1", "knownSourceGaps", "missing (use [])");
106
108
  for (const [name, o] of Object.entries(objects)) {
107
109
  const p = `objectTypes.${name}`;
110
+ if (name === "__proto__")
111
+ v("R1", p, "__proto__ is a reserved name"); // a snapshot key: an assignment would set the prototype
108
112
  if (!isObj(o)) {
109
113
  v("R1", p, "not a mapping");
110
114
  continue;
@@ -123,6 +127,8 @@ export function check(ontology, facts) {
123
127
  if (o.non_link_fields !== undefined && !isObj(o.non_link_fields))
124
128
  v("R1", p, "non_link_fields must be a mapping");
125
129
  for (const [pn, pr] of Object.entries(isObj(o.properties) ? o.properties : {})) {
130
+ if (pn === "__proto__")
131
+ v("R1", `${p}.properties.${pn}`, "__proto__ is a reserved name");
126
132
  if (!isObj(pr) || !CLASSES.includes(pr.class))
127
133
  v("R1", `${p}.properties.${pn}`, `class must be one of ${CLASSES}`);
128
134
  else if (pr.class !== "unknown" && !isObj(pr.cite))
@@ -135,7 +141,7 @@ export function check(ontology, facts) {
135
141
  v("R1", `${p}.properties.${pn}.canonical_values`, "needs values[] and cite");
136
142
  else if (pr.class === "canonical")
137
143
  v("R1", `${p}.properties.${pn}.canonical_values`, "only meaningful on a non-canonical property");
138
- else if (pn === config.stateProperty && states)
144
+ else if (pn === stateProp(name) && states)
139
145
  for (const x of cv.values)
140
146
  if (!states.has(x))
141
147
  v("R1", `${p}.properties.${pn}.canonical_values`, `${x} is not a ${name} state`);
@@ -152,6 +158,14 @@ export function check(ontology, facts) {
152
158
  const cls = objects[o].properties[p].class;
153
159
  return CLASSES.includes(cls) ? cls : null; // a property without a valid class does not resolve (R1 reports it)
154
160
  };
161
+ for (const [n, sm] of Object.entries(stateMachines)) {
162
+ if (!objType(n) || !isObj(sm) || sm.property === undefined)
163
+ continue; // an undeclared object or a machine that is not a mapping is R1 above / below
164
+ if (!isStr(sm.property))
165
+ v("R1", `stateMachines.${n}.property`, "must be a non-empty string");
166
+ else if (propClass(`${n}.${sm.property}`) === null)
167
+ v("R1", `stateMachines.${n}.property`, `${sm.property} does not resolve to a declared property of ${n}`);
168
+ }
155
169
  const linkIds = new Set();
156
170
  for (const [i, l] of links.entries()) {
157
171
  const p = `linkTypes[${i}]`;
@@ -159,6 +173,8 @@ export function check(ontology, facts) {
159
173
  v("R1", p, "link id missing");
160
174
  continue;
161
175
  }
176
+ if (l.id === "__proto__")
177
+ v("R1", `linkTypes.${l.id}`, "__proto__ is a reserved name");
162
178
  if (linkIds.has(l.id))
163
179
  v("R1", `linkTypes.${l.id}`, "duplicate link id");
164
180
  linkIds.add(l.id);
@@ -292,6 +308,8 @@ export function check(ontology, facts) {
292
308
  for (const f of pf)
293
309
  if (!own(params, f) || projectedFields(isObj(params[f]) ? params[f] : {}) !== null)
294
310
  v("R1", `${p}.parameters.${n}`, `projectedFrom field ${f} must be a declared parameter of ${cid} that is not projected`);
311
+ if (isObj(pv) && pv.unresolved !== undefined && !resolvesInDomain(pv))
312
+ v("R1", `${p}.parameters.${n}`, "unresolved: domain needs a required scalar {ref: <Object>} parameter without projectedFrom");
295
313
  }
296
314
  const pids = new Set();
297
315
  for (const [i, pc] of arr(c.conditions).entries()) {
@@ -460,6 +478,14 @@ export function check(ontology, facts) {
460
478
  if (objType(name) && (!isObj(stateMachines[name]) || stateMachines[name].doc !== fact.source.anchor))
461
479
  v("R3", `stateMachines.${name}`, `${fact.source.anchor} has a state machine for ${name}; stateMachines.${name}.doc must be ${fact.source.anchor}`);
462
480
  const byCmds = new Map(); // object -> commands named in `by`
481
+ // read from the ontology alone, so a machine whose section or fact is unusable does not turn every state-editing action into a violation
482
+ for (const [name, st] of Object.entries(stateMachines))
483
+ if (objType(name) && isObj(st) && Array.isArray(st.transitions))
484
+ for (const t of st.transitions)
485
+ if (isObj(t) && Array.isArray(t.by))
486
+ for (const c of t.by)
487
+ if (isStr(c))
488
+ (byCmds.get(name) ?? byCmds.set(name, new Set()).get(name)).add(c);
463
489
  for (const [name, o] of Object.entries(objects)) {
464
490
  if (!isObj(o) || !own(stateMachines, name))
465
491
  continue;
@@ -506,9 +532,6 @@ export function check(ontology, facts) {
506
532
  for (const c of t.by) {
507
533
  if (!own(commands, c))
508
534
  v("R3", `${k}.${e}`, `by ${c} is not a defined command`);
509
- if (!byCmds.has(name))
510
- byCmds.set(name, new Set());
511
- byCmds.get(name).add(c);
512
535
  if (triggers !== undefined && !want.has(c))
513
536
  v("R3", `${name}:${e}:${c}`, `no source transition trigger binds ${c} to ${name} ${t.from} → ${t.to}`, "transition_binding_unsupported");
514
537
  }
@@ -554,13 +577,13 @@ export function check(ontology, facts) {
554
577
  for (const [cid, c] of Object.entries(commands)) {
555
578
  if (!isObj(c))
556
579
  continue;
557
- const stateEdits = new Set(arr(c.edits).filter((e) => e.endsWith(`.${config.stateProperty}`)).map((e) => e.split(".")[0]));
580
+ const stateEdits = new Set(arr(c.edits).filter((e) => e.endsWith(`.${stateProp(e.split(".")[0])}`)).map((e) => e.split(".")[0]));
558
581
  for (const o of stateEdits)
559
582
  if (objType(o) && isObj(stateMachines[o]) && !byCmds.get(o)?.has(cid))
560
- v("R3", `${cid}:${o}.${config.stateProperty}`, `${cid} edits ${o}.${config.stateProperty} but is in no ${o} transition's by`);
583
+ v("R3", `${cid}:${o}.${stateProp(o)}`, `${cid} edits ${o}.${stateProp(o)} but is in no ${o} transition's by`);
561
584
  for (const [o, cs] of byCmds)
562
585
  if (cs.has(cid) && !stateEdits.has(o))
563
- v("R3", `${cid}:${o}.${config.stateProperty}`, `${cid} is in a ${o} transition's by but does not edit ${o}.${config.stateProperty}`);
586
+ v("R3", `${cid}:${o}.${stateProp(o)}`, `${cid} is in a ${o} transition's by but does not edit ${o}.${stateProp(o)}`);
564
587
  }
565
588
  // ------------------------------------------------------------------------------------------ R4
566
589
  for (const [cid, c] of Object.entries(commands)) {
@@ -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, projectedFields, RESERVED_NAMES, TIMESTAMP_MAX, TIMESTAMP_MIN } from "./expr.js";
4
+ import { checkPredicate, fitsType, own, projectedFields, RESERVED_NAMES, resolvesInDomain, 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";
@@ -14,9 +14,9 @@ export class UndefinedDerived extends Error {
14
14
  this.name = "UndefinedDerived";
15
15
  }
16
16
  }
17
- // A field of a snapshot row. An absent key is an incomplete snapshot and throws; null is a value.
17
+ // A field of a snapshot row. An absent key, or the value undefined, is an incomplete snapshot and throws; null is a value.
18
18
  function field(row, key, what) {
19
- if (!Object.hasOwn(row, key))
19
+ if (!Object.hasOwn(row, key) || row[key] === undefined)
20
20
  throw new Error(`snapshot ${what} has no ${key}`);
21
21
  return row[key];
22
22
  }
@@ -52,7 +52,9 @@ class Session {
52
52
  return true;
53
53
  }
54
54
  allRows(type) {
55
- const rows = own(this.env.snapshot.objects, type) ?? [];
55
+ const rows = own(this.env.snapshot.objects, type);
56
+ if (rows === undefined)
57
+ throw new Error(`snapshot lacks object type ${type}`);
56
58
  for (const r of rows)
57
59
  if (typeof r.id !== "string")
58
60
  throw new Error(`a ${type} row has no string id`);
@@ -80,6 +82,12 @@ class Session {
80
82
  const all = own(this.env.snapshot.links, link.id);
81
83
  if (all === undefined)
82
84
  throw new Error(`snapshot lacks link ${link.id}`);
85
+ for (const r of all)
86
+ for (const k of ["from", "to"]) {
87
+ const v = field(r, k, `${link.id} link row`);
88
+ if (typeof v !== "string")
89
+ throw new Error(`snapshot ${link.id} link row: ${k} = ${JSON.stringify(v)} is not a string id`);
90
+ }
83
91
  const by = this.ont.scope?.by;
84
92
  const tenantEnd = this.mode(link.from) === "tenant" || this.mode(link.to) === "tenant";
85
93
  rows = by !== undefined && tenantEnd ? all.filter((r) => field(r, by, `${link.id} link row`) === this.env.scope) : all;
@@ -321,7 +329,8 @@ function predicate(s, f, e) {
321
329
  throw new Error(`not a predicate expression: ${Object.keys(e)[0]}`);
322
330
  }
323
331
  const topFrame = (env) => ({ action: env.action, vars: {} });
324
- // Value expressions. `bindings` are taken as having passed decide's shape and binding layers.
332
+ // Value expressions. `bindings` are taken as having passed decide's shape and binding layers: a ref with
333
+ // `unresolved: domain` whose id does not resolve is passed as null, as decide binds it.
325
334
  export function evalValue(ont, expr, env) {
326
335
  return value(new Session(ont, env), topFrame(env), expr);
327
336
  }
@@ -377,6 +386,8 @@ const condId = (row) => ("passes" in row.when ? row.when.passes : row.when.fails
377
386
  function resolve(s, actionId) {
378
387
  const action = s.ont.actionTypes[actionId];
379
388
  // A ref parameter binds one object; each element of a set-of-ref parameter is a referenced object too (array order).
389
+ // A scalar ref with `unresolved: domain` binds to null when its id is not visible.
390
+ const unresolved = []; // unresolved: domain parameters whose id did not resolve
380
391
  for (const [name, p] of Object.entries(action.parameters ?? {})) {
381
392
  const t = p.type;
382
393
  const isSet = typeof t !== "string" && "set" in t;
@@ -391,15 +402,22 @@ function resolve(s, actionId) {
391
402
  for (const id of isSet ? v : [v]) {
392
403
  if (s.byId(elem.ref).has(id))
393
404
  continue;
405
+ // Absent and out of scope give the same null, so existence in another tenant is not revealed.
406
+ if (resolvesInDomain(p)) {
407
+ unresolved.push(name);
408
+ break;
409
+ }
394
410
  if (s.allRows(elem.ref).some((r) => r.id === id))
395
411
  return { layer: "invocation", result: "SCOPE_DENIED", param: name };
396
412
  return { layer: "invocation", result: "INVALID_BINDING", param: name, reason: "not_found" };
397
413
  }
398
414
  }
415
+ const b = s.env.bindings;
416
+ const d = unresolved.length === 0 ? s : new Session(s.ont, { ...s.env, bindings: Object.fromEntries(Object.keys(action.parameters ?? {}).filter((n) => Object.hasOwn(b, n)).map((n) => [n, unresolved.includes(n) ? null : b[n]])) });
399
417
  const decision = action.decision;
400
418
  if (decision === undefined)
401
419
  return { layer: "indeterminate", reason: "no_decision_table" };
402
- const frame = topFrame(s.env);
420
+ const frame = topFrame(d.env);
403
421
  const cache = new Map();
404
422
  const disposition = (result) => own(s.ont.dispositions, result).class; // checked by decide before evaluation
405
423
  for (const [i, row] of decision.rows.entries()) {
@@ -411,7 +429,7 @@ function resolve(s, actionId) {
411
429
  v = {};
412
430
  else
413
431
  try {
414
- v = predicate(s, frame, cond.expr);
432
+ v = predicate(d, frame, cond.expr);
415
433
  }
416
434
  catch (err) {
417
435
  if (!(err instanceof UndefinedDerived))
@@ -117,6 +117,7 @@ export type ActionType = {
117
117
  optional?: true;
118
118
  values?: string[];
119
119
  projectedFrom?: string[];
120
+ unresolved?: "domain";
120
121
  }>;
121
122
  permission: Permission;
122
123
  conditions: Condition[];
@@ -126,6 +127,12 @@ export declare function projectedFields(p: {
126
127
  type?: unknown;
127
128
  projectedFrom?: unknown;
128
129
  }): string[] | null;
130
+ export declare function resolvesInDomain(p: {
131
+ type?: unknown;
132
+ optional?: unknown;
133
+ projectedFrom?: unknown;
134
+ unresolved?: unknown;
135
+ }): boolean;
129
136
  export interface Stage1Ontology {
130
137
  scope?: {
131
138
  by: string;
package/dist/src/expr.js CHANGED
@@ -17,6 +17,11 @@ export function projectedFields(p) {
17
17
  const fs = Array.isArray(p.projectedFrom) ? [...new Set(p.projectedFrom.filter((f) => typeof f === "string"))] : [];
18
18
  return isObj(p.type) && typeof p.type.ref === "string" && fs.length > 0 ? fs : null;
19
19
  }
20
+ // Whether an id of p that does not resolve binds to null (`unresolved: domain`): only on a required scalar {ref: T}
21
+ // parameter (no optional key) without projectedFrom. On any other parameter the key has no effect, and R1 reports it.
22
+ export function resolvesInDomain(p) {
23
+ return p.unresolved === "domain" && isObj(p.type) && typeof p.type.ref === "string" && !("set" in p.type) && p.optional === undefined && p.projectedFrom === undefined;
24
+ }
20
25
  export class ExprError extends Error {
21
26
  path;
22
27
  constructor(path, msg) {
@@ -143,6 +143,7 @@ export type QueryAction = {
143
143
  optional: boolean;
144
144
  values: string[] | null;
145
145
  projectedFrom: string[] | null;
146
+ unresolved: string | null;
146
147
  cite: QueryCite | null;
147
148
  }[];
148
149
  conditions: {
@@ -214,6 +215,7 @@ export type QueryObject = {
214
215
  }[];
215
216
  stateMachine: {
216
217
  doc: S;
218
+ property: S;
217
219
  initial: S;
218
220
  terminal: string[];
219
221
  transitions: {
package/dist/src/query.js CHANGED
@@ -331,6 +331,7 @@ export function queryAction(ont, id) {
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
333
  projectedFrom: Array.isArray(map(p).projectedFrom) ? map(p).projectedFrom.filter((x) => typeof x === "string") : null,
334
+ unresolved: typeof map(p).unresolved === "string" ? map(p).unresolved : null,
334
335
  cite: citeOf(map(p).cite),
335
336
  })),
336
337
  conditions: conditionsOf(a).map((c) => ({
@@ -451,6 +452,7 @@ export function queryObject(ont, id) {
451
452
  stateMachine: sm
452
453
  ? {
453
454
  doc: str(sm.doc),
455
+ property: str(sm.property),
454
456
  initial: str(sm.initial),
455
457
  terminal: strs(sm.terminal),
456
458
  transitions: arr(sm.transitions)
@@ -80,6 +80,21 @@ export function reconcile(ont, cases) {
80
80
  return cannot("runtime_missing");
81
81
  if (ENVELOPE.includes(reported.result))
82
82
  return cannot("envelope");
83
+ // A result this action can give: not SCOPE_DENIED (internal: SPEC section 9, Existence is not revealed), and an
84
+ // invocation result or one of its domain results. The check reads only the ontology, so a runtime defect is found
85
+ // also without an expectation, without a snapshot and when decide throws. An action that the ontology does not
86
+ // have is not checked here: decide reports it (evaluator_error). A decision table that cannot be read skips the
87
+ // check too: decide then throws and reports it.
88
+ let results;
89
+ try {
90
+ results = own(ont.actionTypes, c.action) !== undefined ? domainResults(ont, c.action) : undefined;
91
+ }
92
+ catch {
93
+ results = undefined;
94
+ }
95
+ const mapped = (r) => r !== "SCOPE_DENIED" && (INVOCATION.includes(r) || (results ?? []).includes(r));
96
+ if (results !== undefined && !mapped(reported.result))
97
+ return cannot("runtime_unmapped", reported.result);
83
98
  if (expected === null)
84
99
  return cannot("expectation_missing");
85
100
  if (snapshot === null)
@@ -92,12 +107,7 @@ export function reconcile(ont, cases) {
92
107
  catch (e) {
93
108
  return cannot("evaluator_error", e instanceof Error ? e.message : String(e));
94
109
  }
95
- // A result this action can give: not SCOPE_DENIED (internal: SPEC section 9, Existence is not revealed), and an
96
- // invocation result or one of its domain results. Defects of the input (runtime, then scenario) come before INDETERMINATE.
97
- const results = domainResults(ont, c.action);
98
- const mapped = (r) => r !== "SCOPE_DENIED" && (INVOCATION.includes(r) || results.includes(r));
99
- if (!mapped(reported.result))
100
- return cannot("runtime_unmapped", reported.result);
110
+ // Defects of the input (runtime, then scenario) come before INDETERMINATE.
101
111
  if (!mapped(expected.result))
102
112
  return cannot("expectation_unmapped", expected.result);
103
113
  if (d.layer === "indeterminate")
@@ -1,4 +1,4 @@
1
- import { navType, objectBinding, own } from "./expr.js";
1
+ import { navType, objectBinding, own, resolvesInDomain } from "./expr.js";
2
2
  export const MAX_NULLABLE_REFS = 8;
3
3
  class NoExpression extends Error {
4
4
  }
@@ -125,8 +125,8 @@ function nullability(ont, facts) {
125
125
  const param = local || ctx.action === undefined ? undefined : own(ont.actionTypes[ctx.action]?.parameters, head);
126
126
  if (!local && param === undefined)
127
127
  return true; // not reachable after the type check; conservative
128
- if (param?.optional === true)
129
- return true; // an optional parameter, and every property read through it
128
+ if (param !== undefined && (param.optional === true || resolvesInDomain(param)))
129
+ return true; // an optional parameter or a ref with unresolved: domain, and every property read through it
130
130
  if (prop === undefined)
131
131
  return false; // an object binding, or a required parameter
132
132
  const o = objectBinding(ont, head, ctx);
@@ -135,7 +135,7 @@ function nullability(ont, facts) {
135
135
  }
136
136
  // Nullability of the paths in ctx. A field is non-null only when its field list says so; a field that is not listed,
137
137
  // or an object without a field list, has unknown nullability and may be null. An optional parameter may be null; so
138
- // may every property read through it.
138
+ // may every property read through it. A ref parameter with `unresolved: domain` is the same.
139
139
  export function mayNullIn(ont, facts, ctx) {
140
140
  return nullability(ont, facts)(ctx);
141
141
  }
@@ -61,7 +61,7 @@ Parameters and parameter and actor reads (`parameters`, `{param}`, `{actor}`) ne
61
61
  - `crossContextMechanisms`: the legal `via` values of `crossContext` (R5).
62
62
  - `actionIdPattern`: the pattern of an action id (R1).
63
63
  - `linkFieldPattern`: the pattern of a field that holds a link (R6).
64
- - `stateProperty`: the name of the state property (R1, R3).
64
+ - `stateProperty`: the default name of the state property (R1, R3). An ontology machine names another property of its object with `stateMachines.<Object>.property`.
65
65
 
66
66
  ## 6. Run the example
67
67
 
@@ -140,7 +140,10 @@
140
140
  "objectTypes": {
141
141
  "type": "object",
142
142
  "propertyNames": {
143
- "minLength": 1
143
+ "minLength": 1,
144
+ "not": {
145
+ "const": "__proto__"
146
+ }
144
147
  },
145
148
  "description": "Declared objects: object name → definition. Foundation: Palantir object type. R1: must be a mapping. R2: the name must occur as a whole identifier in the section of its `doc` anchor. R5: the name must be a member of its context's list.",
146
149
  "additionalProperties": {
@@ -249,7 +252,10 @@
249
252
  "properties": {
250
253
  "type": "object",
251
254
  "propertyNames": {
252
- "minLength": 1
255
+ "minLength": 1,
256
+ "not": {
257
+ "const": "__proto__"
258
+ }
253
259
  },
254
260
  "description": "Modelled properties: property name → classification. Foundation: Palantir property. R1: required (use {}). R2: each name must be a field of the object's source field list (else R2 `property_not_field`). Only properties listed here can be referenced by edits, conditions, derivedProperties.reads and materializedFrom.reads (R1).",
255
261
  "additionalProperties": {
@@ -315,7 +321,7 @@
315
321
  "values",
316
322
  "cite"
317
323
  ],
318
- "description": "Values of a NON-canonical property that the source explicitly marks as canonical. Extension (R1, R4); no Palantir analog. R1: needs values[] (non-empty) and cite; not allowed when class is canonical; on the configured state property of an object with `stateMachines`, each value must be a state of that object. R4: each value must occur as a whole identifier in this cite's quote (else R4 `canonical_value_unsupported`); a condition value read `{prop, values}` of this property passes R4 only if all its values are in this list.",
324
+ "description": "Values of a NON-canonical property that the source explicitly marks as canonical. Extension (R1, R4); no Palantir analog. R1: needs values[] (non-empty) and cite; not allowed when class is canonical; on the state property of an object with `stateMachines` (`stateMachines.<Object>.property`, default the configured state property), each value must be a state of that object. R4: each value must occur as a whole identifier in this cite's quote (else R4 `canonical_value_unsupported`); a condition value read `{prop, values}` of this property passes R4 only if all its values are in this list.",
319
325
  "properties": {
320
326
  "values": {
321
327
  "type": "array",
@@ -409,12 +415,17 @@
409
415
  "terminal",
410
416
  "transitions"
411
417
  ],
412
- "description": "State machine of the object. Extension (R1, R3); no Palantir analog. R3 compares it with the machine the adapter extracts from the source: the state set, the initial state and the transition set must be equal; `terminal` must equal the source states that have no outgoing edge; every source state must be reachable from the initial state. Also R3: an action appears in some `by` of this object's transitions if and only if its `edits` contains `<Object>.<state property>`.",
418
+ "description": "State machine of the object. Extension (R1, R3); no Palantir analog. R3 compares it with the machine the adapter extracts from the source: the state set, the initial state and the transition set must be equal; `terminal` must equal the source states that have no outgoing edge; every source state must be reachable from the initial state. Also R3: an action appears in some `by` of this object's transitions if and only if its `edits` contains `<Object>.<state property>`. The state property is `property` when it is given, else the configured state property (profile `stateProperty`, default `state`).",
413
419
  "properties": {
414
420
  "doc": {
415
421
  "$ref": "#/$defs/anchor",
416
422
  "description": "The anchor of the source section that holds the state machine (R3: must be exactly the anchor the adapter reports for this object)."
417
423
  },
424
+ "property": {
425
+ "type": "string",
426
+ "minLength": 1,
427
+ "description": "Optional. The property of the object that holds its state. Absent: the configured state property (profile `stateProperty`, default `state`). Extension (R1, R3); no Palantir analog. R1: must be a non-empty string and a declared property of the object (else R1, key `stateMachines.<Name>.property`). R1 and R3 use it in place of the configured state property for this object only: the `canonical_values` state check, and `by` if and only if `edits` contains `<Object>.<property>`."
428
+ },
418
429
  "initial": {
419
430
  "type": "string",
420
431
  "minLength": 1,
@@ -501,6 +512,9 @@
501
512
  "id": {
502
513
  "type": "string",
503
514
  "minLength": 1,
515
+ "not": {
516
+ "const": "__proto__"
517
+ },
504
518
  "description": "Unique link id (R1); the key used in `actionTypes.<ID>.link_effects`."
505
519
  },
506
520
  "from": {
@@ -1009,13 +1023,14 @@
1009
1023
  "type": "object",
1010
1024
  "additionalProperties": false,
1011
1025
  "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. `projectedFrom` (only with `type: {ref: <Object>}`) lists the request fields, an alternate key, that the project's projection resolves to this ref; each field is a declared parameter of the same action that is not projected (R1); 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).",
1026
+ "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; each field is a declared parameter of the same action that is not projected (R1); 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). `unresolved: domain` (only on a `type: {ref: <Object>}` parameter with no `optional` and no `projectedFrom`): an id that does not resolve (absent, or only outside the scope) binds to null, not to `INVALID_BINDING` `not_found` or `SCOPE_DENIED`; a decision row tests it with `isNull` / `isNotNull`; on any other parameter R1 reports it and `decide` ignores it (SPEC section 9, Domain resolution).",
1013
1027
  "properties": {
1014
1028
  "cite": { "$ref": "#/$defs/cite" },
1015
1029
  "type": { "$ref": "#/$defs/valueType" },
1016
1030
  "optional": { "const": true },
1017
1031
  "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 } }
1032
+ "projectedFrom": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
1033
+ "unresolved": { "const": "domain" }
1019
1034
  },
1020
1035
  "allOf": [
1021
1036
  {
@@ -1025,6 +1040,14 @@
1025
1040
  {
1026
1041
  "if": { "required": ["projectedFrom"] },
1027
1042
  "then": { "required": ["type"], "properties": { "type": { "type": "object", "required": ["ref"] } } }
1043
+ },
1044
+ {
1045
+ "if": { "required": ["unresolved"] },
1046
+ "then": {
1047
+ "required": ["type"],
1048
+ "properties": { "type": { "type": "object", "required": ["ref"] } },
1049
+ "not": { "anyOf": [{ "required": ["optional"] }, { "required": ["projectedFrom"] }] }
1050
+ }
1028
1051
  }
1029
1052
  ]
1030
1053
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githonllc/avouch",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/vocab.ttl CHANGED
@@ -137,6 +137,11 @@ ofv:stateMachines a rdf:Property ;
137
137
  rdfs:comment "State machine of an object type. YAML: stateMachines.<name>" ;
138
138
  rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
139
139
 
140
+ ofv:property a rdf:Property ;
141
+ rdfs:label "property" ;
142
+ rdfs:comment "Property of the object that holds its state; default the configured state property. YAML: stateMachines.<name>.property" ;
143
+ rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
144
+
140
145
  ofv:initial a rdf:Property ;
141
146
  rdfs:label "initial" ;
142
147
  rdfs:comment "Initial state. YAML: stateMachines.<name>.initial" ;
@@ -483,6 +488,11 @@ ofv:projectedFrom a rdf:Property ;
483
488
  rdfs:comment "Request fields (an alternate key; each a declared parameter of the same action that is not projected) that the project's projection resolves to this ref parameter. An optional projected parameter is always given: null when a field is missing or null, or when no object has the key. A required one is omitted when a field is missing or null, and null when no object has the key. YAML: actionTypes.<ID>.parameters.<n>.projectedFrom[]" ;
484
489
  rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
485
490
 
491
+ ofv:unresolved a rdf:Property ;
492
+ rdfs:label "unresolved" ;
493
+ rdfs:comment "How a ref parameter binds an id that does not resolve. The only value is domain: the id binds to null, and a decision row tests it. Only on a required scalar ref parameter without ofv:projectedFrom. YAML: actionTypes.<ID>.parameters.<n>.unresolved" ;
494
+ rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
495
+
486
496
  ofv:principals a rdf:Property ;
487
497
  rdfs:label "principals" ;
488
498
  rdfs:comment "Actor kinds that one permission alternative admits, with its own citation; an actor of another kind is denied. YAML: actionTypes.<ID>.permission.principals, actionTypes.<ID>.permission.any_of[].principals" ;