@githonllc/avouch 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SPEC.md CHANGED
@@ -28,6 +28,17 @@ Only generic needs go into the format. A special-case need stays in the project
28
28
 
29
29
  Example: objects shared by several contexts (a shared kernel) are a cross-project pattern. The format already expresses them: the shared objects get their own context, and actions that write them declare `crossContext`. An exemption such as "writes to shared objects need no declaration" fails criteria 2 and 3, so it is not in the format.
30
30
 
31
+ ### Limit of the expression language
32
+
33
+ The expression language (section 9) is a restricted predicate language that can be checked statically. It reads domain facts and decides the conditions of decision tables. It is not a language in which a project implements its rules, for two reasons: the ontology states the rules independently of the runtime, so that agreement in `reconcile` means something; and the static checks of section 9 (types, the UNKNOWN analysis) must stay decidable.
34
+
35
+ So a new expression node must meet the three criteria above and do one of these two things:
36
+
37
+ - read a fact that no node reads now: of the snapshot, the invocation or the caller, including a field of a parameter or of a stored value;
38
+ - decide a bounded claim over facts: a comparison, a quantifier over a finite collection, or a count or sum over a finite collection inside a comparison.
39
+
40
+ Value nodes such as `{lit}`, `plus` and `dateIn` only give a comparison its operands. Not admitted: user-defined functions, recursion, unbounded iteration, nodes that build new collections, records or strings, and other arithmetic than `plus`, `dateIn` and the count or sum above.
41
+
31
42
  ## 2. Concepts
32
43
 
