@wirecat/cli-core 0.557.0 → 0.559.0

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli/index.d.ts +0 -1
  3. package/dist/cli/index.js +0 -1
  4. package/dist/domain/records.d.ts +161 -0
  5. package/dist/domain/records.js +234 -0
  6. package/dist/entities/fact.d.ts +1 -12
  7. package/dist/entities/fact.js +0 -13
  8. package/dist/entities/index.d.ts +2 -12
  9. package/dist/entities/meeting-findings.d.ts +1 -1
  10. package/dist/errors/reasons.js +10 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/meetings/findings-rules.d.ts +7 -4
  14. package/dist/meetings/findings-rules.js +11 -4
  15. package/dist/meetings/payment.d.ts +10 -6
  16. package/dist/meetings/payment.js +21 -6
  17. package/dist/runs/secrets.d.ts +2 -0
  18. package/dist/runs/secrets.js +2 -0
  19. package/dist/services/index.d.ts +1 -0
  20. package/dist/services/index.js +1 -0
  21. package/dist/services/ingest.d.ts +45 -0
  22. package/dist/services/ingest.js +80 -0
  23. package/dist/services/project-status/index.d.ts +1 -1
  24. package/dist/services/project-status/index.js +7 -3
  25. package/dist/services/project-status/signals/payments.d.ts +3 -3
  26. package/dist/services/project-status/signals/payments.js +43 -17
  27. package/dist/store/evidence-findings.js +16 -8
  28. package/dist/store/index.d.ts +2 -1
  29. package/dist/store/project-transfer/request.d.ts +1 -1
  30. package/dist/store/project-transfer/schema.d.ts +2 -2
  31. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  32. package/dist/store/sqlite/accounts.js +4 -0
  33. package/dist/store/sqlite/facts.d.ts +0 -13
  34. package/dist/store/sqlite/facts.js +5 -58
  35. package/dist/store/sqlite/finance.d.ts +4 -0
  36. package/dist/store/sqlite/finance.js +19 -4
  37. package/dist/store/sqlite/findings-core.js +5 -14
  38. package/dist/store/sqlite/manifest.js +2 -0
  39. package/dist/store/sqlite/migrations.generated.js +18 -0
  40. package/dist/store/sqlite/open-store.js +2 -0
  41. package/dist/store/sqlite/payment-entries.d.ts +26 -0
  42. package/dist/store/sqlite/payment-entries.js +91 -0
  43. package/dist/store/sqlite/payment-facts.d.ts +12 -0
  44. package/dist/store/sqlite/payment-facts.js +92 -0
  45. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  46. package/dist/store/sqlite/records.d.ts +81 -0
  47. package/dist/store/sqlite/records.js +372 -0
  48. package/dist/store/sqlite/schema.d.ts +353 -0
  49. package/dist/store/sqlite/schema.js +37 -0
  50. package/dist/store/store.d.ts +3 -1
  51. package/dist/store/store.js +5 -0
  52. package/package.json +4 -2
  53. package/schemas/records-envelope.v1.schema.json +108 -0
  54. package/dist/cli/payment-query.d.ts +0 -8
  55. package/dist/cli/payment-query.js +0 -27
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.559.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **Imports through one envelope (store version 47).** `ingest(store, envelope, { account, dryRun })` in
15
+ `./services` takes the v1 records envelope (`schema_version`, `source { provider, account, batch_id }`, `records[]`
16
+ with `key`, `type`, `external_id`, `operation` `upsert` or `delete`, `fields`, `revision`, `relations[]`), or a JSONL
17
+ file through `envelopeOfJsonl`. Its JSON Schema (Draft 2020-12) is `RECORDS_ENVELOPE_SCHEMA` and
18
+ `@wirecat/cli-core/schemas/records-envelope.v1.schema.json`. Each record is kept in the new `records` table, unique
19
+ per account, type and external id: the same import twice writes nothing, a lower revision is refused, a delete is
20
+ soft, and a field that looks like a secret is refused. The receipt counts created, updated, unchanged, deleted and
21
+ refused records, and each error carries a JSON pointer and a stable code.
22
+ - **An import never writes confirmed money.** An `invoice` or `payment` record becomes a proposal by the bot `import`,
23
+ decided in the proposal inbox. A reference to a record not yet imported waits for it, and a person or organization
24
+ record is linked only by the owner (`store.records.map`), never by name. `store.records` lists and reads records,
25
+ waiting and flagged ones too.
26
+
27
+ ## 0.558.0 — 02.11.2026
28
+
29
+ ### Changed — may break callers
30
+
31
+ - **A payment is no longer a fact (store version 46).** Money lives only in `store.finance`. `facts.add` refuses type
32
+ `payment` with reason `fact.payment-retired`, naming `store.finance.addPayment`, and a trigger refuses one from an
33
+ older build. `FACT_TYPES`, the `fact` entity and `Fact` lose `payment`; `PaymentFilter`, `facts.list({ payment })`,
34
+ `PAYMENT_QUERY_FIELDS` and `paymentFilterOf` are gone. The first writable open moves stored payment facts into rows.
35
+ - **An accepted payment finding writes a finance row, not a fact.** A paid one is a `payment:` row, an expected one
36
+ an `invoice:` of one line, proposed by the agent and confirmed by the owner's acceptance. `supersedes` now names
37
+ the `invoice:` a paid one settles or a cancelled one voids, not a `fact:`. An expected one needs a `person:` or
38
+ `organization:` counterparty, and the amount must fit the currency's decimals. New optional field `paid_at`.
39
+ - **`projects status` reads money from `store.finance`.** The `payments` signal (version 3) lists payments and open
40
+ invoices by their `payment:` and `invoice:` refs, `status` being `proposed` or `confirmed`; the open total counts
41
+ confirmed invoices only. Finance rows gain `evidence`, and `addInvoice` and `addPayment` take it.
42
+
10
43
  ## 0.557.0 — 02.11.2026
