@feltdb/core 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/authority-scope.d.ts +14 -0
  2. package/dist/authority-scope.d.ts.map +1 -0
  3. package/dist/authority-scope.js +34 -0
  4. package/dist/create/package-versions.js +1 -1
  5. package/dist/create/server-source/crates/feltdb/src/lib.rs +343 -1
  6. package/dist/create/server-source/crates/feltdb/src/state_model.rs +15 -2
  7. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +845 -0
  8. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  9. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1346 -2
  10. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +37 -0
  11. package/dist/db.d.ts +10 -1
  12. package/dist/db.d.ts.map +1 -1
  13. package/dist/db.js +46 -4
  14. package/dist/error-codes.d.ts +7 -1
  15. package/dist/error-codes.d.ts.map +1 -1
  16. package/dist/error-codes.js +20 -4
  17. package/dist/file-db.d.ts.map +1 -1
  18. package/dist/file-db.js +5 -1
  19. package/dist/index-core.d.ts +1 -0
  20. package/dist/index-core.d.ts.map +1 -1
  21. package/dist/index-core.js +1 -0
  22. package/dist/operation-admission.d.ts +5 -0
  23. package/dist/operation-admission.d.ts.map +1 -1
  24. package/dist/operation-admission.js +0 -7
  25. package/dist/studio/app.d.ts.map +1 -1
  26. package/dist/studio/components/index.js +2 -2
  27. package/dist/studio/{components-Duq2xQWt.js → components-CehkcX_k.js} +2355 -2355
  28. package/dist/studio/control-plane/Agents.d.ts +6 -0
  29. package/dist/studio/control-plane/Agents.d.ts.map +1 -0
  30. package/dist/studio/control-plane/Data.d.ts +10 -0
  31. package/dist/studio/control-plane/Data.d.ts.map +1 -0
  32. package/dist/studio/control-plane/Instance.d.ts +7 -0
  33. package/dist/studio/control-plane/Instance.d.ts.map +1 -0
  34. package/dist/studio/control-plane/Operations.d.ts +6 -0
  35. package/dist/studio/control-plane/Operations.d.ts.map +1 -0
  36. package/dist/studio/control-plane/Overview.d.ts +4 -0
  37. package/dist/studio/control-plane/Overview.d.ts.map +1 -0
  38. package/dist/studio/control-plane/Schema.d.ts +4 -0
  39. package/dist/studio/control-plane/Schema.d.ts.map +1 -0
  40. package/dist/studio/control-plane/State.d.ts +4 -0
  41. package/dist/studio/control-plane/State.d.ts.map +1 -0
  42. package/dist/studio/control-plane/context.d.ts +42 -0
  43. package/dist/studio/control-plane/context.d.ts.map +1 -0
  44. package/dist/studio/control-plane/index.d.ts +18 -0
  45. package/dist/studio/control-plane/index.d.ts.map +1 -0
  46. package/dist/studio/control-plane/index.js +2 -0
  47. package/dist/studio/control-plane/primitives.d.ts +70 -0
  48. package/dist/studio/control-plane/primitives.d.ts.map +1 -0
  49. package/dist/studio/control-plane-D6TT2fCQ.js +3064 -0
  50. package/dist/studio/index.d.ts +2 -0
  51. package/dist/studio/index.d.ts.map +1 -1
  52. package/dist/studio/index.js +326 -323
  53. package/dist/studio/studio.css +1 -1
  54. package/dist/studio/utils/control-plane-api.d.ts +442 -0
  55. package/dist/studio/utils/control-plane-api.d.ts.map +1 -0
  56. package/dist/studio/utils/control-plane-api.js +204 -0
  57. package/dist/studio/utils/index.d.ts +1 -0
  58. package/dist/studio/utils/index.d.ts.map +1 -1
  59. package/dist/studio/utils/index.js +13 -12
  60. package/dist/studio-app/assets/{feltdb_wasm-C1VhI-U5.js → feltdb_wasm-DksXDnLa.js} +1 -1
  61. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  62. package/dist/studio-app/assets/{index-Cd_NPw14.css → index-BrsF-kWL.css} +1 -1
  63. package/dist/studio-app/assets/index-CjELc-ul.js +30 -0
  64. package/dist/studio-app/index.html +2 -2
  65. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  66. package/package.json +1 -1
  67. package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
  68. package/dist/studio-app/assets/index-ByHX4xDq.js +0 -29
