@githonllc/avouch 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,7 +130,7 @@ Inside a Claude Code session, the same two steps are `/plugin marketplace add gi
130
130
 
131
131
  The plugin has two skills:
132
132
  - `cli` (`/avouch:cli`): checks an ontology and runs `avouch query`.
133
- - `scan` (`/avouch:scan [project-dir]`): drafts or updates a project's ontology from its design documents. The result is a candidate. `avouch check` must exit 0, and each source gap is listed with a proposed fix to the document.
133
+ - `scan` (`/avouch:scan [project-dir]`): drafts or updates a project's ontology from its design documents. The result is a candidate. `avouch check` must exit 0, and each source gap is listed with a proposed fix to the document. When most objects have no documented field list, it also writes `avouch/doc-patch.md`: field lists, foreign keys and store names derived from the code, for a human to review and merge into the documents. It never drafts states, permissions or rules from code.
134
134
 
135
135
  Before 0.1.3 the two skills were named `avouch-cli` and `aquery-scan`.
136
136
 
package/SPEC.md CHANGED
@@ -49,7 +49,7 @@ The definitions quoted in the "Palantir analog" column are short quotations from
49
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 |
50
50
  | `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
51
  | `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
- | `edits` / `creates` | foundation | action rules: modify / create object | |
52
+ | `edits` / `creates` / `deletes` | foundation | action rules: modify / create / delete object | `deletes` is a hard delete of datasource rows; a soft delete is an `edits` |
53
53
  | `link_effects` | foundation + constraint | action rules: create / delete link | every incident link needs an entry, including `none` / `unspecified` (R6 sweep) |
54
54
  | `emits` | extension | none (Palantir side effects are notifications / webhooks after submission) | events written in the same operation; R7, R8 |
55
55
  | `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 |