11
44
 
12
45
  ### Added
@@ -111,7 +111,6 @@ export { type Saving, stored } from "./messenger/stored.js";
111
111
  export { dateOrNone } from "./messenger/tasks-command.js";
112
112
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
113
113
  export { positiveCount, window, withPaging } from "./paging.js";
114
- export { PAYMENT_QUERY_FIELDS, paymentFilterOf } from "./payment-query.js";
115
114
  export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
116
115
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
117
116
  export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
package/dist/cli/index.js CHANGED
@@ -101,7 +101,6 @@ export { stored } from "./messenger/stored.js";
101
101
  export { dateOrNone } from "./messenger/tasks-command.js";
102
102
  export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
103
103
  export { positiveCount, window, withPaging } from "./paging.js";
104
- export { PAYMENT_QUERY_FIELDS, paymentFilterOf } from "./payment-query.js";
105
104
  export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
106
105
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
107
106
  export { createProgram, run } from "./program.js";
@@ -0,0 +1,161 @@
1
+ export declare const RECORDS_SCHEMA_VERSION = 1;
2
+ export declare const RECORD_OPERATIONS: readonly ["upsert", "delete"];
3
+ /** Types with a table of their own; anything else a source sends is `custom:<name>` and stays a record only. */
4
+ export declare const RECORD_ENTITY_TYPES: readonly ["invoice", "payment", "person", "organization", "project"];
5
+ export type RecordOperation = (typeof RECORD_OPERATIONS)[number];
6
+ export type RecordEntityType = (typeof RECORD_ENTITY_TYPES)[number];
7
+ export interface RecordRelation {
8
+ /** What the target is to this record: `counterparty`, `payer`, `settles`, or a source's own word. */
9
+ role: string;
10
+ type: string;
11
+ external_id: string;
12
+ }
13
+ export interface EnvelopeRecord {
14
+ /** The caller's name for this line, unique in the envelope; the receipt cites it. */
15
+ key: string;
16
+ type: string;
17
+ external_id: string;
18
+ operation: RecordOperation;
19
+ fields?: Record<string, unknown>;
20
+ /** Goes up with each change at the source; a lower one than the store holds is refused. */
21
+ revision?: number;
22
+ relations?: RecordRelation[];
23
+ }
24
+ export interface EnvelopeSource {
25
+ provider: string;
26
+ account: string;
27
+ batch_id: string;
28
+ }
29
+ export interface RecordsEnvelope {
30
+ schema_version: 1;
31
+ source: EnvelopeSource;
32
+ records: EnvelopeRecord[];
33
+ }
34
+ /** A refusal: `pointer` is an RFC 6901 JSON pointer into the envelope, `code` a stable word callers may branch on. */
35
+ export interface IngestError {
36
+ pointer: string;
37
+ code: string;
38
+ message: string;
39
+ key?: string;
40
+ }
41
+ /** The v1 envelope as JSON Schema (Draft 2020-12); `schemas/records-envelope.v1.schema.json` is this, written out. */
42
+ export declare const RECORDS_ENVELOPE_SCHEMA: {
43
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
44
+ readonly title: "WireCat records envelope, version 1";
45
+ readonly description: string;
46
+ readonly type: "object";
47
+ readonly required: readonly ["schema_version", "source", "records"];
48
+ readonly additionalProperties: false;
49
+ readonly properties: {
50
+ readonly schema_version: {
51
+ readonly const: 1;
52
+ };
53
+ readonly source: {
54
+ readonly $ref: "#/$defs/source";
55
+ };
56
+ readonly records: {
57
+ readonly type: "array";
58
+ readonly items: {
59
+ readonly $ref: "#/$defs/record";
60
+ };
61
+ };
62
+ };
63
+ readonly $defs: {
64
+ readonly source: {
65
+ readonly type: "object";
66
+ readonly required: readonly ["provider", "account", "batch_id"];
67
+ readonly additionalProperties: false;
68
+ readonly properties: {
69
+ readonly provider: {
70
+ readonly type: "string";
71
+ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,62}$";
72
+ readonly description: "The system the records come from";
73
+ };
74
+ readonly account: {
75
+ readonly type: "string";
76
+ readonly minLength: 1;
77
+ readonly maxLength: 512;
78
+ readonly description: "The account in that system";
79
+ };
80
+ readonly batch_id: {
81
+ readonly type: "string";
82
+ readonly minLength: 1;
83
+ readonly maxLength: 512;
84
+ readonly description: "This import, for the receipt";
85
+ };
86
+ };
87
+ };
88
+ readonly record: {
89
+ readonly type: "object";
90
+ readonly required: readonly ["key", "type", "external_id", "operation"];
91
+ readonly additionalProperties: false;
92
+ readonly properties: {
93
+ readonly key: {
94
+ readonly type: "string";
95
+ readonly minLength: 1;
96
+ readonly maxLength: 512;
97
+ };
98
+ readonly type: {
99
+ readonly type: "string";
100
+ readonly pattern: "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
101
+ };
102
+ readonly external_id: {
103
+ readonly type: "string";
104
+ readonly minLength: 1;
105
+ readonly maxLength: 512;
106
+ };
107
+ readonly operation: {
108
+ readonly enum: readonly ["upsert", "delete"];
109
+ };
110
+ readonly fields: {
111
+ readonly type: "object";
112
+ readonly description: "What the source says, as data: never secrets, never instructions";
113
+ };
114
+ readonly revision: {
115
+ readonly type: "integer";
116
+ readonly minimum: 0;
117
+ readonly maximum: number;
118
+ };
119
+ readonly relations: {
120
+ readonly type: "array";
121
+ readonly maxItems: 100;
122
+ readonly items: {
123
+ readonly $ref: "#/$defs/relation";
124
+ };
125
+ };
126
+ };
127
+ };
128
+ readonly relation: {
129
+ readonly type: "object";
130
+ readonly required: readonly ["role", "type", "external_id"];
131
+ readonly additionalProperties: false;
132
+ readonly properties: {
133
+ readonly role: {
134
+ readonly type: "string";
135
+ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,62}$";
136
+ };
137
+ readonly type: {
138
+ readonly type: "string";
139
+ readonly pattern: "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
140
+ };
141
+ readonly external_id: {
142
+ readonly type: "string";
143
+ readonly minLength: 1;
144
+ readonly maxLength: 512;
145
+ };
146
+ };
147
+ };
148
+ };
149
+ };
150
+ export declare const pointerOf: (...segments: (string | number)[]) => string;
151
+ /** Why one record cannot be taken, or nothing; `index` is its place in `records`. */
152
+ export declare const recordErrors: (record: unknown, index: number) => IngestError[];
153
+ /** The envelope's own errors (not its records'): when there is one, no record is taken. */
154
+ export declare const envelopeErrors: (envelope: unknown, account: string) => IngestError[];
155
+ /** The same fields and relations in any key or relation order give the same fingerprint. */
156
+ export declare const recordFingerprint: (fields: Record<string, unknown>, relations: RecordRelation[]) => string;
157
+ /**
158
+ * A JSONL import as the envelope it stands for: the first line holds `schema_version` and `source`, each further
159
+ * non-empty line one record. A line that is not JSON comes back as a record the envelope check refuses, at its index.
160
+ */
161
+ export declare const envelopeOfJsonl: (text: string) => unknown;
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { holdsCredential, secretNamed } from "../runs/secrets.js";
3
+ export const RECORDS_SCHEMA_VERSION = 1;
4
+ export const RECORD_OPERATIONS = ["upsert", "delete"];
5
+ /** Types with a table of their own; anything else a source sends is `custom:<name>` and stays a record only. */
6
+ export const RECORD_ENTITY_TYPES = ["invoice", "payment", "person", "organization", "project"];
7
+ const WORD = "^[a-z0-9][a-z0-9_-]{0,62}$";
8
+ const TYPE = "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
9
+ const MAX_ID = 512;
10
+ const MAX_FIELDS_BYTES = 65_536;
11
+ const MAX_RELATIONS = 100;
12
+ /** The v1 envelope as JSON Schema (Draft 2020-12); `schemas/records-envelope.v1.schema.json` is this, written out. */
13
+ export const RECORDS_ENVELOPE_SCHEMA = {
14
+ $schema: "https://json-schema.org/draft/2020-12/schema",
15
+ title: "WireCat records envelope, version 1",
16
+ description: "Records one source sends to the store. JSONL is the same model: the first line holds schema_version and " +
17
+ "source, each further line one record.",
18
+ type: "object",
19
+ required: ["schema_version", "source", "records"],
20
+ additionalProperties: false,
21
+ properties: {
22
+ schema_version: { const: RECORDS_SCHEMA_VERSION },
23
+ source: { $ref: "#/$defs/source" },
24
+ records: { type: "array", items: { $ref: "#/$defs/record" } },
25
+ },
26
+ $defs: {
27
+ source: {
28
+ type: "object",
29
+ required: ["provider", "account", "batch_id"],
30
+ additionalProperties: false,
31
+ properties: {
32
+ provider: { type: "string", pattern: WORD, description: "The system the records come from" },
33
+ account: { type: "string", minLength: 1, maxLength: MAX_ID, description: "The account in that system" },
34
+ batch_id: { type: "string", minLength: 1, maxLength: MAX_ID, description: "This import, for the receipt" },
35
+ },
36
+ },
37
+ record: {
38
+ type: "object",
39
+ required: ["key", "type", "external_id", "operation"],
40
+ additionalProperties: false,
41
+ properties: {
42
+ key: { type: "string", minLength: 1, maxLength: MAX_ID },
43
+ type: { type: "string", pattern: TYPE },
44
+ external_id: { type: "string", minLength: 1, maxLength: MAX_ID },
45
+ operation: { enum: [...RECORD_OPERATIONS] },
46
+ fields: {
47
+ type: "object",
48
+ description: "What the source says, as data: never secrets, never instructions",
49
+ },
50
+ revision: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
51
+ relations: { type: "array", maxItems: MAX_RELATIONS, items: { $ref: "#/$defs/relation" } },
52
+ },
53
+ },
54
+ relation: {
55
+ type: "object",
56
+ required: ["role", "type", "external_id"],
57
+ additionalProperties: false,
58
+ properties: {
59
+ role: { type: "string", pattern: WORD },
60
+ type: { type: "string", pattern: TYPE },
61
+ external_id: { type: "string", minLength: 1, maxLength: MAX_ID },
62
+ },
63
+ },
64
+ },
65
+ };
66
+ const wordPattern = new RegExp(WORD);
67
+ const typePattern = new RegExp(TYPE);
68
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
69
+ const escapePointer = (segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
70
+ export const pointerOf = (...segments) => segments.map((s) => `/${escapePointer(s)}`).join("");
71
+ const idText = (value) => typeof value === "string" && value.trim() !== "" && value.length <= MAX_ID && !value.includes("\0");
72
+ const extraKeys = (value, allowed) => Object.keys(value).filter((key) => !allowed.includes(key));
73
+ /** The first secret in `fields`, as a pointer below it: a secret-named key, or a value in a known credential shape. */
74
+ const secretIn = (value, at) => {
75
+ if (typeof value === "string")
76
+ return holdsCredential(value) ? at : undefined;
77
+ if (Array.isArray(value)) {
78
+ for (const [index, item] of value.entries()) {
79
+ const found = secretIn(item, [...at, index]);
80
+ if (found)
81
+ return found;
82
+ }
83
+ return undefined;
84
+ }
85
+ if (!isObject(value))
86
+ return undefined;
87
+ for (const [key, item] of Object.entries(value)) {
88
+ if (secretNamed(key))
89
+ return [...at, key];
90
+ const found = secretIn(item, [...at, key]);
91
+ if (found)
92
+ return found;
93
+ }
94
+ return undefined;
95
+ };
96
+ const relationErrors = (relations, at) => {
97
+ if (relations === undefined)
98
+ return [];
99
+ if (!Array.isArray(relations) || relations.length > MAX_RELATIONS)
100
+ return [invalid(at, `relations is a list of at most ${MAX_RELATIONS}`)];
101
+ return relations.flatMap((relation, index) => {
102
+ const here = [...at, index];
103
+ if (!isObject(relation))
104
+ return [invalid(here, "a relation is an object")];
105
+ const extra = extraKeys(relation, ["role", "type", "external_id"]);
106
+ if (extra.length > 0)
107
+ return [invalid([...here, extra[0]], "a relation holds role, type and external_id")];
108
+ if (typeof relation.role !== "string" || !wordPattern.test(relation.role))
109
+ return [invalid([...here, "role"], "a role is lowercase letters, digits, - and _")];
110
+ if (typeof relation.type !== "string" || !typePattern.test(relation.type))
111
+ return [invalid([...here, "type"], `a type is one of ${RECORD_ENTITY_TYPES.join(", ")} or custom:<name>`)];
112
+ if (!idText(relation.external_id))
113
+ return [invalid([...here, "external_id"], `an external id takes 1–${MAX_ID} characters`)];
114
+ return [];
115
+ });
116
+ };
117
+ const invalid = (at, message, code = "record.invalid") => ({
118
+ pointer: pointerOf(...at),
119
+ code,
120
+ message,
121
+ });
122
+ /** Why one record cannot be taken, or nothing; `index` is its place in `records`. */
123
+ export const recordErrors = (record, index) => {
124
+ const at = ["records", index];
125
+ if (!isObject(record))
126
+ return [invalid(at, "a record is an object")];
127
+ const key = typeof record.key === "string" ? record.key : undefined;
128
+ const withKey = (errors) => errors.map((error) => (key === undefined ? error : { ...error, key }));
129
+ const extra = extraKeys(record, ["key", "type", "external_id", "operation", "fields", "revision", "relations"]);
130
+ if (extra.length > 0)
131
+ return withKey([invalid([...at, extra[0]], `a record has no ${extra[0]}`)]);
132
+ if (!idText(record.key))
133
+ return [invalid([...at, "key"], `a key takes 1–${MAX_ID} characters`)];
134
+ if (typeof record.type !== "string" || !typePattern.test(record.type))
135
+ return withKey([
136
+ invalid([...at, "type"], `a type is one of ${RECORD_ENTITY_TYPES.join(", ")} or custom:<name>`, "record.type"),
137
+ ]);
138
+ if (!idText(record.external_id))
139
+ return withKey([invalid([...at, "external_id"], `an external id takes 1–${MAX_ID} characters`)]);
140
+ if (!RECORD_OPERATIONS.includes(record.operation))
141
+ return withKey([invalid([...at, "operation"], "an operation is upsert or delete")]);
142
+ if (record.revision !== undefined &&
143
+ (typeof record.revision !== "number" || !Number.isSafeInteger(record.revision) || record.revision < 0))
144
+ return withKey([invalid([...at, "revision"], "a revision is a whole number, 0 or more")]);
145
+ if (record.operation === "upsert" && !isObject(record.fields))
146
+ return withKey([invalid([...at, "fields"], "an upsert carries its fields as an object")]);
147
+ if (record.fields !== undefined && !isObject(record.fields))
148
+ return withKey([invalid([...at, "fields"], "fields is an object")]);
149
+ if (record.fields !== undefined && Buffer.byteLength(JSON.stringify(record.fields)) > MAX_FIELDS_BYTES)
150
+ return withKey([invalid([...at, "fields"], `fields take at most ${MAX_FIELDS_BYTES} bytes`, "record.too-large")]);
151
+ const secret = record.fields === undefined ? undefined : secretIn(record.fields, [...at, "fields"]);
152
+ if (secret)
153
+ return withKey([
154
+ invalid(secret, "a secret never goes into the store: drop this field at the source", "record.secret-field"),
155
+ ]);
156
+ return withKey(relationErrors(record.relations, [...at, "relations"]));
157
+ };
158
+ /** The envelope's own errors (not its records'): when there is one, no record is taken. */
159
+ export const envelopeErrors = (envelope, account) => {
160
+ if (!isObject(envelope))
161
+ return [invalid([], "an envelope is an object", "envelope.invalid")];
162
+ const extra = extraKeys(envelope, ["schema_version", "source", "records"]);
163
+ if (extra.length > 0)
164
+ return [invalid([extra[0]], `an envelope has no ${extra[0]}`, "envelope.invalid")];
165
+ if (envelope.schema_version !== RECORDS_SCHEMA_VERSION)
166
+ return [
167
+ invalid(["schema_version"], `this build reads schema_version ${RECORDS_SCHEMA_VERSION}, not ${JSON.stringify(envelope.schema_version)}`, "envelope.schema-version"),
168
+ ];
169
+ const { source } = envelope;
170
+ if (!isObject(source))
171
+ return [invalid(["source"], "source is an object", "envelope.invalid")];
172
+ const sourceExtra = extraKeys(source, ["provider", "account", "batch_id"]);
173
+ if (sourceExtra.length > 0)
174
+ return [
175
+ invalid(["source", sourceExtra[0]], "source holds provider, account and batch_id", "envelope.invalid"),
176
+ ];
177
+ if (typeof source.provider !== "string" || !wordPattern.test(source.provider))
178
+ return [invalid(["source", "provider"], "a provider is lowercase letters, digits, - and _", "envelope.invalid")];
179
+ if (!idText(source.account))
180
+ return [invalid(["source", "account"], `an account takes 1–${MAX_ID} characters`, "envelope.invalid")];
181
+ if (!idText(source.batch_id))
182
+ return [invalid(["source", "batch_id"], `a batch id takes 1–${MAX_ID} characters`, "envelope.invalid")];
183
+ if (source.account !== account)
184
+ return [
185
+ invalid(["source", "account"], `the envelope is for account ${JSON.stringify(source.account)}, the import for ${JSON.stringify(account)}`, "envelope.account-mismatch"),
186
+ ];
187
+ if (!Array.isArray(envelope.records))
188
+ return [invalid(["records"], "records is a list", "envelope.invalid")];
189
+ return [];
190
+ };
191
+ const canonical = (value) => {
192
+ if (Array.isArray(value))
193
+ return value.map(canonical);
194
+ if (!isObject(value))
195
+ return value;
196
+ return Object.fromEntries(Object.keys(value)
197
+ .sort()
198
+ .map((key) => [key, canonical(value[key])]));
199
+ };
200
+ /** The same fields and relations in any key or relation order give the same fingerprint. */
201
+ export const recordFingerprint = (fields, relations) => createHash("sha256")
202
+ .update(JSON.stringify(canonical({
203
+ fields,
204
+ relations: relations
205
+ .map((relation) => [relation.role, relation.type, relation.external_id])
206
+ .sort((a, b) => a.join("\0").localeCompare(b.join("\0"))),
207
+ })))
208
+ .digest("hex");
209
+ /**
210
+ * A JSONL import as the envelope it stands for: the first line holds `schema_version` and `source`, each further
211
+ * non-empty line one record. A line that is not JSON comes back as a record the envelope check refuses, at its index.
212
+ */
213
+ export const envelopeOfJsonl = (text) => {
214
+ const lines = text.split(/\r?\n/);
215
+ const first = lines.findIndex((line) => line.trim() !== "");
216
+ if (first < 0)
217
+ return {};
218
+ const parse = (line) => {
219
+ try {
220
+ return JSON.parse(line);
221
+ }
222
+ catch {
223
+ return line;
224
+ }
225
+ };
226
+ const header = parse(lines[first]);
227
+ const records = [];
228
+ for (let at = first + 1; at < lines.length; at++) {
229
+ const line = lines[at];
230
+ if (line.trim() !== "")
231
+ records.push(parse(line));
232
+ }
233
+ return isObject(header) ? { ...header, records } : header;
234
+ };
@@ -2,7 +2,7 @@ import * as v from "valibot";
2
2
  export declare const fact: v.StrictObjectSchema<{
3
3
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4
4
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
5
- readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise", "payment"], undefined>;
5
+ readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
6
6
  readonly text: v.StringSchema<undefined>;
7
7
  readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
8
8
  readonly project: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -29,17 +29,6 @@ export declare const fact: v.StrictObjectSchema<{
29
29
  /** Who recorded it: the owner, or an agent whose fact waits as proposed. */
30
30
  readonly origin: v.PicklistSchema<["owner", "agent"], undefined>;
31
31
  readonly evidence: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
32
- /** A `payment` fact's fields. */
33
- readonly payment: v.OptionalSchema<v.NullableSchema<v.StrictObjectSchema<{
34
- readonly amount: v.StringSchema<undefined>;
35
- readonly currency: v.StringSchema<undefined>;
36
- readonly direction: v.PicklistSchema<readonly ["to_us", "from_us"], undefined>;
37
- readonly counterparty: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
38
- readonly counterparty_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
39
- readonly due_at: v.NullableSchema<v.StringSchema<undefined>, undefined>;
40
- readonly state: v.PicklistSchema<readonly ["expected", "paid", "cancelled"], undefined>;
41
- readonly item: v.NullableSchema<v.StringSchema<undefined>, undefined>;
42
- }, undefined>, undefined>, undefined>;
43
32
  /** With `--sources`: each piece of evidence read back. */
44
33
  readonly sources: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
45
34
  readonly ref: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
@@ -1,17 +1,6 @@
1
1
  import * as v from "valibot";
2
2
  import { FACT_STATUSES, FACT_TYPES, MEMORY_SCOPES } from "../domain/knowledge.js";
3
- import { PAYMENT_DIRECTIONS, PAYMENT_STATES } from "../meetings/index.js";
4
3
  import { author, evidenceSource, id, reference, text, time } from "./fields.js";
5
- const payment = v.strictObject({
6
- amount: text,
7
- currency: text,
8
- direction: v.picklist(PAYMENT_DIRECTIONS),
9
- counterparty: v.nullable(reference),
10
- counterparty_name: v.nullable(text),
11
- due_at: v.nullable(text),
12
- state: v.picklist(PAYMENT_STATES),
13
- item: v.nullable(text),
14
- });
15
4
  export const fact = v.strictObject({
16
5
  ref: reference,
17
6
  id,
@@ -35,8 +24,6 @@ export const fact = v.strictObject({
35
24
  /** Who recorded it: the owner, or an agent whose fact waits as proposed. */
36
25
  origin: v.picklist(["owner", "agent"]),
37
26
  evidence: v.array(reference),
38
- /** A `payment` fact's fields. */
39
- payment: v.optional(v.nullable(payment)),
40
27
  /** With `--sources`: each piece of evidence read back. */
41
28
  sources: v.optional(v.array(evidenceSource)),
42
29
  created_at: time,
@@ -5761,7 +5761,7 @@ export declare const ENTITIES: {
5761
5761
  readonly fact: v.StrictObjectSchema<{
5762
5762
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
5763
5763
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
5764
- readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise", "payment"], undefined>;
5764
+ readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
5765
5765
  readonly text: v.StringSchema<undefined>;
5766
5766
  readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
5767
5767
  readonly project: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -5784,16 +5784,6 @@ export declare const ENTITIES: {
5784
5784
  readonly scope: v.NullableSchema<v.PicklistSchema<readonly ["personal", "work"], undefined>, undefined>;
5785
5785
  readonly origin: v.PicklistSchema<["owner", "agent"], undefined>;
5786
5786
  readonly evidence: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5787
- readonly payment: v.OptionalSchema<v.NullableSchema<v.StrictObjectSchema<{
5788
- readonly amount: v.StringSchema<undefined>;
5789
- readonly currency: v.StringSchema<undefined>;
5790
- readonly direction: v.PicklistSchema<readonly ["to_us", "from_us"], undefined>;
5791
- readonly counterparty: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5792
- readonly counterparty_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5793
- readonly due_at: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5794
- readonly state: v.PicklistSchema<readonly ["expected", "paid", "cancelled"], undefined>;
5795
- readonly item: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5796
- }, undefined>, undefined>, undefined>;
5797
5787
  readonly sources: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
5798
5788
  readonly ref: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5799
5789
  readonly kind: v.StringSchema<undefined>;
@@ -6589,7 +6579,7 @@ export declare const ENTITIES: {
6589
6579
  readonly fingerprint: v.StringSchema<undefined>;
6590
6580
  }, undefined>, undefined>;
6591
6581
  readonly kind: v.LiteralSchema<"fact", undefined>;
6592
- readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise", "payment"], undefined>;
6582
+ readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
6593
6583
  readonly statement: v.StringSchema<undefined>;
6594
6584
  }, undefined>, v.StrictObjectSchema<{
6595
6585
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -17,7 +17,7 @@ export declare const meetingFindings: v.StrictObjectSchema<{
17
17
  readonly fingerprint: v.StringSchema<undefined>;
18
18
  }, undefined>, undefined>;
19
19
  readonly kind: v.LiteralSchema<"fact", undefined>;
20
- readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise", "payment"], undefined>;
20
+ readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
21
21
  readonly statement: v.StringSchema<undefined>;
22
22
  }, undefined>, v.StrictObjectSchema<{
23
23
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -138,6 +138,7 @@ const OTHERS = [
138
138
  validation("input.required", "the command needs an answer and cannot ask for one"),
139
139
  validation("input.too-large", "the input is over its size limit"),
140
140
  ["meeting-project.stale", "configuration_error", "the meeting's project changed meanwhile"],
141
+ validation("fact.payment-retired", "a payment is a payments row in store.finance, not a fact"),
141
142
  validation("finance.allocate", "the invoice or payment cannot take an allocation in its state"),
142
143
  validation("finance.currency", "the payment and the invoice are in different currencies; nothing is converted"),
143
144
  validation("finance.field", "a field of the invoice or payment is not valid"),
@@ -150,6 +151,15 @@ const OTHERS = [
150
151
  validation("finance.ref", "the reference names no invoice or payment"),
151
152
  validation("finance.status", "the invoice's status does not allow this"),
152
153
  validation("finance.total", "the invoice's total is not the sum of its lines"),
154
+ validation("record.entity", "the invoice or payment the record stands for was refused"),
155
+ ["record.map-owner-only", "permission_error", "only the owner says which entity a source record is"],
156
+ validation("record.map-type", "a record maps only to an entity of its own type"),
157
+ validation("record.mapped", "the record already stands for another entity"),
158
+ validation("record.not-found", "the store holds no such record to delete"),
159
+ validation("record.invalid", "a field of the record is not the kind the entity needs"),
160
+ validation("record.reference-not-found", "a reference in the record's fields is not in the store"),
161
+ validation("record.relation-ambiguous", "the record names more than one target for a role that takes one"),
162
+ validation("record.stale-revision", "the store holds a newer revision of the record"),
153
163
  validation("memory.evidence-required", "a memory needs its evidence"),
154
164
  validation("memory.scope-required", "a memory needs its scope"),
155
165
  validation("money.amount", "the amount is not a decimal string in the currency's scale"),
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, type Kind, kindIn, k
14
14
  export type { RankingGraphEvidence, RankingGraphLink } from "./domain/rankings-graph.js";
15
15
  export type { RankingComponent, RankingInput, RankingMeasure, RankingOptions, RankingTarget, RankingWeights, ScorePreset, } from "./domain/rankings-options.js";
16
16
  export { rankingOptions } from "./domain/rankings-options.js";
17
+ export { type EnvelopeRecord, type EnvelopeSource, envelopeOfJsonl, type IngestError, RECORD_ENTITY_TYPES, RECORD_OPERATIONS, RECORDS_ENVELOPE_SCHEMA, RECORDS_SCHEMA_VERSION, type RecordEntityType, type RecordOperation, type RecordRelation, type RecordsEnvelope, } from "./domain/records.js";
17
18
  export { canonicalReference, formatReference, looksLikeReference, parseReference, REF_KINDS, type Reference, type RefKindName, type RefKindSpec, } from "./domain/references.js";
18
19
  export { normalizeTag, TAG_TYPES, type TagType } from "./domain/tags.js";
19
20
  export { type AppErrors, DETAILS as CORE_DETAILS, failureContext, REASONS as CORE_REASONS } from "./errors/reasons.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { CURRENCY_SCALES, currencyOf, minorUnits, moneyLabel, moneyOf, } from ".
9
9
  export { countedPoll } from "./domain/polls.js";
10
10
  export { FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, kindIn, kindOf, PROVIDERS, providerNamed, providers, UNKNOWN_PROVIDER, useProviders, } from "./domain/providers.js";
11
11
  export { rankingOptions } from "./domain/rankings-options.js";
12
+ export { envelopeOfJsonl, RECORD_ENTITY_TYPES, RECORD_OPERATIONS, RECORDS_ENVELOPE_SCHEMA, RECORDS_SCHEMA_VERSION, } from "./domain/records.js";
12
13
  export { canonicalReference, formatReference, looksLikeReference, parseReference, REF_KINDS, } from "./domain/references.js";
13
14
  export { normalizeTag, TAG_TYPES } from "./domain/tags.js";
14
15
  export { DETAILS as CORE_DETAILS, failureContext, REASONS as CORE_REASONS } from "./errors/reasons.js";
@@ -14,16 +14,19 @@ interface FindingBase {
14
14
  }
15
15
  export interface FactFinding extends FindingBase {
16
16
  kind: "fact";
17
- type: Exclude<(typeof FACT_TYPES)[number], "payment">;
17
+ type: (typeof FACT_TYPES)[number];
18
18
  statement: string;
19
19
  }
20
- /** A payment an agent read; confirming it records a `payment` fact. A paid one supersedes the expected one. */
20
+ /**
21
+ * A payment an agent read. Confirming it writes money through `store.finance`, never a fact: a paid one a `payments`
22
+ * row, an expected one an invoice; a paid one that supersedes an invoice settles it, a cancelled one voids it.
23
+ */
21
24
  export interface PaymentFinding extends FindingBase {
22
25
  kind: "payment";
23
- /** The fact's full sentence; without one, the payment's title. */
26
+ /** The full sentence, kept as the proposal's reason; without one, the payment's title. */
24
27
  statement?: string;
25
28
  payment: Payment;
26
- /** The `fact:` of the expected payment this one replaces, once it is paid or cancelled. */
29
+ /** The `invoice:` of the expected payment this one settles (paid) or voids (cancelled). */
27
30
  supersedes?: string;
28
31
  }
29
32
  export interface TaskFinding extends FindingBase {