33
44
  A key is foundation when Palantir Foundry has the same concept (names follow https://www.palantir.com/docs/foundry/ontology/overview/ and the pages for each type). A key is extension when Palantir has no analog; the rule that uses it is named.
@@ -46,7 +57,7 @@ The definitions quoted in the "Palantir analog" column are short quotations from
46
57
  | `derivedProperties.<d>.expr` / `cite` / `type` | foundation | derived property definition | the definition as an expression over `self` (instead of `reads`), its source text and its declared type; a predicate expression has type `boolean`; without `expr` the derived property is undefined for evaluation; R1, R2, R4, R12 |
47
58
  | `linkTypes` | foundation | link type: "the schema definition of a relationship between two object types" | `via` / `table` map to a foreign key or a many-to-many datasource; `create_rule` is an extension (R5) |
48
59
  | `actionTypes` | foundation | action type: "the schema definition of a set of changes or edits to objects, property values, and links that a user can take at once" | |
49
- | `actionTypes.<A>.parameters` | foundation | action parameter: "Parameters are the inputs of an action type." | name → `{cite, type?, optional?, values?}`; read by a condition as `{param}` or, in an expression, `{ref}`; `type` is needed when an expression reads it; `optional: true` lets the caller omit it; `values` (only with `type: enum`) is the value domain of the parameter; R1, R2 |
60
+ | `actionTypes.<A>.parameters` | foundation | action parameter: "Parameters are the inputs of an action type." | name → `{cite, type?, optional?, values?, projectedFrom?}`; read by a condition as `{param}` or, in an expression, `{ref}`; `type` is needed when an expression reads it; `optional: true` lets the caller omit it; `values` (only with `type: enum`) is the value domain of the parameter; `projectedFrom` lists the request fields that the project resolves to this ref; it takes effect only on a `type: {ref: <Object>}` parameter with at least one field, and such a projected parameter is always given, also when optional (section 9); on any other parameter R1 reports it and `decide` ignores it; R1, R2 |
50
61
  | `conditions` | foundation + constraint | submission criteria: "the conditions that determine whether an action can be submitted"; Palantir submission criteria ≈ the rows whose disposition class is `rejected` | named Boolean claims `{id, expr \| unspecified, reads?, reads_unspecified?, cites[{cite}], scenarios?}`: `expr` is the claim as an expression (section 9); `unspecified` says it is not written as one, and `reads` may then list what it reads (properties, parameters `{param, values?}`, the caller `{actor: id}` / `{actor: keys}`); R1, R4, R12 |
51
62
  | `decision` | extension | none | decision table `{hitPolicy: first, order?: {cite}, rows: [{when: {passes \| fails: <condition>}, result, cites, next?}], otherwise?: {result, cites, next?}}`; `next` is `{actions}`, `{external, cite}` or `{unspecified}`; R1, R12 |
52
63
  | `edits` / `creates` / `deletes` | foundation | action rules: modify / create / delete object | `deletes` is a hard delete of datasource rows; a soft delete is an `edits` |
@@ -104,8 +115,8 @@ The adapter may read the ontology instance (for example to list action ids in a
104
115
 
105
116
  | Rule | Checks | Facts used | Guarantee | Not guaranteed |
106
117
  |---|---|---|---|---|
107
- | R1 | shape, references resolve (a `{param}` read names a parameter of its action; `actor` is `id` or `keys`), ids unique, `formatVersion`, evidence ids exist; expressions type-check (section 9; the properties, parameters and derived properties they read declare `type`; `self` only in derived expressions; quantifier variables do not reuse reserved or bound names; set literals are non-empty with one element type), derived properties have exactly one of `expr` / `reads` and form no cycle, a derived `expr` has `type` and `cite`; a condition has exactly one of `expr` / `unspecified`, `reads` only with `unspecified`; duplicate condition ids are R1 (implementation decision: each entry with an `expr` is type-checked, but every entry with that id takes its type error, R4 reads and literals, and the R12 derived-property part from the last entry with that id that has an `expr`; the static UNKNOWN analysis also uses that last entry, while `decide` evaluates the first entry with that id); `scope` types are declared object types, each listed once; disposition ids are not invocation results; decision rows name a condition of the action and a disposition, only `pending` results have `next` (and must), `next.actions` are declared actions; `principals` is `{kinds: [distinct], cite}`; `idempotencyKey` is `{required: boolean, cite}`; a parameter with `values` has `type: enum` and a non-empty list of distinct non-empty strings, and a string literal compared with that parameter (`eq` / `neq` with `{ref: <name>}` on the other side, or every element of the set literal in `in [{ref: <name>}, {lit: [..]}]`) must be one of its `values` (else a type error); with `config.idempotencyDeclarationRequired: true` (default `false`) an action without `idempotencyKey` fails `idempotency_undeclared` (key `<ID>`) | `config.actionIdPattern`, `config.stateProperty`, `evidence` keys, `config.idempotencyDeclarationRequired` | structure is well-formed | that an expression means what its cite says |
108
- | R2 | anchors unique, quotes verbatim (also under `derivedProperties`, `scope` and `dispositions`), properties are fields, names in the store catalog, parameter names appear in their quote (`parameter_not_in_quote`), and so does each of a parameter's `values` (`parameter_value_not_in_quote`, key `<ID>:<name>:<value>`); with `scope`: each tenant type's field list has `<by>` (`scope_field_missing`, key `<Object>.<by>`), no global type's field list has it (`scope_field_present`), `<by>` is in the scope quote (`scope_by_not_in_quote`, key `scope`); each disposition id is in its own quote (`disposition_not_in_quote`, key `dispositions.<ID>`); a declared `type` has a cite (`type_without_provenance`, key `<Object>.<prop>` or `derivedProperties.<id>`; a derived property with `expr` and no cite is R1 only) | `section`, `containsTerm`, `fieldList`, `storeCatalog` | quotes exist in the source | that a quote supports the claim |
118
+ | R1 | shape, references resolve (a `{param}` read names a parameter of its action; `actor` is `id` or `keys`), ids unique, `formatVersion`, evidence ids exist; expressions type-check (section 9; the properties, parameters and derived properties they read declare `type`; `self` only in derived expressions; quantifier variables do not reuse reserved or bound names; set literals are non-empty with one element type), derived properties have exactly one of `expr` / `reads` and form no cycle, a derived `expr` has `type` and `cite`; a condition has exactly one of `expr` / `unspecified`, `reads` only with `unspecified`; duplicate condition ids are R1 (implementation decision: each entry with an `expr` is type-checked, but every entry with that id takes its type error, R4 reads and literals, and the R12 derived-property part from the last entry with that id that has an `expr`; the static UNKNOWN analysis also uses that last entry, while `decide` evaluates the first entry with that id); `scope` types are declared object types, each listed once; disposition ids are not invocation results; decision rows name a condition of the action and a disposition, only `pending` results have `next` (and must), `next.actions` are declared actions; `principals` is `{kinds: [distinct], cite}`; `idempotencyKey` is `{required: boolean, cite}`; a parameter with `values` has `type: enum` and a non-empty list of distinct non-empty strings, and a string literal compared with that parameter (`eq` / `neq` with `{ref: <name>}` on the other side, or every element of the set literal in `in [{ref: <name>}, {lit: [..]}]`) must be one of its `values` (else a type error); a parameter with `projectedFrom` has `type: {ref: <Object>}` and a non-empty list of distinct non-empty field names; with `config.idempotencyDeclarationRequired: true` (default `false`) an action without `idempotencyKey` fails `idempotency_undeclared` (key `<ID>`) | `config.actionIdPattern`, `config.stateProperty`, `evidence` keys, `config.idempotencyDeclarationRequired` | structure is well-formed | that an expression means what its cite says |
119
+ | R2 | anchors unique, quotes verbatim (also under `derivedProperties`, `scope` and `dispositions`), properties are fields, names in the store catalog, parameter names appear in their quote (`parameter_not_in_quote`), or, for a projected parameter, each of its fields (`projection_field_not_in_quote`, key `<ID>:<name>:<field>`), and so does each of a parameter's `values` (`parameter_value_not_in_quote`, key `<ID>:<name>:<value>`); with `scope`: each tenant type's field list has `<by>` (`scope_field_missing`, key `<Object>.<by>`), no global type's field list has it (`scope_field_present`), `<by>` is in the scope quote (`scope_by_not_in_quote`, key `scope`); each disposition id is in its own quote (`disposition_not_in_quote`, key `dispositions.<ID>`); a declared `type` has a cite (`type_without_provenance`, key `<Object>.<prop>` or `derivedProperties.<id>`; a derived property with `expr` and no cite is R1 only) | `section`, `containsTerm`, `fieldList`, `storeCatalog` | quotes exist in the source | that a quote supports the claim |
109
120
  | R3 | graph equality, terminal states, reachability, `by` = triggers, `by` iff the action edits the state property | `stateMachines`, `transitionTriggers`, `config.stateProperty` | ontology matches the source machine | trigger-cell conditions; objects without a machine in the source |
110
121
  | R4 | conditions read only canonical and policy properties, for an expression also through the derived properties it reaches (parameter and actor reads are request inputs, not state, and are not classified; a derived property without `expr` is not expanded; a hand-written `reads` entry that names a derived property is expanded through its `reads`, or, when it has an `expr`, through the properties that expression reads, as for an expression condition); a non-canonical property passes when every read compares it by `eq` / `neq` with a literal in its `canonical_values`; canonical values and the values of a parameter read appear in their quote (`parameter_value_unsupported`); each literal of an expression appears in one of its condition's quotes, or in the derived property's quote (`literal_unsupported`, key `<ID>:<condition id>:<value>` or `derivedProperties.<id>:<value>`) | none | the property reads of conditions are canonical or policy facts; literals are not invented | that `reads` is complete; that an expression's logic matches its cite |
111
122
  | R5 | object membership, cross-context declaration (`via` or `unspecified`), `create_rule`, entity coverage | `contextMembers`, `config.crossContextMechanisms` | object ownership matches the source | the content of the mechanism |
@@ -146,7 +157,7 @@ The table lists only the optional facts. Every rule may also read the required f
146
157
 
147
158
  ## 8. Versions
148
159
 
149
- The version is in two places: `v1` in the schema `$id`, and `formatVersion: 1` in the ontology file (R1 fails on any other value). A change that breaks existing ontologies raises both. A change that rejects only files without meaning (for example an empty name) is not a break. Adding the `permission.any_of` form is additive: existing files stay valid, so `formatVersion` stays 1. Adding `parameters` and the parameter and actor reads of the submission criteria (since renamed `conditions`) is additive: existing files stay valid, so `formatVersion` stays 1. Adding `values` on enum parameters is additive; `formatVersion` stays 1. Adding `deletes` is additive; `formatVersion` stays 1.
160
+ The version is in two places: `v1` in the schema `$id`, and `formatVersion: 1` in the ontology file (R1 fails on any other value). A change that breaks existing ontologies raises both. A change that rejects only files without meaning (for example an empty name) is not a break. Adding the `permission.any_of` form is additive: existing files stay valid, so `formatVersion` stays 1. Adding `parameters` and the parameter and actor reads of the submission criteria (since renamed `conditions`) is additive: existing files stay valid, so `formatVersion` stays 1. Adding `values` on enum parameters is additive; `formatVersion` stays 1. Adding `deletes` is additive; `formatVersion` stays 1. Adding `projectedFrom` on ref parameters is additive; `formatVersion` stays 1.
150
161
  During 0.x the v1 draft changes in place (`formatVersion` 1, `$id` v1); the rule above applies from the 1.0 semantic freeze.
151
162
 
152
163
  ## 9. Decision semantics
@@ -155,7 +166,7 @@ Stage 1A; runtime consistency is Stage 1B. Code: `src/expr.ts` (AST, types, stat
155
166
 
156
167
  ### Expressions and two-level evaluation
157
168
 
158
- Every node is a single-key map. The reserved names `now`, `actor` and `self` are exported as `RESERVED_NAMES`. The node set is fixed (`EXPR_NODES`):
169
+ Every node is a single-key map. The reserved names `now`, `actor` and `self` are exported as `RESERVED_NAMES`. The node set is fixed (`EXPR_NODES`); section 1, "Limit of the expression language", says which new nodes can be admitted:
159
170
 
160
171
  | Level | Nodes | Result |
161
172
  |---|---|---|
@@ -220,7 +231,7 @@ Nullability comes from the facts and the parameters; only this analysis treats u
220
231
 
221
232
  - `fieldList` gives each field with `nullable` (a trailing `?` in the source). A field is non-null only when its object's field list says `nullable: false`. Unknown nullability is analysed as may-be-null: a field that is not listed, or an object without a field list (R2 reports both).
222
233
  - `now` and `actor.id` / `actor.keys` are never null. A quantifier variable or `self` is not null; its property `x.prop` may be null unless the field is non-null.
223
- - An optional parameter may be null, and so may every property read through it. A required parameter is not null (a missing one is `INVALID_BINDING`); its property may be null unless the field is non-null.
234
+ - An optional parameter may be null, and so may every property read through it. A required parameter is not null (a missing one, or a null projected one, is `INVALID_BINDING`); its property may be null unless the field is non-null.
224
235
 
225
236
  Counting: the analysis collects the distinct normalized paths that may be null and that the outer evaluation reads (`self` is replaced by the `of` of the derived property that is inlined). Reads only inside a quantifier are not counted, because a quantifier is T or F outside; a read that also occurs outside a quantifier is counted there. The `from` of a navigation is never counted: a null ref navigates to the empty set, not to U. With k paths, all 2^k combinations are evaluated; for k > 8 (`MAX_NULLABLE_REFS`) the condition is taken as may-be-U without enumeration. A quantifier body is analysed on its own with the same evaluator and the same counting, its variable added to the visible names; a quantifier nested in the body is again T or F there and is analysed on its own.
226
237
 
@@ -264,14 +275,24 @@ A request-shape check before `decide` (so before its shape step), permission and
264
275
 
265
276
  | Step | Result |
266
277
  |---|---|
267
- | shape (no snapshot read): undeclared parameter (first by sorted name), missing required parameter (absent or null, in declaration order), value not fitting its type, or an `enum` value not among the parameter's `values` | `INVALID_BINDING` with `unknown_param`, `missing`, `type` |
278
+ | shape (no snapshot read): undeclared parameter (first by sorted name), missing parameter (in declaration order: a required one that is absent or null, except a null projected parameter; a projected parameter that is absent, also when optional), value not fitting its type, or an `enum` value not among the parameter's `values` | `INVALID_BINDING` with `unknown_param`, `missing`, `type` |
268
279
  | permission: each alternative is denied (a key missing, or `principals: {kinds, cite}` whose `kinds` lacks `actor.kind`; the cite is not read), passed (all keys and conditional keys), or undecided (only conditional keys missing); the `{none}` form passes, or is denied when its `principals` `kinds` lacks `actor.kind` (never undecided) | `PERMISSION_DENIED`; INDETERMINATE `permission_condition` / `permission_unknown` |
269
280
  | envelope (idempotency) | `REPLAYED`, `IDEMPOTENCY_CONFLICT`; **not implemented by `decide`**, which has no idempotency key |
270
- | binding and scope: parameters are checked in declaration order, across all parameters (scalar ref and `{set: {ref: T}}`), and the first failing parameter gives the result; each ref parameter is looked up in its own type only; each element of a `{set: {ref: T}}` parameter is bound the same way, in array order, and the first failing element gives the result | `SCOPE_DENIED` (the id exists only outside the scope), `INVALID_BINDING` `not_found` (absent from the type, including ids of other types) |
281
+ | binding and scope: parameters are checked in declaration order, across all parameters (scalar ref and `{set: {ref: T}}`), and the first failing parameter gives the result; each ref parameter is looked up in its own type only; each element of a `{set: {ref: T}}` parameter is bound the same way, in array order, and the first failing element gives the result; a required projected parameter with the value null fails as `not_found` | `SCOPE_DENIED` (the id exists only outside the scope), `INVALID_BINDING` `not_found` (absent from the type, including ids of other types) |
271
282
  | decision: first-hit table | a domain result `{result, class, row}` or INDETERMINATE |
272
283
 
273
284
  The invocation results are a fixed set (`INVOCATION_RESULTS`), not declared by actions. A null optional ref binds; reading its properties gives null. Invalid input (unknown action, scope argument against the declaration, an ill-typed condition, an untyped parameter, a parameter named `now`, `actor` or `self`, a decision row naming an unknown condition, a decision row or `otherwise` whose `result` has no disposition, a type both global and root, a corrupt or incomplete snapshot) throws; it is never a result. A row without a field that evaluation reads (such as the scope field) is incomplete; a field with the value null is not.
274
285
 
286
+ Projected parameters. A `{ref: T}` parameter with `projectedFrom: [<field>, …]` (at least one field) is projected: it is not a field of the request. The project's projection reads those request fields (an alternate key, for example `source` and `external_ref`) and looks up the object of the parameter's ref type as the runtime looks it up: with the same scope rules (only in the caller's scope for a tenant or root type; everywhere for a global type) and the same soft delete filter. It never passes the id of an object outside the scope. When a key field is missing from the request, the projection omits the binding; when no object has the key, it passes null; else it passes the id. Null is not the same as an absent binding:
287
+
288
+ | Binding | Required parameter | Optional parameter |
289
+ |---|---|---|
290
+ | absent | `INVALID_BINDING` `missing` (shape) | `INVALID_BINDING` `missing` (shape) |
291
+ | null | `INVALID_BINDING` `not_found` (binding and scope), as for an id that does not resolve | an ordinary value, for example "no other object has this key", which a condition tests with `isNull` |
292
+ | id | bound as any ref parameter | bound as any ref parameter |
293
+
294
+ An absent binding is a valid input (the request lacks a key field); when the runtime gives another result, `reconcile` shows the difference as a mismatch. On any other parameter (another type, for example `{set: {ref: T}}`, or no field) R1 reports `projectedFrom` and `decide` ignores it: the parameter behaves as without `projectedFrom`. The format does not read the fields or their values; it has no alternate-key model.
295
+
275
296
  ### Decision table and INDETERMINATE
276
297
 
277
298
  `passes(c) := eval(c) = T`; `fails(c) := eval(c) != T`. Rows are tried in order; the first hit gives `{layer: domain, result, class, row, next?}`, with `class` from `dispositions`. Only the rows actually reached matter. INDETERMINATE is neither an invocation result nor a domain result, and is never filled in as rejected or applied. Its reasons:
@@ -341,3 +362,5 @@ The command line reads and writes these types as JSON lines, one `RuntimeCase` o
341
362
  ### Known limit
342
363
 
343
364
  `dateIn` uses the time zone data of the runtime. Near a time zone rule change the evaluator and another runtime with a different tzdata version may give different dates.
365
+
366
+ An optional projected parameter carries a fact that the project computes from the same database as the runtime, for example the object that already holds a unique key. Avouch checks the decision given that fact, not whether the fact is right. When the projection and the runtime are wrong in the same way (for example the projection passes null and the runtime has lost its unique index), they agree and `reconcile` gives `match`.
@@ -1,7 +1,7 @@
1
1
  // Format-level ontology checker (rules R1–R12). Pure functions: no I/O. Project facts arrive through SourceFacts (contract.ts).
2
2
  // Fail closed: a missing anchor, a parse failure or a missing field is a violation, never a skip.
3
3
  import { IDENT, RULES } from "./contract.js";
4
- import { derivedPropertyType, isObj, newReads } from "./expr.js";
4
+ import { derivedPropertyType, isObj, newReads, projectedFields } from "./expr.js";
5
5
  import { analyzeConditions, asReads, parseScope, quotesOf, stage1Violations } from "./stage1-check.js";
6
6
  export { IDENT };
7
7
  const CLASSES = ["canonical", "derived", "policy", "unknown"];
@@ -284,6 +284,9 @@ export function check(ontology, facts) {
284
284
  const vs = isObj(pv) ? pv.values : undefined;
285
285
  if (vs !== undefined && !(pv.type === "enum" && Array.isArray(vs) && vs.length > 0 && vs.every(isStr) && new Set(vs).size === vs.length))
286
286
  v("R1", `${p}.parameters.${n}`, "values needs type enum and a non-empty list of distinct non-empty strings");
287
+ const pf = isObj(pv) ? pv.projectedFrom : undefined;
288
+ if (pf !== undefined && !(isObj(pv.type) && isStr(pv.type.ref) && Array.isArray(pf) && pf.length > 0 && pf.every(isStr) && new Set(pf).size === pf.length))
289
+ v("R1", `${p}.parameters.${n}`, "projectedFrom needs type {ref: <Object>} and a non-empty list of distinct non-empty field names");
287
290
  }
288
291
  const pids = new Set();
289
292
  for (const [i, pc] of arr(c.conditions).entries()) {
@@ -429,7 +432,14 @@ export function check(ontology, facts) {
429
432
  for (const [n, pv] of Object.entries(isObj(c.parameters) ? c.parameters : {})) {
430
433
  if (!isObj(pv) || !isStr(pv.cite?.quote))
431
434
  continue;
432
- if (!IDENT(n).test(pv.cite.quote))
435
+ // A projected parameter is not a request field: its quote names the fields it is projected from.
436
+ const fields = projectedFields(pv);
437
+ if (fields !== null) {
438
+ for (const f of fields)
439
+ if (!IDENT(f).test(pv.cite.quote))
440
+ v("R2", `${cid}:${n}:${f}`, `parameter ${n} is projected from ${f}, which does not appear in its cite quote`, "projection_field_not_in_quote");
441
+ }
442
+ else if (!IDENT(n).test(pv.cite.quote))
433
443
  v("R2", `${cid}:${n}`, `parameter ${n} does not appear in its cite quote`, "parameter_not_in_quote");
434
444
  for (const x of Array.isArray(pv.values) ? pv.values.filter(isStr) : [])
435
445
  if (!IDENT(x).test(pv.cite.quote))
@@ -1,7 +1,7 @@
1
1
  // Decision evaluation: evalValue / evalPredicate (two levels, Kleene logic) and decide (invocation layer, then decision table).
2
2
  // Deterministic: no wall clock, no randomness, no global mutable state; `now` comes from the caller.
3
3
  // Invalid input (ontology, snapshot, scope argument) throws; it is never a result.
4
- import { checkPredicate, fitsType, own, RESERVED_NAMES, TIMESTAMP_MAX, TIMESTAMP_MIN } from "./expr.js";
4
+ import { checkPredicate, fitsType, own, projectedFields, RESERVED_NAMES, TIMESTAMP_MAX, TIMESTAMP_MIN } from "./expr.js";
5
5
  // The fixed invocation result set of the format (defined in ./expr). decide never returns the first two (the idempotency
6
6
  // envelope is not implemented here: it has no idempotency key).
7
7
  export { INVOCATION_RESULTS } from "./expr.js";
@@ -347,7 +347,9 @@ function admit(ont, actionId, bindings, actor) {
347
347
  for (const [name, p] of Object.entries(params)) {
348
348
  const v = own(bindings, name);
349
349
  if (v === null || v === undefined) {
350
- if (p.optional === true)
350
+ // A projected ref parameter is always given: null means the projection found nothing (not_found at the binding step).
351
+ const projected = projectedFields(p) !== null;
352
+ if (v === null ? p.optional === true || projected : p.optional === true && !projected)
351
353
  continue;
352
354
  return { layer: "invocation", result: "INVALID_BINDING", param: name, reason: "missing" };
353
355
  }
@@ -380,6 +382,8 @@ function resolve(s, actionId) {
380
382
  if (typeof elem === "string" || !("ref" in elem))
381
383
  continue;
382
384
  const v = own(s.env.bindings, name);
385
+ if (v === null && p.optional !== true && projectedFields(p) !== null)
386
+ return { layer: "invocation", result: "INVALID_BINDING", param: name, reason: "not_found" };
383
387
  if (v === null || v === undefined)
384
388
  continue;
385
389
  for (const id of isSet ? v : [v]) {
@@ -116,11 +116,16 @@ export type ActionType = {
116
116
  type?: Type;
117
117
  optional?: true;
118
118
  values?: string[];
119
+ projectedFrom?: string[];
119
120
  }>;
120
121
  permission: Permission;
121
122
  conditions: Condition[];
122
123
  decision?: Decision;
123
124
  };
125
+ export declare function projectedFields(p: {
126
+ type?: unknown;
127
+ projectedFrom?: unknown;
128
+ }): string[] | null;
124
129
  export interface Stage1Ontology {
125
130
  scope?: {
126
131
  by: string;
package/dist/src/expr.js CHANGED
@@ -11,6 +11,12 @@ export const DISPOSITION_CLASSES = ["rejected", "recorded", "pending", "applied"
11
11
  // not be one of these names.
12
12
  // Every invocation-layer result, SCOPE_DENIED included. SCOPE_DENIED is internal and never reported (SPEC section 9).
13
13
  export const INVOCATION_RESULTS = ["REPLAYED", "IDEMPOTENCY_CONFLICT", "INVALID_BINDING", "SCOPE_DENIED", "PERMISSION_DENIED"];
14
+ // The distinct request fields of a projected parameter, or null: only a scalar {ref: T} parameter with at least one field is
15
+ // projected; on any other parameter projectedFrom has no effect.
16
+ export function projectedFields(p) {
17
+ const fs = Array.isArray(p.projectedFrom) ? [...new Set(p.projectedFrom.filter((f) => typeof f === "string"))] : [];
18
+ return isObj(p.type) && typeof p.type.ref === "string" && fs.length > 0 ? fs : null;
19
+ }
14
20
  export class ExprError extends Error {
15
21
  path;
16
22
  constructor(path, msg) {
@@ -142,6 +142,7 @@ export type QueryAction = {
142
142
  type: Type | null;
143
143
  optional: boolean;
144
144
  values: string[] | null;
145
+ projectedFrom: string[] | null;
145
146
  cite: QueryCite | null;
146
147
  }[];
147
148
  conditions: {
package/dist/src/query.js CHANGED
@@ -330,6 +330,7 @@ export function queryAction(ont, id) {
330
330
  type: typeOf(map(p).type),
331
331
  optional: map(p).optional === true,
332
332
  values: Array.isArray(map(p).values) ? map(p).values.filter((x) => typeof x === "string") : null,
333
+ projectedFrom: Array.isArray(map(p).projectedFrom) ? map(p).projectedFrom.filter((x) => typeof x === "string") : null,
333
334
  cite: citeOf(map(p).cite),
334
335
  })),
335
336
  conditions: conditionsOf(a).map((c) => ({
@@ -673,7 +673,7 @@
673
673
  "type": "object",
674
674
  "propertyNames": { "minLength": 1 },
675
675
  "additionalProperties": { "$ref": "#/$defs/parameter" },
676
- "description": "Optional action parameters: parameter name → {cite}. Foundation: Palantir action parameters (\"Parameters are the inputs of an action type.\"). A condition reads a parameter as `{param: <name>}` or, in an expression, as `{ref: <name>}`. R1: must be a mapping; each entry carries a cite. R2: the cite quote must occur verbatim, and the parameter name must appear in it (R2 `parameter_not_in_quote`, key `<ID>:<name>`), and so must each of its `values` (R2 `parameter_value_not_in_quote`, key `<ID>:<name>:<value>`). A declared parameter that no condition reads is not a failure."
676
+ "description": "Optional action parameters: parameter name → {cite}. Foundation: Palantir action parameters (\"Parameters are the inputs of an action type.\"). A condition reads a parameter as `{param: <name>}` or, in an expression, as `{ref: <name>}`. R1: must be a mapping; each entry carries a cite. R2: the cite quote must occur verbatim, and the parameter name must appear in it (R2 `parameter_not_in_quote`, key `<ID>:<name>`), or, for a projected parameter (`type: {ref: <Object>}` with at least one `projectedFrom` field), each of its fields (R2 `projection_field_not_in_quote`, key `<ID>:<name>:<field>`), and so must each of its `values` (R2 `parameter_value_not_in_quote`, key `<ID>:<name>:<value>`). A declared parameter that no condition reads is not a failure."
677
677
  },
678
678
  "edits": {
679
679
  "type": "array",
@@ -1009,17 +1009,22 @@
1009
1009
  "type": "object",
1010
1010
  "additionalProperties": false,
1011
1011
  "required": ["cite"],
1012
- "description": "One action parameter. Foundation: Palantir action parameter. The name is the map key; the cite is the source text that names it. `type` is required when an expression reads the parameter; `optional: true` lets the caller omit it (it is then null). `values` is the value domain of an `enum` parameter (only with `type: enum`): a value outside it is `INVALID_BINDING` `type`; R1 checks its shape and that literals compared with the parameter are among them, R2 that each value appears in the cite quote.",
1012
+ "description": "One action parameter. Foundation: Palantir action parameter. The name is the map key; the cite is the source text that names it. `type` is required when an expression reads the parameter; `optional: true` lets the caller omit it (it is then null). `values` is the value domain of an `enum` parameter (only with `type: enum`): a value outside it is `INVALID_BINDING` `type`; R1 checks its shape and that literals compared with the parameter are among them, R2 that each value appears in the cite quote. `projectedFrom` (only with `type: {ref: <Object>}`) lists the request fields, an alternate key, that the project's projection resolves to this ref; on any other parameter R1 reports it and `decide` ignores it; the cite quote must name each field instead of the parameter name (R2 `projection_field_not_in_quote`); a null value of a required projected parameter is `INVALID_BINDING` `not_found`, and an absent one is `missing`, also with `optional: true` (SPEC section 9).",
1013
1013
  "properties": {
1014
1014
  "cite": { "$ref": "#/$defs/cite" },
1015
1015
  "type": { "$ref": "#/$defs/valueType" },
1016
1016
  "optional": { "const": true },
1017
- "values": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }
1017
+ "values": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
1018
+ "projectedFrom": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }
1018
1019
  },
1019
1020
  "allOf": [
1020
1021
  {
1021
1022
  "if": { "required": ["values"] },
1022
1023
  "then": { "required": ["type"], "properties": { "type": { "const": "enum" } } }
1024
+ },
1025
+ {
1026
+ "if": { "required": ["projectedFrom"] },
1027
+ "then": { "required": ["type"], "properties": { "type": { "type": "object", "required": ["ref"] } } }
1023
1028
  }
1024
1029
  ]
1025
1030
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githonllc/avouch",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/vocab.ttl CHANGED
@@ -475,7 +475,12 @@ ofv:set a rdf:Property ;
475
475
 
476
476
  ofv:optional a rdf:Property ;
477
477
  rdfs:label "optional" ;
478
- rdfs:comment "The caller may leave the parameter out; it is then null. YAML: actionTypes.<ID>.parameters.<n>.optional" ;
478
+ rdfs:comment "The caller may leave the parameter out; it is then null. Not for a projected parameter: it is always given, and null is a value. YAML: actionTypes.<ID>.parameters.<n>.optional" ;
479
+ rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
480
+
481
+ ofv:projectedFrom a rdf:Property ;
482
+ rdfs:label "projectedFrom" ;
483
+ rdfs:comment "Request fields (an alternate key) that the project's projection resolves to this ref parameter. A projected parameter is always given; null means the projection found nothing. YAML: actionTypes.<ID>.parameters.<n>.projectedFrom[]" ;
479
484
  rdfs:isDefinedBy <https://w3id.org/avouch/v1/vocab> .
480
485
 
481
486
  ofv:principals a rdf:Property ;