avouch 0.1.22
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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +356 -0
- package/SPEC.md +379 -0
- package/dist/cli/avouch.d.ts +2 -0
- package/dist/cli/avouch.js +68 -0
- package/dist/cli/check.d.ts +86 -0
- package/dist/cli/check.js +333 -0
- package/dist/cli/facts.d.ts +3 -0
- package/dist/cli/facts.js +157 -0
- package/dist/cli/gate.d.ts +3 -0
- package/dist/cli/gate.js +149 -0
- package/dist/cli/init.d.ts +21 -0
- package/dist/cli/init.js +282 -0
- package/dist/cli/jsonl.d.ts +5 -0
- package/dist/cli/jsonl.js +20 -0
- package/dist/cli/query.d.ts +2 -0
- package/dist/cli/query.js +138 -0
- package/dist/cli/reconcile.d.ts +9 -0
- package/dist/cli/reconcile.js +131 -0
- package/dist/src/checker.d.ts +3 -0
- package/dist/src/checker.js +1136 -0
- package/dist/src/contract.d.ts +135 -0
- package/dist/src/contract.js +3 -0
- package/dist/src/coverage.d.ts +65 -0
- package/dist/src/coverage.js +237 -0
- package/dist/src/evaluate.d.ts +66 -0
- package/dist/src/evaluate.js +535 -0
- package/dist/src/expr.d.ts +209 -0
- package/dist/src/expr.js +497 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +11 -0
- package/dist/src/ledger.d.ts +96 -0
- package/dist/src/ledger.js +241 -0
- package/dist/src/markdown.d.ts +66 -0
- package/dist/src/markdown.js +701 -0
- package/dist/src/patch.d.ts +6 -0
- package/dist/src/patch.js +37 -0
- package/dist/src/query.d.ts +333 -0
- package/dist/src/query.js +739 -0
- package/dist/src/reconcile.d.ts +61 -0
- package/dist/src/reconcile.js +151 -0
- package/dist/src/stage1-check.d.ts +31 -0
- package/dist/src/stage1-check.js +329 -0
- package/dist/src/touched.d.ts +1 -0
- package/dist/src/touched.js +9 -0
- package/dist/src/unknown.d.ts +10 -0
- package/dist/src/unknown.js +173 -0
- package/docs/adapter-guide.md +187 -0
- package/docs/markdown-convention.md +635 -0
- package/ontology.schema.json +1757 -0
- package/package.json +57 -0
- package/templates/agents-block.md +12 -0
- package/vocab.ttl +524 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Avouch v1: specification
|
|
2
|
+
|
|
3
|
+
Avouch is an ontology spec whose every claim cites its source. Status: experimental draft. It is not a standard.
|
|
4
|
+
|
|
5
|
+
Format version: `formatVersion: 1` in the ontology file; `v1` in the schema `$id` (`https://w3id.org/avouch/v1/schema.json`).
|
|
6
|
+
|
|
7
|
+
Identifiers: every Avouch identifier is under `https://w3id.org/avouch/`, a permanent redirect service. Write only these identifiers in files and exports. The schema `$id` is `https://w3id.org/avouch/v1/schema.json`, and the vocabulary namespace is `https://w3id.org/avouch/v1/vocab#`. `https://avouch.dev/` is where the documents are hosted. It is a location, never an identifier.
|
|
8
|
+
|
|
9
|
+
## 1. Three layers
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
(1) format layer this package schema, fact contract, rules R1-R12
|
|
13
|
+
(2) project profile+adapter the project's own code config, rule switches, parsers of the project's sources
|
|
14
|
+
(3) ontology instance <project>.ontology.yaml the project's objects, links, actions
|
|
15
|
+
```
|
|
16
|
+
|
|
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
|
+
|
|
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
|
+
|
|
21
|
+
### Admission criteria for the format layer
|
|
22
|
+
|
|
23
|
+
Only generic needs go into the format. A special-case need stays in the project profile or the ontology instance. A new concept, field or rule enters the format only when all three are true:
|
|
24
|
+
|
|
25
|
+
1. It is a modelling pattern that applies across projects, not the habit of one project.
|
|
26
|
+
2. The existing constructs cannot express it, or can express it only by a workaround that loses a check.
|
|
27
|
+
3. Its meaning holds no project-specific values. Such values go into the profile configuration (for example `config.crossContextMechanisms`).
|
|
28
|
+
|
|
29
|
+
Example: objects shared by several contexts (a shared kernel) are a cross-project pattern. The format already expresses them: the shared objects get their own context, and actions that write them declare `crossContext`. An exemption such as "writes to shared objects need no declaration" fails criteria 2 and 3, so it is not in the format.
|
|
30
|
+
|
|
31
|
+
### Limit of the expression language
|
|
32
|
+
|
|
33
|
+
The expression language (section 9) is a restricted predicate language that can be checked statically. It reads domain facts and decides the conditions of decision tables. It is not a language in which a project implements its rules, for two reasons: the ontology states the rules independently of the runtime, so that agreement in `reconcile` means something; and the static checks of section 9 (types, the UNKNOWN analysis) must stay decidable.
|
|
34
|
+
|
|
35
|
+
So a new expression node must meet the three criteria above and do one of these two things:
|
|
36
|
+
|
|
37
|
+
- read a fact that no node reads now: of the snapshot, the invocation or the caller, including a field of a parameter or of a stored value;
|
|
38
|
+
- decide a bounded claim over facts: a comparison, a quantifier over a finite collection, or a count or sum over a finite collection inside a comparison.
|
|
39
|
+
|
|
40
|
+
Value nodes such as `{lit}`, `plus` and `dateIn` only give a comparison its operands. Not admitted: user-defined functions, recursion, unbounded iteration, nodes that build new collections, records or strings, and other arithmetic than `plus`, `dateIn` and the count or sum above.
|
|
41
|
+
|
|
42
|
+
## 2. Concepts
|
|
43
|
+
|
|
44
|
+
A key is foundation when Palantir Foundry has the same concept (names follow https://www.palantir.com/docs/foundry/ontology/overview/ and the pages for each type). A key is extension when Palantir has no analog; the rule that uses it is named.
|
|
45
|
+
|
|
46
|
+
The definitions quoted in the "Palantir analog" column are short quotations from the Palantir Foundry documentation. They belong to their authors and are not covered by the license of this project.
|
|
47
|
+
|
|
48
|
+
| Key | Class | Palantir analog | Note |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `objectTypes` | foundation | object type: "the schema definition of a real-world entity or event" | no primary key or title key |
|
|
51
|
+
| `objectTypes.<O>.datasource` | foundation | backing datasource | R7 maps written stores back to objects |
|
|
52
|
+
| `properties` | foundation | property: "the schema definition of a characteristic of a real-world entity or event" | |
|
|
53
|
+
| `properties.<p>.class` | extension | none | canonical / derived / policy / opaque / unknown. `opaque`: the source decides that the value is not a fact: it is not computed from the facts of the ontology, the ontology does not model how it is made (for example an estimate, a score, a cache or a value that a process outside the actions writes), and no condition reads it; needs a cite, has no `canonical_values`, and is not the `via` of a link type. `unknown`: the source does not classify it. R1, R4 |
|
|
54
|
+
| `properties.<p>.type` | foundation | property base type | value type (`enum timestamp duration date timezone id integer number boolean string`, `{ref: <Object>}`, `{set: <type>}`); needed when an expression reads the property; needs a cite (R2 `type_without_provenance`) |
|
|
55
|
+
| `properties.<p>.materializedFrom` | extension | none | stored property materialized with its inputs; R11. Not a Palantir derived property (computed at runtime, read-only) |
|
|
56
|
+
| `properties.<p>.from` | extension | none | `{field, key}`: the property is the value of the top-level key `<key>` of the JSON object in the column `<field>` of the same row; needs `type`; `field` is a declared property of the same object without `from` and `type`; may be null; a projected `{ref: <Object>}` property is not navigable (it is not the `via` of a link type); R1, R2, R11 (section 9, `projectSnapshot`) |
|
|
57
|
+
| `derivedProperties` | foundation | derived property: "calculated at runtime based on the values of other properties or links" | unstored predicates |
|
|
58
|
+
| `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 |
|
|
59
|
+
| `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) |
|
|
60
|
+
| `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" | |
|
|
61
|
+
| `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 |
|
|
62
|
+
| `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 |
|
|
63
|
+
| `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 |
|
|
64
|
+
| `edits` / `creates` / `deletes` | foundation | action rules: modify / create / delete object | `deletes` is a hard delete of datasource rows; a soft delete is an `edits` |
|
|
65
|
+
| `link_effects` | foundation + constraint | action rules: create / delete link | every incident link needs an entry, including `none` / `unspecified` (R6 sweep) |
|
|
66
|
+
| `emits` | extension | none (Palantir side effects are notifications / webhooks after submission) | events written in the same operation; R7, R8 |
|
|
67
|
+
| `permission` | extension | roles / submission criteria on the current user are approximate. Forms: `unknown`, `{keys, cite, …}`, `{none, cite}` (optionally with `principals`), `{any_of: [keys-form, …]}` (OR of keys-forms, at least 2) | R1, R10 |
|
|
68
|
+
| `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 |
|
|
69
|
+
| `permission` … `conditional_when` | extension | none | optional on a keys-form or an `any_of` alternative, only with `conditional_keys`: when the conditional keys are needed, as a predicate over request inputs (section 9, Permission conditions); F: not needed, T: needed, U or absent: undecided; R1, R4 |
|
|
70
|
+
| `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 |
|
|
71
|
+
| `dispositions` | extension | none | optional result vocabulary `{<ID>: {class: rejected \| recorded \| pending \| applied, cite}}`; decision rows name these ids; R1, R2 |
|
|
72
|
+
| `stateMachines` | extension | none | R1, R3; `stateMachines.<O>.property` (optional) names the property of `<O>` that holds its state; absent, it is `config.stateProperty` |
|
|
73
|
+
| `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 |
|
|
74
|
+
| `evidence` | extension | none | id of the executed evidence, resolved by the evidence adapter; R1, R6, R7 |
|
|
75
|
+
| `cite` / `knownSourceGaps` | extension | none | provenance and waivers; R2 |
|
|
76
|
+
| `crossContext`, `infrastructureStores`, `outOfScopeObjectTypes` | extension | none | optional; `crossContext` is `{via, cite}` (legal `via` values come from the project config) or `{unspecified, cite}` when the source does not say the mechanism (R5 `mechanism_unspecified`, waivable) |
|
|
77
|
+
|
|
78
|
+
Keys that keep their original names (not in the Palantir mapping): `permission_rows_not_commands`, `link_effects`, `non_link_fields`, `edit_cites`, `doc_term`, `create_rule`, and the link key `table`.
|
|
79
|
+
|
|
80
|
+
## 3. Field reference
|
|
81
|
+
|
|
82
|
+
The schema `ontology.schema.json` is the field reference: each `description` states whether the field is foundation or extension, and which rule enforces what. The description text and this section are the same source.
|
|
83
|
+
|
|
84
|
+
## 4. Fact contract
|
|
85
|
+
|
|
86
|
+
The contract is `src/contract.ts` (types `SourceFacts`, `Fact`, `Diagnostic`, `FormatConfig`, `Invocation`, `StoreDelta`, `RowId`, `EvaluationInputs`, `StateMachineFact`, `Permissions`). Every fact carries its `source` (`anchor`, optional `locator`).
|
|
87
|
+
|
|
88
|
+
No fact that an adapter returns may come from text generated from the ontology; the adapter excludes such text before it extracts any fact. The provenance of a claim is its `cite`, or a fact that the adapter returns with its source location.
|
|
89
|
+
|
|
90
|
+
Evidence is a list of invocation records per evidence id (`Invocation`). One record is one invocation of the operation:
|
|
91
|
+
|
|
92
|
+
- `id`: generated by the harness; it is not the idempotency key of the request.
|
|
93
|
+
- `outcome`: `committed`, or `rejected` when every attempt rolled back.
|
|
94
|
+
- `snapshot`: a reference to the snapshot taken before the invocation, or `null`.
|
|
95
|
+
- `inputs`: the evaluation inputs `bindings`, `actor`, `scope` and `now`.
|
|
96
|
+
- `delta`: the committed net change; `null` (present, not omitted) when the outcome is `rejected`. Per store, row by row, with a stable row identity that the adapter supplies (for example a SQLite rowid; not a key column, because an update can change a key column). The row identity must stay the same across an UPDATE; a replace-style write (for example `INSERT OR REPLACE`) gives the row a new identity and shows up as a delete and an insert. A row identity must not be reused within one invocation: a store that gives a deleted row's identity to a new row in the same invocation (for example a SQLite rowid table without `AUTOINCREMENT`, where the row with the largest rowid is deleted and a row is then inserted) makes the delete and the insert show up as an update. An insert gives the row and its after image; an update gives the row and the before and after values of each changed column; a delete gives the row and its before image. `events` lists the event types written.
|
|
97
|
+
- `disposition`: optional; the disposition that the runtime reported.
|
|
98
|
+
- `rolledBackAttempts`: a non-negative integer; rolled-back attempts are only counted (printed in `info`); they are never in `delta`.
|
|
99
|
+
|
|
100
|
+
Records are runtime data (for example parsed JSON), so the checker checks their shape. A well-formed record is a mapping with a non-empty string `id`, a non-negative integer `rolledBackAttempts`, and either `outcome: committed` with a `delta` whose `stores` is a mapping of store deltas (lists `inserted`, `updated` and `deleted`; each entry has a string or number `row` and an `after`, `changes` or `before` mapping) and whose `events` is a list of strings, or `outcome: rejected` with `delta: null`. Any other record (including a list entry that is not a mapping) counts as neither committed nor rejected, adds nothing to the observed changes or the attempt count, and is reported as an info diagnostic (not a violation): `R7 evidence <evidence id>: malformed invocation <invocation id>: <reasons>`, where the id is `[<list index>]` when the record has no string `id` (an adapter should always set an `id`). An invocation list that is not a list counts as empty and is reported once the same way (id `list`). The check never throws.
|
|
101
|
+
|
|
102
|
+
Aggregates (changed stores, committed count) are derived by the checker from the records; the adapter does not store them. How the adapter takes snapshots, and its size and time budget, belong to the adapter, not to this format.
|
|
103
|
+
|
|
104
|
+
Three states of a fact:
|
|
105
|
+
|
|
106
|
+
| State | Meaning |
|
|
107
|
+
|---|---|
|
|
108
|
+
| field is `undefined` | the adapter does not provide this fact. If a rule that needs it is enabled: one violation, kind `fact_missing`, key `facts.<field>`; the checks that depend on the fact are skipped |
|
|
109
|
+
| field present | the adapter parsed as much as it could; parse failures are reported as diagnostics; the value holds only what parsed (it may be empty). The checker compares as usual (fail closed) |
|
|
110
|
+
| exception | an entry of `stateMachines` may be `null`: the machine did not parse, a diagnostic was reported, and the checker skips the graph comparison for that object |
|
|
111
|
+
|
|
112
|
+
The adapter may read the ontology instance (for example to list action ids in a trigger cell). Therefore facts are built per ontology, including per mutation. The returned `SourceFacts` is a plain object literal whose functions are own properties, so a test can drop one fact with `{ ...facts, eventCatalog: undefined }`.
|
|
113
|
+
|
|
114
|
+
`diagnostics` are adapter findings (parse errors, duplicate rows, harness errors). For example, the evidence adapter reports an observed write that it cannot attribute to exactly one evidence id as an R7 diagnostic. They are reported and waived exactly like violations. `info` lines are printed.
|
|
115
|
+
|
|
116
|
+
## 5. Rules R1-R12
|
|
117
|
+
|
|
118
|
+
| Rule | Checks | Facts used | Guarantee | Not guaranteed |
|
|
119
|
+
|---|---|---|---|---|
|
|
120
|
+
| 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}`; a permission `conditional_when` is only on a form with `conditional_keys`, uses only the nodes `eq` `neq` `lt` `lte` `gt` `gte` `and` `or` `not` `in` `subsetOf` `isNull` `isNotNull` `ref` `lit`, reads only `actor.id` / `actor.keys` / `actor.kind` and parameters of its action that are not projected (a `{ref}` without a dot), and type-checks as a predicate (key `<ID>`); `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; a property whose `class` is not `unknown` has a `cite`, an `opaque` property has no `canonical_values`, and no link type has an `opaque` property as its `via` (key `linkTypes.<L>.via`); the `via` of a foreign-key link (no `table`, or `table` is the `datasource` of an object type), when it is a declared property of `from` with a `type`, has type `id` or `{ref: T}` (key `linkTypes.<L>.via`; not checked for an `opaque` or projected `via`, which is R1 already); the `scope.by` field, when it is a declared property of a tenant type with a `type` and without `from`, has type `id` or `{ref: T}` (key `objectTypes.<O>.properties.<by>`); a property with `from` has `type`, its `from` has a non-empty `field` and `key`, and `field` names a declared property of the same object without `from` and `type` (key `objectTypes.<O>.properties.<p>.from`); a property with `from` is not `id`, not the `scope.by` field and not the state property of an object with a machine (same key); it is not the `via` of a link type (key `linkTypes.<L>.via`), and no action names it in `edits` (key `actionTypes.<ID>`; the action edits its `field`); 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 |
|
|
121
|
+
| R2 | anchors unique, quotes verbatim (also under `derivedProperties`, `scope` and `dispositions`), properties are fields (except a projected property (`from`): its name is not a field line (`projected_property_is_field`) and, when its cite has a quote, its key is in that quote (`projection_key_not_in_quote`), both keyed `<Object>.<prop>`), 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; for example, that a quote of a list keeps the other items of the list when the claim depends on them or on the list being complete (a verbatim quote can stop inside a list) |
|
|
122
|
+
| 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 |
|
|
123
|
+
| R4 | conditions read only canonical and policy properties, for an expression also through the derived properties it reaches (a navigation over a foreign-key link reads its `via` on the rows of the link's `from` object, in both directions, when `via` is a declared property of that object; a `via` that is not a declared property is the link's own column, whose source is the link's `doc`, and is not classified; a `via` whose name, or the name of whose object, has a dot is not classified; a navigation over a pure link reads no property; 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 other than an `opaque` one passes when every read compares it by `eq` / `neq` with a literal in its `canonical_values` (the read of a `via` by a navigation is not such a comparison); 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; section 9, Literals in quotes), or in the derived property's quote (`literal_unsupported`, key `<ID>:<condition id>:<value>` or `derivedProperties.<id>:<value>`); each literal of a permission `conditional_when` appears in the `conditional_cite` quote of its form (`literal_unsupported`, key `<ID>:conditional_when:<value>` or `<ID>:any_of[<i>].conditional_when:<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 |
|
|
124
|
+
| 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 |
|
|
125
|
+
| 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 |
|
|
126
|
+
| R7 | observed writes are declared; events are in `emits`; changed columns are declared (`column_undeclared`, key `<ID>:<Object>.<prop>`); declared effects are witnessed (`effect_unwitnessed`); with `config.evidenceRequired: true` (default `false`) an action without `evidence` fails `evidence_missing` (key `<ID>`) | `evidence`, `config.evidenceRequired` | every store changed by a committed invocation is in the touched objects' `datasource`, a pure link table with an `effect`, or `infrastructureStores`; every observed event is in `emits`; every column changed on an existing row of a touched object's `datasource` is, for each touched object of that store, a property of a resolvable `edits` entry of that same object (objects may share a store; a column that one object declares is still reported for each other touched object of the store that does not declare it); every resolvable `edits` entry (resolved as R1 resolves it; an unresolvable entry is R1 only) was changed on an existing row (key `<ID>:<Object>.<prop>`), every `creates` object had a row inserted (key `<ID>:creates:<Object>`), every `deletes` object had a row deleted (key `<ID>:deletes:<Object>`) and every pure link table with an `effect` had a row inserted or deleted (key `<ID>:link:<link id>`) by at least one committed invocation | actions without evidence (unless `evidenceRequired`); that a not yet witnessed effect is unreachable; atomicity, which refers to the committed effect (one invocation may have several attempts) and which the project profile or evidence adapter must enforce |
|
|
127
|
+
| R8 | emitted events are in the catalog | `eventCatalog` | | |
|
|
128
|
+
| R9 | scenario cross references | `scenarios` | every action is cited; with `config.scenarioCoverage: "enforced"` (default `"printed"`, coverage only printed) every condition without `scenarios` fails `criterion_uncovered` (key `<ID>:<condition id>`) and every non-`none`, non-`unspecified` link effect without `scenarios` fails `effect_uncovered` (key `<ID>:<link id>`); `unspecified` effects are not reported again, R6 reports them | with `printed`, that scenarios cover every condition; that a listed scenario supports the claim |
|
|
129
|
+
| R10 | permission key sets equal the source rows (for `any_of`: the union over alternatives of keys ∪ conditional_keys; each alternative's cite must also name its own keys); cites name the required source | `permissions`, `config.requiredSource.R10` | keys match the source | |
|
|
130
|
+
| R11 | an action that edits an input, creates an input's object, or deletes an input's object (unless it also deletes the materialized property's own object, whose row is gone) lists the materialized property. An action that deletes the property's own object and also edits or creates an input is still reported, because the edited inputs may belong to other rows; waive it with a reason when they cannot. The count is column level: an action that edits `<O>.<field>` counts as an action that edits each property projected from it (`from`), whichever key of the JSON column it writes. | none | declaration is complete | same-operation atomicity of the committed effect (one invocation may have several attempts), which the evidence adapter guarantees; that the computation of the value agrees with `reads`: the check does not find a materialized value that keeps its own stored value, whether `reads` lists the property or not. A reliable check needs a mapping from the sources to the materialized value that can be proved (the semantics of the materialization, the provenance of the transformation, or evidence from an adapter), which the format does not have |
|
|
131
|
+
| R12 | decision tables, all waivable: an action with conditions has a `decision` (`decision_table_missing`, key `<ID>`); a table has `otherwise` (`otherwise_missing`); a row does not use a condition without expression, or one that reaches a derived property without expression (`condition_unspecified`, key `<ID>:<condition id>`) (implementation decision: like the R4 and literal checks, the derived-property part is computed only for conditions whose expression passes the R1 type check); rows with different results have `order` (`row_order_uncited`); for rows `i < j` with different results whose position cites are in one section, the position of `i` comes first (`row_order_mismatch`, key `<ID>:rows.<i>:rows.<j>`, once per pair; implementation decision, since facts carry no order across sections: a row's position cite is in the section of the first listed cite whose quote names its result, and its position is the earliest such quote in that section). A row whose position-cite section is `missing` or `ambiguous` has no position and is not compared. Rows whose position cites are in different sections are not compared, even if they also cite a shared section; ordering across sections relies on `decision.order` and scenarios with overlapping inputs; each row names its result in a cite quote (`result_unsupported`, key `<ID>:rows.<i>` or `<ID>:otherwise`); `next: {unspecified}` is reported (`next_unspecified`); a row `passes(c)` where c may be UNKNOWN, no earlier row is `fails(c)`, and a later row up to the first later `fails(c)` row (inclusive), or `otherwise` when there is no such row, has class `applied` or `pending` (`unsafe_fallthrough`, key `<ID>:rows.<i>`); the body of an `exists` / `none` / `all` may be UNKNOWN (`quantifier_absorbs_unknown`, key `<ID>:<condition id>:<path>` or `derivedProperties.<id>:<path>`, path as in type errors, for example `$.and[1].none`); both see section 9, Static UNKNOWN analysis | `section`, `fieldList` | every row has a source for its result and, within one section, for its place | that rows do not overlap; that the order cite covers the whole table |
|
|
132
|
+
|
|
133
|
+
## 6. Rule switches
|
|
134
|
+
|
|
135
|
+
`config.rules` must have an entry for every rule.
|
|
136
|
+
|
|
137
|
+
- A missing entry: one violation `{ rule, kind: "rule_not_configured", key: "config.rules.<R>" }`; the rule runs as enabled.
|
|
138
|
+
- `{ disabled: "<reason>" }`: violations of that rule and diagnostics with that rule are dropped; `info` gets `rule <R> disabled: <reason>`; the facts the rule needs are not checked for presence.
|
|
139
|
+
- `true`: the rule runs. For each fact the rule needs, `undefined` gives `fact_missing` (section 4):
|
|
140
|
+
|
|
141
|
+
| Rule | Needed optional fact | Checks skipped when it is missing |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| R1, R4, R11, R12 | none | none |
|
|
144
|
+
| R2 | `storeCatalog` | store catalog membership |
|
|
145
|
+
| R3 | `stateMachines` | all except the `doc` anchor check (`stateMachines.<O>.doc` must give exactly one source section) |
|
|
146
|
+
| R3 | `transitionTriggers` | trigger binding checks |
|
|
147
|
+
| R5 | `contextMembers` | membership and entity coverage |
|
|
148
|
+
| R6 | `evidence` | `none_but_written` |
|
|
149
|
+
| R7 | `evidence` | all except `evidence_missing` |
|
|
150
|
+
| R8 | `eventCatalog` | catalog membership |
|
|
151
|
+
| R9 | `scenarios` | all |
|
|
152
|
+
| R10 | `permissions` | all |
|
|
153
|
+
|
|
154
|
+
The table lists only the optional facts. Every rule may also read the required facts `section`, `containsTerm` and `fieldList`. When an adapter cannot answer them (`section` gives `missing` or `ambiguous`, `fieldList` gives `null`), the reading rule degrades as written in its own row: for example, R12 compares no pair of rows for `row_order_mismatch`, and the UNKNOWN analysis treats every field of that object as nullable.
|
|
155
|
+
|
|
156
|
+
## 7. Waivers
|
|
157
|
+
|
|
158
|
+
`knownSourceGaps` entries waive a violation when `rule`, `kind` and `key` all match. Waived items are printed. A waiver that matches nothing fails under its own rule with kind `stale_waiver`. Waivers treat violations and adapter diagnostics alike.
|
|
159
|
+
|
|
160
|
+
## 8. Versions
|
|
161
|
+
|
|
162
|
+
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. A fix that makes a check or `decide` do what the rule text already states is not a break either, also when it rejects a file or a snapshot that passed before; `formatVersion` does not change. 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. Adding `conditional_when` on a permission form with `conditional_keys` is additive: a form without it is undecided as before when only conditional keys are missing; `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. Adding the property class `opaque` (with a required cite, no `canonical_values` and no use as a link `via`) is additive: existing files stay valid and `unknown` keeps its meaning; `formatVersion` stays 1. Adding `from` on properties is additive; `formatVersion` stays 1. The rule that a foreign-key `via` and the scope field hold ids rejects an ontology whose `via` or scope field is a declared property with a type other than `id` or `{ref: T}` (R1), and makes `decide` throw for a snapshot whose `via` or scope field value is neither a string nor null, or does not fit the declared type. Such a file or snapshot can have passed before: navigation gave the empty set, the row was not visible, the result did not change (for a row in another scope), or, for a `via` or scope field declared with another type (for example `enum`) that held id strings, navigation and the scope filter worked. `reconcile` gives `evaluator_error` for such a snapshot. This change can break a file that worked; it is made under the 0.x rule below, and `formatVersion` stays 1. Adding the scalar type `number`, and number literals that are not integers, is additive: the schema rejected both before, so no conforming file used them; `formatVersion` stays 1. R4 now finds a number literal in a quote by its value (section 9, Literals in quotes): a number literal that was found only as a part of a longer text, for example `5` in "2.5", "-5" or "No.5", or `1` in "1,000", now gets `literal_unsupported`, and a number literal can now be found where it was not found before, for example `{lit: -5}` in "−5 degrees" (with U+2212), so a `knownSourceGaps` waiver for such a violation now fails as `stale_waiver`. This change can break a file that passed; it is made under the 0.x rule below, and `formatVersion` stays 1. Adding the scalar type `string`, read only by `isNull` and `isNotNull`, is additive: the schema accepted `string` only as a set element before, so no conforming file used it as a scalar; `formatVersion` stays 1.
|
|
163
|
+
During 0.x the v1 draft changes in place (`formatVersion` 1, `$id` v1); the rule above applies from the 1.0 semantic freeze.
|
|
164
|
+
|
|
165
|
+
## 9. Decision semantics
|
|
166
|
+
|
|
167
|
+
Stage 1A; runtime consistency is Stage 1B. Code: `src/expr.ts` (AST, types, static check) and `src/evaluate.ts` (`evalValue`, `evalPredicate`, `decide`). The schema carries these keys (section 1); this section defines their meaning. Examples use a toy library: `Branch` (root), `Person` (global), `Member`, `Book`, `Loan` (tenant, `by: branch_id`).
|
|
168
|
+
|
|
169
|
+
### Expressions and two-level evaluation
|
|
170
|
+
|
|
171
|
+
Every node is a single-key map. The reserved names `now`, `actor` and `self` are exported as `RESERVED_NAMES`. The node set is fixed (`EXPR_NODES`); section 1, "Limit of the expression language", says which new nodes can be admitted:
|
|
172
|
+
|
|
173
|
+
| Level | Nodes | Result |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| value | `{ref: path}`, `{lit: v}`, `{plus: [t, d]}`, `{dateIn: [t, tz]}`, `{derived: {id, of: {ref: x}}}` | a typed value or null (`evalValue`) |
|
|
176
|
+
| predicate | `eq` `neq` `lt` `lte` `gt` `gte` `[a, b]`; `and` `or` `[p, q, …]` (2 or more); `not` `[p]`; `in` `[element, set]`; `subsetOf` `[set, set]`; `isNull` `isNotNull` `[a]`; `exists` `none` `all` `{as, in: <nav>, where?}` | T, F or U (`evalPredicate`); a value node of type `boolean` is also a predicate |
|
|
177
|
+
| navigation | `{nav: {from: {ref: x}, link, dir: forward \| reverse}}` | a set of objects; only as the `in` of a quantifier |
|
|
178
|
+
|
|
179
|
+
`{passes: c}` and `{fails: c}` are not expression nodes; they occur only in the `when` of a decision row. An omitted `where` is T.
|
|
180
|
+
|
|
181
|
+
Names in `{ref}` (at most one dot): `now` (timestamp), `actor.id` (id), `actor.keys` (`{set: string}`), `actor.kind` (`enum`: the kind of the caller, as in `principals`; no value domain is declared, so the value of a literal compared with it is checked only by R4 `literal_unsupported`, and, as for any two `enum` values, a comparison with another `enum` property or parameter type-checks whether or not it has a meaning), a parameter, a quantifier variable, and `self` (only in a derived property expression). `x.prop` reads a declared, typed property of the object binding `x`; a derived property is read only through `{derived}`; `x.id` is not supported (write `{ref: x}`). A quantifier variable must not reuse a reserved name, a parameter name or an outer variable. A derived expression sees only `self`, `now`, `actor` and its own quantifier variables.
|
|
182
|
+
|
|
183
|
+
### Types and values
|
|
184
|
+
|
|
185
|
+
`Scalar = enum | timestamp | duration | date | timezone | id | integer | number | boolean | string | {ref: T}`; `Type = Scalar | {set: Scalar}`.
|
|
186
|
+
|
|
187
|
+
| Type | Value | Operations |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `timestamp` | epoch milliseconds, a safe integer in [`TIMESTAMP_MIN`, `TIMESTAMP_MAX`] = [-62135510400000, 253402214399999] (0001-01-02T00:00:00.000Z .. 9999-12-30T23:59:59.999Z, inclusive) | compare; `plus(timestamp, duration) -> timestamp` |
|
|
190
|
+
| `duration` | seconds, fixed length (safe integer) | compare |
|
|
191
|
+
| `integer` | safe integer | compare only, no arithmetic |
|
|
192
|
+
| `number` | a finite number: a JSON number that is not NaN or ±Infinity, held as an IEEE 754 double (for example a quantity such as 2.5, or a coordinate) | compare only, no arithmetic; an `integer` and a `number` are two types |
|
|
193
|
+
| `date` | `"YYYY-MM-DD"` | compare (lexical order); `dateIn(timestamp, timezone) -> date`, year padded to 4 digits |
|
|
194
|
+
| `timezone` | a time-zone name accepted by the host's `Intl.DateTimeFormat`; use IANA names, because other forms such as UTC offsets are engine-dependent | only in `dateIn` |
|
|
195
|
+
| `enum`, `id`, `{ref: T}` | string | `eq`, `neq`; `id` compares with any `{ref: T}`; two refs need the same `T` |
|
|
196
|
+
| `boolean` | `true` / `false` | `eq`, `neq`, `isNull`; in a predicate position true / false / null give T / F / U; no literals |
|
|
197
|
+
| `string` | any string, `""` included (`""` is a value, not null) | only `isNull`, `isNotNull`; no literals; as a set element (`{set: string}`, for example `actor.keys`), see `{set: E}` |
|
|
198
|
+
| `{set: E}` | array (order and repeats do not matter); `E` may be any scalar, `boolean` included | only `in`, `subsetOf` |
|
|
199
|
+
| any | `null` = no value | |
|
|
200
|
+
|
|
201
|
+
Type rules: `lt` `lte` `gt` `gte` need two operands of one type among timestamp, date, duration, integer, number. An `integer` and a `number` are two types, so a comparison of the two is an error; a literal takes the type of its position, so `{lit: 2}` fits a `number` position and `{lit: 2.5}` does not fit an `integer` position. A value of type `string` (a property, a parameter or a derived property) is read only by `isNull` and `isNotNull`: a comparison with a `string` operand, an `in` whose element has type `string`, and a literal in a `string` position are errors. A string literal can still be an element in a `{set: string}` position: `in({lit: "a"}, {ref: actor.keys})` and `subsetOf({ref: actor.keys}, {lit: ["a", "b"]})` type-check. `eq` and `neq` reject sets. A literal has no type of its own; it takes the type of its position (the other operand, the function argument, the set element) and must fit it. An operation with two literal operands is an error. `isNull` / `isNotNull` take a non-literal value. A predicate node in a value position is an error; a value node in a predicate position is an error unless its type is `boolean`. There are no boolean literals: write `x` or `not(x)`, not `eq(x, true)`. A derived property used in an expression declares `type`. If its `expr` root is a predicate node, the declared type must be `boolean`; a derived property declared `boolean` must have a predicate root (a value root such as `{ref: self.flag}` is an error); for any other declared type the root is a value node of that type. `evalValue` of a boolean derived property gives true / false / null (U gives null). Dependency cycles are errors.
|
|
202
|
+
|
|
203
|
+
One format table (`fitsType`) serves literals and parameter values: `id` and `{ref: T}` are non-empty strings; `enum` and `string` a string; `timezone` a name that `Intl.DateTimeFormat` accepts (no `RangeError`); `date` matches `^\d{4}-\d{2}-\d{2}$`; `timestamp` a safe integer within the bounds above; `duration` and `integer` safe integers; `number` a finite number (not NaN, not ±Infinity); `boolean` `true` or `false`; `{set: E}` an array whose elements fit `E`. A literal that does not fit is a static error (an `ExprError`); a parameter value that does not fit is `INVALID_BINDING` `type`. `values` does not change `fitsType`: a parameter value that fits `enum` but is not among the parameter's `values` is `INVALID_BINDING` `type` from a separate check in the shape step. Properties have no `values`. A project's request validation may reject such a value earlier from `values`; that is the project's projection. The timestamp bounds keep a one-day margin from the years 0 and 10000, which covers every UTC offset, so `dateIn` always gives a year from 1 to 9999. A `plus` result, a `dateIn` timestamp operand or a `now` outside the bounds throws ("timestamp out of supported range"): it means malformed input, not U. `decide` checks `now` in step 0 (input errors), after the action lookup; direct `evalValue` / `evalPredicate` callers get the range check only through `plus` and `dateIn`. A snapshot property value that is not null and does not fit the property's declared type (for example `1` for a `boolean`) is a malformed snapshot and throws when read. `plus` and `dateIn` with a null operand give null.
|
|
204
|
+
|
|
205
|
+
Literals in quotes. R4 (`literal_unsupported`) looks for each literal of an expression in a cite quote, by the JSON kind of the literal. A string literal is found where it appears as a whole identifier: no ASCII letter, digit or `_` directly before or after it; the empty string is never found. A number literal is found when a number of the quote has the same value. Two separate rules give the numbers of a quote: the span rule sets where a unit of text begins and ends, and the number rule sets which units are numbers. The span rule only stops a match of a number inside a longer token, such as "1.5.x" or "v1e-7"; it does not change what a number is. A span is a longest run of ASCII letters, ASCII digits and `_`, in which a joiner joins two parts: `.` when a letter or a digit follows it; `+`, `-` or `−` (U+2212) only when a digit follows it; `,` only when a digit comes before it and exactly three digits, then no digit, follow it. A span may also begin with `.`, `+`, `-` or `−` directly before a digit. A number is an optional sign (`+`, `-` or `−`; `−` is read as `-`), an integer part (`0`, or digits that do not begin with `0`), an optional fraction (`.` and digits) and an optional exponent (`e` or `E`, an optional sign and digits). The spans of a quote, from left to right, give its numbers: when the whole span is one number, the span gives that number; else, when the span is two numbers without an exponent joined by `-` or `−`, and the second has no sign (a range), the span gives both; else the span gives no number, and no part of it is read again. So ".5" is a span and not a number. Examples: `{lit: 2.5}` is found in "2.50 kg" and in "最多2.5公斤"; `{lit: 5}` is found in "5-7 days", "5+ years", "(5)" and "a 5-year period", and not in "2.5 kg", "-5 degrees", "No.5", "05", "5kg" or ".5%"; `{lit: 14}` is found in "a 14-day window"; `{lit: 7}` is not found in "1e-7" or "v1e-7", `{lit: 8}` not in "UTC+8", `{lit: 19}` not in "COVID-19", `{lit: 1.5}` not in "version 1.5.x", and `{lit: 0.5}` not in ".5 kg"; `{lit: -5}` is found in "−5 degrees"; "1,000" and "version 1.2.3" give no number, and "values 1,2,3" gives 1, 2 and 3. A string literal stays a string: `{lit: "5"}` is found in "2.5". The key of a violation for a number literal holds the JavaScript string form of the number, for example `2.5`, `1e+21` for `1e21`, and `0` for `-0`. So a string literal and a number literal with the same string form, such as `"5"` and `5`, give violations with the same key, and one waiver covers both.
|
|
206
|
+
|
|
207
|
+
### Three-valued logic
|
|
208
|
+
|
|
209
|
+
A comparison, `in` or `subsetOf` with a null operand is U. `and`, `or`, `not` follow Kleene:
|
|
210
|
+
|
|
211
|
+
| `and` | T | F | U | | `or` | T | F | U | | `not` | |
|
|
212
|
+
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
213
|
+
| **T** | T | F | U | | **T** | T | T | T | | T | F |
|
|
214
|
+
| **F** | F | F | F | | **F** | T | F | U | | F | T |
|
|
215
|
+
| **U** | U | F | U | | **U** | T | U | U | | U | U |
|
|
216
|
+
|
|
217
|
+
Host language comparisons (`null != "A"` is true in JavaScript) are not used. `isNull` / `isNotNull` give T or F. Evaluation does not short-circuit: all operands of every node with more than one operand (`eq` `neq` `lt` `lte` `gt` `gte`, `in`, `subsetOf`, `plus`, `dateIn`, `and`, `or`) and all elements of a quantifier are evaluated before they are combined; an invalid-snapshot error takes precedence over an undefined derived property; the reported derived property is the first in operand order. A derived property without `expr` makes a condition undefined only when evaluation reaches it: a derived property of a null optional ref is null without reading its expression, and an empty quantifier never evaluates its `where`.
|
|
218
|
+
|
|
219
|
+
### Quantifiers and the closed world
|
|
220
|
+
|
|
221
|
+
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).
|
|
222
|
+
|
|
223
|
+
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. Navigation over a foreign-key link reads the `via` of a row: in the forward direction the row that navigation starts from, in the reverse direction each row of the `from` type after the scope filter. Each `via` that navigation reads must be a string or null. A `via` with another value (for example a number) is a corrupt snapshot, and `decide` throws; it does not give the empty set. A `via` that is a declared property with a `type` must also fit that type, as every property value that is read (so `""` throws). A `via` that is a declared property with a `type` has type `id` or `{ref: T}` (R1); a `via` that is not declared, or is declared without a `type`, is not decoded, so the harness must give it as a string. For a store with integer keys, declare the `via` with type `{ref: T}` so that `decode` (`projectSnapshot`) maps it to a string, or convert it in the harness. 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.
|
|
224
|
+
|
|
225
|
+
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(...))`.
|
|
226
|
+
|
|
227
|
+
### Static UNKNOWN analysis
|
|
228
|
+
|
|
229
|
+
Code: `src/unknown.ts`. Two R12 checks, both waivable, find conditions whose U can turn into an unsafe result:
|
|
230
|
+
|
|
231
|
+
- `unsafe_fallthrough` (key `<ID>:rows.<i>`): all three hold. Row `i` is `passes(c)` and c may be U; no earlier row is `fails(c)`; a later row has a result of class `applied` or `pending`, where the scan stops at the first later `fails(c)` row (included), and `otherwise` counts only when there is no such row. When c is U, that `fails(c)` row matches, so no row after it is reached. Implementation decision: Stage 1 does not analyse the other conditions, so every later row before that stop is taken as reachable. When two rows use `passes(c)`, each is reported. A condition that may be U is not a violation by itself.
|
|
232
|
+
- `quantifier_absorbs_unknown`: the body (`where`) of an `exists`, `none` or `all` may be U; the quantifier turns that U into T or F. Every quantifier of a condition is checked, also when no row uses the condition (key `<ID>:<condition id>:<path>`), and every quantifier of a derived property expression (key `derivedProperties.<id>:<path>`). The path uses the type-error notation: `$.none`, `$.and[1].none`, `$.all.where.and[0].exists`. A quantifier inside a derived property is reported once, for the derived property, not again for each condition that reads it.
|
|
233
|
+
|
|
234
|
+
Soundness contract: for each combination of null / non-null over the nullable reads, the concrete result of `evalPredicate` is in the abstract result. False positives are allowed; false negatives are not. "May be U" means only "not proven two-valued".
|
|
235
|
+
|
|
236
|
+
Nullability comes from the facts and the parameters; only this analysis treats unknown nullability as null:
|
|
237
|
+
|
|
238
|
+
- `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).
|
|
239
|
+
- A projected property (`from`) may be null, whatever the field list says: its column may be null, and its key may be absent or null.
|
|
240
|
+
- `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.
|
|
241
|
+
- 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.
|
|
242
|
+
|
|
243
|
+
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 and the `via` of a foreign-key link are never counted: a null ref navigates to the empty set, and a null `via` gives no element (forward, the empty set; reverse, that row is not an element), not 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.
|
|
244
|
+
|
|
245
|
+
Abstract evaluation for one combination. A value position gives a subset of {null, value}; a predicate position gives a subset of {T, F, U}:
|
|
246
|
+
|
|
247
|
+
| Node | Result |
|
|
248
|
+
|---|---|
|
|
249
|
+
| `ref` | null when its path is null in the combination, else value |
|
|
250
|
+
| `lit` (also a set literal) | value |
|
|
251
|
+
| `plus`, `dateIn` | null when an operand may be null; value when both operands may be values |
|
|
252
|
+
| `eq` `neq` `lt` `lte` `gt` `gte`, `in`, `subsetOf` | U when an operand may be null; T and F when both operands may be values (no narrowing by constants) |
|
|
253
|
+
| `isNull` / `isNotNull` | T / F for a null operand, F / T for a value |
|
|
254
|
+
| `and`, `or` | Kleene over every pair of the operand results, folded from the left |
|
|
255
|
+
| `not` | T and F swap; U stays U |
|
|
256
|
+
| `exists`, `none`, `all` | T and F |
|
|
257
|
+
| a boolean value in a predicate position | U when null; T and F when a value |
|
|
258
|
+
| `{derived: {id, of: {ref: r}}}` | when `r` is null: null (U in a predicate position), without reading the expression; else the expression is inlined with `self` replaced by `r`. A value root gives its value result; a predicate root gives its predicate result, read as null for U and value for T or F in a value position |
|
|
259
|
+
|
|
260
|
+
Not analysed: an expression that fails the type check (R1), and a condition whose expression reaches a derived property without `expr` anywhere (`condition_unspecified` reports it when a row uses the condition); such a condition is excluded as a whole. A derived property expression is excluded as a whole only when it fails the type check; otherwise each of its quantifier bodies is analysed on its own, and only a body whose evaluation reaches a derived property without `expr` is skipped.
|
|
261
|
+
|
|
262
|
+
Known false positives, accepted. Fix each with an explicit null guard where the read is, or with a waiver:
|
|
263
|
+
|
|
264
|
+
- Correlation between reads is ignored: `and(gt(x, 1), eq(y, 2), neq(y, 2))` is always F, but is reported as may-be-U.
|
|
265
|
+
- A quantifier body does not see an outer guard: `and(isNotNull({ref: member_id.person_id}), all(...))` does not stop a report on a body that compares `member_id.person_id`. Guard inside the body.
|
|
266
|
+
|
|
267
|
+
Empty navigation from a null optional ref is a known risk (above), not an UNKNOWN; it is not reported.
|
|
268
|
+
|
|
269
|
+
### Scope
|
|
270
|
+
|
|
271
|
+
`scope` is optional. Without it nothing is filtered, `SCOPE_DENIED` never occurs, and `decide` needs `scope = null`. With it, `decide` needs a non-null scope, and each object type has a mode: listed in `scope.global` -> not filtered; `scope.root` -> `id = scope`; any other type -> tenant, `<by> = scope`. The filter is by object type, not by path: every row read (binding lookup, navigation target, pure link row with a tenant end) is filtered first, and lookups by id run on filtered rows (ids may repeat across tenants). Two rows of one type with one id after filtering is a corrupt snapshot (throws). When evaluation reads a tenant type, or a pure link with a tenant end, the filter reads the scope field of each of its rows, also of a row in another scope. A scope field that is not a string and not null (for example a number) is a corrupt snapshot (throws). A scope field that is a declared property with a `type` must also fit that type. A null scope field matches no scope, so the row is not visible, also for a direct `evalPredicate` call with scope null.
|
|
272
|
+
|
|
273
|
+
### Invocation layer and order
|
|
274
|
+
|
|
275
|
+
`decide(ont, actionId, snapshot, scope, bindings, actor, now)` with `actor = {id, keys, kind}`. Order:
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
shape -> permission -> envelope -> binding and scope -> decision
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
A request-shape check before `decide` (so before its shape step), permission and the envelope rejects a missing key on a `required: true` action (see `idempotencyKey`); it is outside `decide`.
|
|
282
|
+
|
|
283
|
+
| Step | Result |
|
|
284
|
+
|---|---|
|
|
285
|
+
| 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` |
|
|
286
|
+
| 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, when only conditional keys are missing, decided by its `conditional_when` (Permission conditions, below): F passed, T denied, U or absent undecided; the action passes when one alternative passes, and is denied when every alternative is denied; the `{none}` form passes, or is denied when its `principals` `kinds` lacks `actor.kind` (never undecided) | `PERMISSION_DENIED`; INDETERMINATE `permission_condition` / `permission_unknown` |
|
|
287
|
+
| envelope (idempotency) | `REPLAYED`, `IDEMPOTENCY_CONFLICT`; **not implemented by `decide`**, which has no idempotency key |
|
|
288
|
+
| 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) |
|
|
289
|
+
| decision: first-hit table | a domain result `{result, class, row}` or INDETERMINATE |
|
|
290
|
+
|
|
291
|
+
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. (A permission `conditional_when` runs before binding: there `{ref: <name>}` gives the id that the caller gave; see Permission conditions.) Invalid input (unknown action, scope argument against the declaration, an ill-typed condition, a permission `conditional_when` that R1 rejects, an actor that is not `{id: string, keys: string[], kind: string}`, 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. Each row of an object type that evaluation reads must have an `id` that is a string, also a row in another scope; a row without one is a corrupt snapshot, and `decide` throws `a <T> row has no string id`.
|
|
292
|
+
|
|
293
|
+
Permission conditions. A keys-form or an `any_of` alternative with `conditional_keys` may have `conditional_when`: a predicate that says when the conditional keys are needed. It is evaluated in the permission step, after the shape step and before binding, with the three-valued logic of this section, and it reads no snapshot. It is evaluated only for an alternative whose `principals` admit the actor, whose keys the actor all holds, and whose conditional keys the actor does not all hold. F: the alternative passes (the conditional keys are not needed). T: the alternative is denied. U, or no `conditional_when`: the alternative is undecided. It reads only request inputs and literals: `actor.id`, `actor.keys`, `actor.kind`, and the parameters of the action that are not projected. Only the nodes `eq` `neq` `lt` `lte` `gt` `gte` `and` `or` `not` `in` `subsetOf` `isNull` `isNotNull` `ref` `lit` are allowed (R1; `decide` throws on another node, as for an ill-typed condition). `{ref: <parameter>}` gives the value as the caller gave it, null when it is absent: for a `{ref: T}` parameter the given id, not looked up (also with `unresolved: domain`, whose id may later bind to null); for a `{set: …}` parameter the given array. So `isNotNull({ref: p})` means "the caller gave p". A projected parameter is not a request field, so R1 rejects a read of it. A comparison with an absent optional parameter is U, so the alternative stays undecided. To make an absent parameter give F, write `and(isNotNull({ref: x}), gt({ref: x}, {lit: 1000}))`.
|
|
294
|
+
|
|
295
|
+
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:
|
|
296
|
+
|
|
297
|
+
| Binding | Required parameter | Optional parameter |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| absent | `INVALID_BINDING` `missing` (shape): a field is missing from the request or is null | `INVALID_BINDING` `missing` (shape): the projection did not give the binding, an error of the project |
|
|
300
|
+
| null | `INVALID_BINDING` `not_found` (binding and scope), as for an id that does not resolve | an ordinary value: a field is missing or null, or no object has the key; for example "no other object has this key", which a condition tests with `isNull` |
|
|
301
|
+
| id | bound as any ref parameter | bound as any ref parameter |
|
|
302
|
+
|
|
303
|
+
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.
|
|
304
|
+
|
|
305
|
+
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.
|
|
306
|
+
|
|
307
|
+
### Decision table and INDETERMINATE
|
|
308
|
+
|
|
309
|
+
`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:
|
|
310
|
+
|
|
311
|
+
| Reason | When |
|
|
312
|
+
|---|---|
|
|
313
|
+
| `unspecified` | a reached row needs a condition without `expr`, or a condition whose evaluation reads a derived property without `expr` (then `derived` names it) |
|
|
314
|
+
| `no_row_matched` | no row hit and no `otherwise` |
|
|
315
|
+
| `no_decision_table` | the action has no `decision` |
|
|
316
|
+
| `permission_condition` | no alternative passes and not every alternative is denied: an alternative misses only conditional keys, and its `conditional_when` is U or absent |
|
|
317
|
+
| `permission_unknown` | `permission: unknown` |
|
|
318
|
+
|
|
319
|
+
### Runtime consistency
|
|
320
|
+
|
|
321
|
+
Code: `src/reconcile.ts`. For one invocation, three results must be equal:
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
decide(snapshot before the invocation, inputs) = result reported by the runtime = scenario expectation
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Types:
|
|
328
|
+
|
|
329
|
+
- `Reported = {result, reason?}`: a result as the runtime or a scenario states it. `reason` is set only for `INVALID_BINDING`. There is no `param`: runtimes do not report it.
|
|
330
|
+
- `RuntimeCase = {id, action, snapshot, inputs: {bindings, actor, scope, now}, reported, expected}`: `id` is generated by the harness; `snapshot` is the typed snapshot taken before the invocation, or `null`; `reported` is what the runtime reported, or `null` (not reported); `expected` is the scenario expectation, or `null` (none).
|
|
331
|
+
- `ReconcileRow`: `{id, action, status: match, result, row?}`, `{id, action, status: mismatch, evaluator, reported, expected, row?}`, or `{id, action, status: cannot_reconcile, reason, detail?}`. `row` is set only when the evaluator result is a domain result (step 9).
|
|
332
|
+
- `Decode = (raw, type) => Value | undefined` and `TypeDefect = {object, prop, row, value}`.
|
|
333
|
+
- `LinkColumns = {<link id>: {from, to}}`: for each pure link, the columns of its table that hold the `from` and `to` ids.
|
|
334
|
+
|
|
335
|
+
`reconcile(ont, cases)` gives exactly one row per case, in input order. For each case the first step that applies gives the row:
|
|
336
|
+
|
|
337
|
+
| Step | Condition | Row |
|
|
338
|
+
|---|---|---|
|
|
339
|
+
| 1 | `reported.result` is `REPLAYED` or `IDEMPOTENCY_CONFLICT` | `cannot_reconcile`, `envelope`; `decide` is not called |
|
|
340
|
+
| 2 | `reported` is `null` | `cannot_reconcile`, `runtime_missing` |
|
|
341
|
+
| 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` |
|
|
342
|
+
| 4 | `expected` is `null` | `cannot_reconcile`, `expectation_missing` |
|
|
343
|
+
| 5 | `snapshot` is `null` | `cannot_reconcile`, `snapshot_missing` |
|
|
344
|
+
| 6 | `decide(ont, action, snapshot, scope, bindings, actor, now)` throws | `cannot_reconcile`, `evaluator_error`; `detail` is the error message |
|
|
345
|
+
| 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` |
|
|
346
|
+
| 8 | the result is INDETERMINATE | `cannot_reconcile`, `indeterminate`; `detail` is its reason |
|
|
347
|
+
| 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 |
|
|
348
|
+
|
|
349
|
+
Two `Reported` values are equal when `result` is equal and `reason` is equal (both absent counts as equal).
|
|
350
|
+
|
|
351
|
+
A missing runtime result is never filled in from the decision table; neither is a missing expectation. The evaluator result is computed only to compare. Envelope results are not reconciled: the evaluator does not implement the envelope, and a replay does not recompute domain conditions, so a replay is not a new decision. An action without a decision table gives no domain result, so every reported result other than `INVALID_BINDING` and `PERMISSION_DENIED` is unmapped for it (envelope results stop at step 1).
|
|
352
|
+
|
|
353
|
+
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.
|
|
354
|
+
|
|
355
|
+
`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).
|
|
356
|
+
|
|
357
|
+
`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}`:
|
|
358
|
+
|
|
359
|
+
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.
|
|
360
|
+
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` and no `from` 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. Then each property `p` with `from: {field, key}` is set, in key order, from `raw`, the value of the column `field` of the row (an own key only); a stored column named `p` is never used (it is replaced or deleted). When `raw` is absent or `undefined`, `p` is not set (`decide` throws when it reads it). When `raw` is null, `p` is null. A plain object is an object whose prototype is `Object.prototype` or null (so an array, a `Buffer`, a `Date`, a `Map` or a class instance is not). A string `raw` is parsed with `JSON.parse`, and the result must be a plain object (so a string that parses to null, a number or an array is not); a `raw` that is a plain object is taken as it is. A parse error, or a parse result or a `raw` that is not a plain object, is a defect `{object, prop: p, row: String(row.id), value: raw}`, and `p` is not set. When the key is not an own key of the object, or its value is null or `undefined`, `p` is null. Else the value is not passed to `decode` (it is a JSON value, and `decode` maps stored column values only): when it fits the type (`fitsType`), `p` gets it; else it is a defect with that value as `value`, and `p` is not set. A harness that builds its own snapshot (for example the input of the `reconcile` CLI, which is taken as given) must set each projected property the same way.
|
|
361
|
+
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 (a projected property with a defect is not set); do not reconcile with it.
|
|
362
|
+
|
|
363
|
+
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.
|
|
364
|
+
|
|
365
|
+
`Invocation.disposition` stays the optional Stage 0 field of the fact contract. `reconcile` does not read it.
|
|
366
|
+
|
|
367
|
+
The command line reads and writes these types as JSON lines, one `RuntimeCase` or one `ReconcileRow` per line (`avouch reconcile`, `avouch gate verify`; README, "Runtime consistency on the command line"). A `Value` is a JSON string, number, boolean, null or array. An optional field that is absent (`reason`, `detail`, `row`, a binding that is not given) is an absent key; a binding with the value null is `null`. The coverage rule of `avouch gate verify` is a rule of the tool, not of this format.
|
|
368
|
+
|
|
369
|
+
### Existence is not revealed
|
|
370
|
+
|
|
371
|
+
`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).
|
|
372
|
+
|
|
373
|
+
### Known limit
|
|
374
|
+
|
|
375
|
+
`dateIn` uses the time zone data of the runtime. Near a time zone rule change the evaluator and another runtime with a different tzdata version may give different dates.
|
|
376
|
+
|
|
377
|
+
A `number` is compared as an IEEE 754 double, and the profile's `decode` turns a stored value into that number (for example the text of a SQL `NUMERIC` column). A value that a double cannot hold exactly (more than about 15 significant digits, or a value too small or too large) can become another number, so a runtime that compares exact decimals and `decide` can disagree on it. Exact amounts, such as money, need another type, which the format does not have.
|
|
378
|
+
|
|
379
|
+
An optional projected parameter carries a fact that the project computes from the same database as the runtime, for example the object that already holds a unique key. Avouch checks the decision given that fact, not whether the fact is right. When the projection and the runtime are wrong in the same way (for example the projection passes null and the runtime has lost its unique index), they agree and `reconcile` gives `match`.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolveProject, runCheck } from "./check.js";
|
|
3
|
+
import { FACTS_USAGE, runFacts } from "./facts.js";
|
|
4
|
+
import { INIT_USAGE, runInit } from "./init.js";
|
|
5
|
+
import { GATE_DOCS_USAGE, GATE_USAGE, runGate } from "./gate.js";
|
|
6
|
+
import { QUERY_USAGE, runQuery } from "./query.js";
|
|
7
|
+
import { expectPositionals, parseOptions, RECONCILE_USAGE, runReconcile, wantsHelp } from "./reconcile.js";
|
|
8
|
+
const CHECK_USAGE = "usage: avouch check [-f <ontology.yaml>] [--config <avouch.config.json>] [--adapter <module|config.json>]";
|
|
9
|
+
async function check(args) {
|
|
10
|
+
if (wantsHelp(args, ["-f", "--config", "--adapter"])) {
|
|
11
|
+
console.log(CHECK_USAGE);
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
let opts;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = parseOptions(args, ["-f", "--config", "--adapter"], []);
|
|
17
|
+
expectPositionals(parsed.pos, 0);
|
|
18
|
+
opts = parsed.opts;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// only an option-syntax error gets the usage line; any other error is one line (the catch around main)
|
|
22
|
+
console.error((error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " "));
|
|
23
|
+
console.error(CHECK_USAGE);
|
|
24
|
+
return 2;
|
|
25
|
+
}
|
|
26
|
+
const project = resolveProject({ file: opts.get("-f"), config: opts.get("--config"), adapter: opts.get("--adapter"), check: true });
|
|
27
|
+
const { schemaErrors: errors, report } = await runCheck(project.file, project.adapter);
|
|
28
|
+
for (const error of errors)
|
|
29
|
+
console.log(`SCHEMA ${error.instancePath || "/"}: ${error.message}`);
|
|
30
|
+
for (const x of report.violations)
|
|
31
|
+
console.log(`FAIL ${x.rule} ${x.kind} ${x.key}: ${x.msg}`);
|
|
32
|
+
for (const line of report.info)
|
|
33
|
+
console.log(`INFO ${line}`);
|
|
34
|
+
const ok = errors.length === 0 && report.violations.length === 0;
|
|
35
|
+
console.log(`${ok ? "PASS" : "FAIL"}: ${errors.length} schema error(s), ${report.violations.length} violation(s), ${report.waived.length} waived`);
|
|
36
|
+
return ok ? 0 : 1;
|
|
37
|
+
}
|
|
38
|
+
const USAGES = [CHECK_USAGE, QUERY_USAGE, RECONCILE_USAGE, GATE_USAGE, GATE_DOCS_USAGE, FACTS_USAGE, INIT_USAGE];
|
|
39
|
+
async function main() {
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
42
|
+
for (const usage of USAGES)
|
|
43
|
+
console.log(usage);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
if (args[0] === "check")
|
|
47
|
+
return check(args.slice(1));
|
|
48
|
+
if (args[0] === "query")
|
|
49
|
+
return runQuery(args.slice(1));
|
|
50
|
+
if (args[0] === "reconcile")
|
|
51
|
+
return runReconcile(args.slice(1));
|
|
52
|
+
if (args[0] === "gate")
|
|
53
|
+
return runGate(args.slice(1));
|
|
54
|
+
if (args[0] === "facts")
|
|
55
|
+
return runFacts(args.slice(1));
|
|
56
|
+
if (args[0] === "init")
|
|
57
|
+
return runInit(args.slice(1));
|
|
58
|
+
for (const usage of USAGES)
|
|
59
|
+
console.error(usage);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
process.exitCode = await main();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error((error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " "));
|
|
67
|
+
process.exitCode = 2;
|
|
68
|
+
}
|