@feltdb/core 0.11.4 → 0.11.5

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.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Architecture invariants.
3
+ *
4
+ * application / human
5
+ * │ declares
6
+ * ▼
7
+ * FeltDB ──── invariant (identity, declaration, version, lifecycle, provenance)
8
+ * │ │
9
+ * │ snapshot │ referenced by exact version
10
+ * ▼ ▼
11
+ * evaluator (jev, or any other) ────────▶ evaluation (PASS | DRIFT | UNKNOWN)
12
+ * │
13
+ * ▼
14
+ * FeltDB
15
+ *
16
+ * An architecture invariant is a durable declaration that some condition is
17
+ * expected to remain true of an application: across code, contracts,
18
+ * configuration, endpoints, secrets, authority boundaries, or any other
19
+ * architectural surface.
20
+ *
21
+ * FeltDB owns the declaration. It stores invariant identity, the versioned
22
+ * declaration, lifecycle, provenance, and the durable history of what was
23
+ * evaluated. It does not decide whether an invariant is currently satisfied,
24
+ * and it contains no evaluator.
25
+ *
26
+ * An evaluator owns the judgment. It reads a declaration snapshot, collects
27
+ * evidence, and records an advisory evaluation against the exact invariant
28
+ * version it read. `evaluations.record()` can only write evaluations: the
29
+ * store it is given rejects a write to any other collection, so an evaluator
30
+ * cannot redefine, reword, or retire the invariant it was asked about, and a
31
+ * DRIFT result never changes the declaration it drifted from.
32
+ *
33
+ * An invariant references the authorities it constrains; it never restates
34
+ * them. `.flow` remains authoritative for its contract and configuration
35
+ * model, AppPort for AppPort contracts, AuthBoundry for authorization
36
+ * decisions, and FeltDB for durable application state. A reference names the
37
+ * authority and states the relationship that must remain true about it.
38
+ */
39
+ import { canonicalApplicationBytes } from './canonical-application.js';
40
+ import { FeltDBErrorCode, FeltDBServiceError } from './error-codes.js';
41
+ /** Durable collection holding one head record per declared invariant. */
42
+ export const FELTDB_ARCHITECTURE_INVARIANT_COLLECTION = '_feltdb.ArchitectureInvariant';
43
+ /** Durable collection holding the immutable declaration of every version. */
44
+ export const FELTDB_ARCHITECTURE_INVARIANT_VERSION_COLLECTION = '_feltdb.ArchitectureInvariantVersion';
45
+ /** Durable collection holding immutable advisory evaluations. */
46
+ export const FELTDB_ARCHITECTURE_EVALUATION_COLLECTION = '_feltdb.ArchitectureInvariantEvaluation';
47
+ export const ARCHITECTURE_INVARIANT_CONTRACT_VERSION = 1;
48
+ /** Largest page a bounded invariant or evaluation query may request. */
49
+ export const ARCHITECTURE_INVARIANT_MAX_LIMIT = 1_000;
50
+ /** Page size used when a caller does not request one. */
51
+ export const ARCHITECTURE_INVARIANT_DEFAULT_LIMIT = 100;
52
+ export const ARCHITECTURE_INVARIANT_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
53
+ export const ARCHITECTURE_INVARIANT_STATUSES = ['active', 'inactive'];
54
+ /**
55
+ * How an invariant can be established.
56
+ *
57
+ * Not every invariant is mechanically checkable. Some are decided by a test or
58
+ * a conformance check, some need evidence interpreted by an advisory
59
+ * evaluator, and some can only be escalated to a person. All three are
60
+ * first-class: `human-review` is a complete answer to "how is this verified",
61
+ * not an admission that the model failed to describe it.
62
+ */
63
+ export const ARCHITECTURE_VERIFICATION_METHODS = [
64
+ 'automated-test', 'contract-conformance', 'source-analysis',
65
+ 'runtime-observation', 'benchmark', 'jev-advisory', 'human-review',
66
+ ];
67
+ /**
68
+ * Advisory results an evaluator may record.
69
+ *
70
+ * `UNKNOWN` exists so that missing evidence stays missing evidence. An
71
+ * evaluator that cannot establish a condition records `UNKNOWN`; it does not
72
+ * record `DRIFT`, and FeltDB never promotes one to the other.
73
+ */
74
+ export const ARCHITECTURE_EVALUATION_RESULTS = ['PASS', 'DRIFT', 'UNKNOWN'];
75
+ export const ARCHITECTURE_INVARIANT_CAPABILITIES = {
76
+ declare: 'architecture:invariant.declare',
77
+ read: 'architecture:invariant.read',
78
+ evaluate: 'architecture:evaluation.record',
79
+ readEvaluation: 'architecture:evaluation.read',
80
+ };
81
+ /**
82
+ * Declaration fields an evaluation may never carry.
83
+ *
84
+ * An evaluator states what it observed. The moment its output could also carry
85
+ * a `severity` or a `description`, recording an evaluation would be a way to
86
+ * redefine the invariant, and the authority boundary would be advisory in name
87
+ * only. These are rejected rather than ignored, so a caller that believes it
88
+ * is changing the declaration finds out.
89
+ */
90
+ const EVALUATION_FORBIDDEN_FIELDS = [
91
+ 'name', 'description', 'category', 'severity', 'scope', 'status',
92
+ 'verification', 'references', 'version', 'declaration_digest', 'provenance',
93
+ ];
94
+ const EVIDENCE_REFERENCE = /^[a-z][a-z0-9+.-]*:.+$/;
95
+ function invalid(message) {
96
+ return new FeltDBServiceError(message, FeltDBErrorCode.VALIDATION_ERROR);
97
+ }
98
+ function requireText(value, label, max = 4_000) {
99
+ if (typeof value !== 'string' || !value.trim())
100
+ throw invalid(`${label} must be a non-empty string`);
101
+ const text = value.trim();
102
+ if (text.length > max)
103
+ throw invalid(`${label} must be at most ${max} characters`);
104
+ return text;
105
+ }
106
+ /**
107
+ * Identities become durable record keys, so a separator inside one would let
108
+ * two different declarations resolve to the same row.
109
+ */
110
+ function requireIdentity(value, label) {
111
+ const text = requireText(value, label, 200);
112
+ if (/[:/\s]/.test(text))
113
+ throw invalid(`${label} must not contain ":", "/", or whitespace`);
114
+ return text;
115
+ }
116
+ function requireMember(value, allowed, label) {
117
+ if (typeof value !== 'string' || !allowed.includes(value)) {
118
+ throw invalid(`${label} must be one of ${allowed.join(', ')}`);
119
+ }
120
+ return value;
121
+ }
122
+ function normalizeEvidence(value, label) {
123
+ if (value === undefined)
124
+ return [];
125
+ if (!Array.isArray(value))
126
+ throw invalid(`${label} must be an array of evidence references`);
127
+ const seen = new Set();
128
+ for (const entry of value) {
129
+ const reference = requireText(entry, `${label} entry`, 1_000);
130
+ if (!EVIDENCE_REFERENCE.test(reference)) {
131
+ throw invalid(`${label} entry "${reference}" must be a "scheme:reference" evidence reference`);
132
+ }
133
+ seen.add(reference);
134
+ }
135
+ return [...seen];
136
+ }
137
+ /**
138
+ * An invariant must say how it can be established, even when the answer is
139
+ * that only a person can decide it. An invariant nobody can verify by any
140
+ * means is a sentence, not an invariant.
141
+ */
142
+ function normalizeVerification(value, label) {
143
+ if (!Array.isArray(value) || value.length === 0) {
144
+ throw invalid(`${label} must declare at least one way this invariant can be established`);
145
+ }
146
+ return value.map((entry, position) => {
147
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
148
+ throw invalid(`${label}[${position}] must be an object`);
149
+ }
150
+ const input = entry;
151
+ const reference = input.reference === undefined ? undefined : requireText(input.reference, `${label}[${position}].reference`, 1_000);
152
+ if (reference !== undefined && !EVIDENCE_REFERENCE.test(reference)) {
153
+ throw invalid(`${label}[${position}].reference must be a "scheme:reference" evidence reference`);
154
+ }
155
+ return {
156
+ method: requireMember(input.method, ARCHITECTURE_VERIFICATION_METHODS, `${label}[${position}].method`),
157
+ ...(reference === undefined ? {} : { reference }),
158
+ ...(input.evaluator === undefined ? {} : { evaluator: requireText(input.evaluator, `${label}[${position}].evaluator`, 200) }),
159
+ ...(input.predicate === undefined ? {} : { predicate: requireText(input.predicate, `${label}[${position}].predicate`) }),
160
+ ...(input.notes === undefined ? {} : { notes: requireText(input.notes, `${label}[${position}].notes`) }),
161
+ ...(input.parameters === undefined ? {} : (() => {
162
+ if (typeof input.parameters !== 'object' || input.parameters === null || Array.isArray(input.parameters)) {
163
+ throw invalid(`${label}[${position}].parameters must be an object`);
164
+ }
165
+ return { parameters: input.parameters };
166
+ })()),
167
+ };
168
+ });
169
+ }
170
+ function normalizeReferences(value, label) {
171
+ if (value === undefined)
172
+ return [];
173
+ if (!Array.isArray(value))
174
+ throw invalid(`${label} must be an array`);
175
+ return value.map((entry, position) => {
176
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
177
+ throw invalid(`${label}[${position}] must be an object`);
178
+ }
179
+ const reference = entry;
180
+ return {
181
+ authority: requireText(reference.authority, `${label}[${position}].authority`, 200),
182
+ reference: requireText(reference.reference, `${label}[${position}].reference`, 1_000),
183
+ relationship: requireText(reference.relationship, `${label}[${position}].relationship`),
184
+ };
185
+ });
186
+ }
187
+ function normalizeDeclaration(input) {
188
+ return {
189
+ name: requireText(input.name, 'name', 300),
190
+ description: requireText(input.description, 'description'),
191
+ category: requireIdentity(input.category, 'category'),
192
+ severity: requireMember(input.severity, ARCHITECTURE_INVARIANT_SEVERITIES, 'severity'),
193
+ scope: requireIdentity(input.scope, 'scope'),
194
+ verification: normalizeVerification(input.verification, 'verification'),
195
+ references: normalizeReferences(input.references, 'references'),
196
+ };
197
+ }
198
+ function declarationOf(value) {
199
+ return {
200
+ name: value.name,
201
+ description: value.description,
202
+ category: value.category,
203
+ severity: value.severity,
204
+ scope: value.scope,
205
+ verification: value.verification,
206
+ references: value.references,
207
+ };
208
+ }
209
+ /**
210
+ * Digest over the canonical application encoding FeltDB already uses, so an
211
+ * evaluation can prove which words it was evaluating.
212
+ */
213
+ async function declarationDigest(declaration) {
214
+ const bytes = canonicalApplicationBytes(declaration);
215
+ const digest = await crypto.subtle.digest('SHA-256', bytes.slice().buffer);
216
+ return `sha256:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}`;
217
+ }
218
+ function sameDeclaration(left, right) {
219
+ const encode = (value) => new TextDecoder().decode(canonicalApplicationBytes(value));
220
+ return encode(declarationOf(left)) === encode(declarationOf(right));
221
+ }
222
+ function boundedLimit(value) {
223
+ if (value === undefined)
224
+ return ARCHITECTURE_INVARIANT_DEFAULT_LIMIT;
225
+ if (!Number.isInteger(value) || value < 1 || value > ARCHITECTURE_INVARIANT_MAX_LIMIT) {
226
+ throw new FeltDBServiceError(`limit must be an integer between 1 and ${ARCHITECTURE_INVARIANT_MAX_LIMIT}`, FeltDBErrorCode.QUERY_LIMIT_EXCEEDED);
227
+ }
228
+ return value;
229
+ }
230
+ function timestamp(value) {
231
+ return value.toISOString();
232
+ }
233
+ function resolveEvaluatedAt(value, fallback) {
234
+ if (value === undefined)
235
+ return timestamp(fallback);
236
+ const resolved = value instanceof Date ? value : new Date(value);
237
+ if (Number.isNaN(resolved.getTime()))
238
+ throw invalid('evaluatedAt must be a valid date');
239
+ return timestamp(resolved);
240
+ }
241
+ /**
242
+ * A store that can only write one collection.
243
+ *
244
+ * `evaluations.record()` runs against this, which is what makes "an evaluation
245
+ * cannot mutate an invariant" a property of the substrate rather than a rule
246
+ * the recording path is trusted to follow.
247
+ */
248
+ export function restrictWritesTo(store, collection) {
249
+ const guard = (target) => {
250
+ if (target !== collection) {
251
+ throw new FeltDBServiceError(`An evaluator may only write ${collection}; ${target} is owned by the declaring authority`, FeltDBErrorCode.FORBIDDEN);
252
+ }
253
+ };
254
+ return {
255
+ read: (target, recordId) => store.read(target, recordId),
256
+ query: request => store.query(request),
257
+ create: async (target, recordId, value) => { guard(target); return store.create(target, recordId, value); },
258
+ replace: async (target, recordId, value) => { guard(target); return store.replace(target, recordId, value); },
259
+ };
260
+ }
261
+ /** Adapt a FeltDB instance to the durable substrate this service writes through. */
262
+ export function createArchitectureInvariantStore(db) {
263
+ return {
264
+ async read(collection, recordId) {
265
+ return (await db.collection(collection).get(recordId)) ?? undefined;
266
+ },
267
+ async create(collection, recordId, value) {
268
+ await db.collection(collection).insert(value, recordId);
269
+ },
270
+ async replace(collection, recordId, value) {
271
+ await db.collection(collection).update(recordId, value);
272
+ },
273
+ query: request => db.query(request),
274
+ };
275
+ }
276
+ function toInvariant(row) {
277
+ const { invariant_id, id: _recordId, __version: _rowVersion, ...rest } = row;
278
+ return { ...rest, id: invariant_id };
279
+ }
280
+ function toEvaluation(row) {
281
+ const { evaluation_id, id: _recordId, __version: _rowVersion, evaluator_id: _evaluatorId, ...rest } = row;
282
+ return { ...rest, id: evaluation_id };
283
+ }
284
+ function toVersion(row) {
285
+ const { id: _recordId, __version: _rowVersion, ...rest } = row;
286
+ return rest;
287
+ }
288
+ /**
289
+ * The durable architecture invariant substrate.
290
+ *
291
+ * `invariants` declares what must be true. `evaluations` retains what an
292
+ * advisory evaluator observed about it. Neither side can do the other's job.
293
+ */
294
+ export class FeltDBArchitectureInvariantService {
295
+ scope;
296
+ actor;
297
+ source;
298
+ authorization;
299
+ clock;
300
+ newEvaluationId;
301
+ evaluationStore;
302
+ store;
303
+ constructor(store, options) {
304
+ this.store = store;
305
+ this.scope = {
306
+ tenantId: requireIdentity(options.scope?.tenantId, 'scope.tenantId'),
307
+ applicationId: requireIdentity(options.scope?.applicationId, 'scope.applicationId'),
308
+ };
309
+ if (!options.actor?.id || !options.actor?.kind)
310
+ throw invalid('actor requires a subject kind and id');
311
+ this.actor = { kind: options.actor.kind, id: requireText(options.actor.id, 'actor.id', 200) };
312
+ this.source = requireText(options.source, 'source', 200);
313
+ this.authorization = options.authorization;
314
+ this.clock = options.now ?? (() => new Date());
315
+ this.newEvaluationId = options.newEvaluationId ?? (() => `ae_${crypto.randomUUID().replace(/-/g, '')}`);
316
+ this.evaluationStore = restrictWritesTo(store, FELTDB_ARCHITECTURE_EVALUATION_COLLECTION);
317
+ }
318
+ /** Durable record key. Scoping by tenant and application is part of identity. */
319
+ scopedKey(recordId) {
320
+ return `${this.scope.tenantId}/${this.scope.applicationId}/${recordId}`;
321
+ }
322
+ versionKey(invariantId, version) {
323
+ return `${this.scopedKey(invariantId)}@${version}`;
324
+ }
325
+ resourceUri(collection, recordId) {
326
+ return `flow://${this.scope.applicationId}/${collection}/${recordId}`;
327
+ }
328
+ /**
329
+ * Ask FeltDB's existing authorization boundary, and fail closed.
330
+ *
331
+ * With no gate configured the caller has not asked for narrowing, and this
332
+ * substrate must not invent an authorization answer FeltDB did not give.
333
+ */
334
+ async authorize(capability, collection, recordId) {
335
+ if (!this.authorization)
336
+ return undefined;
337
+ const decision = await this.authorization.gate.check({
338
+ subject: this.authorization.subject,
339
+ tenant_id: this.scope.tenantId,
340
+ application_id: this.scope.applicationId,
341
+ resource: { uri: this.resourceUri(collection, recordId) },
342
+ capability,
343
+ context: { timestamp: this.clock().getTime() },
344
+ });
345
+ if (decision?.allowed !== true) {
346
+ throw new FeltDBServiceError(`${capability} was not allowed for ${this.resourceUri(collection, recordId)}${decision?.reason ? `: ${decision.reason}` : ''}`, FeltDBErrorCode.FORBIDDEN);
347
+ }
348
+ return decision.decision_id;
349
+ }
350
+ provenance(decisionId, at) {
351
+ return {
352
+ subject: this.authorization?.subject ?? this.actor,
353
+ tenant_id: this.scope.tenantId,
354
+ application_id: this.scope.applicationId,
355
+ recorded_at: timestamp(at),
356
+ source: this.source,
357
+ ...(decisionId ? { authorization_decision_id: decisionId } : {}),
358
+ };
359
+ }
360
+ async readInvariantRow(invariantId) {
361
+ const row = await this.store.read(FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, this.scopedKey(invariantId));
362
+ if (!row || row.tenant_id !== this.scope.tenantId || row.application_id !== this.scope.applicationId) {
363
+ throw new FeltDBServiceError(`Architecture invariant ${invariantId} was not found`, FeltDBErrorCode.NOT_FOUND);
364
+ }
365
+ return row;
366
+ }
367
+ /**
368
+ * Every collection read is a bounded, tenant- and application-scoped query
369
+ * with an explicit order, so a page is reproducible and a cursor resumes
370
+ * where the previous page ended instead of rescanning the collection.
371
+ */
372
+ scopedQuery(collection, where, orderBy, limit, cursor) {
373
+ return {
374
+ collection,
375
+ where: [
376
+ { field: 'tenant_id', eq: this.scope.tenantId },
377
+ { field: 'application_id', eq: this.scope.applicationId },
378
+ ...(where ?? []),
379
+ ],
380
+ orderBy,
381
+ limit,
382
+ ...(cursor ? { cursor } : {}),
383
+ };
384
+ }
385
+ async writeVersion(invariantId, version, declaration, digest, at, provenance, supersedes, reason) {
386
+ const record = {
387
+ id: this.versionKey(invariantId, version),
388
+ invariant_id: invariantId,
389
+ tenant_id: this.scope.tenantId,
390
+ application_id: this.scope.applicationId,
391
+ contract_version: ARCHITECTURE_INVARIANT_CONTRACT_VERSION,
392
+ version,
393
+ ...declaration,
394
+ declaration_digest: digest,
395
+ ...(supersedes === undefined ? {} : { supersedes }),
396
+ ...(reason ? { change_reason: reason } : {}),
397
+ declared_at: timestamp(at),
398
+ declared_by: provenance.subject,
399
+ provenance,
400
+ };
401
+ await this.store.create(FELTDB_ARCHITECTURE_INVARIANT_VERSION_COLLECTION, record.id, record);
402
+ return toVersion(record);
403
+ }
404
+ invariants = {
405
+ /** Declare an invariant. The declaration becomes version 1. */
406
+ create: async (input) => {
407
+ const invariantId = requireIdentity(input?.id, 'id');
408
+ const declaration = normalizeDeclaration(input);
409
+ const decisionId = await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.declare, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, invariantId);
410
+ const key = this.scopedKey(invariantId);
411
+ if (await this.store.read(FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, key)) {
412
+ throw new FeltDBServiceError(`Architecture invariant ${invariantId} is already declared for this application`, FeltDBErrorCode.CONFLICT);
413
+ }
414
+ const at = this.clock();
415
+ const provenance = this.provenance(decisionId, at);
416
+ const digest = await declarationDigest(declaration);
417
+ const row = {
418
+ id: key,
419
+ invariant_id: invariantId,
420
+ tenant_id: this.scope.tenantId,
421
+ application_id: this.scope.applicationId,
422
+ contract_version: ARCHITECTURE_INVARIANT_CONTRACT_VERSION,
423
+ ...declaration,
424
+ status: 'active',
425
+ version: 1,
426
+ declaration_digest: digest,
427
+ created_at: timestamp(at),
428
+ created_by: provenance.subject,
429
+ updated_at: timestamp(at),
430
+ updated_by: provenance.subject,
431
+ provenance,
432
+ };
433
+ await this.writeVersion(invariantId, 1, declaration, digest, at, provenance);
434
+ await this.store.create(FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, key, row);
435
+ return toInvariant(row);
436
+ },
437
+ /** Read the current declaration. */
438
+ get: async (invariantId) => {
439
+ const id = requireIdentity(invariantId, 'id');
440
+ await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.read, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, id);
441
+ return toInvariant(await this.readInvariantRow(id));
442
+ },
443
+ /** Read one immutable declaration version. */
444
+ version: async (invariantId, version) => {
445
+ const id = requireIdentity(invariantId, 'id');
446
+ if (!Number.isInteger(version) || version < 1)
447
+ throw invalid('version must be a positive integer');
448
+ await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.read, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, id);
449
+ const row = await this.store.read(FELTDB_ARCHITECTURE_INVARIANT_VERSION_COLLECTION, this.versionKey(id, version));
450
+ if (!row || row.tenant_id !== this.scope.tenantId || row.application_id !== this.scope.applicationId) {
451
+ throw new FeltDBServiceError(`Architecture invariant ${id} has no version ${version}`, FeltDBErrorCode.NOT_FOUND);
452
+ }
453
+ return toVersion(row);
454
+ },
455
+ /** Page the declaration history of one invariant, oldest version first. */
456
+ versions: async (invariantId, input = {}) => {
457
+ const id = requireIdentity(invariantId, 'id');
458
+ await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.read, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, id);
459
+ const page = await this.store.query(this.scopedQuery(FELTDB_ARCHITECTURE_INVARIANT_VERSION_COLLECTION, [{ field: 'invariant_id', eq: id }], [{ field: 'version', direction: 'asc' }], boundedLimit(input.limit), input.cursor));
460
+ return {
461
+ versions: page.records.map(toVersion),
462
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
463
+ exhausted: page.exhausted,
464
+ };
465
+ },
466
+ /** Page declared invariants within this tenant and application. */
467
+ list: async (input = {}) => {
468
+ await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.read, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, '*');
469
+ const where = [];
470
+ if (input.status !== undefined)
471
+ where.push({ field: 'status', eq: requireMember(input.status, ARCHITECTURE_INVARIANT_STATUSES, 'status') });
472
+ if (input.category !== undefined)
473
+ where.push({ field: 'category', eq: requireIdentity(input.category, 'category') });
474
+ if (input.severity !== undefined)
475
+ where.push({ field: 'severity', eq: requireMember(input.severity, ARCHITECTURE_INVARIANT_SEVERITIES, 'severity') });
476
+ if (input.scope !== undefined)
477
+ where.push({ field: 'scope', eq: requireIdentity(input.scope, 'scope') });
478
+ const page = await this.store.query(this.scopedQuery(FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, where, [{ field: 'invariant_id', direction: 'asc' }], boundedLimit(input.limit), input.cursor));
479
+ return {
480
+ invariants: page.records.map(toInvariant),
481
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
482
+ exhausted: page.exhausted,
483
+ };
484
+ },
485
+ /**
486
+ * Change what an invariant means.
487
+ *
488
+ * A changed declaration becomes a new version; the previous version keeps
489
+ * its exact words, so evaluations recorded against it stay readable. An
490
+ * update that changes nothing is a no-op and creates no version.
491
+ */
492
+ update: async (invariantId, input = {}) => {
493
+ const id = requireIdentity(invariantId, 'id');
494
+ const decisionId = await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.declare, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, id);
495
+ const row = await this.readInvariantRow(id);
496
+ if (input.expectedVersion !== undefined && input.expectedVersion !== row.version) {
497
+ throw new FeltDBServiceError(`Architecture invariant ${id} is at version ${row.version}, not ${input.expectedVersion}`, FeltDBErrorCode.VERSION_CONFLICT);
498
+ }
499
+ if (row.status !== 'active') {
500
+ throw new FeltDBServiceError(`Architecture invariant ${id} is ${row.status} and cannot be redeclared`, FeltDBErrorCode.CONFLICT);
501
+ }
502
+ const declaration = normalizeDeclaration({
503
+ ...declarationOf(row),
504
+ ...(input.name === undefined ? {} : { name: input.name }),
505
+ ...(input.description === undefined ? {} : { description: input.description }),
506
+ ...(input.category === undefined ? {} : { category: input.category }),
507
+ ...(input.severity === undefined ? {} : { severity: input.severity }),
508
+ ...(input.scope === undefined ? {} : { scope: input.scope }),
509
+ ...(input.verification === undefined ? {} : { verification: input.verification }),
510
+ ...(input.references === undefined ? {} : { references: input.references }),
511
+ });
512
+ if (sameDeclaration(row, declaration))
513
+ return toInvariant(row);
514
+ const at = this.clock();
515
+ const provenance = this.provenance(decisionId, at);
516
+ const version = row.version + 1;
517
+ const digest = await declarationDigest(declaration);
518
+ await this.writeVersion(id, version, declaration, digest, at, provenance, row.version, input.reason);
519
+ const updated = {
520
+ ...row,
521
+ ...declaration,
522
+ version,
523
+ declaration_digest: digest,
524
+ updated_at: timestamp(at),
525
+ updated_by: provenance.subject,
526
+ provenance,
527
+ };
528
+ await this.store.replace(FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, row.id, updated);
529
+ return toInvariant(updated);
530
+ },
531
+ /**
532
+ * Retire an invariant without rewriting it.
533
+ *
534
+ * Deactivation is lifecycle, not meaning: the declaration keeps its
535
+ * version, and evaluations recorded against it remain readable.
536
+ */
537
+ deactivate: async (invariantId, input = {}) => {
538
+ const id = requireIdentity(invariantId, 'id');
539
+ const decisionId = await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.declare, FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, id);
540
+ const row = await this.readInvariantRow(id);
541
+ if (input.expectedVersion !== undefined && input.expectedVersion !== row.version) {
542
+ throw new FeltDBServiceError(`Architecture invariant ${id} is at version ${row.version}, not ${input.expectedVersion}`, FeltDBErrorCode.VERSION_CONFLICT);
543
+ }
544
+ if (row.status === 'inactive')
545
+ return toInvariant(row);
546
+ const at = this.clock();
547
+ const provenance = this.provenance(decisionId, at);
548
+ const updated = {
549
+ ...row,
550
+ status: 'inactive',
551
+ updated_at: timestamp(at),
552
+ updated_by: provenance.subject,
553
+ deactivated_at: timestamp(at),
554
+ deactivated_by: provenance.subject,
555
+ ...(input.reason ? { deactivation_reason: requireText(input.reason, 'reason') } : {}),
556
+ provenance,
557
+ };
558
+ await this.store.replace(FELTDB_ARCHITECTURE_INVARIANT_COLLECTION, row.id, updated);
559
+ return toInvariant(updated);
560
+ },
561
+ };
562
+ evaluations = {
563
+ /**
564
+ * Retain one advisory evaluation.
565
+ *
566
+ * The evaluation binds to the exact invariant version it names, and to
567
+ * that version's declaration digest. A conclusive result must cite
568
+ * evidence; `UNKNOWN` need not, because the absence of evidence is what it
569
+ * reports.
570
+ */
571
+ record: async (input) => {
572
+ if (!input || typeof input !== 'object')
573
+ throw invalid('an evaluation input object is required');
574
+ const supplied = Object.keys(input);
575
+ const forbidden = EVALUATION_FORBIDDEN_FIELDS.filter(field => supplied.includes(field));
576
+ if (forbidden.length) {
577
+ throw new FeltDBServiceError(`An evaluation may not carry invariant declaration fields (${forbidden.join(', ')}); `
578
+ + 'the declaring authority owns the invariant definition', FeltDBErrorCode.FORBIDDEN);
579
+ }
580
+ const invariantId = requireIdentity(input.invariantId, 'invariantId');
581
+ if (!Number.isInteger(input.invariantVersion) || input.invariantVersion < 1) {
582
+ throw invalid('invariantVersion must be a positive integer');
583
+ }
584
+ const result = requireMember(input.result, ARCHITECTURE_EVALUATION_RESULTS, 'result');
585
+ const explanation = requireText(input.explanation, 'explanation');
586
+ const evidence = normalizeEvidence(input.evidence, 'evidence');
587
+ if (result !== 'UNKNOWN' && evidence.length === 0) {
588
+ throw invalid(`a ${result} evaluation must reference the evidence it was derived from`);
589
+ }
590
+ if (input.confidence !== undefined
591
+ && (typeof input.confidence !== 'number' || !Number.isFinite(input.confidence)
592
+ || input.confidence < 0 || input.confidence > 1)) {
593
+ throw invalid('confidence must be a number between 0 and 1');
594
+ }
595
+ const findings = (input.findings ?? []).map((finding, position) => {
596
+ const supporting = normalizeEvidence(finding?.evidence, `findings[${position}].evidence`);
597
+ return {
598
+ summary: requireText(finding?.summary, `findings[${position}].summary`),
599
+ ...(finding?.severity === undefined ? {} : {
600
+ severity: requireMember(finding.severity, ARCHITECTURE_INVARIANT_SEVERITIES, `findings[${position}].severity`),
601
+ }),
602
+ ...(supporting.length ? { evidence: supporting } : {}),
603
+ };
604
+ });
605
+ const evaluationId = input.evaluationId === undefined
606
+ ? this.newEvaluationId()
607
+ : requireIdentity(input.evaluationId, 'evaluationId');
608
+ const decisionId = await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.evaluate, FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, evaluationId);
609
+ // The declaration is read, never written: the version must already exist,
610
+ // and the evaluation carries its digest so the words can be proved later.
611
+ const declared = await this.evaluationStore.read(FELTDB_ARCHITECTURE_INVARIANT_VERSION_COLLECTION, this.versionKey(invariantId, input.invariantVersion));
612
+ if (!declared || declared.tenant_id !== this.scope.tenantId || declared.application_id !== this.scope.applicationId) {
613
+ throw new FeltDBServiceError(`Architecture invariant ${invariantId} has no version ${input.invariantVersion} to evaluate`, FeltDBErrorCode.NOT_FOUND);
614
+ }
615
+ const key = this.scopedKey(evaluationId);
616
+ if (await this.evaluationStore.read(FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, key)) {
617
+ throw new FeltDBServiceError(`Evaluation ${evaluationId} is already recorded`, FeltDBErrorCode.IDEMPOTENCY_CONFLICT);
618
+ }
619
+ const at = this.clock();
620
+ const row = {
621
+ id: key,
622
+ evaluation_id: evaluationId,
623
+ tenant_id: this.scope.tenantId,
624
+ application_id: this.scope.applicationId,
625
+ contract_version: ARCHITECTURE_INVARIANT_CONTRACT_VERSION,
626
+ invariant_id: invariantId,
627
+ invariant_version: declared.version,
628
+ invariant_declaration_digest: declared.declaration_digest,
629
+ evaluator_id: requireText(input.evaluator, 'evaluator', 200),
630
+ evaluator: {
631
+ id: requireText(input.evaluator, 'evaluator', 200),
632
+ version: input.evaluatorVersion === undefined ? 'unspecified' : requireText(input.evaluatorVersion, 'evaluatorVersion', 200),
633
+ },
634
+ result,
635
+ evidence,
636
+ explanation,
637
+ findings,
638
+ ...(input.confidence === undefined ? {} : { confidence: input.confidence }),
639
+ evaluated_at: resolveEvaluatedAt(input.evaluatedAt, at),
640
+ recorded_at: timestamp(at),
641
+ provenance: this.provenance(decisionId, at),
642
+ };
643
+ await this.evaluationStore.create(FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, key, row);
644
+ return toEvaluation(row);
645
+ },
646
+ get: async (evaluationId) => {
647
+ const id = requireIdentity(evaluationId, 'evaluationId');
648
+ await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.readEvaluation, FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, id);
649
+ const row = await this.store.read(FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, this.scopedKey(id));
650
+ if (!row || row.tenant_id !== this.scope.tenantId || row.application_id !== this.scope.applicationId) {
651
+ throw new FeltDBServiceError(`Evaluation ${id} was not found`, FeltDBErrorCode.NOT_FOUND);
652
+ }
653
+ return toEvaluation(row);
654
+ },
655
+ /** Page retained evaluations within this tenant and application. */
656
+ list: async (input = {}) => {
657
+ await this.authorize(ARCHITECTURE_INVARIANT_CAPABILITIES.readEvaluation, FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, '*');
658
+ const where = [];
659
+ if (input.invariantId !== undefined)
660
+ where.push({ field: 'invariant_id', eq: requireIdentity(input.invariantId, 'invariantId') });
661
+ if (input.invariantVersion !== undefined) {
662
+ if (!Number.isInteger(input.invariantVersion) || input.invariantVersion < 1)
663
+ throw invalid('invariantVersion must be a positive integer');
664
+ where.push({ field: 'invariant_version', eq: input.invariantVersion });
665
+ }
666
+ if (input.result !== undefined)
667
+ where.push({ field: 'result', eq: requireMember(input.result, ARCHITECTURE_EVALUATION_RESULTS, 'result') });
668
+ if (input.evaluator !== undefined)
669
+ where.push({ field: 'evaluator_id', eq: requireText(input.evaluator, 'evaluator', 200) });
670
+ const page = await this.store.query(this.scopedQuery(FELTDB_ARCHITECTURE_EVALUATION_COLLECTION, where, [{ field: 'recorded_at', direction: 'desc' }], boundedLimit(input.limit), input.cursor));
671
+ return {
672
+ evaluations: page.records.map(toEvaluation),
673
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
674
+ exhausted: page.exhausted,
675
+ };
676
+ },
677
+ };
678
+ /**
679
+ * Return the current declaration and its latest evaluation for each invariant.
680
+ *
681
+ * The invariant page is the snapshot boundary: history is never expanded and
682
+ * every evaluation lookup is limited to one record.
683
+ */
684
+ snapshot = async (input = {}) => {
685
+ const page = await this.invariants.list(input);
686
+ const entries = await Promise.all(page.invariants.map(async (invariant) => {
687
+ const evaluations = await this.evaluations.list({
688
+ invariantId: invariant.id,
689
+ invariantVersion: invariant.version,
690
+ limit: 1,
691
+ });
692
+ return {
693
+ invariant,
694
+ version: await this.invariants.version(invariant.id, invariant.version),
695
+ ...(evaluations.evaluations[0] ? { latestEvaluation: evaluations.evaluations[0] } : {}),
696
+ };
697
+ }));
698
+ return {
699
+ entries,
700
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
701
+ exhausted: page.exhausted,
702
+ };
703
+ };
704
+ }
705
+ /** Bind the durable architecture invariant substrate to a FeltDB instance. */
706
+ export function createArchitectureInvariantService(db, options) {
707
+ return new FeltDBArchitectureInvariantService(createArchitectureInvariantStore(db), options);
708
+ }