@githonllc/avouch 0.1.11 → 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 +1 -3
- package/SPEC.md +20 -18
- package/dist/src/checker.js +17 -4
- package/dist/src/evaluate.js +25 -7
- package/dist/src/expr.d.ts +7 -0
- package/dist/src/expr.js +5 -0
- package/dist/src/query.d.ts +1 -0
- package/dist/src/query.js +1 -0
- package/dist/src/reconcile.js +16 -6
- package/dist/src/unknown.js +4 -4
- package/ontology.schema.json +22 -4
- package/package.json +1 -1
- package/vocab.ttl +5 -0
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.
|
|
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` |
|
|
@@ -115,7 +115,7 @@ 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); `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 |
|
|
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
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 |
|
|
@@ -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` |
|
|
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 | `
|
|
331
|
-
| 4 | `
|
|
332
|
-
| 5 | `
|
|
333
|
-
| 6 | `
|
|
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
|
|
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
|
|
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
|
|
package/dist/src/checker.js
CHANGED
|
@@ -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"];
|
|
@@ -107,6 +107,8 @@ export function check(ontology, facts) {
|
|
|
107
107
|
v("R1", "knownSourceGaps", "missing (use [])");
|
|
108
108
|
for (const [name, o] of Object.entries(objects)) {
|
|
109
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
|
|
110
112
|
if (!isObj(o)) {
|
|
111
113
|
v("R1", p, "not a mapping");
|
|
112
114
|
continue;
|
|
@@ -125,6 +127,8 @@ export function check(ontology, facts) {
|
|
|
125
127
|
if (o.non_link_fields !== undefined && !isObj(o.non_link_fields))
|
|
126
128
|
v("R1", p, "non_link_fields must be a mapping");
|
|
127
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");
|
|
128
132
|
if (!isObj(pr) || !CLASSES.includes(pr.class))
|
|
129
133
|
v("R1", `${p}.properties.${pn}`, `class must be one of ${CLASSES}`);
|
|
130
134
|
else if (pr.class !== "unknown" && !isObj(pr.cite))
|
|
@@ -169,6 +173,8 @@ export function check(ontology, facts) {
|
|
|
169
173
|
v("R1", p, "link id missing");
|
|
170
174
|
continue;
|
|
171
175
|
}
|
|
176
|
+
if (l.id === "__proto__")
|
|
177
|
+
v("R1", `linkTypes.${l.id}`, "__proto__ is a reserved name");
|
|
172
178
|
if (linkIds.has(l.id))
|
|
173
179
|
v("R1", `linkTypes.${l.id}`, "duplicate link id");
|
|
174
180
|
linkIds.add(l.id);
|
|
@@ -302,6 +308,8 @@ export function check(ontology, facts) {
|
|
|
302
308
|
for (const f of pf)
|
|
303
309
|
if (!own(params, f) || projectedFields(isObj(params[f]) ? params[f] : {}) !== null)
|
|
304
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");
|
|
305
313
|
}
|
|
306
314
|
const pids = new Set();
|
|
307
315
|
for (const [i, pc] of arr(c.conditions).entries()) {
|
|
@@ -470,6 +478,14 @@ export function check(ontology, facts) {
|
|
|
470
478
|
if (objType(name) && (!isObj(stateMachines[name]) || stateMachines[name].doc !== fact.source.anchor))
|
|
471
479
|
v("R3", `stateMachines.${name}`, `${fact.source.anchor} has a state machine for ${name}; stateMachines.${name}.doc must be ${fact.source.anchor}`);
|
|
472
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);
|
|
473
489
|
for (const [name, o] of Object.entries(objects)) {
|
|
474
490
|
if (!isObj(o) || !own(stateMachines, name))
|
|
475
491
|
continue;
|
|
@@ -516,9 +532,6 @@ export function check(ontology, facts) {
|
|
|
516
532
|
for (const c of t.by) {
|
|
517
533
|
if (!own(commands, c))
|
|
518
534
|
v("R3", `${k}.${e}`, `by ${c} is not a defined command`);
|
|
519
|
-
if (!byCmds.has(name))
|
|
520
|
-
byCmds.set(name, new Set());
|
|
521
|
-
byCmds.get(name).add(c);
|
|
522
535
|
if (triggers !== undefined && !want.has(c))
|
|
523
536
|
v("R3", `${name}:${e}:${c}`, `no source transition trigger binds ${c} to ${name} ${t.from} → ${t.to}`, "transition_binding_unsupported");
|
|
524
537
|
}
|
package/dist/src/evaluate.js
CHANGED
|
@@ -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(
|
|
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(
|
|
432
|
+
v = predicate(d, frame, cond.expr);
|
|
415
433
|
}
|
|
416
434
|
catch (err) {
|
|
417
435
|
if (!(err instanceof UndefinedDerived))
|
package/dist/src/expr.d.ts
CHANGED
|
@@ -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) {
|
package/dist/src/query.d.ts
CHANGED
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) => ({
|
package/dist/src/reconcile.js
CHANGED
|
@@ -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
|
-
//
|
|
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")
|
package/dist/src/unknown.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/ontology.schema.json
CHANGED
|
@@ -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": {
|
|
@@ -506,6 +512,9 @@
|
|
|
506
512
|
"id": {
|
|
507
513
|
"type": "string",
|
|
508
514
|
"minLength": 1,
|
|
515
|
+
"not": {
|
|
516
|
+
"const": "__proto__"
|
|
517
|
+
},
|
|
509
518
|
"description": "Unique link id (R1); the key used in `actionTypes.<ID>.link_effects`."
|
|
510
519
|
},
|
|
511
520
|
"from": {
|
|
@@ -1014,13 +1023,14 @@
|
|
|
1014
1023
|
"type": "object",
|
|
1015
1024
|
"additionalProperties": false,
|
|
1016
1025
|
"required": ["cite"],
|
|
1017
|
-
"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).",
|
|
1018
1027
|
"properties": {
|
|
1019
1028
|
"cite": { "$ref": "#/$defs/cite" },
|
|
1020
1029
|
"type": { "$ref": "#/$defs/valueType" },
|
|
1021
1030
|
"optional": { "const": true },
|
|
1022
1031
|
"values": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
|
|
1023
|
-
"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" }
|
|
1024
1034
|
},
|
|
1025
1035
|
"allOf": [
|
|
1026
1036
|
{
|
|
@@ -1030,6 +1040,14 @@
|
|
|
1030
1040
|
{
|
|
1031
1041
|
"if": { "required": ["projectedFrom"] },
|
|
1032
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
|
+
}
|
|
1033
1051
|
}
|
|
1034
1052
|
]
|
|
1035
1053
|
},
|
package/package.json
CHANGED
package/vocab.ttl
CHANGED
|
@@ -488,6 +488,11 @@ ofv:projectedFrom a rdf:Property ;
|
|
|
488
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[]" ;
|
|
489
489
|
rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
|
|
490
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
|
+
|
|
491
496
|
ofv:principals a rdf:Property ;
|
|
492
497
|
rdfs:label "principals" ;
|
|
493
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" ;
|