@feltdb/core 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli/index.js +0 -0
  2. package/dist/composition.d.ts +697 -0
  3. package/dist/composition.d.ts.map +1 -0
  4. package/dist/composition.js +1218 -0
  5. package/dist/create/package-versions.js +1 -1
  6. package/dist/create/server-source/crates/feltdb/src/lib.rs +363 -2
  7. package/dist/create/server-source/crates/feltdb/src/read_basis.rs +257 -0
  8. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +442 -73
  9. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +18 -4
  10. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +290 -7
  11. package/dist/create/server-source/crates/feltdb-server/src/main.rs +500 -23
  12. package/dist/db.d.ts +12 -1
  13. package/dist/db.d.ts.map +1 -1
  14. package/dist/db.js +42 -3
  15. package/dist/embedded-query.d.ts.map +1 -1
  16. package/dist/embedded-query.js +20 -6
  17. package/dist/embedded-transaction.d.ts +21 -1
  18. package/dist/embedded-transaction.d.ts.map +1 -1
  19. package/dist/embedded-transaction.js +96 -0
  20. package/dist/feltdb.d.ts +8 -0
  21. package/dist/feltdb.d.ts.map +1 -1
  22. package/dist/file-db.d.ts +25 -1
  23. package/dist/file-db.d.ts.map +1 -1
  24. package/dist/file-db.js +54 -3
  25. package/dist/flowspec.d.ts +7 -0
  26. package/dist/flowspec.d.ts.map +1 -1
  27. package/dist/flowspec.js +13 -1
  28. package/dist/http-db.d.ts +7 -0
  29. package/dist/http-db.d.ts.map +1 -1
  30. package/dist/http-db.js +38 -6
  31. package/dist/index-core.d.ts +1 -0
  32. package/dist/index-core.d.ts.map +1 -1
  33. package/dist/index-core.js +1 -0
  34. package/dist/indexeddb-db.d.ts +3 -1
  35. package/dist/indexeddb-db.d.ts.map +1 -1
  36. package/dist/indexeddb-db.js +50 -2
  37. package/dist/intent.d.ts +4 -2
  38. package/dist/intent.d.ts.map +1 -1
  39. package/dist/memory-db.d.ts +2 -1
  40. package/dist/memory-db.d.ts.map +1 -1
  41. package/dist/memory-db.js +13 -1
  42. package/dist/proposal.d.ts +5 -0
  43. package/dist/proposal.d.ts.map +1 -1
  44. package/dist/proposal.js +6 -2
  45. package/dist/runtime-snapshot.d.ts +76 -2
  46. package/dist/runtime-snapshot.d.ts.map +1 -1
  47. package/dist/runtime-snapshot.js +68 -7
  48. package/dist/stable-hash.d.ts +17 -0
  49. package/dist/stable-hash.d.ts.map +1 -0
  50. package/dist/stable-hash.js +82 -0
  51. package/dist/state-impact.d.ts +245 -0
  52. package/dist/state-impact.d.ts.map +1 -0
  53. package/dist/state-impact.js +404 -0
  54. package/dist/studio-app/assets/{feltdb_wasm-D58hv7aa.js → feltdb_wasm-DGGqm9GK.js} +1 -1
  55. package/dist/studio-app/assets/feltdb_wasm_bg-BYJo87WY.wasm +0 -0
  56. package/dist/studio-app/assets/index-qLeZcPW_.js +30 -0
  57. package/dist/studio-app/index.html +1 -1
  58. package/dist/transaction.d.ts +61 -1
  59. package/dist/transaction.d.ts.map +1 -1
  60. package/dist/transaction.js +59 -0
  61. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  62. package/package.json +1 -1
  63. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  64. package/dist/studio-app/assets/feltdb_wasm_bg-BgFXYOtc.wasm +0 -0
  65. package/dist/studio-app/assets/index-gR6d3Tys.js +0 -30