@@ -110,11 +110,11 @@ The adapter may read the ontology instance (for example to list action ids in a
110
110
  | 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
111
  | 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 |
112
112
  | 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 |
113
- | 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>`) 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 |
113
+ | 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 |
114
114
  | R8 | emitted events are in the catalog | `eventCatalog` | | |
115
115
  | 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 |
116
116
  | 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 | |
117
- | R11 | an action that edits an input lists the materialized property | none | declaration is complete | same-operation atomicity of the committed effect (one invocation may have several attempts), which the evidence adapter guarantees |
117
+ | 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. | none | declaration is complete | same-operation atomicity of the committed effect (one invocation may have several attempts), which the evidence adapter guarantees |
118
118
  | 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 |
119
119
 
120
120
  ## 6. Rule switches
@@ -146,7 +146,7 @@ The table lists only the optional facts. Every rule may also read the required f
146
146
 
147
147
  ## 8. Versions
148
148
 
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.
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.
150
150
  During 0.x the v1 draft changes in place (`formatVersion` 1, `$id` v1); the rule above applies from the 1.0 semantic freeze.
151
151
 
152
152
  ## 9. Decision semantics
@@ -261,6 +261,8 @@ export function check(ontology, facts) {
261
261
  v("R1", p, `${f} must be a list`);
262
262
  if (c.creates !== undefined && !Array.isArray(c.creates))
263
263
  v("R1", p, "creates must be a list");
264
+ if (c.deletes !== undefined && !Array.isArray(c.deletes))
265
+ v("R1", p, "deletes must be a list");
264
266
  if (!isObj(c.link_effects))
265
267
  v("R1", p, "link_effects missing (use {})");
266
268
  if (c.crossContext !== undefined) {
@@ -322,6 +324,9 @@ export function check(ontology, facts) {
322
324
  for (const o of arr(c.creates))
323
325
  if (!objType(o) && !outOfScope.has(o))
324
326
  v("R1", p, `creates ${o}: unknown object`);
327
+ for (const o of arr(c.deletes))
328
+ if (!objType(o) && !outOfScope.has(o))
329
+ v("R1", p, `deletes ${o}: unknown object`);
325
330
  for (const e of Object.keys(isObj(c.edit_cites) ? c.edit_cites : {}))
326
331
  if (!arr(c.edits).includes(e) || !isObj(c.edit_cites[e]?.cite))
327
332
  v("R1", `${p}.edit_cites.${e}`, "must name an edit and carry a cite");
@@ -633,7 +638,7 @@ export function check(ontology, facts) {
633
638
  if (!entities.has(x))
634
639
  v("R5", `entity:${x}`, `outOfScopeObjectTypes.${x} is not a source entity`, "out_of_scope_unknown");
635
640
  }
636
- const touched = (c) => new Set([...arr(c.edits).map((e) => e.split(".")[0]), ...arr(c.creates)].filter(objType));
641
+ const touched = (c) => new Set([...arr(c.edits).map((e) => e.split(".")[0]), ...arr(c.creates), ...arr(c.deletes)].filter(objType));
637
642
  for (const [cid, c] of Object.entries(commands)) {
638
643
  if (!isObj(c))
639
644
  continue;
@@ -713,7 +718,7 @@ export function check(ontology, facts) {
713
718
  // columns changed on existing rows (per store; checked per object), and the events written
714
719
  const observe = (id) => {
715
720
  const committed = part(id).committed;
716
- const rowStores = new Set(), insertedStores = new Set(), insDelStores = new Set(), events = new Set();
721
+ const rowStores = new Set(), insertedStores = new Set(), insDelStores = new Set(), deletedStores = new Set(), events = new Set();
717
722
  const changedCols = new Map();
718
723
  for (const i of committed) {
719
724
  for (const e of i.delta.events)
@@ -723,6 +728,8 @@ export function check(ontology, facts) {
723
728
  rowStores.add(tb);
724
729
  if (d.inserted.length)
725
730
  insertedStores.add(tb);
731
+ if (d.deleted.length)
732
+ deletedStores.add(tb);
726
733
  if (d.inserted.length || d.deleted.length)
727
734
  insDelStores.add(tb);
728
735
  for (const u of d.updated)
@@ -730,7 +737,7 @@ export function check(ontology, facts) {
730
737
  changedCols.set(tb, (changedCols.get(tb) ?? new Set()).add(col));
731
738
  }
732
739
  }
733
- return { committed, rowStores, insertedStores, insDelStores, changedCols, events };
740
+ return { committed, rowStores, insertedStores, insDelStores, deletedStores, changedCols, events };
734
741
  };
735
742
  for (const [cid, c] of Object.entries(commands)) {
736
743
  if (!isObj(c))
@@ -783,14 +790,14 @@ export function check(ontology, facts) {
783
790
  v("R7", cid, "the profile requires evidence, but the action declares none", "evidence_missing");
784
791
  if (facts.evidence !== undefined) {
785
792
  // a pure link table (not any object's table) may be written when the action declares an `effect` on that link;
786
- // an object's table still needs the object in edits / creates
793
+ // an object's table still needs the object in edits / creates / deletes
787
794
  const objTables = new Set(Object.values(objects).map((o) => o?.datasource));
788
795
  for (const [cid, c] of Object.entries(commands)) {
789
796
  if (!isObj(c) || c.evidence === undefined)
790
797
  continue;
791
798
  claimed.add(c.evidence);
792
799
  const { rejected } = part(c.evidence);
793
- const { committed, rowStores, insertedStores, insDelStores, changedCols, events } = observe(c.evidence);
800
+ const { committed, rowStores, insertedStores, insDelStores, deletedStores, changedCols, events } = observe(c.evidence);
794
801
  const attempts = [...committed, ...rejected].reduce((n, i) => n + i.rolledBackAttempts, 0); // malformed records add nothing
795
802
  info.push(`R7 ${cid} (${c.evidence}, ${committed.length} committed, ${rejected.length} rejected invocations, ${attempts} rolled-back attempts): changed {${[...rowStores].sort()}} events {${[...events].sort()}}`);
796
803
  if (!committed.length) {
@@ -804,7 +811,7 @@ export function check(ontology, facts) {
804
811
  const allowed = new Set([...declared, ...linkTables, ...infra]);
805
812
  for (const tb of rowStores)
806
813
  if (!allowed.has(tb))
807
- v("R7", `${cid}:${tb}`, `evidence (${c.evidence}) changed store ${tb}, not declared by edits/creates`, "table_undeclared");
814
+ v("R7", `${cid}:${tb}`, `evidence (${c.evidence}) changed store ${tb}, not declared by edits/creates/deletes`, "table_undeclared");
808
815
  for (const e of events)
809
816
  if (!arr(c.emits).includes(e))
810
817
  v("R7", `${cid}:event:${e}`, `evidence (${c.evidence}) wrote event ${e}, not in emits`, "event_undeclared");
@@ -831,6 +838,9 @@ export function check(ontology, facts) {
831
838
  for (const o of arr(c.creates))
832
839
  if (objType(o) && !insertedStores.has(objects[o].datasource))
833
840
  unwitnessed(`creates:${o}`, `inserted a row into ${objects[o].datasource}`);
841
+ for (const o of arr(c.deletes))
842
+ if (objType(o) && !deletedStores.has(objects[o].datasource))
843
+ unwitnessed(`deletes:${o}`, `deleted a row of ${objects[o].datasource}`);
834
844
  for (const l of links.filter(pureLink))
835
845
  if (!insDelStores.has(l.table))
836
846
  unwitnessed(`link:${l.id}`, `inserted or deleted a row of the link store ${l.table}`);
@@ -967,7 +977,9 @@ export function check(ontology, facts) {
967
977
  for (const [cid, c] of Object.entries(commands)) {
968
978
  if (!isObj(c))
969
979
  continue;
970
- const hit = reads.filter((r) => arr(c.edits).includes(r) || arr(c.creates).includes(r.split(".")[0]));
980
+ // deleting an input object writes it, unless the target's own object is deleted too (the target row is gone)
981
+ const hit = reads.filter((r) => arr(c.edits).includes(r) || arr(c.creates).includes(r.split(".")[0])
982
+ || (arr(c.deletes).includes(r.split(".")[0]) && !arr(c.deletes).includes(name)));
971
983
  if (hit.length && !arr(c.edits).includes(target))
972
984
  v("R11", `${cid}:${target}`, `${cid} writes ${hit.join(", ")} (inputs of ${target}) but does not list ${target} in edits`, "materialization_missing");
973
985
  }
@@ -162,6 +162,7 @@ export type QueryAction = {
162
162
  cite: QueryCite | null;
163
163
  }[];
164
164
  creates: string[];
165
+ deletes: string[];
165
166
  emits: string[];
166
167
  linkEffects: {
167
168
  link: string;
@@ -232,6 +233,7 @@ export type QueryObject = {
232
233
  action: string;
233
234
  edits: string[];
234
235
  creates: boolean;
236
+ deletes: boolean;
235
237
  }[];
236
238
  readers: {
237
239
  action: string;
@@ -261,6 +263,7 @@ export type QueryWrites = {
261
263
  path: string[];
262
264
  }[];
263
265
  creators: string[];
266
+ deleters: string[];
264
267
  };
265
268
  export type QueryReads = {
266
269
  target: string;
package/dist/src/query.js CHANGED
@@ -344,6 +344,7 @@ export function queryAction(ont, id) {
344
344
  decision: isObj(a.decision) ? { order: citeOf(map(a.decision.order).cite), rows: decisionRows(an, id, a) } : null,
345
345
  edits: strs(a.edits).map((prop) => ({ prop, cite: citeOf(map(own(editCites, prop)).cite) })),
346
346
  creates: strs(a.creates),
347
+ deletes: strs(a.deletes),
347
348
  emits: strs(a.emits),
348
349
  linkEffects: Object.entries(map(a.link_effects)).flatMap(([link, le]) => {
349
350
  const e = map(le);
@@ -396,8 +397,9 @@ export function queryObject(ont, id) {
396
397
  for (const [aid, a] of sortedActions(ont)) {
397
398
  const edits = strs(a.edits).filter((e) => e.startsWith(prefix));
398
399
  const creates = strs(a.creates).includes(id);
399
- if (edits.length || creates)
400
- writers.push({ action: aid, edits, creates });
400
+ const deletes = strs(a.deletes).includes(id);
401
+ if (edits.length || creates || deletes)
402
+ writers.push({ action: aid, edits, creates, deletes });
401
403
  }
402
404
  if (o === undefined)
403
405
  return { id, context: null, datasource: null, doc: null, outOfScope, scope: null, properties: [], derived: [], stateMachine: null, links: [], writers, readers: [], unanalyzed: [] };
@@ -494,7 +496,8 @@ export function queryWrites(ont, t) {
494
496
  const acts = sortedActions(ont);
495
497
  const writersOf = (prop, path) => acts.filter(([, a]) => strs(a.edits).includes(prop)).map(([aid, a]) => ({ action: aid, cite: citeOf(map(own(map(a.edit_cites), prop)).cite), path }));
496
498
  if (tg.kind === "property")
497
- return { target: t, kind: "property", unanalyzed: [], writers: writersOf(t, []), creators: acts.filter(([, a]) => strs(a.creates).includes(tg.obj)).map(([aid]) => aid) };
499
+ return { target: t, kind: "property", unanalyzed: [], writers: writersOf(t, []), creators: acts.filter(([, a]) => strs(a.creates).includes(tg.obj)).map(([aid]) => aid),
500
+ deleters: acts.filter(([, a]) => strs(a.deletes).includes(tg.obj)).map(([aid]) => aid) };
498
501
  // Breadth first from the derived target: each input once, by its shortest path.
499
502
  const an = new Analysis(ont);
500
503
  const writers = [];
@@ -518,7 +521,7 @@ export function queryWrites(ont, t) {
518
521
  frontier = next;
519
522
  }
520
523
  writers.sort((a, b) => a.path.length - b.path.length || sortStr(a.path.join(), b.path.join()) || sortStr(a.action, b.action));
521
- return { target: t, kind: "derived", unanalyzed: derivedErrors(an, seenDerived), writers, creators: [] };
524
+ return { target: t, kind: "derived", unanalyzed: derivedErrors(an, seenDerived), writers, creators: [], deleters: [] };
522
525
  }
523
526
  export function queryReads(ont, t) {
524
527
  const tg = target(ont, t);
@@ -689,6 +689,13 @@
689
689
  },
690
690
  "description": "Objects the action creates (optional). Foundation (closest analog: the create-object rules of a Palantir action type). R1: each must be a key of `objectTypes` or of `outOfScopeObjectTypes`. Declared objects here are touched (R5, R6, R7) and count as writing every property of that object for R11. R5: creating the `from` object of a link with `create_rule` triggers that rule."
691
691
  },
692
+ "deletes": {
693
+ "type": "array",
694
+ "items": {
695
+ "type": "string"
696
+ },
697
+ "description": "Objects whose rows the action removes (optional). Foundation (closest analog: the delete-object rules of a Palantir action type). R1: each must be a key of `objectTypes` or of `outOfScopeObjectTypes`. A soft delete is an `edits` of the marker property. Declared objects here are touched (R5, R6, R7). R7: for an action with `evidence`, each declared object here must have a row of its datasource deleted by at least one committed invocation (else `effect_unwitnessed`, key `<ID>:deletes:<Object>`); an upsert declares `creates`, not `deletes`. R11: deleting an input of a `materializedFrom` property counts as writing it, unless the object of that property is also listed here."
698
+ },
692
699
  "edit_cites": {
693
700
  "type": "object",
694
701
  "propertyNames": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githonllc/avouch",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",