@nanobpm/urban 0.57.0 → 0.59.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/conformance/index.d.ts +66 -1
- package/dist/context/conformance/index.js +215 -1
- package/dist/context/conformance/records.conformance.js +52 -0
- package/dist/context/schema/index.d.ts +135 -1
- package/dist/context/schema/index.js +330 -1
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/context/conformance/index.ts +285 -2
- package/src/context/conformance/records.conformance.ts +68 -0
- package/src/context/schema/index.ts +443 -2
- package/src/context/schema/schema.test.ts +251 -0
- package/src/runtime/browser/runtime.browser.js +39 -3
- package/src/runtime/browser/runtime.browser.test.ts +43 -1
- package/src/runtime/core/modules/pages.test.ts +5 -3
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/dist/context/conformance/scaffold.conformance.js +0 -10
- package/src/context/conformance/scaffold.conformance.ts +0 -11
- /package/dist/context/conformance/{scaffold.conformance.d.ts → records.conformance.d.ts} +0 -0
|
@@ -1 +1,330 @@
|
|
|
1
|
-
|
|
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
|
+
}
|