@@ -0,0 +1,1218 @@
1
+ import { FeltDBErrorCode, FeltDBServiceError } from './error-codes.js';
2
+ import { AtomicTransactionError, conditionalRefusal } from './transaction.js';
3
+ import { FELTDB_INTENT_COLLECTION, FELTDB_INTENT_EVENT_COLLECTION } from './intent.js';
4
+ import { FELTDB_PROPOSAL_EVENT_COLLECTION, PROPOSAL_TRANSITIONS } from './proposal.js';
5
+ import { FELTDB_COMPOSITION_COLLECTION, FELTDB_COMPOSITION_CONTRACT_COLLECTION, FELTDB_COMPOSITION_TOPOLOGY_COLLECTION, FELTDB_COMPOSITION_VERSION_COLLECTION, FELTDB_STATE_ATTRIBUTION_COLLECTION, FELTDB_STATE_CHANGE_COLLECTION, FELTDB_STATE_DEPENDENCY_COLLECTION, FELTDB_STATE_IMPACT_COLLECTION, FELTDB_STATE_OBSERVATION_COLLECTION, FELTDB_STATE_OWNERSHIP_COLLECTION, FELTDB_STATE_PROPOSAL_COLLECTION, canonicalDigest, changedFields, computeImpactSurface, consumerUri, emptyImpactReads, impactReadSet, impactSurfaceDigest, parseStateUri, recordValue, recordVersion, redactImpactSurface, stateUri, } from './state-impact.js';
6
+ export * from './state-impact.js';
7
+ export const COMPOSITION_CONTRACT_VERSION = 1;
8
+ export const COMPOSITION_MAX_LIMIT = 1_000;
9
+ /** Substrate capabilities, checked through the authorization gate. */
10
+ export const COMPOSITION_CAPABILITIES = {
11
+ /** Record or re-record the authoritative contract identity. */
12
+ bind: 'composition:bind',
13
+ /** Admit a consumer's composition declaration. */
14
+ declare: 'composition:declare',
15
+ /** Act as a declared consumer. */
16
+ act: 'composition:act',
17
+ /** Approve or reject a state proposal. */
18
+ review: 'composition:review',
19
+ };
20
+ /** Every operation a consumer may be authorized for on one collection. */
21
+ export const COMPOSITION_OPERATIONS = ['read', 'observe', 'write', 'delete'];
22
+ /**
23
+ * The state-proposal operations, as a transport would expose them.
24
+ *
25
+ * This is the programmatic contract a service layer maps to its routes: one
26
+ * operation, one call, JSON in and JSON out. The subject is always the
27
+ * authenticated caller bound to the handle — never a request field — and the
28
+ * gate is always the deployment's authorization boundary.
29
+ */
30
+ export const STATE_PROPOSAL_OPERATIONS = [
31
+ { operation: 'intend', on: 'consumer', method: 'intend', authorization: ['composition:act', 'read on every proposed record'],
32
+ writes: [FELTDB_INTENT_COLLECTION, FELTDB_INTENT_EVENT_COLLECTION, FELTDB_STATE_PROPOSAL_COLLECTION, FELTDB_STATE_IMPACT_COLLECTION, FELTDB_PROPOSAL_EVENT_COLLECTION] },
33
+ { operation: 'preview', on: 'consumer', method: 'preview', authorization: ['composition:act', 'read on every proposed record'], writes: [] },
34
+ { operation: 'history', on: 'consumer', method: 'history', authorization: ['composition:act', 'read on every proposed record'], writes: [] },
35
+ { operation: 'inspect', on: 'review', method: 'inspect', authorization: ['composition:review'], writes: [] },
36
+ { operation: 'approve', on: 'review', method: 'approve', authorization: ['composition:review'], writes: [FELTDB_STATE_PROPOSAL_COLLECTION, FELTDB_PROPOSAL_EVENT_COLLECTION] },
37
+ { operation: 'reject', on: 'review', method: 'reject', authorization: ['composition:review'], writes: [FELTDB_STATE_PROPOSAL_COLLECTION, FELTDB_PROPOSAL_EVENT_COLLECTION] },
38
+ { operation: 'rebase', on: 'consumer', method: 'rebase', authorization: ['composition:act', 'read on every proposed record'],
39
+ writes: [FELTDB_STATE_PROPOSAL_COLLECTION, FELTDB_STATE_IMPACT_COLLECTION, FELTDB_PROPOSAL_EVENT_COLLECTION, FELTDB_INTENT_COLLECTION, FELTDB_INTENT_EVENT_COLLECTION] },
40
+ { operation: 'apply', on: 'consumer', method: 'apply', authorization: ['composition:act', 'write (or delete, as owner) on every proposed record'],
41
+ writes: ['contract collections', FELTDB_STATE_CHANGE_COLLECTION, FELTDB_STATE_ATTRIBUTION_COLLECTION, FELTDB_STATE_PROPOSAL_COLLECTION, FELTDB_PROPOSAL_EVENT_COLLECTION, FELTDB_INTENT_COLLECTION, FELTDB_INTENT_EVENT_COLLECTION] },
42
+ ];
43
+ // ─── contract identity ──────────────────────────────────────────────────────
44
+ /** Identity of a compiled contract. The digest is the contract's own hash, not a new one. */
45
+ export function contractIdentity(contract) {
46
+ const manifest = contract.manifest;
47
+ return {
48
+ contract_id: contract.application_id,
49
+ contract_digest: contract.hashes.application,
50
+ model_digest: contract.hashes.model,
51
+ collections: manifest.collections.map(value => value.name).sort(),
52
+ capabilities: manifest.capabilities.map(value => value.name).sort(),
53
+ fields: Object.fromEntries(manifest.collections.map(collection => [collection.name, collection.fields.map(field => ({
54
+ name: field.name, type: field.type, ...(field.reference ? { reference: field.reference } : {}),
55
+ }))])),
56
+ workflows: manifest.workflows.map(value => ({
57
+ name: value.name,
58
+ capabilities: [...new Set(value.steps.map(step => step.capability).filter((name) => Boolean(name)))].sort(),
59
+ })),
60
+ triggers: manifest.triggers.map(value => ({ name: value.name, event: value.event, workflow: value.workflow })),
61
+ };
62
+ }
63
+ const fail = (message, code = FeltDBErrorCode.VALIDATION_ERROR) => new FeltDBServiceError(message, code);
64
+ const forbidden = (message) => fail(message, FeltDBErrorCode.FORBIDDEN);
65
+ function identity(value, label) {
66
+ if (typeof value !== 'string' || !value.trim())
67
+ throw fail(`${label} must be a non-empty string`);
68
+ if (/[\s/:]/.test(value) || value.length > 200)
69
+ throw fail(`${label} must not contain whitespace, "/", or ":" and must be at most 200 characters`);
70
+ return value;
71
+ }
72
+ function text(value, label, max = 4_000) {
73
+ if (typeof value !== 'string' || !value.trim())
74
+ throw fail(`${label} must be a non-empty string`);
75
+ if (value.length > max)
76
+ throw fail(`${label} must be at most ${max} characters`);
77
+ return value.trim();
78
+ }
79
+ const EVIDENCE = /^[a-z][a-z0-9+.-]*:.+$/;
80
+ function evidenceList(value, label) {
81
+ if (value === undefined)
82
+ return [];
83
+ if (!Array.isArray(value))
84
+ throw fail(`${label} must be an array of "scheme:reference" evidence references`);
85
+ return [...new Set(value.map((entry, index) => {
86
+ const reference = text(entry, `${label}[${index}]`, 1_000);
87
+ if (!EVIDENCE.test(reference))
88
+ throw fail(`${label}[${index}] "${reference}" must be a "scheme:reference" evidence reference`);
89
+ return reference;
90
+ }))].sort();
91
+ }
92
+ function closed(input, allowed, label) {
93
+ if (!input || typeof input !== 'object' || Array.isArray(input))
94
+ throw fail(`${label} must be an object`);
95
+ const extra = Object.keys(input).filter(key => !allowed.includes(key));
96
+ if (extra.length) {
97
+ throw fail(`${label} does not accept ${extra.join(', ')}. FeltDB records what happened; what it means belongs to the consumer.`);
98
+ }
99
+ return input;
100
+ }
101
+ function limit(value, fallback = 100) {
102
+ if (value === undefined)
103
+ return fallback;
104
+ if (!Number.isInteger(value) || value < 1 || value > COMPOSITION_MAX_LIMIT) {
105
+ throw fail(`limit must be an integer between 1 and ${COMPOSITION_MAX_LIMIT}`, FeltDBErrorCode.QUERY_LIMIT_EXCEEDED);
106
+ }
107
+ return value;
108
+ }
109
+ function strip(row) {
110
+ const { id: _id, __version: _version, ...rest } = row ?? {};
111
+ return rest;
112
+ }
113
+ const page = (result, map) => ({
114
+ items: result.records.map(map),
115
+ ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}),
116
+ exhausted: result.exhausted,
117
+ });
118
+ // ─── the service ────────────────────────────────────────────────────────────
119
+ /**
120
+ * Bind to the authoritative contract and return a composition handle.
121
+ *
122
+ * The first bind records the contract identity. A later bind with a different
123
+ * digest is refused unless `rebind` is explicit and authorized, so an
124
+ * application holding an old contract cannot quietly redefine the authority.
125
+ */
126
+ export async function openAuthoritativeComposition(db, options) {
127
+ if (!options?.authorization || typeof options.authorization.check !== 'function') {
128
+ throw forbidden('Authoritative composition requires an authorization gate; it fails closed without one');
129
+ }
130
+ const composition = new AuthoritativeComposition(db, options);
131
+ await composition.bind(options.rebind === true);
132
+ return composition;
133
+ }
134
+ export class AuthoritativeComposition {
135
+ db;
136
+ contract;
137
+ tenantId;
138
+ subject;
139
+ environment;
140
+ gate;
141
+ source;
142
+ clock;
143
+ flowHash;
144
+ constructor(db, options) {
145
+ this.db = db;
146
+ this.contract = contractIdentity(options.contract);
147
+ identity(this.contract.contract_id, 'contract id');
148
+ this.tenantId = identity(options.tenantId, 'tenantId');
149
+ if (!options.subject?.kind || !options.subject?.id)
150
+ throw fail('subject requires a kind and an id');
151
+ this.subject = { kind: options.subject.kind, id: text(options.subject.id, 'subject.id', 200) };
152
+ this.source = text(options.source, 'source', 200);
153
+ this.environment = options.environment ?? 'production';
154
+ this.gate = options.authorization;
155
+ this.clock = options.now ?? (() => new Date());
156
+ this.flowHash = options.contract.hashes.model;
157
+ }
158
+ /**
159
+ * @internal Durable record key for a tenant- and contract-scoped identity.
160
+ * Record ids are restricted to `[A-Za-z0-9._-]`, so the scoped identity is
161
+ * digested; the readable identity lives in the row itself.
162
+ */
163
+ async key(...parts) {
164
+ return `k.${(await canonicalDigest([this.tenantId, this.contract.contract_id, ...parts])).slice(7, 47)}`;
165
+ }
166
+ resource(collection, record = '*') {
167
+ return stateUri({ contract: this.contract.contract_id, collection, record });
168
+ }
169
+ now() { return this.clock().toISOString(); }
170
+ /** @internal Ask the gate; fail closed. Returns the decision id. */
171
+ async authorize(capability, uri, operation, consumer) {
172
+ let decision;
173
+ try {
174
+ decision = await this.gate.check({
175
+ subject: this.subject,
176
+ tenant_id: this.tenantId,
177
+ application_id: this.contract.contract_id,
178
+ resource: { uri, attributes: { operation, ...(consumer ? { consumer } : {}) } },
179
+ capability,
180
+ context: { timestamp: this.clock().getTime(), attributes: { operation } },
181
+ });
182
+ }
183
+ catch (error) {
184
+ throw forbidden(`${capability} could not be authorized for ${uri}: ${error instanceof Error ? error.message : String(error)}`);
185
+ }
186
+ if (decision?.allowed !== true) {
187
+ throw forbidden(`${capability} (${operation}) was not allowed for ${this.subject.kind}:${this.subject.id} on ${uri}${decision?.reason ? `: ${decision.reason}` : ''}`);
188
+ }
189
+ return decision.decision_id;
190
+ }
191
+ /** @internal Non-throwing form, for reporting what a reader may do. */
192
+ async allowed(capability, uri, operation, consumer) {
193
+ try {
194
+ await this.authorize(capability, uri, operation, consumer);
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ /** @internal */
202
+ provenance(decisionIds) {
203
+ return {
204
+ subject: this.subject, tenant_id: this.tenantId, contract_id: this.contract.contract_id,
205
+ source: this.source, recorded_at: this.now(), authorization_decision_ids: [...new Set(decisionIds)],
206
+ };
207
+ }
208
+ /** @internal */
209
+ async bind(rebind) {
210
+ const key = await this.key();
211
+ const row = await this.db.collection(FELTDB_COMPOSITION_CONTRACT_COLLECTION).get(key);
212
+ if (row && row.contract_digest === this.contract.contract_digest)
213
+ return;
214
+ if (row && !rebind) {
215
+ throw fail(`The authoritative contract ${this.contract.contract_id} is bound at ${String(row.contract_digest)}, `
216
+ + `not ${this.contract.contract_digest}. Rebind explicitly to change the authority.`, FeltDBErrorCode.CONTRACT_UNSUPPORTED);
217
+ }
218
+ const decision = await this.authorize(COMPOSITION_CAPABILITIES.bind, this.resource(FELTDB_COMPOSITION_CONTRACT_COLLECTION, this.contract.contract_id), 'bind');
219
+ const value = {
220
+ tenant_id: this.tenantId, contract_id: this.contract.contract_id, contract_digest: this.contract.contract_digest,
221
+ model_digest: this.contract.model_digest, collections: this.contract.collections, capabilities: this.contract.capabilities,
222
+ previous_digests: row ? [...(row.previous_digests ?? []), row.contract_digest] : [],
223
+ contract_version: COMPOSITION_CONTRACT_VERSION, provenance: this.provenance([decision]),
224
+ };
225
+ await this.db.transaction(tx => {
226
+ tx.collection(FELTDB_COMPOSITION_CONTRACT_COLLECTION).set(key, value, row ? { ifVersion: recordVersion(row) } : { requireAbsent: true });
227
+ });
228
+ }
229
+ /** @internal Key of a record's attribution head. */
230
+ attributionKey(collection, id) {
231
+ return this.key('attribution', collection, id);
232
+ }
233
+ /** @internal Stage a topology-head advance; every declaration and connection fences on it. */
234
+ async stageTopology(tx) {
235
+ const key = await this.key();
236
+ const head = await this.db.collection(FELTDB_COMPOSITION_TOPOLOGY_COLLECTION).get(key);
237
+ tx.collection(FELTDB_COMPOSITION_TOPOLOGY_COLLECTION).set(key, {
238
+ tenant_id: this.tenantId, contract_id: this.contract.contract_id, advanced_at: this.now(),
239
+ }, head ? { ifVersion: recordVersion(head) } : { requireAbsent: true });
240
+ }
241
+ // ── declarations ──
242
+ normalizeDeclaration(input) {
243
+ closed(input, ['consumer', 'access', 'projections'], 'composition declaration');
244
+ const consumer = identity(input.consumer, 'consumer');
245
+ if (!Array.isArray(input.access) || input.access.length === 0)
246
+ throw fail('access must declare at least one collection');
247
+ const collections = new Set();
248
+ const access = input.access.map((entry, index) => {
249
+ closed(entry, ['collection', 'mode', 'capability'], `access[${index}]`);
250
+ const collection = text(entry.collection, `access[${index}].collection`, 200);
251
+ if (!this.contract.collections.includes(collection))
252
+ throw fail(`access[${index}].collection ${collection} is not a collection of contract ${this.contract.contract_id}`);
253
+ if (collections.has(collection))
254
+ throw fail(`access declares ${collection} more than once`);
255
+ collections.add(collection);
256
+ if (!['observe', 'mutate', 'own'].includes(entry.mode))
257
+ throw fail(`access[${index}].mode must be observe, mutate, or own`);
258
+ const capability = text(entry.capability, `access[${index}].capability`, 200);
259
+ if (!this.contract.capabilities.includes(capability))
260
+ throw fail(`access[${index}].capability ${capability} is not declared by the contract`);
261
+ return { collection, mode: entry.mode, capability };
262
+ }).sort((a, b) => a.collection.localeCompare(b.collection));
263
+ const names = new Set();
264
+ const projections = (input.projections ?? []).map((projection, index) => {
265
+ closed(projection, ['name', 'sources'], `projections[${index}]`);
266
+ const name = identity(projection.name, `projections[${index}].name`);
267
+ if (names.has(name))
268
+ throw fail(`projection ${name} is declared twice`);
269
+ if (this.contract.collections.includes(name)) {
270
+ throw fail(`projection ${name} has the name of an authoritative collection; a projection can never stand in for authority`);
271
+ }
272
+ names.add(name);
273
+ if (!Array.isArray(projection.sources) || projection.sources.length === 0)
274
+ throw fail(`projection ${name} must name its sources`);
275
+ for (const source of projection.sources) {
276
+ if (!collections.has(source))
277
+ throw fail(`projection ${name} derives from ${source}, which this consumer has not declared access to`);
278
+ }
279
+ return { name, sources: [...new Set(projection.sources)].sort() };
280
+ }).sort((a, b) => a.name.localeCompare(b.name));
281
+ return { consumer, access, projections };
282
+ }
283
+ compositions = {
284
+ /**
285
+ * Admit a consumer's composition, or a new version of it.
286
+ *
287
+ * Declaration, version history, exclusive ownership claims, and the
288
+ * topology head commit in one transaction. Ownership is exclusive: a
289
+ * collection owned by another consumer refuses the whole declaration.
290
+ */
291
+ declare: async (input) => {
292
+ const declaration = this.normalizeDeclaration(input);
293
+ const consumer = declaration.consumer;
294
+ const decision = await this.authorize(COMPOSITION_CAPABILITIES.declare, consumerUri(this.contract.contract_id, consumer), 'declare', consumer);
295
+ const headKey = await this.key(consumer);
296
+ const head = await this.db.collection(FELTDB_COMPOSITION_COLLECTION).get(headKey);
297
+ const previous = head ? strip(head) : undefined;
298
+ const owned = new Set(declaration.access.filter(value => value.mode === 'own').map(value => value.collection));
299
+ const previouslyOwned = new Set((previous?.access ?? []).filter(value => value.mode === 'own').map(value => value.collection));
300
+ const claims = new Map();
301
+ for (const collection of new Set([...owned, ...previouslyOwned])) {
302
+ const claim = await this.db.collection(FELTDB_STATE_OWNERSHIP_COLLECTION).get(await this.key(collection));
303
+ if (claim && claim.owner !== consumer && owned.has(collection)) {
304
+ throw fail(`${collection} is owned by ${String(claim.owner)}; ownership is exclusive`, FeltDBErrorCode.CONFLICT);
305
+ }
306
+ claims.set(collection, claim);
307
+ }
308
+ const version = (previous?.version ?? 0) + 1;
309
+ const digest = await canonicalDigest({ contract: this.contract.contract_digest, ...declaration });
310
+ if (previous && previous.digest === digest)
311
+ return previous;
312
+ const composition = {
313
+ consumer, uri: consumerUri(this.contract.contract_id, consumer), tenant_id: this.tenantId,
314
+ contract_id: this.contract.contract_id, contract_digest: this.contract.contract_digest,
315
+ version, digest, status: 'active', access: declaration.access, projections: declaration.projections,
316
+ requires: [...new Set(declaration.access.map(value => value.capability))].sort(),
317
+ declared_at: this.now(), provenance: this.provenance([decision]),
318
+ };
319
+ const versionKey = await this.key(consumer, String(version));
320
+ const claimKeys = new Map(await Promise.all([...claims.keys()].map(async (collection) => [collection, await this.key(collection)])));
321
+ await this.db.transaction(async (tx) => {
322
+ tx.collection(FELTDB_COMPOSITION_COLLECTION).set(headKey, composition, head ? { ifVersion: recordVersion(head) } : { requireAbsent: true });
323
+ tx.collection(FELTDB_COMPOSITION_VERSION_COLLECTION).set(versionKey, composition, { requireAbsent: true });
324
+ for (const [collection, claim] of claims) {
325
+ const claimKey = claimKeys.get(collection);
326
+ if (owned.has(collection) && !claim) {
327
+ tx.collection(FELTDB_STATE_OWNERSHIP_COLLECTION).set(claimKey, {
328
+ tenant_id: this.tenantId, contract_id: this.contract.contract_id, collection, owner: consumer, claimed_at: this.now(),
329
+ }, { requireAbsent: true });
330
+ }
331
+ else if (!owned.has(collection) && claim?.owner === consumer) {
332
+ tx.collection(FELTDB_STATE_OWNERSHIP_COLLECTION).delete(claimKey, { ifVersion: recordVersion(claim) });
333
+ }
334
+ }
335
+ await this.stageTopology(tx);
336
+ });
337
+ return composition;
338
+ },
339
+ get: async (consumer) => {
340
+ const row = await this.db.collection(FELTDB_COMPOSITION_COLLECTION).get(await this.key(identity(consumer, 'consumer')));
341
+ if (!row)
342
+ throw fail(`No composition is declared for ${consumer}`, FeltDBErrorCode.NOT_FOUND);
343
+ return strip(row);
344
+ },
345
+ /** Page declared compositions over this contract. */
346
+ list: async (input = {}) => page(await this.db.query({
347
+ collection: FELTDB_COMPOSITION_COLLECTION,
348
+ where: [{ field: 'tenant_id', eq: this.tenantId }, { field: 'contract_id', eq: this.contract.contract_id }],
349
+ orderBy: [{ field: 'consumer', direction: 'asc' }],
350
+ limit: limit(input.limit), ...(input.cursor ? { cursor: input.cursor } : {}),
351
+ }), row => strip(row)),
352
+ };
353
+ /** Act as a declared consumer. The subject must be authorized to act as it. */
354
+ async consumer(consumer) {
355
+ const id = identity(consumer, 'consumer');
356
+ await this.authorize(COMPOSITION_CAPABILITIES.act, consumerUri(this.contract.contract_id, id), 'act', id);
357
+ const composition = await this.compositions.get(id);
358
+ if (composition.contract_digest !== this.contract.contract_digest) {
359
+ throw fail(`${id} was admitted against contract ${composition.contract_digest}; the authority is ${this.contract.contract_digest}. Redeclare it.`, FeltDBErrorCode.CONTRACT_UNSUPPORTED);
360
+ }
361
+ return new CompositionConsumer(this, this.db, composition);
362
+ }
363
+ // ── the basis snapshot ──
364
+ /**
365
+ * @internal The runtime snapshot impact is computed from: every contract
366
+ * collection plus this contract's composition, dependency, attribution, and
367
+ * topology records. A control-plane materialization, never a request path.
368
+ */
369
+ async basis() {
370
+ const scope = [{ field: 'tenant_id', eq: this.tenantId }, { field: 'contract_id', eq: this.contract.contract_id }];
371
+ const sources = [
372
+ ...this.contract.collections.map(collection => ({ collection })),
373
+ ...[FELTDB_COMPOSITION_COLLECTION, FELTDB_STATE_DEPENDENCY_COLLECTION, FELTDB_STATE_ATTRIBUTION_COLLECTION, FELTDB_COMPOSITION_TOPOLOGY_COLLECTION, FELTDB_COMPOSITION_CONTRACT_COLLECTION]
374
+ .map(collection => ({ collection, where: scope })),
375
+ ];
376
+ const handle = this.db.snapshot({ name: `composition/${this.tenantId}/${this.contract.contract_id}/basis/v2`, sources });
377
+ let snapshot = await handle.refresh();
378
+ // A runtime without revisions cannot prove a materialization did not span
379
+ // a write. Two consecutive builds with the same content identity did not
380
+ // straddle a completed write to anything they read; that is the basis.
381
+ for (let attempt = 0; snapshot.basis.coherence !== 'proven'; attempt++) {
382
+ if (attempt === 3) {
383
+ throw fail('Could not materialize a stable basis snapshot; authoritative state kept changing', FeltDBErrorCode.VERSION_CONFLICT);
384
+ }
385
+ const again = await handle.refresh({ force: true });
386
+ if (again.identity.id === snapshot.identity.id)
387
+ break;
388
+ snapshot = again;
389
+ }
390
+ const topology = snapshot.get(FELTDB_COMPOSITION_TOPOLOGY_COLLECTION, await this.key());
391
+ return {
392
+ snapshot,
393
+ binding: {
394
+ id: snapshot.identity.id, version: snapshot.identity.version,
395
+ ...(snapshot.identity.scope ? { scope: snapshot.identity.scope } : {}),
396
+ coherence: snapshot.basis.coherence, derivation: snapshot.identity.derivation,
397
+ topology_version: recordVersion(topology),
398
+ },
399
+ };
400
+ }
401
+ /** @internal */
402
+ impactOf(snapshot, operations) {
403
+ return computeImpactSurface({ contract: this.contract, tenantId: this.tenantId, basis: snapshot, operations });
404
+ }
405
+ /**
406
+ * @internal The impact of `operations` against a basis snapshot, together
407
+ * with the authority's read basis for exactly the state that impact read.
408
+ *
409
+ * The read basis is captured between two materializations of the basis
410
+ * snapshot; equal snapshot identities prove no write reached the snapshot's
411
+ * sources in between, so the basis describes the state the impact was
412
+ * computed from — not some later state.
413
+ */
414
+ async anchoredImpact(operations) {
415
+ for (let attempt = 0; attempt < 3; attempt++) {
416
+ const { snapshot, binding } = await this.basis();
417
+ const reads = emptyImpactReads();
418
+ const surface = await computeImpactSurface({ contract: this.contract, tenantId: this.tenantId, basis: snapshot, operations, reads });
419
+ const scopeKey = await this.key();
420
+ reads.records.set('binding', { collection: FELTDB_COMPOSITION_CONTRACT_COLLECTION, id: scopeKey });
421
+ const readBasis = await this.db.readBasis(impactReadSet(reads));
422
+ const after = await this.basis();
423
+ if (after.binding.id === binding.id)
424
+ return { snapshot, binding, surface, readBasis };
425
+ }
426
+ throw fail('Authoritative state kept changing while the impact basis was being established; retry', FeltDBErrorCode.VERSION_CONFLICT);
427
+ }
428
+ /** @internal Proposal and impact ids are generated, globally unique, and key-safe. */
429
+ proposalKey(proposalId) { return proposalId; }
430
+ /** @internal */
431
+ async readProposal(proposalId) {
432
+ const row = await this.db.collection(FELTDB_STATE_PROPOSAL_COLLECTION).get(identity(proposalId, 'proposalId'));
433
+ if (!row || row.tenant_id !== this.tenantId || row.contract_id !== this.contract.contract_id) {
434
+ throw fail(`State proposal ${proposalId} was not found`, FeltDBErrorCode.NOT_FOUND);
435
+ }
436
+ const proposal = strip(row);
437
+ const impactRow = await this.db.collection(FELTDB_STATE_IMPACT_COLLECTION).get(proposal.impact_id);
438
+ // Written in the same transaction as the proposal. Its absence is corruption, not a missing optional.
439
+ if (!impactRow)
440
+ throw fail(`State proposal ${proposalId} has no impact record ${proposal.impact_id}`, FeltDBErrorCode.RECOVERY_FAILURE);
441
+ return { proposal, row, impact: strip(impactRow) };
442
+ }
443
+ /**
444
+ * Is the recorded impact still a description of current authoritative state?
445
+ *
446
+ * The direct records are compared by version and digest, and the surface is
447
+ * re-derived from a current snapshot and compared by digest. Unrelated
448
+ * writes advance the basis without blocking; anything the surface depends on
449
+ * blocks.
450
+ *
451
+ * @internal Unauthorized by itself; readers get it through `preview()` and
452
+ * reviewers through `review.inspect()`.
453
+ */
454
+ async readiness(proposalId) {
455
+ return (await this.assess(proposalId)).readiness;
456
+ }
457
+ /**
458
+ * @internal Readiness, re-derived for explanation, and checked against the
459
+ * proposal's persisted read basis.
460
+ *
461
+ * The re-derived surface says *what* differs; the read basis says *whether*
462
+ * anything the approved impact was computed from has changed, by asking the
463
+ * authority for its token over the same reads now. `apply` enforces the same
464
+ * basis inside the commit, so readiness and apply cannot disagree about a
465
+ * change that happened before the check.
466
+ */
467
+ async assess(proposalId) {
468
+ const { proposal, row, impact } = await this.readProposal(proposalId);
469
+ const current = await this.basis();
470
+ const blockers = new Set();
471
+ const changed = [];
472
+ for (const operation of proposal.operations) {
473
+ const record = current.snapshot.get(operation.collection, operation.record_id);
474
+ const value = recordValue(record);
475
+ const digest = value === null ? null : await canonicalDigest(value);
476
+ if (recordVersion(record) !== operation.base_version || digest !== operation.base_value_digest)
477
+ changed.push(operation.subject);
478
+ }
479
+ if (changed.length)
480
+ blockers.add('direct_state_changed');
481
+ if (proposal.contract_digest !== this.contract.contract_digest)
482
+ blockers.add('contract_changed');
483
+ const surface = await this.impactOf(current.snapshot, toOperations(proposal));
484
+ const digest = await impactSurfaceDigest(surface);
485
+ if (digest !== impact.surface_digest)
486
+ blockers.add('impact_changed');
487
+ if (!impact.read_basis)
488
+ blockers.add('basis_changed');
489
+ else if ((await this.db.readBasis(impact.read_basis.reads)).token !== impact.read_basis.token)
490
+ blockers.add('basis_changed');
491
+ if (!['previewed', 'approved'].includes(proposal.status))
492
+ blockers.add('not_current_status');
493
+ const stale = [...blockers].some(value => value !== 'not_current_status');
494
+ return {
495
+ proposal, row, impact,
496
+ readiness: {
497
+ proposal_id: proposal.proposal_id, status: proposal.status,
498
+ readiness: blockers.size ? 'blocked' : 'ready', blockers: [...blockers].sort(), stale,
499
+ base_snapshot: proposal.base_snapshot, current_snapshot: current.binding,
500
+ basis_advanced: current.binding.id !== proposal.base_snapshot.id,
501
+ topology_advanced: current.binding.topology_version !== proposal.base_snapshot.topology_version,
502
+ changed_subjects: changed.sort(), current_impact_digest: digest,
503
+ },
504
+ };
505
+ }
506
+ /** @internal Stage one lifecycle move and its shared `_feltdb.ProposalEvent`. */
507
+ stageProposalMove(tx, proposal, row, to, actor, metadata = {}) {
508
+ if (row && !PROPOSAL_TRANSITIONS[proposal.status].includes(to)) {
509
+ throw fail(`INVALID_PROPOSAL_TRANSITION: ${proposal.status} -> ${to}`, FeltDBErrorCode.CONFLICT);
510
+ }
511
+ const from = row ? proposal.status : undefined;
512
+ const next = { ...proposal, status: to };
513
+ tx.collection(FELTDB_STATE_PROPOSAL_COLLECTION).set(proposal.proposal_id, next, row ? { ifVersion: recordVersion(row) } : { requireAbsent: true });
514
+ this.stageProposalEvent(tx, proposal.proposal_id, from, to, actor, metadata);
515
+ return next;
516
+ }
517
+ /** @internal */
518
+ stageProposalEvent(tx, proposalId, from, to, actor, metadata) {
519
+ const event = {
520
+ id: `pe_${crypto.randomUUID().replace(/-/g, '')}`, proposal_id: proposalId,
521
+ ...(from ? { from_status: from } : {}), to_status: to, actor, created_at: this.now(), metadata: { kind: 'state', ...metadata },
522
+ };
523
+ tx.collection(FELTDB_PROPOSAL_EVENT_COLLECTION).set(event.id, event, { requireAbsent: true });
524
+ }
525
+ /** @internal Stage an intent status move and its `_feltdb.IntentEvent`. */
526
+ stageIntent(tx, intent, row, status, metadata = {}) {
527
+ const next = { ...intent, status };
528
+ tx.collection(FELTDB_INTENT_COLLECTION).set(intent.id, next, row ? { ifVersion: recordVersion(row) } : { requireAbsent: true });
529
+ this.stageIntentEvent(tx, intent.id, status, metadata);
530
+ return next;
531
+ }
532
+ /** @internal */
533
+ stageIntentEvent(tx, intentId, status, metadata) {
534
+ const event = { id: `ie_${crypto.randomUUID().replace(/-/g, '')}`, intent_id: intentId, status, created_at: this.now(), metadata };
535
+ tx.collection(FELTDB_INTENT_EVENT_COLLECTION).set(event.id, event, { requireAbsent: true });
536
+ }
537
+ /** @internal */
538
+ intentFor(proposal, request, parent) {
539
+ return {
540
+ id: proposal.intent_id, application_id: this.contract.contract_id, environment: this.environment,
541
+ raw_request: request, action: 'transition', target: 'state',
542
+ ...(parent ? { parent_intent_id: parent } : {}),
543
+ contract_hash: this.contract.contract_digest, flow_hash: this.flowHash,
544
+ status: 'received', ambiguities: [], created_at: this.now(), proposal_id: proposal.proposal_id,
545
+ };
546
+ }
547
+ review = {
548
+ /**
549
+ * Approve a previewed proposal whose impact is still current.
550
+ *
551
+ * Approval binds to the exact impact record and digest. It grants nothing:
552
+ * `apply` still authorizes every write for the consumer that applies.
553
+ */
554
+ approve: async (proposalId, input = {}) => {
555
+ const { proposal, row, impact } = await this.readProposal(proposalId);
556
+ const decision = await this.authorize(COMPOSITION_CAPABILITIES.review, this.resource(FELTDB_STATE_PROPOSAL_COLLECTION, proposal.proposal_id), 'approve');
557
+ const readiness = await this.readiness(proposalId);
558
+ if (readiness.stale) {
559
+ throw fail(`State proposal ${proposalId} is stale (${readiness.blockers.join(', ')}); rebase it before review`, FeltDBErrorCode.VERSION_CONFLICT);
560
+ }
561
+ let result;
562
+ await this.db.transaction(tx => {
563
+ result = this.stageProposalMove(tx, {
564
+ ...proposal,
565
+ approval: {
566
+ impact_id: impact.impact_id, impact_digest: impact.surface_digest, approved_by: this.subject,
567
+ decision_id: decision, approved_at: this.now(), ...(input.note ? { note: text(input.note, 'note') } : {}),
568
+ },
569
+ }, row, 'approved', `${this.subject.kind}:${this.subject.id}`, { impact_id: impact.impact_id });
570
+ });
571
+ return result;
572
+ },
573
+ reject: async (proposalId, reason) => {
574
+ const { proposal, row } = await this.readProposal(proposalId);
575
+ const decision = await this.authorize(COMPOSITION_CAPABILITIES.review, this.resource(FELTDB_STATE_PROPOSAL_COLLECTION, proposal.proposal_id), 'reject');
576
+ let result;
577
+ await this.db.transaction(tx => {
578
+ result = this.stageProposalMove(tx, {
579
+ ...proposal, rejection: { rejected_by: this.subject, decision_id: decision, rejected_at: this.now(), reason: text(reason, 'reason') },
580
+ }, row, 'rejected', `${this.subject.kind}:${this.subject.id}`, { reason });
581
+ });
582
+ return result;
583
+ },
584
+ /** The full, unredacted proposal and impact, for an authorized reviewer. */
585
+ inspect: async (proposalId) => {
586
+ const { proposal, impact } = await this.readProposal(proposalId);
587
+ await this.authorize(COMPOSITION_CAPABILITIES.review, this.resource(FELTDB_STATE_PROPOSAL_COLLECTION, proposal.proposal_id), 'inspect');
588
+ return { proposal, impact, readiness: await this.readiness(proposalId) };
589
+ },
590
+ };
591
+ }
592
+ const LIFECYCLE_RANK = { proposed: 0, validated: 1, previewed: 2, approved: 3, applied: 4, rejected: 4, expired: 4 };
593
+ function toOperations(proposal) {
594
+ return proposal.operations.map(value => value.operation === 'delete'
595
+ ? { collection: value.collection, id: value.record_id, delete: true }
596
+ : { collection: value.collection, id: value.record_id, value: value.value });
597
+ }
598
+ /**
599
+ * One application, service, or agent composing over the contract.
600
+ *
601
+ * Every operation checks the declaration (the ceiling) and then asks the gate
602
+ * (the authority). Reading does not imply writing, writing does not imply
603
+ * deleting, and referencing or observing implies neither.
604
+ */
605
+ export class CompositionConsumer {
606
+ service;
607
+ db;
608
+ composition;
609
+ constructor(service, db, composition) {
610
+ this.service = service;
611
+ this.db = db;
612
+ this.composition = composition;
613
+ }
614
+ get id() { return this.composition.consumer; }
615
+ get contractId() { return this.service.contract.contract_id; }
616
+ ref(collection, record) { return { contract: this.contractId, collection, ...(record === undefined ? {} : { record }) }; }
617
+ /** Refuse to act on a declaration that has been superseded since this handle was opened. */
618
+ async current() {
619
+ const latest = await this.service.compositions.get(this.id);
620
+ if (latest.version !== this.composition.version) {
621
+ throw fail(`${this.id} is now at composition version ${latest.version}; reopen the consumer`, FeltDBErrorCode.VERSION_CONFLICT);
622
+ }
623
+ }
624
+ entry(collection) {
625
+ return this.composition.access.find(value => value.collection === collection);
626
+ }
627
+ /** Declaration ceiling, then the gate. Returns the capability and the decision id. */
628
+ async access(collection, operation, record) {
629
+ const entry = this.entry(collection);
630
+ if (!entry)
631
+ throw forbidden(`${this.id} has not declared access to ${collection}`);
632
+ if (operation === 'write' && entry.mode === 'observe')
633
+ throw forbidden(`${this.id} observes ${collection}; observing does not include mutation`);
634
+ if (operation === 'delete' && entry.mode !== 'own')
635
+ throw forbidden(`${this.id} does not own ${collection}; only its owner may delete`);
636
+ const decision = await this.service.authorize(entry.capability, stateUri(this.ref(collection, record ?? '*')), operation, this.id);
637
+ return { capability: entry.capability, decision };
638
+ }
639
+ /** What this consumer is authorized to do, per declared collection, evaluated now. */
640
+ async authorizedOperations() {
641
+ return Promise.all(this.composition.access.map(async (entry) => {
642
+ const operations = {};
643
+ for (const operation of COMPOSITION_OPERATIONS) {
644
+ const ceiling = operation === 'write' ? entry.mode !== 'observe' : operation === 'delete' ? entry.mode === 'own' : true;
645
+ operations[operation] = ceiling && await this.service.allowed(entry.capability, stateUri(this.ref(entry.collection, '*')), operation, this.id);
646
+ }
647
+ return { collection: entry.collection, uri: stateUri(this.ref(entry.collection)), mode: entry.mode, capability: entry.capability, operations };
648
+ }));
649
+ }
650
+ async readable(collection) {
651
+ const entry = this.entry(collection);
652
+ return Boolean(entry) && this.service.allowed(entry.capability, stateUri(this.ref(collection, '*')), 'read', this.id);
653
+ }
654
+ // ── authoritative state ──
655
+ async read(collection, id) {
656
+ await this.current();
657
+ await this.access(collection, 'read', id);
658
+ const row = await this.db.collection(collection).get(id);
659
+ const ref = this.ref(collection, id);
660
+ return { uri: stateUri(ref), ref, exists: Boolean(row), version: recordVersion(row), value: recordValue(row) };
661
+ }
662
+ async query(request) {
663
+ await this.current();
664
+ await this.access(request.collection, 'read');
665
+ return page(await this.db.query(request), row => {
666
+ const ref = this.ref(request.collection, String(row.id));
667
+ return { uri: stateUri(ref), ref, exists: true, version: recordVersion(row), value: recordValue(row) };
668
+ });
669
+ }
670
+ /**
671
+ * Stage attributed writes. A write to a collection this consumer only
672
+ * observes, or a delete of a collection it does not own, is refused before
673
+ * anything commits; so is anything the gate denies.
674
+ */
675
+ async mutate(build, options = {}) {
676
+ const writes = [];
677
+ build({
678
+ set: (collection, id, value) => { writes.push({ collection, id: String(id), value }); },
679
+ delete: (collection, id) => { writes.push({ collection, id: String(id) }); },
680
+ });
681
+ return this.commit(writes, { ...options });
682
+ }
683
+ async commit(writes, meta) {
684
+ await this.current();
685
+ if (!writes.length)
686
+ throw fail('a mutation requires at least one write');
687
+ const seen = new Set();
688
+ for (const write of writes) {
689
+ const key = `${write.collection}\u0000${write.id}`;
690
+ if (seen.has(key))
691
+ throw fail(`${write.collection}/${write.id} is written twice in one mutation`);
692
+ seen.add(key);
693
+ if (!this.service.contract.collections.includes(write.collection)) {
694
+ throw forbidden(`${write.collection} is not a collection of contract ${this.contractId}; the substrate writes nothing else as state`);
695
+ }
696
+ }
697
+ const transactionId = meta.transactionId ?? `ctx_${crypto.randomUUID().replace(/-/g, '')}`;
698
+ const recordedAt = this.service.now();
699
+ const planned = await Promise.all(writes.map(async (write, index) => {
700
+ const { capability, decision } = await this.access(write.collection, write.value === undefined ? 'delete' : 'write', write.id);
701
+ const row = await this.db.collection(write.collection).get(write.id);
702
+ const version = recordVersion(row);
703
+ if (write.expected && write.expected.version !== version) {
704
+ throw fail(`${write.collection}/${write.id} is at version ${version}, not ${write.expected.version}`, FeltDBErrorCode.VERSION_CONFLICT);
705
+ }
706
+ if (write.value === undefined && !row)
707
+ throw fail(`${write.collection}/${write.id} does not exist`, FeltDBErrorCode.NOT_FOUND);
708
+ const subject = stateUri(this.ref(write.collection, write.id));
709
+ const headKey = await this.service.attributionKey(write.collection, write.id);
710
+ const head = await this.db.collection(FELTDB_STATE_ATTRIBUTION_COLLECTION).get(headKey);
711
+ const before = recordValue(row);
712
+ const value = write.value === undefined ? null : recordValue(write.value);
713
+ const change = {
714
+ change_id: `${transactionId}.${index + 1}`,
715
+ tenant_id: this.service.tenantId, contract_id: this.contractId, contract_digest: this.service.contract.contract_digest,
716
+ subject, collection: write.collection, record_id: write.id,
717
+ operation: value === null ? 'delete' : row ? 'update' : 'insert',
718
+ version_before: version, version_after: value === null ? null : (version ?? 0) + 1,
719
+ value_digest_before: before === null ? null : await canonicalDigest(before),
720
+ value_digest_after: value === null ? null : await canonicalDigest(value),
721
+ fields_changed: changedFields(before, value),
722
+ attribution_version: (recordVersion(head) ?? 0) + 1,
723
+ transaction_id: transactionId, consumer: this.id, capability,
724
+ ...(meta.causalId ? { causal_id: text(meta.causalId, 'causalId', 200) } : {}),
725
+ ...(meta.proposal ? { intent_id: meta.proposal.intent_id, proposal_id: meta.proposal.proposal_id, impact_id: meta.proposal.impact_id } : {}),
726
+ recorded_at: recordedAt, provenance: this.service.provenance([decision]),
727
+ };
728
+ return { write, row, head, headKey, value, change };
729
+ }));
730
+ const changes = planned.map(value => value.change);
731
+ const transaction = await this.db.transaction(tx => {
732
+ for (const basis of meta.fences ?? [])
733
+ tx.fence(basis);
734
+ for (const { write, row, head, headKey, value, change } of planned) {
735
+ const guard = row ? { ifVersion: recordVersion(row) } : { requireAbsent: true };
736
+ if (value === null)
737
+ tx.collection(write.collection).delete(write.id, guard);
738
+ else
739
+ tx.collection(write.collection).set(write.id, value, guard);
740
+ tx.collection(FELTDB_STATE_CHANGE_COLLECTION).set(change.change_id, change, { requireAbsent: true });
741
+ tx.collection(FELTDB_STATE_ATTRIBUTION_COLLECTION).set(headKey, {
742
+ tenant_id: this.service.tenantId, contract_id: this.contractId, subject: change.subject,
743
+ collection: write.collection, record_id: write.id, last_change_id: change.change_id,
744
+ record_version: change.version_after, transaction_id: transactionId,
745
+ }, head ? { ifVersion: recordVersion(head) } : { requireAbsent: true });
746
+ }
747
+ meta.stage?.(tx, changes, transactionId);
748
+ }, { transactionId });
749
+ return { transaction, changes };
750
+ }
751
+ /** Attributed changes to one collection (optionally one record), oldest first per record. */
752
+ async changes(input) {
753
+ await this.current();
754
+ await this.access(input.collection, 'observe', input.record);
755
+ return page(await this.db.query({
756
+ collection: FELTDB_STATE_CHANGE_COLLECTION,
757
+ where: [
758
+ { field: 'tenant_id', eq: this.service.tenantId }, { field: 'contract_id', eq: this.contractId },
759
+ { field: 'collection', eq: input.collection }, ...(input.record ? [{ field: 'record_id', eq: input.record }] : []),
760
+ ],
761
+ orderBy: input.record ? [{ field: 'attribution_version', direction: 'asc' }] : [{ field: 'recorded_at', direction: 'asc' }],
762
+ limit: limit(input.limit), ...(input.cursor ? { cursor: input.cursor } : {}),
763
+ }), row => strip(row));
764
+ }
765
+ async changeById(changeId) {
766
+ const row = await this.db.collection(FELTDB_STATE_CHANGE_COLLECTION).get(changeId);
767
+ if (!row || row.tenant_id !== this.service.tenantId || row.contract_id !== this.contractId)
768
+ throw fail(`Change ${changeId} was not found`, FeltDBErrorCode.NOT_FOUND);
769
+ return strip(row);
770
+ }
771
+ /**
772
+ * Record that this consumer observed a subject, optionally a specific change.
773
+ *
774
+ * The schema is closed: there is no field for what the observation means.
775
+ */
776
+ async observe(input) {
777
+ await this.current();
778
+ closed(input, ['subject', 'change', 'evidence', 'context', 'observedAt', 'observationId'], 'observation');
779
+ const subject = parseStateUri(text(input.subject, 'subject', 2_000));
780
+ if (subject.contract !== this.contractId)
781
+ throw fail(`${input.subject} belongs to another contract`);
782
+ const { decision } = await this.access(subject.collection, 'observe', subject.record);
783
+ const evidence = evidenceList(input.evidence, 'evidence');
784
+ if (input.change !== undefined) {
785
+ const change = await this.changeById(text(input.change, 'change', 400));
786
+ if (change.subject !== stateUri(subject))
787
+ throw fail(`change ${change.change_id} is not a change to ${input.subject}`);
788
+ evidence.push(`change:${change.change_id}`);
789
+ }
790
+ const context = input.context === undefined ? {} : closed(input.context, ['causal_id', 'parent_observation_id', 'correlation_id'], 'observation context');
791
+ const observationId = input.observationId === undefined ? `obs_${crypto.randomUUID().replace(/-/g, '')}` : identity(input.observationId, 'observationId');
792
+ const observation = {
793
+ observation_id: observationId, tenant_id: this.service.tenantId, contract_id: this.contractId,
794
+ subject: stateUri(subject), ...(input.change ? { change_id: input.change } : {}), consumer: this.id,
795
+ observed_at: input.observedAt ? new Date(input.observedAt).toISOString() : this.service.now(), recorded_at: this.service.now(),
796
+ evidence: [...new Set(evidence)].sort(),
797
+ context: Object.fromEntries(Object.entries(context).map(([key, value]) => [key, text(value, `context.${key}`, 400)])),
798
+ provenance: this.service.provenance([decision]),
799
+ };
800
+ await this.db.transaction(tx => {
801
+ tx.collection(FELTDB_STATE_OBSERVATION_COLLECTION).set(observationId, observation, { requireAbsent: true });
802
+ });
803
+ return observation;
804
+ }
805
+ async observations(input) {
806
+ await this.current();
807
+ const subject = parseStateUri(input.subject);
808
+ await this.access(subject.collection, 'observe', subject.record);
809
+ return page(await this.db.query({
810
+ collection: FELTDB_STATE_OBSERVATION_COLLECTION,
811
+ where: [{ field: 'tenant_id', eq: this.service.tenantId }, { field: 'contract_id', eq: this.contractId }, { field: 'subject', eq: stateUri(subject) }],
812
+ orderBy: [{ field: 'recorded_at', direction: 'asc' }],
813
+ limit: limit(input.limit), ...(input.cursor ? { cursor: input.cursor } : {}),
814
+ }), row => strip(row));
815
+ }
816
+ /**
817
+ * Declare that a change to `from` may matter to each `to` record.
818
+ *
819
+ * A connection is a fact this consumer asserts, with evidence; FeltDB keeps
820
+ * it, attributes it, and follows it during impact traversal. The consumer
821
+ * must be able to read both ends; connecting grants nothing on either.
822
+ */
823
+ async connect(input) {
824
+ await this.current();
825
+ closed(input, ['from', 'to', 'evidence', 'label'], 'connection');
826
+ const evidence = evidenceList(input.evidence, 'evidence');
827
+ if (!evidence.length)
828
+ throw fail('a connection must cite the evidence it was established from');
829
+ const nodes = [input.from, ...(input.to ?? [])].map(value => parseStateUri(text(value, 'state reference', 2_000)));
830
+ if (nodes.length < 2)
831
+ throw fail('a connection needs at least one target');
832
+ const decisions = [];
833
+ for (const node of nodes) {
834
+ if (node.contract !== this.contractId || node.record === undefined)
835
+ throw fail(`${stateUri(node)} must be a record of contract ${this.contractId}`);
836
+ decisions.push((await this.access(node.collection, 'read', node.record)).decision);
837
+ }
838
+ const from = stateUri(nodes[0]);
839
+ const edges = [];
840
+ for (const target of nodes.slice(1)) {
841
+ const to = stateUri(target);
842
+ const edgeId = `e.${(await canonicalDigest({ tenant: this.service.tenantId, from, to, relationship: 'connects', declared_by: this.id })).slice(7, 47)}`;
843
+ edges.push({
844
+ key: edgeId,
845
+ edge: {
846
+ edge_id: edgeId, tenant_id: this.service.tenantId, contract_id: this.contractId, from, to, relationship: 'connects',
847
+ declared_by: this.id, ...(input.label ? { label: text(input.label, 'label', 200) } : {}), evidence,
848
+ declared_at: this.service.now(), provenance: this.service.provenance(decisions),
849
+ },
850
+ });
851
+ }
852
+ const existing = await Promise.all(edges.map(value => this.db.collection(FELTDB_STATE_DEPENDENCY_COLLECTION).get(value.key)));
853
+ const fresh = edges.filter((_, index) => !existing[index]);
854
+ if (fresh.length) {
855
+ await this.db.transaction(async (tx) => {
856
+ for (const { key, edge } of fresh)
857
+ tx.collection(FELTDB_STATE_DEPENDENCY_COLLECTION).set(key, edge, { requireAbsent: true });
858
+ await this.service.stageTopology(tx);
859
+ });
860
+ }
861
+ return edges.map((value, index) => (existing[index] ? strip(existing[index]) : value.edge));
862
+ }
863
+ // ── derived views ──
864
+ /**
865
+ * Materialize one of this consumer's declared projections.
866
+ *
867
+ * A projection is a runtime snapshot of authoritative state: frozen, with no
868
+ * write surface, and reporting its staleness. It never becomes authority.
869
+ */
870
+ async projection(name) {
871
+ await this.current();
872
+ const projection = this.composition.projections.find(value => value.name === name);
873
+ if (!projection)
874
+ throw fail(`${this.id} declares no projection ${name}`, FeltDBErrorCode.NOT_FOUND);
875
+ for (const source of projection.sources)
876
+ await this.access(source, 'read');
877
+ const handle = this.db.snapshot({
878
+ name: `composition/${this.service.tenantId}/${this.contractId}/${this.id}@${this.composition.version}/projection/${name}`,
879
+ sources: projection.sources.map(collection => ({ collection })),
880
+ });
881
+ const snapshot = await handle.refresh();
882
+ return Object.freeze({
883
+ uri: `projection://${encodeURIComponent(this.contractId)}/${encodeURIComponent(this.id)}/${encodeURIComponent(name)}`,
884
+ authoritative: false,
885
+ derived_from: projection.sources.map(collection => stateUri(this.ref(collection))),
886
+ identity: snapshot.identity,
887
+ get: (collection, id) => snapshot.get(collection, id),
888
+ all: (collection) => snapshot.all(collection),
889
+ staleness: () => handle.staleness(),
890
+ });
891
+ }
892
+ /**
893
+ * A consistent, authorized view of the collections this consumer reads,
894
+ * with the attribution boundary: a change is inside the view exactly when
895
+ * the view's attribution head for its subject has reached it.
896
+ */
897
+ async snapshot(input) {
898
+ await this.current();
899
+ const collections = [...new Set(input.collections)].sort();
900
+ const decisions = [];
901
+ for (const collection of collections) {
902
+ const { capability, decision } = await this.access(collection, 'read');
903
+ decisions.push({ collection, capability, decision_id: decision });
904
+ }
905
+ const handle = this.db.snapshot({
906
+ name: `composition/${this.service.tenantId}/${this.contractId}/${this.id}@${this.composition.version}/view/${collections.join(',')}`,
907
+ sources: [...collections.map(collection => ({ collection })), {
908
+ collection: FELTDB_STATE_ATTRIBUTION_COLLECTION,
909
+ where: [{ field: 'tenant_id', eq: this.service.tenantId }, { field: 'contract_id', eq: this.contractId }],
910
+ }],
911
+ });
912
+ const snapshot = await handle.refresh();
913
+ const heads = new Map(snapshot.all(FELTDB_STATE_ATTRIBUTION_COLLECTION)
914
+ .filter(row => row.kind !== 'reference' && collections.includes(String(row.collection))).map(row => [String(row.subject), row]));
915
+ return Object.freeze({
916
+ contract: this.service.contract,
917
+ consumer: this.id,
918
+ composition_version: this.composition.version,
919
+ authorization: { subject: this.service.subject, entries: decisions },
920
+ identity: snapshot.identity,
921
+ coherence: snapshot.basis.coherence,
922
+ authoritative: false,
923
+ get: (collection, id) => {
924
+ if (!collections.includes(collection))
925
+ throw forbidden(`${collection} is not part of this view`);
926
+ const row = snapshot.get(collection, id);
927
+ const ref = this.ref(collection, id);
928
+ return { uri: stateUri(ref), ref, exists: Boolean(row), version: recordVersion(row), value: recordValue(row) };
929
+ },
930
+ includes: (change) => {
931
+ if (!collections.includes(change.collection))
932
+ throw forbidden(`${change.collection} is not part of this view`);
933
+ const head = heads.get(change.subject);
934
+ return Boolean(head) && recordVersion(head) >= change.attribution_version;
935
+ },
936
+ });
937
+ }
938
+ /** Everything this consumer may learn about the composed contract, for an agent or a person. */
939
+ async describe() {
940
+ await this.current();
941
+ const compositions = await this.service.compositions.list({ limit: 100 });
942
+ return {
943
+ contract: this.service.contract,
944
+ self: this.composition,
945
+ compositions: compositions.items.map(value => ({ consumer: value.consumer, uri: value.uri, version: value.version, access: value.access, projections: value.projections })),
946
+ compositions_exhausted: compositions.exhausted,
947
+ authorized_operations: await this.authorizedOperations(),
948
+ };
949
+ }
950
+ /**
951
+ * The durable facts around one record: what changed, what is connected to
952
+ * it and why, what was observed, which projections derive from it, and what
953
+ * this consumer may do. No interpretation.
954
+ */
955
+ async context(uri, input = {}) {
956
+ const record = parseStateUri(uri);
957
+ const subject = await this.read(record.collection, record.record);
958
+ const changes = await this.changes({ collection: record.collection, record: record.record, limit: input.changes ?? 20 });
959
+ const observations = await this.observations({ subject: uri, limit: input.observations ?? 20 });
960
+ const { snapshot, binding } = await this.service.basis();
961
+ const surface = await this.service.impactOf(snapshot, [{ collection: record.collection, id: record.record, value: subject.value ?? {} }]);
962
+ const readable = new Set();
963
+ for (const collection of this.service.contract.collections)
964
+ if (await this.readable(collection))
965
+ readable.add(collection);
966
+ const visible = redactImpactSurface(surface, collection => readable.has(collection));
967
+ return {
968
+ subject, basis: binding, changes: changes.items, observations: observations.items,
969
+ connected: visible.dependent, unknown: visible.unknown,
970
+ authorized_operations: await this.authorizedOperations(),
971
+ };
972
+ }
973
+ // ── understand before mutate ──
974
+ /** Validate the operations, read the basis, and compute the impact. Writes nothing. */
975
+ async prepareProposal(input) {
976
+ const decisions = [];
977
+ for (const operation of input.operations) {
978
+ closed(operation, 'delete' in operation ? ['collection', 'id', 'delete'] : ['collection', 'id', 'value'], 'proposed operation');
979
+ if (!this.service.contract.collections.includes(operation.collection))
980
+ throw fail(`${operation.collection} is not a collection of contract ${this.contractId}`);
981
+ // Proposing requires seeing the state; it does not require being able to write it.
982
+ decisions.push((await this.access(operation.collection, 'read', operation.id)).decision);
983
+ }
984
+ const { snapshot, binding, surface, readBasis } = await this.service.anchoredImpact(input.operations);
985
+ const proposalId = `sp_${crypto.randomUUID().replace(/-/g, '')}`;
986
+ const impactId = `si_${crypto.randomUUID().replace(/-/g, '')}`;
987
+ const operations = await Promise.all(input.operations.map(async (operation) => {
988
+ const row = snapshot.get(operation.collection, operation.id);
989
+ const value = recordValue(row);
990
+ return {
991
+ collection: operation.collection, record_id: operation.id, subject: stateUri(this.ref(operation.collection, operation.id)),
992
+ operation: 'delete' in operation ? 'delete' : 'set',
993
+ ...('delete' in operation ? {} : { value: recordValue(operation.value) }),
994
+ base_version: recordVersion(row), base_value_digest: value === null ? null : await canonicalDigest(value),
995
+ };
996
+ }));
997
+ const provenance = this.service.provenance(decisions);
998
+ const proposalDigest = await canonicalDigest({ contract: this.service.contract.contract_digest, base: binding.id, operations });
999
+ const impact = {
1000
+ impact_id: impactId, proposal_id: proposalId, proposal_digest: proposalDigest,
1001
+ tenant_id: this.service.tenantId, contract_id: this.contractId, contract_digest: this.service.contract.contract_digest,
1002
+ base_snapshot: binding, surface, surface_digest: await impactSurfaceDigest(surface), read_basis: readBasis,
1003
+ computed_by: this.id, computed_at: this.service.now(), grants_authority: false, authoritative: false, provenance,
1004
+ };
1005
+ const proposal = {
1006
+ proposal_id: proposalId, intent_id: input.intentId ?? `int_${crypto.randomUUID().replace(/-/g, '')}`,
1007
+ tenant_id: this.service.tenantId, contract_id: this.contractId,
1008
+ contract_digest: this.service.contract.contract_digest, status: 'previewed', proposed_by: this.id,
1009
+ summary: input.summary ? text(input.summary, 'summary') : input.request, operations, proposal_digest: proposalDigest,
1010
+ base_snapshot: binding, impact_id: impactId, impact_digest: impact.surface_digest,
1011
+ ...(input.parent ? { parent_proposal_id: input.parent.proposal_id } : {}),
1012
+ authoritative: false, created_at: this.service.now(), provenance,
1013
+ };
1014
+ return { proposal, impact, actor: `consumer:${this.id}` };
1015
+ }
1016
+ /**
1017
+ * "I propose this state transition."
1018
+ *
1019
+ * Records the intent, the proposal bound to the current basis snapshot, and
1020
+ * the impact surface computed from that same snapshot, in one transaction.
1021
+ * Authoritative state is not touched.
1022
+ */
1023
+ async intend(input) {
1024
+ await this.current();
1025
+ closed(input, ['request', 'summary', 'operations'], 'state intent');
1026
+ const request = text(input.request, 'request');
1027
+ if (!Array.isArray(input.operations) || !input.operations.length)
1028
+ throw fail('a state intent must propose at least one operation');
1029
+ const prepared = await this.prepareProposal({ request, summary: input.summary, operations: input.operations });
1030
+ await this.db.transaction(tx => {
1031
+ const intent = { ...this.service.intentFor(prepared.proposal, request), status: 'proposal_created' };
1032
+ tx.collection(FELTDB_INTENT_COLLECTION).set(intent.id, intent, { requireAbsent: true });
1033
+ this.service.stageIntentEvent(tx, intent.id, 'received', {});
1034
+ this.service.stageIntentEvent(tx, intent.id, 'proposal_created', { proposal_id: prepared.proposal.proposal_id });
1035
+ stageProposalBundle(this.service, tx, prepared);
1036
+ });
1037
+ return this.preview(prepared.proposal.proposal_id);
1038
+ }
1039
+ /**
1040
+ * "What would change if this were applied?"
1041
+ *
1042
+ * The recorded impact, redacted to what this reader may see, with its
1043
+ * current readiness and this reader's authorization evaluated now.
1044
+ */
1045
+ async preview(proposalId) {
1046
+ await this.current();
1047
+ const { proposal, impact } = await this.service.readProposal(proposalId);
1048
+ for (const operation of proposal.operations)
1049
+ await this.access(operation.collection, 'read', operation.record_id);
1050
+ const readable = new Set();
1051
+ for (const collection of this.service.contract.collections)
1052
+ if (await this.readable(collection))
1053
+ readable.add(collection);
1054
+ const authorization = await Promise.all(proposal.operations.map(async (operation) => {
1055
+ const entry = this.entry(operation.collection);
1056
+ const uri = stateUri(this.ref(operation.collection, '*'));
1057
+ return {
1058
+ node: operation.subject, ...(entry ? { capability: entry.capability } : {}),
1059
+ operations: {
1060
+ write: Boolean(entry && entry.mode !== 'observe') && await this.service.allowed(entry.capability, uri, 'write', this.id),
1061
+ delete: Boolean(entry && entry.mode === 'own') && await this.service.allowed(entry.capability, uri, 'delete', this.id),
1062
+ },
1063
+ };
1064
+ }));
1065
+ const { read_basis: fence, ...visible } = impact;
1066
+ return {
1067
+ proposal,
1068
+ impact: { ...visible, surface: redactImpactSurface(impact.surface, collection => readable.has(collection)), fenced: Boolean(fence) },
1069
+ readiness: await this.service.readiness(proposalId),
1070
+ authorization,
1071
+ effect: { authoritative: false, state_changes_on: 'apply' },
1072
+ };
1073
+ }
1074
+ /**
1075
+ * The durable record of one proposal: its lifecycle events, its intent and
1076
+ * intent events, the changes its application produced, and its lineage.
1077
+ * Bounded reads; redacted like `preview`.
1078
+ */
1079
+ async history(proposalId, input = {}) {
1080
+ const preview = await this.preview(proposalId);
1081
+ const { proposal } = preview;
1082
+ const bound = limit(input.limit);
1083
+ const byField = async (collection, field, value, order) => (await this.db.query({
1084
+ collection, where: [{ field, eq: value }], orderBy: [{ field: order, direction: 'asc' }], limit: bound,
1085
+ })).records.map(row => strip(row));
1086
+ const changes = (await this.db.query({
1087
+ collection: FELTDB_STATE_CHANGE_COLLECTION,
1088
+ where: [{ field: 'tenant_id', eq: this.service.tenantId }, { field: 'contract_id', eq: this.contractId }, { field: 'proposal_id', eq: proposal.proposal_id }],
1089
+ orderBy: [{ field: 'change_id', direction: 'asc' }], limit: bound,
1090
+ })).records.map(row => strip(row));
1091
+ const intentRow = await this.db.collection(FELTDB_INTENT_COLLECTION).get(proposal.intent_id);
1092
+ return {
1093
+ proposal, impact: preview.impact, readiness: preview.readiness,
1094
+ // Events committed together share a timestamp; the lifecycle order breaks the tie.
1095
+ events: (await byField(FELTDB_PROPOSAL_EVENT_COLLECTION, 'proposal_id', proposal.proposal_id, 'created_at'))
1096
+ .sort((left, right) => String(left.created_at).localeCompare(String(right.created_at)) || LIFECYCLE_RANK[left.to_status] - LIFECYCLE_RANK[right.to_status]),
1097
+ intent: intentRow ? { ...strip(intentRow), id: String(intentRow.id) } : null,
1098
+ intent_events: await byField(FELTDB_INTENT_EVENT_COLLECTION, 'intent_id', proposal.intent_id, 'created_at'),
1099
+ changes,
1100
+ lineage: {
1101
+ ...(proposal.parent_proposal_id ? { parent_proposal_id: proposal.parent_proposal_id } : {}),
1102
+ ...(proposal.superseded_by ? { superseded_by: proposal.superseded_by } : {}),
1103
+ },
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Re-propose the same operations against the current snapshot.
1108
+ *
1109
+ * The stale proposal is retired through the shared lifecycle, and the child
1110
+ * proposal gets a fresh impact. Nothing carries over except lineage.
1111
+ */
1112
+ async rebase(proposalId) {
1113
+ await this.current();
1114
+ const { proposal, row } = await this.service.readProposal(proposalId);
1115
+ if (!['proposed', 'validated', 'previewed', 'approved'].includes(proposal.status))
1116
+ throw fail(`State proposal ${proposalId} is ${proposal.status}`, FeltDBErrorCode.CONFLICT);
1117
+ const intentRow = await this.db.collection(FELTDB_INTENT_COLLECTION).get(proposal.intent_id);
1118
+ const prepared = await this.prepareProposal({
1119
+ request: proposal.summary, summary: proposal.summary, operations: toOperations(proposal), parent: proposal, intentId: proposal.intent_id,
1120
+ });
1121
+ await this.db.transaction(tx => {
1122
+ this.service.stageProposalMove(tx, { ...proposal, superseded_by: prepared.proposal.proposal_id }, row, proposal.status === 'approved' ? 'expired' : 'rejected', `consumer:${this.id}`, { reason: 'rebased', child_proposal_id: prepared.proposal.proposal_id });
1123
+ stageProposalBundle(this.service, tx, prepared);
1124
+ if (intentRow) {
1125
+ const intent = strip(intentRow);
1126
+ this.service.stageIntent(tx, { ...intent, id: String(intentRow.id), proposal_id: prepared.proposal.proposal_id }, intentRow, 'proposal_created', { proposal_id: prepared.proposal.proposal_id, parent_proposal_id: proposal.proposal_id });
1127
+ }
1128
+ });
1129
+ return this.preview(prepared.proposal.proposal_id);
1130
+ }
1131
+ /**
1132
+ * Make an approved proposal authoritative.
1133
+ *
1134
+ * The impact is re-derived from a current snapshot and must equal the
1135
+ * approved one; every direct write is CAS-guarded on the proposal's base
1136
+ * version; the topology head is fenced; the gate authorizes every write for
1137
+ * this consumer. State, attribution, proposal, and intent commit together.
1138
+ */
1139
+ async apply(proposalId, options = {}) {
1140
+ await this.current();
1141
+ const { proposal, row, impact, readiness } = await this.service.assess(proposalId);
1142
+ if (proposal.status !== 'approved' || !proposal.approval)
1143
+ throw fail(`State proposal ${proposalId} is ${proposal.status}; only an approved proposal can be applied`, FeltDBErrorCode.CONFLICT);
1144
+ if (proposal.approval.impact_id !== impact.impact_id || proposal.approval.impact_digest !== impact.surface_digest) {
1145
+ throw fail(`The approval of ${proposalId} does not bind its impact`, FeltDBErrorCode.CONFLICT);
1146
+ }
1147
+ const transactionId = `apply_${proposal.proposal_id}`;
1148
+ if (readiness.stale) {
1149
+ await this.recordRefusal(proposal, transactionId, readiness.blockers.join(','));
1150
+ throw fail(`State proposal ${proposalId} was analyzed against ${proposal.base_snapshot.id}; authoritative state has since changed `
1151
+ + `(${readiness.blockers.join(', ')}). Rebase and review it again.`, FeltDBErrorCode.VERSION_CONFLICT);
1152
+ }
1153
+ const intentRow = await this.db.collection(FELTDB_INTENT_COLLECTION).get(proposal.intent_id);
1154
+ const writes = proposal.operations.map(operation => ({
1155
+ collection: operation.collection, id: operation.record_id,
1156
+ ...(operation.operation === 'set' ? { value: operation.value } : {}),
1157
+ expected: { version: operation.base_version },
1158
+ }));
1159
+ try {
1160
+ return await this.commit(writes, {
1161
+ transactionId,
1162
+ causalId: options.causalId ?? proposal.intent_id,
1163
+ proposal,
1164
+ // The authority re-reads exactly the state the approved impact was
1165
+ // computed from, inside its commit decision, and refuses the whole
1166
+ // transaction if any of it changed — whichever write path changed it.
1167
+ fences: [impact.read_basis],
1168
+ stage: (tx, changes, committed) => {
1169
+ this.service.stageProposalMove(tx, {
1170
+ ...proposal,
1171
+ applied: {
1172
+ transaction_id: committed, change_ids: changes.map(value => value.change_id), applied_by: this.id,
1173
+ applied_at: this.service.now(), apply_snapshot: readiness.current_snapshot,
1174
+ },
1175
+ }, row, 'applied', `consumer:${this.id}`, { transaction_id: committed, impact_id: impact.impact_id, read_basis: impact.read_basis.token });
1176
+ if (intentRow) {
1177
+ const intent = strip(intentRow);
1178
+ this.service.stageIntent(tx, { ...intent, id: String(intentRow.id) }, intentRow, 'completed', { proposal_id: proposal.proposal_id, transaction_id: committed });
1179
+ }
1180
+ },
1181
+ });
1182
+ }
1183
+ catch (error) {
1184
+ const refusal = conditionalRefusal(error);
1185
+ const lostRace = refusal.conflict
1186
+ || (error instanceof AtomicTransactionError && /conflict|PRECONDITION_FAILED/i.test(`${error.name} ${error.code ?? ''}`))
1187
+ || (error instanceof FeltDBServiceError && error.code === FeltDBErrorCode.VERSION_CONFLICT);
1188
+ if (!lostRace)
1189
+ throw error;
1190
+ const predicate = error.failure?.predicate ?? (refusal.conflict ? refusal.failure?.predicate : undefined) ?? 'version';
1191
+ await this.recordRefusal(proposal, transactionId, predicate === 'fence' ? 'read_basis_changed' : `precondition_${predicate}`);
1192
+ throw fail(`State proposal ${proposalId}: authoritative state it was approved against changed before the commit `
1193
+ + `(${predicate}). Nothing was applied; rebase and review it again.`, FeltDBErrorCode.VERSION_CONFLICT);
1194
+ }
1195
+ }
1196
+ /**
1197
+ * Record a refused apply in the proposal's history, in its own transaction.
1198
+ *
1199
+ * The refused commit wrote nothing; this is the audit trail that it was
1200
+ * attempted and why it was refused. The proposal record itself is unchanged.
1201
+ */
1202
+ async recordRefusal(proposal, transactionId, reason) {
1203
+ await this.db.transaction(tx => {
1204
+ this.service.stageProposalEvent(tx, proposal.proposal_id, proposal.status, proposal.status, `consumer:${this.id}`, {
1205
+ outcome: 'apply_refused', reason, transaction_id: transactionId,
1206
+ });
1207
+ });
1208
+ }
1209
+ }
1210
+ /** Stage a proposal born `previewed`, its impact, and its lifecycle history. */
1211
+ function stageProposalBundle(service, tx, prepared) {
1212
+ const { proposal, impact, actor } = prepared;
1213
+ tx.collection(FELTDB_STATE_IMPACT_COLLECTION).set(service.proposalKey(impact.impact_id), impact, { requireAbsent: true });
1214
+ tx.collection(FELTDB_STATE_PROPOSAL_COLLECTION).set(service.proposalKey(proposal.proposal_id), proposal, { requireAbsent: true });
1215
+ service.stageProposalEvent(tx, proposal.proposal_id, undefined, 'proposed', actor, { intent_id: proposal.intent_id });
1216
+ service.stageProposalEvent(tx, proposal.proposal_id, 'proposed', 'validated', actor, {});
1217
+ service.stageProposalEvent(tx, proposal.proposal_id, 'validated', 'previewed', actor, { impact_id: impact.impact_id, base_snapshot: proposal.base_snapshot.id });
1218
+ }