@nanobpm/urban 0.56.0 → 0.58.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.
- package/dist/context/adversarial/index.d.ts +1 -0
- package/dist/context/adversarial/index.js +1 -0
- package/dist/context/binding/index.d.ts +1 -0
- package/dist/context/binding/index.js +1 -0
- package/dist/context/conformance/index.d.ts +66 -0
- package/dist/context/conformance/index.js +215 -0
- package/dist/context/conformance/records.conformance.d.ts +1 -0
- package/dist/context/conformance/records.conformance.js +52 -0
- package/dist/context/git/index.d.ts +1 -0
- package/dist/context/git/index.js +1 -0
- package/dist/context/index.d.ts +6 -0
- package/dist/context/index.js +18 -0
- package/dist/context/pii/index.d.ts +1 -0
- package/dist/context/pii/index.js +1 -0
- package/dist/context/retrieval/index.d.ts +1 -0
- package/dist/context/retrieval/index.js +1 -0
- package/dist/context/schema/index.d.ts +135 -0
- package/dist/context/schema/index.js +330 -0
- package/package.json +32 -1
- package/src/context/README.md +60 -0
- package/src/context/adversarial/index.ts +2 -0
- package/src/context/binding/index.ts +2 -0
- package/src/context/conformance/index.ts +285 -0
- package/src/context/conformance/records.conformance.ts +68 -0
- package/src/context/git/index.ts +2 -0
- package/src/context/index.ts +19 -0
- package/src/context/pii/index.ts +2 -0
- package/src/context/retrieval/index.ts +2 -0
- package/src/context/schema/index.ts +443 -0
- package/src/context/schema/schema.test.ts +251 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// Slice S2 (schema) — the memory-record schema for the Urban context layer.
|
|
2
|
+
//
|
|
3
|
+
// This module is the RECORD HALF of the published `@nanobpm/urban/context`
|
|
4
|
+
// contract (imported as `@nanobpm/urban/context/schema`). The consumer repo
|
|
5
|
+
// (`nanobpm/nano-workforce#291`), the git substrate/governance slice (S3), and
|
|
6
|
+
// the adversarial slice (S7) all build against the types + validator exported
|
|
7
|
+
// here, so the surface is deliberately small, stable, and versioned-friendly.
|
|
8
|
+
//
|
|
9
|
+
// The central design goal is a FORGERY-RESISTANT-BY-CONSTRUCTION encoding of the
|
|
10
|
+
// "hypothesis-never-fix" invariant: an unratified / agent-retrospective prior
|
|
11
|
+
// can never be *represented* as a measured / authoritative fact. This is not a
|
|
12
|
+
// runtime policy layered on top — it is baked into the controlled vocabulary and
|
|
13
|
+
// the cross-field validation rules below, so no code path (governance, retrieval,
|
|
14
|
+
// or a hostile caller) can construct a record that launders a hypothesis into a
|
|
15
|
+
// fact. S3 relies on this to gate its write path; S7 attacks it directly.
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Versioning
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The only schema version this module accepts. Records must carry it explicitly
|
|
23
|
+
* so a future producer can bump the shape while old consumers reject-with-reason
|
|
24
|
+
* instead of silently mis-reading a newer record.
|
|
25
|
+
*/
|
|
26
|
+
export const MEMORY_RECORD_SCHEMA_VERSION = 1 as const;
|
|
27
|
+
export type MemoryRecordSchemaVersion = typeof MEMORY_RECORD_SCHEMA_VERSION;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Controlled vocabulary
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The scope ladder — the granularity a record is attached to, from the most
|
|
35
|
+
* specific (a single ladder `element`) up to the whole `corpus`. The array
|
|
36
|
+
* order IS the ladder order (specific → general) and is exported so callers can
|
|
37
|
+
* reason about containment without re-deriving it.
|
|
38
|
+
*/
|
|
39
|
+
export const SCOPE_LADDER = ["element", "instance", "epic", "repo", "corpus"] as const;
|
|
40
|
+
export type MemoryScope = (typeof SCOPE_LADDER)[number];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the record asserts a norm (`normative` — "this is how it should be")
|
|
44
|
+
* or an observation (`empirical` — "this is what was measured/seen").
|
|
45
|
+
*/
|
|
46
|
+
export const MEMORY_MODES = ["normative", "empirical"] as const;
|
|
47
|
+
export type MemoryMode = (typeof MEMORY_MODES)[number];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where the record came from:
|
|
51
|
+
* - `human` — authored by a person.
|
|
52
|
+
* - `agent-retro` — a retrospective prior PROPOSED by an agent. Always a
|
|
53
|
+
* hypothesis until ratified by governance (S3: merge of a bot
|
|
54
|
+
* PR). By construction it can never be authoritative here.
|
|
55
|
+
* - `measured` — produced by an empirical measurement. Authoritative fact.
|
|
56
|
+
* - `instance` — observed directly from a concrete instance run. Fact.
|
|
57
|
+
*/
|
|
58
|
+
export const PROVENANCES = ["human", "agent-retro", "measured", "instance"] as const;
|
|
59
|
+
export type MemoryProvenance = (typeof PROVENANCES)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The authority tier — the forgery target. `hypothesis` is a not-yet-ratified
|
|
63
|
+
* claim; `authoritative` is a ratified fact/fix. The invariants below make it
|
|
64
|
+
* impossible for an `agent-retro` (or otherwise unratified) record to carry the
|
|
65
|
+
* `authoritative` tier. Governance (S3) is the ONLY thing that legitimately
|
|
66
|
+
* mints `authoritative` records, via the ratification transition it owns.
|
|
67
|
+
*/
|
|
68
|
+
export const AUTHORITIES = ["hypothesis", "authoritative"] as const;
|
|
69
|
+
export type MemoryAuthority = (typeof AUTHORITIES)[number];
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// The record
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A memory record. The required fields form the stable published contract; the
|
|
77
|
+
* optional fields are additive and safe for consumers to ignore.
|
|
78
|
+
*/
|
|
79
|
+
export interface MemoryRecord {
|
|
80
|
+
/** Discriminates the schema version (see MEMORY_RECORD_SCHEMA_VERSION). */
|
|
81
|
+
readonly schemaVersion: MemoryRecordSchemaVersion;
|
|
82
|
+
/** Stable, non-empty identifier for the record. */
|
|
83
|
+
readonly id: string;
|
|
84
|
+
/** The ladder level this record is attached to. */
|
|
85
|
+
readonly scope: MemoryScope;
|
|
86
|
+
/** Normative vs empirical. */
|
|
87
|
+
readonly mode: MemoryMode;
|
|
88
|
+
/** Where the record came from. */
|
|
89
|
+
readonly provenance: MemoryProvenance;
|
|
90
|
+
/** Hypothesis vs authoritative — constrained by provenance (see invariants). */
|
|
91
|
+
readonly authority: MemoryAuthority;
|
|
92
|
+
/** The claim/observation the record carries. Non-empty. */
|
|
93
|
+
readonly statement: string;
|
|
94
|
+
/** ISO-8601 timestamp of when the record was created. */
|
|
95
|
+
readonly createdAt: string;
|
|
96
|
+
/** Optional: the specific target within `scope` (e.g. an element id). */
|
|
97
|
+
readonly scopeRef?: string;
|
|
98
|
+
/** Optional: free-form subject/topic the record is about. */
|
|
99
|
+
readonly subject?: string;
|
|
100
|
+
/** Optional: references (ids/urls) backing an empirical/measured claim. */
|
|
101
|
+
readonly evidence?: readonly string[];
|
|
102
|
+
/** Optional: id of a record this one supersedes. */
|
|
103
|
+
readonly supersedes?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Validation result + errors
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** A machine-readable validation failure code. */
|
|
111
|
+
export type ValidationCode =
|
|
112
|
+
| "not-an-object"
|
|
113
|
+
| "unsupported-schema-version"
|
|
114
|
+
| "missing-field"
|
|
115
|
+
| "wrong-type"
|
|
116
|
+
| "empty-string"
|
|
117
|
+
| "invalid-vocabulary"
|
|
118
|
+
| "invalid-timestamp"
|
|
119
|
+
| "invariant-violation";
|
|
120
|
+
|
|
121
|
+
/** A single, human- and machine-readable validation error. */
|
|
122
|
+
export interface ValidationError {
|
|
123
|
+
/** Dotted path to the offending field (empty for whole-record errors). */
|
|
124
|
+
readonly path: string;
|
|
125
|
+
/** Stable machine code. */
|
|
126
|
+
readonly code: ValidationCode;
|
|
127
|
+
/** Human-readable explanation. */
|
|
128
|
+
readonly message: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The result of validating an untrusted value. */
|
|
132
|
+
export type ValidationResult =
|
|
133
|
+
| { readonly ok: true; readonly record: MemoryRecord }
|
|
134
|
+
| { readonly ok: false; readonly errors: readonly ValidationError[] };
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Small, assertion-free narrowing helpers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
// A "plain object" is one whose untrusted properties we can read WITHOUT risking
|
|
141
|
+
// a throw: it must carry `Object.prototype` (or a null prototype) and expose only
|
|
142
|
+
// own data properties. Objects with accessor properties (getters/setters) or
|
|
143
|
+
// exotic prototypes are rejected, because reading such a property could throw
|
|
144
|
+
// (e.g. `{ get id() { throw ... } }`) and break `validateMemoryRecord`'s "never
|
|
145
|
+
// throws" contract for hostile inputs. Any reflection error is likewise swallowed
|
|
146
|
+
// as a rejection rather than allowed to propagate.
|
|
147
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
148
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
149
|
+
try {
|
|
150
|
+
const proto = Object.getPrototypeOf(v);
|
|
151
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
152
|
+
for (const key of Reflect.ownKeys(v)) {
|
|
153
|
+
const descriptor = Object.getOwnPropertyDescriptor(v, key);
|
|
154
|
+
if (descriptor === undefined || descriptor.get !== undefined || descriptor.set !== undefined) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
165
|
+
return typeof v === "string" && v.length > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function includes<T extends string>(vocabulary: readonly T[], v: unknown): v is T {
|
|
169
|
+
return typeof v === "string" && vocabulary.some((entry) => entry === v);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Single source of truth for how a controlled-vocabulary field fails: absent is
|
|
173
|
+
// `missing-field`, a non-string is `wrong-type` (so callers can tell a bad type
|
|
174
|
+
// apart from a drifted value), and a string outside the ladder is
|
|
175
|
+
// `invalid-vocabulary`. Shared by scope/mode/provenance/authority.
|
|
176
|
+
function vocabularyCode(value: unknown): "missing-field" | "wrong-type" | "invalid-vocabulary" {
|
|
177
|
+
if (value === undefined) return "missing-field";
|
|
178
|
+
if (typeof value !== "string") return "wrong-type";
|
|
179
|
+
return "invalid-vocabulary";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Single source of truth for how a non-empty-string field fails: absent is
|
|
183
|
+
// `missing-field`, an empty string is `empty-string` (a valid type callers can
|
|
184
|
+
// surface distinctly in UX), and any other non-string is `wrong-type`. Shared by
|
|
185
|
+
// required (`id`, `statement`) and optional (`scopeRef`, `supersedes`) fields;
|
|
186
|
+
// optional callers guard `undefined` before calling so it never surfaces.
|
|
187
|
+
function nonEmptyStringCode(value: unknown): "missing-field" | "empty-string" | "wrong-type" {
|
|
188
|
+
if (value === undefined) return "missing-field";
|
|
189
|
+
if (value === "") return "empty-string";
|
|
190
|
+
return "wrong-type";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isIsoTimestamp(v: unknown): v is string {
|
|
194
|
+
if (typeof v !== "string" || v.length === 0) return false;
|
|
195
|
+
// Require a real, round-trippable ISO-8601 instant (date + time + zone).
|
|
196
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(v)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return Number.isFinite(Date.parse(v));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isStringArray(v: unknown): v is readonly string[] {
|
|
203
|
+
return Array.isArray(v) && v.every((entry) => typeof entry === "string");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// The invariant — the heart of forgery-resistance
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/** The three vocabulary fields the cross-field invariants constrain. */
|
|
211
|
+
export interface InvariantContext {
|
|
212
|
+
readonly mode: MemoryMode;
|
|
213
|
+
readonly provenance: MemoryProvenance;
|
|
214
|
+
readonly authority: MemoryAuthority;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Enumerates every invariant violation in `ctx`. An empty array means the
|
|
219
|
+
* (mode, provenance, authority) triple is internally consistent.
|
|
220
|
+
*
|
|
221
|
+
* The rules, together, guarantee the hypothesis-never-fix invariant:
|
|
222
|
+
* R1 agent-retro ⇒ hypothesis (a proposed prior is never a fact)
|
|
223
|
+
* R2 measured ⇒ authoritative (a measurement is a fact, not a guess)
|
|
224
|
+
* R3 measured ⇒ empirical (you cannot "measure" a norm)
|
|
225
|
+
* R4 instance ⇒ authoritative (a real observation is a fact)
|
|
226
|
+
* R5 instance ⇒ empirical (an observation is not a norm)
|
|
227
|
+
* R6 normative ⇒ human | agent-retro (only people/agents author norms)
|
|
228
|
+
*
|
|
229
|
+
* A consequence worth stating explicitly (and tested by S7): the ONLY way to
|
|
230
|
+
* reach `authoritative` is provenance ∈ {human, measured, instance}; agent-retro
|
|
231
|
+
* is structurally excluded, so an unratified prior can never be laundered into a
|
|
232
|
+
* fact by any field permutation.
|
|
233
|
+
*/
|
|
234
|
+
export function checkInvariants(ctx: InvariantContext): readonly ValidationError[] {
|
|
235
|
+
const errors: ValidationError[] = [];
|
|
236
|
+
// Cross-field invariants are whole-record violations (they constrain the
|
|
237
|
+
// mode/provenance/authority triple jointly), so report them at the empty
|
|
238
|
+
// (whole-record) path rather than mis-pointing consumers at `authority`.
|
|
239
|
+
const push = (message: string) =>
|
|
240
|
+
errors.push({ path: "", code: "invariant-violation", message });
|
|
241
|
+
|
|
242
|
+
if (ctx.provenance === "agent-retro" && ctx.authority === "authoritative") {
|
|
243
|
+
push(
|
|
244
|
+
"hypothesis-never-fix: an 'agent-retro' prior is unratified and can never be 'authoritative'. Ratification is governance's job (S3: merge of the proposing PR).",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (ctx.provenance === "measured" && ctx.authority !== "authoritative") {
|
|
248
|
+
push("a 'measured' record is an empirical fact and must be 'authoritative'.");
|
|
249
|
+
}
|
|
250
|
+
if (ctx.provenance === "measured" && ctx.mode !== "empirical") {
|
|
251
|
+
push("a 'measured' record is an observation and must have mode 'empirical', not 'normative'.");
|
|
252
|
+
}
|
|
253
|
+
if (ctx.provenance === "instance" && ctx.authority !== "authoritative") {
|
|
254
|
+
push("an 'instance' observation is a fact and must be 'authoritative'.");
|
|
255
|
+
}
|
|
256
|
+
if (ctx.provenance === "instance" && ctx.mode !== "empirical") {
|
|
257
|
+
push("an 'instance' observation must have mode 'empirical', not 'normative'.");
|
|
258
|
+
}
|
|
259
|
+
if (ctx.mode === "normative" && !(ctx.provenance === "human" || ctx.provenance === "agent-retro")) {
|
|
260
|
+
push(
|
|
261
|
+
"a 'normative' record asserts a norm and may only be authored by 'human' or proposed by 'agent-retro'.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return errors;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Convenience predicate: is this a ratified, authoritative fact/fix? */
|
|
268
|
+
export function isAuthoritative(record: MemoryRecord): boolean {
|
|
269
|
+
return record.authority === "authoritative";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// The validator
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validate an untrusted value as a MemoryRecord. On success returns the record
|
|
278
|
+
* narrowed to `MemoryRecord`; on failure returns EVERY error found (not just the
|
|
279
|
+
* first) so callers can surface all problems at once. Never throws.
|
|
280
|
+
*/
|
|
281
|
+
export function validateMemoryRecord(input: unknown): ValidationResult {
|
|
282
|
+
const errors: ValidationError[] = [];
|
|
283
|
+
|
|
284
|
+
if (!isPlainObject(input)) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
errors: [{ path: "", code: "not-an-object", message: "record must be a plain object." }],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Version first — a wrong version means the rest of the shape is unreliable.
|
|
292
|
+
// Distinguish an absent version (missing-field, like every other required
|
|
293
|
+
// field), a wrong-typed version (wrong-type, e.g. a string), and a
|
|
294
|
+
// present-but-unsupported numeric version (unsupported-schema-version) so the
|
|
295
|
+
// structured error is honest and consistent with the other required fields.
|
|
296
|
+
if (input.schemaVersion !== MEMORY_RECORD_SCHEMA_VERSION) {
|
|
297
|
+
const version = input.schemaVersion;
|
|
298
|
+
const versionCode: "missing-field" | "wrong-type" | "unsupported-schema-version" =
|
|
299
|
+
version === undefined ? "missing-field" : typeof version !== "number" ? "wrong-type" : "unsupported-schema-version";
|
|
300
|
+
errors.push({
|
|
301
|
+
path: "schemaVersion",
|
|
302
|
+
code: versionCode,
|
|
303
|
+
message:
|
|
304
|
+
versionCode === "missing-field"
|
|
305
|
+
? "schemaVersion is required."
|
|
306
|
+
: versionCode === "wrong-type"
|
|
307
|
+
? "schemaVersion must be a number."
|
|
308
|
+
: `unsupported schemaVersion; expected ${MEMORY_RECORD_SCHEMA_VERSION}.`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Capture fields into const locals: TypeScript's aliased-condition narrowing
|
|
313
|
+
// persists a type-guard result across statements only for `const` references,
|
|
314
|
+
// not for repeated property accesses. This lets us build the typed record at
|
|
315
|
+
// the end WITHOUT any `as` cast (forbidden by the biome plugin).
|
|
316
|
+
const idVal: unknown = input.id;
|
|
317
|
+
const statementVal: unknown = input.statement;
|
|
318
|
+
const createdAtVal: unknown = input.createdAt;
|
|
319
|
+
const scopeVal: unknown = input.scope;
|
|
320
|
+
const modeVal: unknown = input.mode;
|
|
321
|
+
const provenanceVal: unknown = input.provenance;
|
|
322
|
+
const authorityVal: unknown = input.authority;
|
|
323
|
+
|
|
324
|
+
const idOk = isNonEmptyString(idVal);
|
|
325
|
+
if (!idOk) {
|
|
326
|
+
errors.push({
|
|
327
|
+
path: "id",
|
|
328
|
+
code: nonEmptyStringCode(idVal),
|
|
329
|
+
message: "id must be a non-empty string.",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const statementOk = isNonEmptyString(statementVal);
|
|
333
|
+
if (!statementOk) {
|
|
334
|
+
errors.push({
|
|
335
|
+
path: "statement",
|
|
336
|
+
code: nonEmptyStringCode(statementVal),
|
|
337
|
+
message: "statement must be a non-empty string.",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const createdAtOk = isIsoTimestamp(createdAtVal);
|
|
341
|
+
if (!createdAtOk) {
|
|
342
|
+
errors.push({
|
|
343
|
+
path: "createdAt",
|
|
344
|
+
code: createdAtVal === undefined ? "missing-field" : "invalid-timestamp",
|
|
345
|
+
message: "createdAt must be an ISO-8601 timestamp with a timezone.",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const scopeOk = includes(SCOPE_LADDER, scopeVal);
|
|
350
|
+
if (!scopeOk) {
|
|
351
|
+
errors.push({ path: "scope", code: vocabularyCode(scopeVal), message: `scope must be one of: ${SCOPE_LADDER.join(", ")}.` });
|
|
352
|
+
}
|
|
353
|
+
const modeOk = includes(MEMORY_MODES, modeVal);
|
|
354
|
+
if (!modeOk) {
|
|
355
|
+
errors.push({ path: "mode", code: vocabularyCode(modeVal), message: `mode must be one of: ${MEMORY_MODES.join(", ")}.` });
|
|
356
|
+
}
|
|
357
|
+
const provenanceOk = includes(PROVENANCES, provenanceVal);
|
|
358
|
+
if (!provenanceOk) {
|
|
359
|
+
errors.push({ path: "provenance", code: vocabularyCode(provenanceVal), message: `provenance must be one of: ${PROVENANCES.join(", ")}.` });
|
|
360
|
+
}
|
|
361
|
+
const authorityOk = includes(AUTHORITIES, authorityVal);
|
|
362
|
+
if (!authorityOk) {
|
|
363
|
+
errors.push({ path: "authority", code: vocabularyCode(authorityVal), message: `authority must be one of: ${AUTHORITIES.join(", ")}.` });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Optional fields: reject wrong types, ignore when absent.
|
|
367
|
+
const scopeRefVal: unknown = input.scopeRef;
|
|
368
|
+
const subjectVal: unknown = input.subject;
|
|
369
|
+
const evidenceVal: unknown = input.evidence;
|
|
370
|
+
const supersedesVal: unknown = input.supersedes;
|
|
371
|
+
if (scopeRefVal !== undefined && !isNonEmptyString(scopeRefVal)) {
|
|
372
|
+
errors.push({ path: "scopeRef", code: nonEmptyStringCode(scopeRefVal), message: "scopeRef, if present, must be a non-empty string." });
|
|
373
|
+
}
|
|
374
|
+
if (subjectVal !== undefined && typeof subjectVal !== "string") {
|
|
375
|
+
errors.push({ path: "subject", code: "wrong-type", message: "subject, if present, must be a string." });
|
|
376
|
+
}
|
|
377
|
+
if (evidenceVal !== undefined && !isStringArray(evidenceVal)) {
|
|
378
|
+
errors.push({ path: "evidence", code: "wrong-type", message: "evidence, if present, must be an array of strings." });
|
|
379
|
+
}
|
|
380
|
+
if (supersedesVal !== undefined && !isNonEmptyString(supersedesVal)) {
|
|
381
|
+
errors.push({ path: "supersedes", code: nonEmptyStringCode(supersedesVal), message: "supersedes, if present, must be a non-empty string." });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Cross-field invariants can only run once the vocabulary fields are sound.
|
|
385
|
+
if (modeOk && provenanceOk && authorityOk) {
|
|
386
|
+
for (const err of checkInvariants({ mode: modeVal, provenance: provenanceVal, authority: authorityVal })) {
|
|
387
|
+
errors.push(err);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (errors.length > 0) {
|
|
392
|
+
return { ok: false, errors };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// All guards passed; aliased narrowing makes the const locals fully typed here.
|
|
396
|
+
if (!scopeOk || !modeOk || !provenanceOk || !authorityOk || !idOk || !statementOk || !createdAtOk) {
|
|
397
|
+
return { ok: false, errors: [{ path: "", code: "wrong-type", message: "internal: unexpected narrowing failure." }] };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const record: MemoryRecord = {
|
|
401
|
+
schemaVersion: MEMORY_RECORD_SCHEMA_VERSION,
|
|
402
|
+
id: idVal,
|
|
403
|
+
scope: scopeVal,
|
|
404
|
+
mode: modeVal,
|
|
405
|
+
provenance: provenanceVal,
|
|
406
|
+
authority: authorityVal,
|
|
407
|
+
statement: statementVal,
|
|
408
|
+
createdAt: createdAtVal,
|
|
409
|
+
...(isNonEmptyString(scopeRefVal) ? { scopeRef: scopeRefVal } : {}),
|
|
410
|
+
...(typeof subjectVal === "string" ? { subject: subjectVal } : {}),
|
|
411
|
+
...(isStringArray(evidenceVal) ? { evidence: evidenceVal } : {}),
|
|
412
|
+
...(isNonEmptyString(supersedesVal) ? { supersedes: supersedesVal } : {}),
|
|
413
|
+
};
|
|
414
|
+
return { ok: true, record };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Type guard form of {@link validateMemoryRecord}. */
|
|
418
|
+
export function isMemoryRecord(input: unknown): input is MemoryRecord {
|
|
419
|
+
return validateMemoryRecord(input).ok;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Error thrown by {@link assertMemoryRecord}. Carries the structured errors. */
|
|
423
|
+
export class MemoryRecordValidationError extends Error {
|
|
424
|
+
readonly errors: readonly ValidationError[];
|
|
425
|
+
constructor(errors: readonly ValidationError[]) {
|
|
426
|
+
super(`invalid memory record: ${errors.map((e) => `${e.path || "<record>"}: ${e.message}`).join("; ")}`);
|
|
427
|
+
this.name = "MemoryRecordValidationError";
|
|
428
|
+
this.errors = errors;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Throwing form: returns the validated record or throws
|
|
434
|
+
* {@link MemoryRecordValidationError}. Handy for governance/write paths that
|
|
435
|
+
* treat an invalid record as a hard failure.
|
|
436
|
+
*/
|
|
437
|
+
export function assertMemoryRecord(input: unknown): MemoryRecord {
|
|
438
|
+
const result = validateMemoryRecord(input);
|
|
439
|
+
if (!result.ok) {
|
|
440
|
+
throw new MemoryRecordValidationError(result.errors);
|
|
441
|
+
}
|
|
442
|
+
return result.record;
|
|
443
|
+
}
|