@@ -0,0 +1,845 @@
1
+ //! The human control plane's server surface.
2
+ //!
3
+ //! FeltDB has three ways of being used, and they are not three implementations:
4
+ //!
5
+ //! | surface | who | interface |
6
+ //! | --- | --- | --- |
7
+ //! | application | code | the public API |
8
+ //! | agent | tools | the declared/projection interface |
9
+ //! | human | Studio | the same public API |
10
+ //!
11
+ //! Studio is the third of those, not a fourth implementation of the first. This
12
+ //! module holds what the human surface needs and the other two did not already
13
+ //! expose: the declared capability set Studio reads before it renders anything,
14
+ //! and the JSON projections of the state model that make revisions, semantic
15
+ //! diffs, conflict classification and reconciliation reachable over HTTP.
16
+ //!
17
+ //! # Why the capability set is data
18
+ //!
19
+ //! A control plane that draws a button for an operation its backend cannot
20
+ //! perform is worse than one that draws nothing: the button is a claim. So the
21
+ //! server declares what it can do, [`capabilities`] is the only source of that
22
+ //! claim, and Studio renders an explicit *unsupported* state for everything the
23
+ //! declaration does not mark available. Nothing in the UI may assume a
24
+ //! capability the server did not declare, and nothing here may declare one the
25
+ //! server does not implement — [`capabilities`] names the route that serves each
26
+ //! entry so the claim can be checked mechanically.
27
+ //!
28
+ //! # Why the state projections live here
29
+ //!
30
+ //! [`feltdb::state_model`] is expressed in Rust types that do not survive
31
+ //! `serde_json` unchanged. `ReconciliationPlan::path_overrides` is a
32
+ //! `BTreeMap<Vec<PathComponent>, Value>`, and JSON has no non-string keys; a
33
+ //! path is a sequence of *typed* components, and flattening it to `a.b.0` would
34
+ //! lose the object-key/array-index distinction that
35
+ //! [`feltdb::state_model::path_relation`] depends on. The projections here are
36
+ //! therefore explicit and lossless: a path is a JSON array whose string members
37
+ //! are object keys and whose number members are array indices.
38
+
39
+ use std::collections::BTreeMap;
40
+
41
+ use feltdb::state_model::{
42
+ ChangeKind, ConflictClass, ConflictClassification, PathComponent, ReconciliationPlan,
43
+ SemanticDiff, StateId, StateRevision,
44
+ };
45
+ use feltdb::{Operation, OperationType};
46
+ use serde::{Deserialize, Serialize};
47
+ use serde_json::{json, Value};
48
+
49
+ // ============================================================================
50
+ // Declared capabilities
51
+ // ============================================================================
52
+
53
+ /// Whether the instance can perform a control-plane capability.
54
+ ///
55
+ /// `Partial` exists because the alternative is dishonest in both directions.
56
+ /// Record history is the case that forced it: revisions are minted by some
57
+ /// write paths and not others, so declaring the capability `Available` would
58
+ /// promise history for records that have none, and declaring it `Unsupported`
59
+ /// would deny history that demonstrably exists.
60
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
61
+ #[serde(rename_all = "snake_case")]
62
+ pub enum CapabilityStatus {
63
+ /// The server implements this, and the named surface serves it.
64
+ Available,
65
+ /// The server implements this for some inputs only. `note` says which.
66
+ Partial,
67
+ /// No FeltDB contract performs this. Studio must render it as unavailable.
68
+ Unsupported,
69
+ }
70
+
71
+ /// One control-plane capability, as the server declares it.
72
+ ///
73
+ /// Serialized outward only. The server is the sole author of this document;
74
+ /// nothing reads one back in, and giving it a `Deserialize` would invite a
75
+ /// caller to supply the claim the server is supposed to be making.
76
+ #[derive(Clone, Debug, Serialize)]
77
+ pub struct ControlPlaneCapability {
78
+ /// Stable identifier. Studio gates on this, never on a route string.
79
+ pub id: &'static str,
80
+ /// Studio navigation area this belongs to.
81
+ pub area: &'static str,
82
+ /// Human-readable name, used as the capability's row in the matrix.
83
+ pub title: &'static str,
84
+ /// What the instance can actually do.
85
+ pub status: CapabilityStatus,
86
+ /// The public routes that serve it. Empty exactly when nothing serves it.
87
+ pub surface: &'static [&'static str],
88
+ /// Why the status is what it is. Required for everything not `Available`.
89
+ pub note: &'static str,
90
+ }
91
+
92
+ /// The capabilities this server implements, in navigation order.
93
+ ///
94
+ /// This is the whole claim. `surface` is not decoration: an `Available` or
95
+ /// `Partial` entry names routes that exist, an `Unsupported` entry names none,
96
+ /// and `control_plane_declaration_is_honest` in this module's tests enforces
97
+ /// both halves.
98
+ pub const CAPABILITIES: &[ControlPlaneCapability] = &[
99
+ ControlPlaneCapability {
100
+ id: "overview.instance",
101
+ area: "overview",
102
+ title: "Instance, authority, runtime and storage status",
103
+ status: CapabilityStatus::Available,
104
+ surface: &["GET /v1/control-plane/overview"],
105
+ note: "",
106
+ },
107
+ ControlPlaneCapability {
108
+ id: "data.collections.list",
109
+ area: "data",
110
+ title: "List collections",
111
+ status: CapabilityStatus::Available,
112
+ surface: &["GET /v1/application/schema"],
113
+ note: "",
114
+ },
115
+ ControlPlaneCapability {
116
+ id: "data.records.browse",
117
+ area: "data",
118
+ title: "Browse records",
119
+ status: CapabilityStatus::Available,
120
+ surface: &["GET /v1/data/{collection}"],
121
+ note: "",
122
+ },
123
+ ControlPlaneCapability {
124
+ id: "data.records.inspect",
125
+ area: "data",
126
+ title: "Inspect a complete record",
127
+ status: CapabilityStatus::Available,
128
+ surface: &["GET /v1/data/{collection}/{id}"],
129
+ note: "",
130
+ },
131
+ ControlPlaneCapability {
132
+ id: "data.records.create",
133
+ area: "data",
134
+ title: "Create record",
135
+ status: CapabilityStatus::Partial,
136
+ surface: &[
137
+ "GET /v1/control-plane/actions",
138
+ "POST /api/applications/{application_id}/revisions/{revision_id}/actions/{action}",
139
+ ],
140
+ note: "Only through an action the application contract declares. A collection with no declared create action cannot be written to, by Studio or by anything else.",
141
+ },
142
+ ControlPlaneCapability {
143
+ id: "data.records.update",
144
+ area: "data",
145
+ title: "Update record",
146
+ status: CapabilityStatus::Partial,
147
+ surface: &[
148
+ "GET /v1/control-plane/actions",
149
+ "POST /api/applications/{application_id}/revisions/{revision_id}/actions/{action}",
150
+ ],
151
+ note: "Only through a declared update action. The action accepts `if_version` for compare-and-set; a stale version is refused, not overwritten.",
152
+ },
153
+ ControlPlaneCapability {
154
+ id: "data.records.delete",
155
+ area: "data",
156
+ title: "Delete record",
157
+ status: CapabilityStatus::Partial,
158
+ surface: &[
159
+ "GET /v1/control-plane/actions",
160
+ "POST /api/applications/{application_id}/revisions/{revision_id}/actions/{action}",
161
+ ],
162
+ note: "Only through a declared delete action.",
163
+ },
164
+ ControlPlaneCapability {
165
+ id: "data.records.sort",
166
+ area: "data",
167
+ title: "Sort a browsed collection",
168
+ status: CapabilityStatus::Unsupported,
169
+ surface: &[],
170
+ note: "The inspection route lists records in record-key order and takes no sort parameter, so the browser cannot offer one and does not pretend to. Ordered, filtered reads do exist for applications through `POST /v1/query`; using them here would mean moving the browser to that route, which it has not been.",
171
+ },
172
+ ControlPlaneCapability {
173
+ id: "data.schema.inspect",
174
+ area: "data",
175
+ title: "Schema inspection",
176
+ status: CapabilityStatus::Available,
177
+ surface: &["GET /v1/application/schema", "GET /v1/application"],
178
+ note: "",
179
+ },
180
+ ControlPlaneCapability {
181
+ id: "data.indexes.inspect",
182
+ area: "data",
183
+ title: "Index inspection",
184
+ status: CapabilityStatus::Available,
185
+ surface: &["GET /v1/control-plane/indexes"],
186
+ note: "",
187
+ },
188
+ ControlPlaneCapability {
189
+ id: "data.indexes.rebuild",
190
+ area: "data",
191
+ title: "Rebuild an index",
192
+ status: CapabilityStatus::Available,
193
+ surface: &["POST /v1/control-plane/indexes/rebuild"],
194
+ note: "",
195
+ },
196
+ ControlPlaneCapability {
197
+ id: "data.records.history",
198
+ area: "state",
199
+ title: "Record version history",
200
+ status: CapabilityStatus::Partial,
201
+ surface: &["GET /v1/state/resources/{resource}/history"],
202
+ note: "A revision is minted by the direct-authority write path and reconstructed by replication apply. Transactions committed through declared application actions advance the authority revision without minting a state revision, so records written that way have no revision history to show.",
203
+ },
204
+ ControlPlaneCapability {
205
+ id: "state.resources.list",
206
+ area: "state",
207
+ title: "State resources and heads",
208
+ status: CapabilityStatus::Available,
209
+ surface: &["GET /v1/state/resources"],
210
+ note: "",
211
+ },
212
+ ControlPlaneCapability {
213
+ id: "state.history",
214
+ area: "state",
215
+ title: "State history: revisions, parents, authority, timestamps",
216
+ status: CapabilityStatus::Available,
217
+ surface: &[
218
+ "GET /v1/state/resources/{resource}/history",
219
+ "GET /v1/state/revisions/{id}",
220
+ ],
221
+ note: "",
222
+ },
223
+ ControlPlaneCapability {
224
+ id: "state.diff.semantic",
225
+ area: "state",
226
+ title: "Semantic state diff",
227
+ status: CapabilityStatus::Available,
228
+ surface: &["POST /v1/state/diff"],
229
+ note: "",
230
+ },
231
+ ControlPlaneCapability {
232
+ id: "state.conflict.classify",
233
+ area: "state",
234
+ title: "Conflict classification",
235
+ status: CapabilityStatus::Available,
236
+ surface: &["POST /v1/state/conflicts"],
237
+ note: "",
238
+ },
239
+ ControlPlaneCapability {
240
+ id: "state.reconciliation.plan",
241
+ area: "state",
242
+ title: "Reconciliation plan and preview",
243
+ status: CapabilityStatus::Available,
244
+ surface: &["POST /v1/state/reconciliation/plan"],
245
+ note: "",
246
+ },
247
+ ControlPlaneCapability {
248
+ id: "state.reconciliation.execute",
249
+ area: "state",
250
+ title: "Reconciliation execution",
251
+ status: CapabilityStatus::Available,
252
+ surface: &["POST /v1/state/reconciliation/execute"],
253
+ note: "",
254
+ },
255
+ ControlPlaneCapability {
256
+ id: "state.branch.heads",
257
+ area: "state",
258
+ title: "Named branches and branch heads",
259
+ status: CapabilityStatus::Unsupported,
260
+ surface: &[],
261
+ note: "The state model deliberately owns history, not currentness: it has no branch refs. `GET /v1/state/resources` reports a head per resource, which is the newest revision by sequence, not a named branch.",
262
+ },
263
+ ControlPlaneCapability {
264
+ id: "provenance.record",
265
+ area: "state",
266
+ title: "Who and what changed a record",
267
+ status: CapabilityStatus::Available,
268
+ surface: &["GET /v1/control-plane/provenance/{collection}/{id}"],
269
+ note: "",
270
+ },
271
+ ControlPlaneCapability {
272
+ id: "provenance.transactions",
273
+ area: "operations",
274
+ title: "Recent commits and the actor that made them",
275
+ status: CapabilityStatus::Available,
276
+ surface: &["GET /v1/control-plane/transactions"],
277
+ note: "Answered from a bounded window over recent commits; older ones are in the durable transaction log, which no route reads back. Instance-scoped: it requires `state:read` on the instance and is not filtered per application, which makes it an operator surface rather than an application-member one.",
278
+ },
279
+ ControlPlaneCapability {
280
+ id: "agents.list",
281
+ area: "agents",
282
+ title: "Agents registered against an application",
283
+ status: CapabilityStatus::Available,
284
+ surface: &["GET /v1/control-plane/agents"],
285
+ note: "",
286
+ },
287
+ ControlPlaneCapability {
288
+ id: "agents.identity",
289
+ area: "agents",
290
+ title: "Agent identity, access, activity and sessions",
291
+ status: CapabilityStatus::Available,
292
+ surface: &["GET /v1/control-plane/agents/{agent_id}"],
293
+ note: "",
294
+ },
295
+ ControlPlaneCapability {
296
+ id: "agents.register",
297
+ area: "agents",
298
+ title: "Register an agent and grant it capabilities",
299
+ status: CapabilityStatus::Available,
300
+ surface: &[
301
+ "POST /api/applications/{application_id}/principals",
302
+ "POST /api/principals/{principal_id}/capabilities",
303
+ ],
304
+ note: "",
305
+ },
306
+ ControlPlaneCapability {
307
+ id: "agents.work",
308
+ area: "agents",
309
+ title: "Give an agent bounded, durable work",
310
+ status: CapabilityStatus::Available,
311
+ surface: &[
312
+ "POST /v1/workloads",
313
+ "GET /v1/workloads",
314
+ "GET /v1/workloads/{id}",
315
+ "GET /v1/workloads/{id}/history",
316
+ "GET /v1/workloads/{id}/result",
317
+ "POST /v1/workloads/{id}/cancel",
318
+ "POST /v1/workloads/{id}/retry",
319
+ ],
320
+ note: "",
321
+ },
322
+ ControlPlaneCapability {
323
+ id: "agents.work.review_gate",
324
+ area: "agents",
325
+ title: "Review and approve an agent's proposed data changes before they apply",
326
+ status: CapabilityStatus::Unsupported,
327
+ surface: &[],
328
+ note: "There is no propose-then-approve step for record changes. A workload executes when a worker claims it, and an agent that may write, writes. A human can bound the work in advance (capabilities, scope, `not_before`) and can cancel it, and every change it makes is attributable afterwards — but Studio cannot stage an agent's record changes for approval, so it does not offer to. Contract changes are different: `/v1/proposals` has a real validate/preview/approve gate, and it governs the application contract rather than its data.",
329
+ },
330
+ ControlPlaneCapability {
331
+ id: "agents.sessions",
332
+ area: "agents",
333
+ title: "Agent sessions and the worker executing them",
334
+ status: CapabilityStatus::Available,
335
+ surface: &["GET /v1/control-plane/agents/{agent_id}", "GET /v1/workers"],
336
+ note: "",
337
+ },
338
+ ControlPlaneCapability {
339
+ id: "agents.authorization_decisions",
340
+ area: "agents",
341
+ title: "Authorization decisions taken for an agent",
342
+ status: CapabilityStatus::Available,
343
+ surface: &["GET /v1/control-plane/agents/{agent_id}"],
344
+ note: "",
345
+ },
346
+ ControlPlaneCapability {
347
+ id: "agents.task_context",
348
+ area: "agents",
349
+ title: "An agent's current task or reasoning context",
350
+ status: CapabilityStatus::Partial,
351
+ surface: &["GET /v1/workloads/{id}", "GET /v1/workloads/{id}/context"],
352
+ note: "A workload carries the input, capability snapshot and checkpoints its own protocol records. FeltDB stores no goal text, prompt, or model context of its own, so Studio shows the workload's declared input and nothing it did not receive.",
353
+ },
354
+ ControlPlaneCapability {
355
+ id: "operations.recent",
356
+ area: "operations",
357
+ title: "Recent writes and their operation records",
358
+ status: CapabilityStatus::Available,
359
+ surface: &["GET /v1/control-plane/operations"],
360
+ note: "Bounded by the change log the instance retains — compaction removes older operations, so this is recent activity, not an operation archive. Instance-scoped: it requires `state:read` on the instance and is not filtered per application, which makes it an operator surface rather than an application-member one.",
361
+ },
362
+ ControlPlaneCapability {
363
+ id: "operations.rejections",
364
+ area: "operations",
365
+ title: "Rejected operations: CAS failures and stale versions",
366
+ status: CapabilityStatus::Partial,
367
+ surface: &["POST /api/applications/{application_id}/revisions/{revision_id}/actions/{action}"],
368
+ note: "A refusal is returned to its caller as a structured PRECONDITION_FAILED with the expected and actual version. The instance keeps no durable log of refusals, so Studio can show the refusals it caused and cannot show refusals other callers caused.",
369
+ },
370
+ ControlPlaneCapability {
371
+ id: "operations.events",
372
+ area: "operations",
373
+ title: "Live state event stream",
374
+ status: CapabilityStatus::Available,
375
+ surface: &["GET /v1/events"],
376
+ note: "",
377
+ },
378
+ ControlPlaneCapability {
379
+ id: "operations.transaction_status",
380
+ area: "operations",
381
+ title: "One transaction: status, idempotency and attribution",
382
+ status: CapabilityStatus::Available,
383
+ surface: &["GET /v1/control-plane/transactions/{transaction_id}"],
384
+ note: "",
385
+ },
386
+ ControlPlaneCapability {
387
+ id: "operations.correlation_id",
388
+ area: "operations",
389
+ title: "Follow one request across operations and events",
390
+ status: CapabilityStatus::Unsupported,
391
+ surface: &[],
392
+ note: "A `request_id` is minted per error response and carried nowhere else: successful responses have none, and no operation, event or transaction records one. There is no id to filter a stream by, so Studio offers no correlation filter. A transaction id is the identifier that does span a commit's operations, and `GET /v1/control-plane/transactions/{transaction_id}` follows it.",
393
+ },
394
+ ControlPlaneCapability {
395
+ id: "operations.audit_log",
396
+ area: "operations",
397
+ title: "Searchable audit log",
398
+ status: CapabilityStatus::Unsupported,
399
+ surface: &[],
400
+ note: "The audit log is append-only and write-only from the server's side: it is a security artifact read out of band, and no route serves it back.",
401
+ },
402
+ ControlPlaneCapability {
403
+ id: "replication.health",
404
+ area: "replication",
405
+ title: "Authorities, peers, causal position and replication health",
406
+ status: CapabilityStatus::Available,
407
+ surface: &["GET /v1/control-plane/replication"],
408
+ note: "",
409
+ },
410
+ ControlPlaneCapability {
411
+ id: "applications.list",
412
+ area: "applications",
413
+ title: "Applications and runtime status",
414
+ status: CapabilityStatus::Available,
415
+ surface: &["GET /v1/control-plane/applications"],
416
+ note: "",
417
+ },
418
+ ControlPlaneCapability {
419
+ id: "monitoring.metrics",
420
+ area: "monitoring",
421
+ title: "Requests, rejections, storage and state growth",
422
+ status: CapabilityStatus::Available,
423
+ surface: &["GET /v1/control-plane/overview", "GET /metrics"],
424
+ note: "",
425
+ },
426
+ ControlPlaneCapability {
427
+ id: "monitoring.latency",
428
+ area: "monitoring",
429
+ title: "Request latency distribution",
430
+ status: CapabilityStatus::Unsupported,
431
+ surface: &[],
432
+ note: "The instance counts requests, sheds and deadline overruns; it records no latency histogram, and a chart drawn from counters would be invented.",
433
+ },
434
+ ControlPlaneCapability {
435
+ id: "settings.instance",
436
+ area: "settings",
437
+ title: "Instance identity, storage, authority and limits",
438
+ status: CapabilityStatus::Available,
439
+ surface: &["GET /v1/control-plane/overview", "GET /v1/diagnostics"],
440
+ note: "",
441
+ },
442
+ ControlPlaneCapability {
443
+ id: "settings.mutate",
444
+ area: "settings",
445
+ title: "Change instance configuration",
446
+ status: CapabilityStatus::Unsupported,
447
+ surface: &[],
448
+ note: "Configuration is supplied at start-up by the deployment. No route mutates it, so Studio shows configuration and does not offer to edit it.",
449
+ },
450
+ ControlPlaneCapability {
451
+ id: "developer.tool_projection",
452
+ area: "developer",
453
+ title: "Projected agent tool or MCP configuration",
454
+ status: CapabilityStatus::Unsupported,
455
+ surface: &[],
456
+ note: "FeltDB projects no agent tool manifest of its own. What it does publish is its own interface description at `/v1/openapi.json`, which an agent can read to discover what an application declares and what it may call. Emitting a tool configuration Studio invented, for a projection FeltDB does not serve, would be a claim about an interface that does not exist.",
457
+ },
458
+ ControlPlaneCapability {
459
+ id: "developer.access",
460
+ area: "developer",
461
+ title: "Connection details, SDK and CLI snippets",
462
+ status: CapabilityStatus::Available,
463
+ surface: &["GET /v1/control-plane/developer"],
464
+ note: "",
465
+ },
466
+ ];
467
+
468
+ /// The declared capability set, as the capabilities route serves it.
469
+ pub fn capabilities() -> Value {
470
+ json!({
471
+ "contract": "feltdb.control-plane.capabilities",
472
+ "version": 1,
473
+ "capabilities": CAPABILITIES,
474
+ })
475
+ }
476
+
477
+ // ============================================================================
478
+ // State model projections
479
+ // ============================================================================
480
+
481
+ /// A semantic path as JSON: object keys are strings, array indices are numbers.
482
+ ///
483
+ /// The distinction is preserved rather than rendered, because `path_relation`
484
+ /// compares components structurally and `["a","0"]` is not `["a",0]`.
485
+ pub fn path_to_json(path: &[PathComponent]) -> Value {
486
+ Value::Array(
487
+ path.iter()
488
+ .map(|component| match component {
489
+ PathComponent::Key(key) => Value::String(key.clone()),
490
+ PathComponent::Index(index) => json!(index),
491
+ })
492
+ .collect(),
493
+ )
494
+ }
495
+
496
+ /// Reads a path back. Refuses anything that is not a string or an index.
497
+ pub fn path_from_json(value: &Value) -> Result<Vec<PathComponent>, String> {
498
+ let members = value
499
+ .as_array()
500
+ .ok_or_else(|| "a path must be an array".to_string())?;
501
+ members
502
+ .iter()
503
+ .map(|member| match member {
504
+ Value::String(key) => Ok(PathComponent::Key(key.clone())),
505
+ Value::Number(number) => number
506
+ .as_u64()
507
+ .map(|index| PathComponent::Index(index as usize))
508
+ .ok_or_else(|| "an array index must be a non-negative integer".to_string()),
509
+ _ => Err("a path component must be an object key or an array index".to_string()),
510
+ })
511
+ .collect()
512
+ }
513
+
514
+ fn change_kind_name(kind: &ChangeKind) -> &'static str {
515
+ match kind {
516
+ ChangeKind::Added => "added",
517
+ ChangeKind::Removed => "removed",
518
+ ChangeKind::Changed => "changed",
519
+ }
520
+ }
521
+
522
+ fn conflict_class_name(class: &ConflictClass) -> &'static str {
523
+ match class {
524
+ ConflictClass::Independent => "independent",
525
+ ConflictClass::Convergent => "convergent",
526
+ ConflictClass::Conflict => "conflict",
527
+ }
528
+ }
529
+
530
+ /// A semantic diff, in the order the contract produced it.
531
+ pub fn diff_to_json(diff: &SemanticDiff) -> Value {
532
+ json!({
533
+ "contract": "feltdb.state.diff",
534
+ "changes": diff.changes.iter().map(|change| json!({
535
+ "path": path_to_json(&change.path),
536
+ "kind": change_kind_name(&change.kind),
537
+ "old_value": change.old_value,
538
+ "new_value": change.new_value,
539
+ })).collect::<Vec<_>>(),
540
+ "is_read_only": diff.is_read_only,
541
+ })
542
+ }
543
+
544
+ /// A conflict classification, per path and overall.
545
+ pub fn classification_to_json(classification: &ConflictClassification) -> Value {
546
+ json!({
547
+ "contract": "feltdb.state.conflict",
548
+ "overall": conflict_class_name(&classification.overall),
549
+ "path_conflicts": classification.path_conflicts.iter().map(|conflict| json!({
550
+ "path": path_to_json(&conflict.path),
551
+ "classification": conflict_class_name(&conflict.classification),
552
+ "base_value": conflict.base_value,
553
+ "left_value": conflict.left_value,
554
+ "right_value": conflict.right_value,
555
+ })).collect::<Vec<_>>(),
556
+ })
557
+ }
558
+
559
+ /// A revision, without its content.
560
+ ///
561
+ /// History listings use this: a resource's history is a chain of facts about
562
+ /// *when and by whom*, and carrying every revision's full content would make
563
+ /// listing a resource cost as much as reading all of it.
564
+ pub fn revision_summary_to_json(revision: &StateRevision) -> Value {
565
+ json!({
566
+ "state_id": revision.id.as_hex(),
567
+ "resource": revision.resource,
568
+ "content_id": revision.content_id.as_hex(),
569
+ "parent_id": revision.parent_id.as_ref().map(StateId::as_hex),
570
+ "sequence": revision.sequence,
571
+ "authority": revision.authority,
572
+ "timestamp_ms": revision.timestamp_ms,
573
+ "metadata": revision.metadata,
574
+ })
575
+ }
576
+
577
+ /// A revision with its content, and whether it still verifies against its id.
578
+ ///
579
+ /// `integrity_verified` is reported rather than assumed. The store checks
580
+ /// integrity when a revision is written; a reader that shows history to a human
581
+ /// re-checks it, because the one thing worse than no history is history that
582
+ /// silently does not match what it claims to record.
583
+ pub fn revision_to_json(revision: &StateRevision, content: Value) -> Value {
584
+ let mut document = revision_summary_to_json(revision);
585
+ if let Some(object) = document.as_object_mut() {
586
+ object.insert("content".into(), content);
587
+ object.insert(
588
+ "integrity_verified".into(),
589
+ json!(revision.verify_integrity()),
590
+ );
591
+ }
592
+ document
593
+ }
594
+
595
+ /// The plan a caller asked for.
596
+ ///
597
+ /// `parent_choice` is a branch name rather than a boolean because `true` does
598
+ /// not say *left* to anyone reading a request body, and choosing the wrong
599
+ /// branch silently discards the other one's changes.
600
+ #[derive(Clone, Debug, Deserialize)]
601
+ pub struct ReconciliationPlanRequest {
602
+ pub base: String,
603
+ pub left: String,
604
+ pub right: String,
605
+ pub parent_choice: String,
606
+ #[serde(default)]
607
+ pub overrides: Vec<PathOverrideRequest>,
608
+ }
609
+
610
+ /// One path the caller decides explicitly, whatever the branches hold there.
611
+ #[derive(Clone, Debug, Deserialize)]
612
+ pub struct PathOverrideRequest {
613
+ pub path: Value,
614
+ pub value: Value,
615
+ }
616
+
617
+ impl ReconciliationPlanRequest {
618
+ /// Builds the plan this request names.
619
+ ///
620
+ /// Every failure here is the caller's request being unusable, never a
621
+ /// server fault, so each returns a message naming the part that was wrong.
622
+ pub fn to_plan(&self) -> Result<ReconciliationPlan, String> {
623
+ let parent_choice = match self.parent_choice.as_str() {
624
+ "left" => true,
625
+ "right" => false,
626
+ other => {
627
+ return Err(format!(
628
+ "parent_choice must be \"left\" or \"right\", not {other:?}"
629
+ ))
630
+ }
631
+ };
632
+ let mut overrides: BTreeMap<Vec<PathComponent>, Value> = BTreeMap::new();
633
+ for entry in &self.overrides {
634
+ overrides.insert(path_from_json(&entry.path)?, entry.value.clone());
635
+ }
636
+ let mut plan = ReconciliationPlan::new(
637
+ StateId::from_hex(self.left.clone()),
638
+ StateId::from_hex(self.right.clone()),
639
+ StateId::from_hex(self.base.clone()),
640
+ parent_choice,
641
+ );
642
+ plan.path_overrides = overrides;
643
+ Ok(plan)
644
+ }
645
+ }
646
+
647
+ /// A plan, echoed back in the same shape it was requested in.
648
+ pub fn plan_to_json(plan: &ReconciliationPlan) -> Value {
649
+ json!({
650
+ "base": plan.base_id.as_hex(),
651
+ "left": plan.left_id.as_hex(),
652
+ "right": plan.right_id.as_hex(),
653
+ "parent_choice": if plan.parent_choice { "left" } else { "right" },
654
+ "overrides": plan.path_overrides.iter().map(|(path, value)| json!({
655
+ "path": path_to_json(path),
656
+ "value": value,
657
+ })).collect::<Vec<_>>(),
658
+ })
659
+ }
660
+
661
+ // ============================================================================
662
+ // Operation projections
663
+ // ============================================================================
664
+
665
+ fn operation_type_name(kind: &OperationType) -> &'static str {
666
+ match kind {
667
+ OperationType::Insert => "insert",
668
+ OperationType::Update => "update",
669
+ OperationType::Delete => "delete",
670
+ }
671
+ }
672
+
673
+ /// The collection a capability names.
674
+ ///
675
+ /// A capability is `{state_namespace}:{collection}` for application data and a
676
+ /// bare collection for the direct-authority routes. Splitting from the right
677
+ /// covers both, and covers an application whose state namespace differs from
678
+ /// the server's — which is the usual case, and the one that made an earlier
679
+ /// left-anchored version report every application collection as unmatched.
680
+ pub fn collection_of(capability: &str) -> &str {
681
+ capability
682
+ .rsplit_once(':')
683
+ .map(|(_, collection)| collection)
684
+ .unwrap_or(capability)
685
+ }
686
+
687
+ /// What an operation did, without what it wrote.
688
+ ///
689
+ /// The value is deliberately dropped rather than redacted. An operations view
690
+ /// answers *what happened, to what, by whom, in what order*; carrying record
691
+ /// contents through it would make every write's payload readable from a second
692
+ /// route with none of the per-record authorization that
693
+ /// `GET /v1/data/{collection}/{id}` applies.
694
+ pub fn operation_summary_to_json(operation: &Operation) -> Value {
695
+ json!({
696
+ "operation_id": operation.op_id,
697
+ "type": operation_type_name(&operation.op_type),
698
+ "capability": operation.capability,
699
+ "collection": collection_of(&operation.capability),
700
+ "key": operation.key,
701
+ "instance_id": operation.instance_id,
702
+ "sequence": operation.sequence,
703
+ "timestamp_ms": operation.timestamp_ms as u64,
704
+ "content_hash": operation.content_hash,
705
+ "vector_clock": operation.vector_clock,
706
+ "state_id": operation.revision.as_ref().map(|revision| revision.id.clone()),
707
+ })
708
+ }
709
+
710
+ #[cfg(test)]
711
+ mod tests {
712
+ use super::*;
713
+
714
+ /// The declaration is the product claim, so it is checked like one.
715
+ #[test]
716
+ fn control_plane_declaration_is_honest() {
717
+ let mut seen = std::collections::BTreeSet::new();
718
+ for capability in CAPABILITIES {
719
+ assert!(
720
+ seen.insert(capability.id),
721
+ "capability {} is declared twice",
722
+ capability.id
723
+ );
724
+ match capability.status {
725
+ CapabilityStatus::Unsupported => {
726
+ assert!(
727
+ capability.surface.is_empty(),
728
+ "{} is unsupported and still names a surface",
729
+ capability.id
730
+ );
731
+ assert!(
732
+ !capability.note.is_empty(),
733
+ "{} is unsupported without saying why",
734
+ capability.id
735
+ );
736
+ }
737
+ CapabilityStatus::Partial => {
738
+ assert!(
739
+ !capability.surface.is_empty(),
740
+ "{} is partial and names no surface",
741
+ capability.id
742
+ );
743
+ assert!(
744
+ !capability.note.is_empty(),
745
+ "{} is partial without saying which inputs it covers",
746
+ capability.id
747
+ );
748
+ }
749
+ CapabilityStatus::Available => assert!(
750
+ !capability.surface.is_empty(),
751
+ "{} is available and names no surface",
752
+ capability.id
753
+ ),
754
+ }
755
+ for route in capability.surface {
756
+ let (method, path) = route
757
+ .split_once(' ')
758
+ .unwrap_or_else(|| panic!("{} names a surface without a method", capability.id));
759
+ assert!(
760
+ ["GET", "POST", "PUT", "DELETE"].contains(&method),
761
+ "{} names an unknown method {method}",
762
+ capability.id
763
+ );
764
+ assert!(
765
+ path.starts_with('/'),
766
+ "{} names a relative path {path}",
767
+ capability.id
768
+ );
769
+ }
770
+ }
771
+ }
772
+
773
+ #[test]
774
+ fn a_path_survives_the_round_trip_with_its_component_kinds() {
775
+ let path = vec![
776
+ PathComponent::Key("items".into()),
777
+ PathComponent::Index(2),
778
+ PathComponent::Key("0".into()),
779
+ ];
780
+ let encoded = path_to_json(&path);
781
+ assert_eq!(encoded, json!(["items", 2, "0"]));
782
+ assert_eq!(path_from_json(&encoded).unwrap(), path);
783
+ }
784
+
785
+ #[test]
786
+ fn a_path_component_that_is_neither_key_nor_index_is_refused() {
787
+ assert!(path_from_json(&json!(["a", true])).is_err());
788
+ assert!(path_from_json(&json!(["a", -1])).is_err());
789
+ assert!(path_from_json(&json!("a")).is_err());
790
+ }
791
+
792
+ #[test]
793
+ fn a_plan_request_names_its_branch_by_name() {
794
+ let request: ReconciliationPlanRequest = serde_json::from_value(json!({
795
+ "base": "b", "left": "l", "right": "r", "parent_choice": "right",
796
+ "overrides": [{"path": ["o", 0], "value": 7}],
797
+ }))
798
+ .unwrap();
799
+ let plan = request.to_plan().unwrap();
800
+ assert!(!plan.parent_choice);
801
+ assert_eq!(plan.path_overrides.len(), 1);
802
+ assert_eq!(plan_to_json(&plan)["parent_choice"], json!("right"));
803
+ assert_eq!(plan_to_json(&plan)["overrides"][0]["path"], json!(["o", 0]));
804
+ }
805
+
806
+ #[test]
807
+ fn a_plan_request_with_an_unnamed_branch_is_refused() {
808
+ let request: ReconciliationPlanRequest = serde_json::from_value(json!({
809
+ "base": "b", "left": "l", "right": "r", "parent_choice": "either",
810
+ }))
811
+ .unwrap();
812
+ assert!(request.to_plan().is_err());
813
+ }
814
+
815
+ #[test]
816
+ fn a_diff_carries_typed_paths_and_named_kinds() {
817
+ let diff = SemanticDiff::compute(&json!({"a": 1}), &json!({"a": 2, "b": [3]}));
818
+ let encoded = diff_to_json(&diff);
819
+ let changes = encoded["changes"].as_array().unwrap();
820
+ assert!(changes
821
+ .iter()
822
+ .any(|change| change["path"] == json!(["a"]) && change["kind"] == json!("changed")));
823
+ assert!(changes
824
+ .iter()
825
+ .any(|change| change["kind"] == json!("added") && change["path"] == json!(["b"])));
826
+ }
827
+
828
+ #[test]
829
+ fn an_operation_summary_never_carries_the_value_it_wrote() {
830
+ let operation = Operation::insert(
831
+ 1,
832
+ "instance".into(),
833
+ 1,
834
+ "User:usr_1".into(),
835
+ json!({"api_token": "must-not-appear"}),
836
+ "serde_json::Value".into(),
837
+ "app-namespace:User".into(),
838
+ );
839
+ let summary = operation_summary_to_json(&operation);
840
+ assert_eq!(summary["collection"], json!("User"));
841
+ assert_eq!(collection_of("Doc"), "Doc");
842
+ assert_eq!(summary["type"], json!("insert"));
843
+ assert!(!summary.to_string().contains("must-not-appear"));
844
+ }
845
+ }