@nanobpm/urban 0.57.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.
@@ -1 +1,330 @@
1
- export {};
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
+ // Versioning
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * The only schema version this module accepts. Records must carry it explicitly
21
+ * so a future producer can bump the shape while old consumers reject-with-reason
22
+ * instead of silently mis-reading a newer record.
23
+ */
24
+ export const MEMORY_RECORD_SCHEMA_VERSION = 1;
25
+ // ---------------------------------------------------------------------------
26
+ // Controlled vocabulary
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * The scope ladder — the granularity a record is attached to, from the most
30
+ * specific (a single ladder `element`) up to the whole `corpus`. The array
31
+ * order IS the ladder order (specific → general) and is exported so callers can
32
+ * reason about containment without re-deriving it.
33
+ */
34
+ export const SCOPE_LADDER = ["element", "instance", "epic", "repo", "corpus"];
35
+ /**
36
+ * Whether the record asserts a norm (`normative` — "this is how it should be")
37
+ * or an observation (`empirical` — "this is what was measured/seen").
38
+ */
39
+ export const MEMORY_MODES = ["normative", "empirical"];
40
+ /**
41
+ * Where the record came from:
42
+ * - `human` — authored by a person.
43
+ * - `agent-retro` — a retrospective prior PROPOSED by an agent. Always a
44
+ * hypothesis until ratified by governance (S3: merge of a bot
45
+ * PR). By construction it can never be authoritative here.
46
+ * - `measured` — produced by an empirical measurement. Authoritative fact.
47
+ * - `instance` — observed directly from a concrete instance run. Fact.
48
+ */
49
+ export const PROVENANCES = ["human", "agent-retro", "measured", "instance"];
50
+ /**
51
+ * The authority tier — the forgery target. `hypothesis` is a not-yet-ratified
52
+ * claim; `authoritative` is a ratified fact/fix. The invariants below make it
53
+ * impossible for an `agent-retro` (or otherwise unratified) record to carry the
54
+ * `authoritative` tier. Governance (S3) is the ONLY thing that legitimately
55
+ * mints `authoritative` records, via the ratification transition it owns.
56
+ */
57
+ export const AUTHORITIES = ["hypothesis", "authoritative"];
58
+ // ---------------------------------------------------------------------------
59
+ // Small, assertion-free narrowing helpers
60
+ // ---------------------------------------------------------------------------
61
+ // A "plain object" is one whose untrusted properties we can read WITHOUT risking
62
+ // a throw: it must carry `Object.prototype` (or a null prototype) and expose only
63
+ // own data properties. Objects with accessor properties (getters/setters) or
64
+ // exotic prototypes are rejected, because reading such a property could throw
65
+ // (e.g. `{ get id() { throw ... } }`) and break `validateMemoryRecord`'s "never
66
+ // throws" contract for hostile inputs. Any reflection error is likewise swallowed
67
+ // as a rejection rather than allowed to propagate.
68
+ function isPlainObject(v) {
69
+ if (typeof v !== "object" || v === null || Array.isArray(v))
70
+ return false;
71
+ try {
72
+ const proto = Object.getPrototypeOf(v);
73
+ if (proto !== Object.prototype && proto !== null)
74
+ return false;
75
+ for (const key of Reflect.ownKeys(v)) {
76
+ const descriptor = Object.getOwnPropertyDescriptor(v, key);
77
+ if (descriptor === undefined || descriptor.get !== undefined || descriptor.set !== undefined) {
78
+ return false;
79
+ }
80
+ }
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ function isNonEmptyString(v) {
88
+ return typeof v === "string" && v.length > 0;
89
+ }
90
+ function includes(vocabulary, v) {
91
+ return typeof v === "string" && vocabulary.some((entry) => entry === v);
92
+ }
93
+ // Single source of truth for how a controlled-vocabulary field fails: absent is
94
+ // `missing-field`, a non-string is `wrong-type` (so callers can tell a bad type
95
+ // apart from a drifted value), and a string outside the ladder is
96
+ // `invalid-vocabulary`. Shared by scope/mode/provenance/authority.
97
+ function vocabularyCode(value) {
98
+ if (value === undefined)
99
+ return "missing-field";
100
+ if (typeof value !== "string")
101
+ return "wrong-type";
102
+ return "invalid-vocabulary";
103
+ }
104
+ // Single source of truth for how a non-empty-string field fails: absent is
105
+ // `missing-field`, an empty string is `empty-string` (a valid type callers can
106
+ // surface distinctly in UX), and any other non-string is `wrong-type`. Shared by
107
+ // required (`id`, `statement`) and optional (`scopeRef`, `supersedes`) fields;
108
+ // optional callers guard `undefined` before calling so it never surfaces.
109
+ function nonEmptyStringCode(value) {
110
+ if (value === undefined)
111
+ return "missing-field";
112
+ if (value === "")
113
+ return "empty-string";
114
+ return "wrong-type";
115
+ }
116
+ function isIsoTimestamp(v) {
117
+ if (typeof v !== "string" || v.length === 0)
118
+ return false;
119
+ // Require a real, round-trippable ISO-8601 instant (date + time + zone).
120
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(v)) {
121
+ return false;
122
+ }
123
+ return Number.isFinite(Date.parse(v));
124
+ }
125
+ function isStringArray(v) {
126
+ return Array.isArray(v) && v.every((entry) => typeof entry === "string");
127
+ }
128
+ /**
129
+ * Enumerates every invariant violation in `ctx`. An empty array means the
130
+ * (mode, provenance, authority) triple is internally consistent.
131
+ *
132
+ * The rules, together, guarantee the hypothesis-never-fix invariant:
133
+ * R1 agent-retro ⇒ hypothesis (a proposed prior is never a fact)
134
+ * R2 measured ⇒ authoritative (a measurement is a fact, not a guess)
135
+ * R3 measured ⇒ empirical (you cannot "measure" a norm)
136
+ * R4 instance ⇒ authoritative (a real observation is a fact)
137
+ * R5 instance ⇒ empirical (an observation is not a norm)
138
+ * R6 normative ⇒ human | agent-retro (only people/agents author norms)
139
+ *
140
+ * A consequence worth stating explicitly (and tested by S7): the ONLY way to
141
+ * reach `authoritative` is provenance ∈ {human, measured, instance}; agent-retro
142
+ * is structurally excluded, so an unratified prior can never be laundered into a
143
+ * fact by any field permutation.
144
+ */
145
+ export function checkInvariants(ctx) {
146
+ const errors = [];
147
+ // Cross-field invariants are whole-record violations (they constrain the
148
+ // mode/provenance/authority triple jointly), so report them at the empty
149
+ // (whole-record) path rather than mis-pointing consumers at `authority`.
150
+ const push = (message) => errors.push({ path: "", code: "invariant-violation", message });
151
+ if (ctx.provenance === "agent-retro" && ctx.authority === "authoritative") {
152
+ push("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).");
153
+ }
154
+ if (ctx.provenance === "measured" && ctx.authority !== "authoritative") {
155
+ push("a 'measured' record is an empirical fact and must be 'authoritative'.");
156
+ }
157
+ if (ctx.provenance === "measured" && ctx.mode !== "empirical") {
158
+ push("a 'measured' record is an observation and must have mode 'empirical', not 'normative'.");
159
+ }
160
+ if (ctx.provenance === "instance" && ctx.authority !== "authoritative") {
161
+ push("an 'instance' observation is a fact and must be 'authoritative'.");
162
+ }
163
+ if (ctx.provenance === "instance" && ctx.mode !== "empirical") {
164
+ push("an 'instance' observation must have mode 'empirical', not 'normative'.");
165
+ }
166
+ if (ctx.mode === "normative" && !(ctx.provenance === "human" || ctx.provenance === "agent-retro")) {
167
+ push("a 'normative' record asserts a norm and may only be authored by 'human' or proposed by 'agent-retro'.");
168
+ }
169
+ return errors;
170
+ }
171
+ /** Convenience predicate: is this a ratified, authoritative fact/fix? */
172
+ export function isAuthoritative(record) {
173
+ return record.authority === "authoritative";
174
+ }
175
+ // ---------------------------------------------------------------------------
176
+ // The validator
177
+ // ---------------------------------------------------------------------------
178
+ /**
179
+ * Validate an untrusted value as a MemoryRecord. On success returns the record
180
+ * narrowed to `MemoryRecord`; on failure returns EVERY error found (not just the
181
+ * first) so callers can surface all problems at once. Never throws.
182
+ */
183
+ export function validateMemoryRecord(input) {
184
+ const errors = [];
185
+ if (!isPlainObject(input)) {
186
+ return {
187
+ ok: false,
188
+ errors: [{ path: "", code: "not-an-object", message: "record must be a plain object." }],
189
+ };
190
+ }
191
+ // Version first — a wrong version means the rest of the shape is unreliable.
192
+ // Distinguish an absent version (missing-field, like every other required
193
+ // field), a wrong-typed version (wrong-type, e.g. a string), and a
194
+ // present-but-unsupported numeric version (unsupported-schema-version) so the
195
+ // structured error is honest and consistent with the other required fields.
196
+ if (input.schemaVersion !== MEMORY_RECORD_SCHEMA_VERSION) {
197
+ const version = input.schemaVersion;
198
+ const versionCode = version === undefined ? "missing-field" : typeof version !== "number" ? "wrong-type" : "unsupported-schema-version";
199
+ errors.push({
200
+ path: "schemaVersion",
201
+ code: versionCode,
202
+ message: versionCode === "missing-field"
203
+ ? "schemaVersion is required."
204
+ : versionCode === "wrong-type"
205
+ ? "schemaVersion must be a number."
206
+ : `unsupported schemaVersion; expected ${MEMORY_RECORD_SCHEMA_VERSION}.`,
207
+ });
208
+ }
209
+ // Capture fields into const locals: TypeScript's aliased-condition narrowing
210
+ // persists a type-guard result across statements only for `const` references,
211
+ // not for repeated property accesses. This lets us build the typed record at
212
+ // the end WITHOUT any `as` cast (forbidden by the biome plugin).
213
+ const idVal = input.id;
214
+ const statementVal = input.statement;
215
+ const createdAtVal = input.createdAt;
216
+ const scopeVal = input.scope;
217
+ const modeVal = input.mode;
218
+ const provenanceVal = input.provenance;
219
+ const authorityVal = input.authority;
220
+ const idOk = isNonEmptyString(idVal);
221
+ if (!idOk) {
222
+ errors.push({
223
+ path: "id",
224
+ code: nonEmptyStringCode(idVal),
225
+ message: "id must be a non-empty string.",
226
+ });
227
+ }
228
+ const statementOk = isNonEmptyString(statementVal);
229
+ if (!statementOk) {
230
+ errors.push({
231
+ path: "statement",
232
+ code: nonEmptyStringCode(statementVal),
233
+ message: "statement must be a non-empty string.",
234
+ });
235
+ }
236
+ const createdAtOk = isIsoTimestamp(createdAtVal);
237
+ if (!createdAtOk) {
238
+ errors.push({
239
+ path: "createdAt",
240
+ code: createdAtVal === undefined ? "missing-field" : "invalid-timestamp",
241
+ message: "createdAt must be an ISO-8601 timestamp with a timezone.",
242
+ });
243
+ }
244
+ const scopeOk = includes(SCOPE_LADDER, scopeVal);
245
+ if (!scopeOk) {
246
+ errors.push({ path: "scope", code: vocabularyCode(scopeVal), message: `scope must be one of: ${SCOPE_LADDER.join(", ")}.` });
247
+ }
248
+ const modeOk = includes(MEMORY_MODES, modeVal);
249
+ if (!modeOk) {
250
+ errors.push({ path: "mode", code: vocabularyCode(modeVal), message: `mode must be one of: ${MEMORY_MODES.join(", ")}.` });
251
+ }
252
+ const provenanceOk = includes(PROVENANCES, provenanceVal);
253
+ if (!provenanceOk) {
254
+ errors.push({ path: "provenance", code: vocabularyCode(provenanceVal), message: `provenance must be one of: ${PROVENANCES.join(", ")}.` });
255
+ }
256
+ const authorityOk = includes(AUTHORITIES, authorityVal);
257
+ if (!authorityOk) {
258
+ errors.push({ path: "authority", code: vocabularyCode(authorityVal), message: `authority must be one of: ${AUTHORITIES.join(", ")}.` });
259
+ }
260
+ // Optional fields: reject wrong types, ignore when absent.
261
+ const scopeRefVal = input.scopeRef;
262
+ const subjectVal = input.subject;
263
+ const evidenceVal = input.evidence;
264
+ const supersedesVal = input.supersedes;
265
+ if (scopeRefVal !== undefined && !isNonEmptyString(scopeRefVal)) {
266
+ errors.push({ path: "scopeRef", code: nonEmptyStringCode(scopeRefVal), message: "scopeRef, if present, must be a non-empty string." });
267
+ }
268
+ if (subjectVal !== undefined && typeof subjectVal !== "string") {
269
+ errors.push({ path: "subject", code: "wrong-type", message: "subject, if present, must be a string." });
270
+ }
271
+ if (evidenceVal !== undefined && !isStringArray(evidenceVal)) {
272
+ errors.push({ path: "evidence", code: "wrong-type", message: "evidence, if present, must be an array of strings." });
273
+ }
274
+ if (supersedesVal !== undefined && !isNonEmptyString(supersedesVal)) {
275
+ errors.push({ path: "supersedes", code: nonEmptyStringCode(supersedesVal), message: "supersedes, if present, must be a non-empty string." });
276
+ }
277
+ // Cross-field invariants can only run once the vocabulary fields are sound.
278
+ if (modeOk && provenanceOk && authorityOk) {
279
+ for (const err of checkInvariants({ mode: modeVal, provenance: provenanceVal, authority: authorityVal })) {
280
+ errors.push(err);
281
+ }
282
+ }
283
+ if (errors.length > 0) {
284
+ return { ok: false, errors };
285
+ }
286
+ // All guards passed; aliased narrowing makes the const locals fully typed here.
287
+ if (!scopeOk || !modeOk || !provenanceOk || !authorityOk || !idOk || !statementOk || !createdAtOk) {
288
+ return { ok: false, errors: [{ path: "", code: "wrong-type", message: "internal: unexpected narrowing failure." }] };
289
+ }
290
+ const record = {
291
+ schemaVersion: MEMORY_RECORD_SCHEMA_VERSION,
292
+ id: idVal,
293
+ scope: scopeVal,
294
+ mode: modeVal,
295
+ provenance: provenanceVal,
296
+ authority: authorityVal,
297
+ statement: statementVal,
298
+ createdAt: createdAtVal,
299
+ ...(isNonEmptyString(scopeRefVal) ? { scopeRef: scopeRefVal } : {}),
300
+ ...(typeof subjectVal === "string" ? { subject: subjectVal } : {}),
301
+ ...(isStringArray(evidenceVal) ? { evidence: evidenceVal } : {}),
302
+ ...(isNonEmptyString(supersedesVal) ? { supersedes: supersedesVal } : {}),
303
+ };
304
+ return { ok: true, record };
305
+ }
306
+ /** Type guard form of {@link validateMemoryRecord}. */
307
+ export function isMemoryRecord(input) {
308
+ return validateMemoryRecord(input).ok;
309
+ }
310
+ /** Error thrown by {@link assertMemoryRecord}. Carries the structured errors. */
311
+ export class MemoryRecordValidationError extends Error {
312
+ errors;
313
+ constructor(errors) {
314
+ super(`invalid memory record: ${errors.map((e) => `${e.path || "<record>"}: ${e.message}`).join("; ")}`);
315
+ this.name = "MemoryRecordValidationError";
316
+ this.errors = errors;
317
+ }
318
+ }
319
+ /**
320
+ * Throwing form: returns the validated record or throws
321
+ * {@link MemoryRecordValidationError}. Handy for governance/write paths that
322
+ * treat an invalid record as a hard failure.
323
+ */
324
+ export function assertMemoryRecord(input) {
325
+ const result = validateMemoryRecord(input);
326
+ if (!result.ok) {
327
+ throw new MemoryRecordValidationError(result.errors);
328
+ }
329
+ return result.record;
330
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,2 +1,285 @@
1
- // Slice S2 (conformance corpus + fixtures) — owner replaces this placeholder barrel.
2
- export {};
1
+ // Slice S2 (conformance) — the shared conformance corpus + a reusable runner.
2
+ //
3
+ // Published as `@nanobpm/urban/context/conformance`. This subpath IS the
4
+ // contract: BOTH the producer (this repo) and the consumer
5
+ // (`nanobpm/nano-workforce#291`) are held to the SAME corpus, and the
6
+ // adversarial slice (S7) imports the corpus + runner from here directly. It is
7
+ // intentionally self-contained — it does NOT re-export the schema module, so a
8
+ // consumer can import it without pulling the whole schema surface, and the top
9
+ // barrel never sees a duplicate symbol.
10
+ //
11
+ // The runner is a PURE function over a validator callback (no test framework),
12
+ // so any party can point it at their own validator and get a structured report.
13
+ // The `.conformance.ts` sibling wires it into `node:test` for `test:conformance`.
14
+
15
+ import { MEMORY_RECORD_SCHEMA_VERSION, SCOPE_LADDER } from "../schema/index.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Public shapes
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Whether the corpus expects a validator to accept or reject an input. */
22
+ export type ConformanceExpectation = "accept" | "reject";
23
+
24
+ /** A single corpus entry: an input and the outcome every conformant validator must produce. */
25
+ export interface ConformanceCase {
26
+ /** Unique, stable, human-readable case name. */
27
+ readonly name: string;
28
+ /** The required outcome. */
29
+ readonly expect: ConformanceExpectation;
30
+ /** The (untrusted) value fed to the validator. */
31
+ readonly input: unknown;
32
+ /** Why this case exists / what rule it exercises. */
33
+ readonly note: string;
34
+ }
35
+
36
+ /**
37
+ * The minimal validator contract the corpus tests. A conformant validator maps
38
+ * an untrusted value to an accept/reject decision via `{ ok }`.
39
+ * `validateMemoryRecord` (result form) adapts to this shape via
40
+ * {@link fromResultValidator}; the boolean `isMemoryRecord` conforms by wrapping
41
+ * its result — `(input) => ({ ok: isMemoryRecord(input) })`.
42
+ */
43
+ export type ConformanceValidator = (input: unknown) => { readonly ok: boolean };
44
+
45
+ /** The outcome of running one case through a validator. */
46
+ export interface ConformanceCaseResult {
47
+ readonly case: ConformanceCase;
48
+ readonly expected: ConformanceExpectation;
49
+ readonly actual: ConformanceExpectation;
50
+ readonly passed: boolean;
51
+ }
52
+
53
+ /** The aggregate report from {@link runConformance}. */
54
+ export interface ConformanceReport {
55
+ readonly total: number;
56
+ readonly passed: number;
57
+ readonly failed: number;
58
+ readonly results: readonly ConformanceCaseResult[];
59
+ readonly failures: readonly ConformanceCaseResult[];
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Corpus construction
64
+ // ---------------------------------------------------------------------------
65
+
66
+ interface Triple {
67
+ readonly mode: string;
68
+ readonly provenance: string;
69
+ readonly authority: string;
70
+ }
71
+
72
+ /**
73
+ * Every (provenance, mode, authority) triple that is VALID by the schema
74
+ * invariants. Instantiated across all scopes below, this covers every
75
+ * scope × provenance × mode combination that a well-formed record can take.
76
+ */
77
+ const VALID_TRIPLES: readonly Triple[] = [
78
+ { provenance: "human", mode: "normative", authority: "hypothesis" },
79
+ { provenance: "human", mode: "normative", authority: "authoritative" },
80
+ { provenance: "human", mode: "empirical", authority: "hypothesis" },
81
+ { provenance: "human", mode: "empirical", authority: "authoritative" },
82
+ { provenance: "agent-retro", mode: "normative", authority: "hypothesis" },
83
+ { provenance: "agent-retro", mode: "empirical", authority: "hypothesis" },
84
+ { provenance: "measured", mode: "empirical", authority: "authoritative" },
85
+ { provenance: "instance", mode: "empirical", authority: "authoritative" },
86
+ ];
87
+
88
+ /**
89
+ * Every (provenance, mode, authority) triple that MUST be rejected by the
90
+ * invariants, with the rule it violates. The first two are the headline
91
+ * hypothesis-never-fix invariant (agent-retro can never be authoritative).
92
+ */
93
+ const INVALID_TRIPLES: readonly (Triple & { readonly why: string })[] = [
94
+ { provenance: "agent-retro", mode: "normative", authority: "authoritative", why: "hypothesis-never-fix: agent-retro can never be authoritative" },
95
+ { provenance: "agent-retro", mode: "empirical", authority: "authoritative", why: "hypothesis-never-fix: agent-retro can never be authoritative" },
96
+ { provenance: "measured", mode: "empirical", authority: "hypothesis", why: "measured must be authoritative" },
97
+ { provenance: "measured", mode: "normative", authority: "authoritative", why: "measured must be empirical, not normative" },
98
+ { provenance: "instance", mode: "empirical", authority: "hypothesis", why: "instance must be authoritative" },
99
+ { provenance: "instance", mode: "normative", authority: "authoritative", why: "instance must be empirical, not normative" },
100
+ ];
101
+
102
+ function baseRecord(scope: string, triple: Triple): Record<string, unknown> {
103
+ return {
104
+ schemaVersion: MEMORY_RECORD_SCHEMA_VERSION,
105
+ id: `rec-${scope}-${triple.provenance}-${triple.mode}-${triple.authority}`,
106
+ scope,
107
+ mode: triple.mode,
108
+ provenance: triple.provenance,
109
+ authority: triple.authority,
110
+ statement: `example ${triple.mode} ${triple.provenance} record at ${scope} scope`,
111
+ createdAt: "2026-01-01T00:00:00.000Z",
112
+ };
113
+ }
114
+
115
+ function buildCombinationalCases(): readonly ConformanceCase[] {
116
+ const cases: ConformanceCase[] = [];
117
+ for (const scope of SCOPE_LADDER) {
118
+ for (const triple of VALID_TRIPLES) {
119
+ cases.push({
120
+ name: `accept ${scope}/${triple.mode}/${triple.provenance}/${triple.authority}`,
121
+ expect: "accept",
122
+ input: baseRecord(scope, triple),
123
+ note: "well-formed record; valid vocabulary + consistent invariants",
124
+ });
125
+ }
126
+ for (const triple of INVALID_TRIPLES) {
127
+ cases.push({
128
+ name: `reject ${scope}/${triple.mode}/${triple.provenance}/${triple.authority}`,
129
+ expect: "reject",
130
+ input: baseRecord(scope, triple),
131
+ note: triple.why,
132
+ });
133
+ }
134
+ }
135
+ return cases;
136
+ }
137
+
138
+ /** Structural / vocabulary-drift rejects that are independent of scope. */
139
+ const STRUCTURAL_CASES: readonly ConformanceCase[] = [
140
+ { name: "reject non-object (number)", expect: "reject", input: 42, note: "record must be an object" },
141
+ { name: "reject non-object (null)", expect: "reject", input: null, note: "record must be an object" },
142
+ { name: "reject non-object (array)", expect: "reject", input: [], note: "record must be a plain object, not an array" },
143
+ {
144
+ name: "reject unsupported schemaVersion",
145
+ expect: "reject",
146
+ input: { schemaVersion: 2, id: "x", scope: "repo", mode: "empirical", provenance: "human", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
147
+ note: "schemaVersion must be exactly 1",
148
+ },
149
+ {
150
+ name: "reject missing schemaVersion",
151
+ expect: "reject",
152
+ input: { id: "x", scope: "repo", mode: "empirical", provenance: "human", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
153
+ note: "schemaVersion is required",
154
+ },
155
+ {
156
+ name: "reject missing id",
157
+ expect: "reject",
158
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, scope: "repo", mode: "empirical", provenance: "human", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
159
+ note: "id is required",
160
+ },
161
+ {
162
+ name: "reject empty statement",
163
+ expect: "reject",
164
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "repo", mode: "empirical", provenance: "human", authority: "authoritative", statement: "", createdAt: "2026-01-01T00:00:00.000Z" },
165
+ note: "statement must be non-empty",
166
+ },
167
+ {
168
+ name: "reject drifted scope vocabulary",
169
+ expect: "reject",
170
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "galaxy", mode: "empirical", provenance: "human", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
171
+ note: "scope must be in the ladder vocabulary",
172
+ },
173
+ {
174
+ name: "reject drifted mode vocabulary",
175
+ expect: "reject",
176
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "repo", mode: "prescriptive", provenance: "human", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
177
+ note: "mode must be normative|empirical",
178
+ },
179
+ {
180
+ name: "reject drifted provenance vocabulary",
181
+ expect: "reject",
182
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "repo", mode: "empirical", provenance: "robot", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
183
+ note: "provenance must be in the vocabulary",
184
+ },
185
+ {
186
+ name: "reject drifted authority vocabulary",
187
+ expect: "reject",
188
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "repo", mode: "empirical", provenance: "human", authority: "supreme", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
189
+ note: "authority must be hypothesis|authoritative",
190
+ },
191
+ {
192
+ name: "reject non-ISO timestamp",
193
+ expect: "reject",
194
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "repo", mode: "empirical", provenance: "human", authority: "authoritative", statement: "s", createdAt: "yesterday" },
195
+ note: "createdAt must be ISO-8601",
196
+ },
197
+ {
198
+ name: "reject wrong-typed id",
199
+ expect: "reject",
200
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: 123, scope: "repo", mode: "empirical", provenance: "human", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z" },
201
+ note: "id must be a string",
202
+ },
203
+ {
204
+ name: "reject wrong-typed evidence",
205
+ expect: "reject",
206
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "repo", mode: "empirical", provenance: "measured", authority: "authoritative", statement: "s", createdAt: "2026-01-01T00:00:00.000Z", evidence: "not-an-array" },
207
+ note: "evidence, if present, must be a string array",
208
+ },
209
+ {
210
+ name: "accept well-formed measured record with evidence + optionals",
211
+ expect: "accept",
212
+ input: { schemaVersion: MEMORY_RECORD_SCHEMA_VERSION, id: "x", scope: "instance", scopeRef: "elem-7", subject: "latency", mode: "empirical", provenance: "measured", authority: "authoritative", statement: "p99 improved", createdAt: "2026-01-01T00:00:00.000Z", evidence: ["run-1", "run-2"], supersedes: "rec-old" },
213
+ note: "optional fields accepted when well-typed",
214
+ },
215
+ ];
216
+
217
+ /**
218
+ * The full conformance corpus: exhaustive scope × valid/invalid triples, plus
219
+ * structural and vocabulary-drift cases. Deterministic and stable — treat it as
220
+ * the versioned contract fixture both producer and consumer are held to.
221
+ */
222
+ export const corpus: readonly ConformanceCase[] = [...buildCombinationalCases(), ...STRUCTURAL_CASES];
223
+
224
+ /** The subset of the corpus that must be accepted. */
225
+ export const validFixtures: readonly ConformanceCase[] = corpus.filter((c) => c.expect === "accept");
226
+
227
+ /** The subset of the corpus that must be rejected. */
228
+ export const invalidFixtures: readonly ConformanceCase[] = corpus.filter((c) => c.expect === "reject");
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // The runner
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Adapt a `{ ok } | { ... }`-returning validator (like `validateMemoryRecord`)
236
+ * to the {@link ConformanceValidator} shape. Any function returning an object
237
+ * with a boolean `ok` already conforms; this is a typed identity helper.
238
+ */
239
+ export function fromResultValidator(validate: (input: unknown) => { readonly ok: boolean }): ConformanceValidator {
240
+ return (input: unknown) => ({ ok: validate(input).ok });
241
+ }
242
+
243
+ /**
244
+ * Run `validate` against the corpus (or a supplied subset) and return a
245
+ * structured report. Pure: does not throw and does not depend on a test runner.
246
+ */
247
+ export function runConformance(
248
+ validate: ConformanceValidator,
249
+ cases: readonly ConformanceCase[] = corpus,
250
+ ): ConformanceReport {
251
+ const results: ConformanceCaseResult[] = cases.map((c) => {
252
+ // A validator that throws (even accidentally) must not crash the run: treat
253
+ // a throw as a rejection so the runner stays pure and the doc above holds.
254
+ let ok: boolean;
255
+ try {
256
+ ok = validate(c.input).ok;
257
+ } catch {
258
+ ok = false;
259
+ }
260
+ const actual: ConformanceExpectation = ok ? "accept" : "reject";
261
+ return { case: c, expected: c.expect, actual, passed: actual === c.expect };
262
+ });
263
+ const failures = results.filter((r) => !r.passed);
264
+ return {
265
+ total: results.length,
266
+ passed: results.length - failures.length,
267
+ failed: failures.length,
268
+ results,
269
+ failures,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Assert full conformance: throws with a readable summary if any case fails.
275
+ * Consumers can call this in their own test suite to be held to the contract.
276
+ */
277
+ export function assertConformance(validate: ConformanceValidator, cases: readonly ConformanceCase[] = corpus): void {
278
+ const report = runConformance(validate, cases);
279
+ if (report.failed > 0) {
280
+ const detail = report.failures
281
+ .map((f) => ` - ${f.case.name}: expected ${f.expected}, got ${f.actual} (${f.case.note})`)
282
+ .join("\n");
283
+ throw new Error(`conformance failed: ${report.failed}/${report.total} case(s):\n${detail}`);
284
+ }
285
+ }