@feltdb/core 0.12.2 → 0.15.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 (126) hide show
  1. package/dist/agent-runtime.d.ts +64 -2
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +121 -11
  4. package/dist/agent-state.d.ts +48 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +174 -12
  7. package/dist/agent.d.ts +10 -1
  8. package/dist/agent.d.ts.map +1 -1
  9. package/dist/agent.js +10 -1
  10. package/dist/build-identity.json +4 -4
  11. package/dist/cli/commands.js +34 -7
  12. package/dist/cli/workspace-integration.js +8 -0
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +30 -8
  16. package/dist/create/build-identity.json +4 -4
  17. package/dist/create/cli.js +47 -38
  18. package/dist/create/create.js +21 -8
  19. package/dist/create/package-versions.js +1 -1
  20. package/dist/create/server-source/Cargo.lock +6 -6
  21. package/dist/create/server-source/Cargo.toml +1 -1
  22. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  23. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  24. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
  25. package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
  26. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  27. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  28. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  29. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  30. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  31. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  33. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
  34. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  35. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  37. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  38. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  39. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
  40. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
  41. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  42. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  43. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  44. package/dist/create/setup-summary.js +40 -0
  45. package/dist/db.d.ts +91 -10
  46. package/dist/db.d.ts.map +1 -1
  47. package/dist/db.js +170 -31
  48. package/dist/deployment.d.ts +19 -0
  49. package/dist/deployment.d.ts.map +1 -1
  50. package/dist/deployment.js +38 -2
  51. package/dist/development-runtime-bridge.d.ts +20 -0
  52. package/dist/development-runtime-bridge.d.ts.map +1 -1
  53. package/dist/development-runtime-bridge.js +61 -12
  54. package/dist/embedded-query.d.ts.map +1 -1
  55. package/dist/embedded-query.js +5 -4
  56. package/dist/embedded-transaction.d.ts +8 -4
  57. package/dist/embedded-transaction.d.ts.map +1 -1
  58. package/dist/embedded-transaction.js +55 -19
  59. package/dist/error-codes.d.ts +31 -2
  60. package/dist/error-codes.d.ts.map +1 -1
  61. package/dist/error-codes.js +41 -2
  62. package/dist/feltdb.d.ts +7 -0
  63. package/dist/feltdb.d.ts.map +1 -1
  64. package/dist/file-db.d.ts +27 -0
  65. package/dist/file-db.d.ts.map +1 -1
  66. package/dist/file-db.js +145 -7
  67. package/dist/http-client.d.ts +11 -3
  68. package/dist/http-client.d.ts.map +1 -1
  69. package/dist/http-client.js +27 -14
  70. package/dist/http-db.d.ts +40 -1
  71. package/dist/http-db.d.ts.map +1 -1
  72. package/dist/http-db.js +285 -58
  73. package/dist/index.js +1 -1
  74. package/dist/local-state-lock.d.ts +34 -0
  75. package/dist/local-state-lock.d.ts.map +1 -0
  76. package/dist/local-state-lock.js +225 -0
  77. package/dist/reactive-graph.d.ts +0 -3
  78. package/dist/reactive-graph.d.ts.map +1 -1
  79. package/dist/reactive-graph.js +11 -1
  80. package/dist/runtime-registry.d.ts +7 -2
  81. package/dist/runtime-registry.d.ts.map +1 -1
  82. package/dist/runtime-registry.js +2 -2
  83. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  84. package/dist/studio/app.d.ts +8 -1
  85. package/dist/studio/app.d.ts.map +1 -1
  86. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  87. package/dist/studio/components/KeyManagementPanel.js +1 -1
  88. package/dist/studio/components/index.js +2 -2
  89. package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
  90. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  91. package/dist/studio/control-plane/Instance.d.ts +4 -1
  92. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  93. package/dist/studio/control-plane/Operations.d.ts +4 -1
  94. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  95. package/dist/studio/control-plane/index.js +2 -2
  96. package/dist/studio/control-plane/primitives.d.ts +16 -0
  97. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  98. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  99. package/dist/studio/index.d.ts +1 -0
  100. package/dist/studio/index.d.ts.map +1 -1
  101. package/dist/studio/index.js +384 -247
  102. package/dist/studio/navigation.d.ts +57 -0
  103. package/dist/studio/navigation.d.ts.map +1 -0
  104. package/dist/studio/studio.css +1 -1
  105. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  106. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  107. package/dist/studio/utils/control-plane-api.js +5 -1
  108. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  109. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  110. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  111. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  112. package/dist/studio-app/index.html +2 -2
  113. package/dist/transaction.d.ts +27 -11
  114. package/dist/transaction.d.ts.map +1 -1
  115. package/dist/transaction.js +20 -14
  116. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  117. package/dist/wasm/package.json +1 -1
  118. package/dist/workload.d.ts +31 -3
  119. package/dist/workload.d.ts.map +1 -1
  120. package/dist/workload.js +28 -3
  121. package/dist/workspace/local-development-authority.d.ts +19 -0
  122. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  123. package/dist/workspace/local-development-authority.js +335 -15
  124. package/package.json +1 -1
  125. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  126. package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
@@ -1020,19 +1020,25 @@ fn retention_cost_grows_with_the_window_and_inflates_the_log() {
1020
1020
 
1021
1021
  // Compare the best of repeated independent runs. The workspace executes
1022
1022
  // other crash/soak processes concurrently, so a single wall-clock sample
1023
- // can measure scheduler delay rather than retention work. Taking the
1024
- // minimum preserves the envelope claim while filtering that one-sided
1025
- // noise; every sample still performs the complete durable workload.
1026
- fn sample(run_once: impl Fn() -> (std::time::Duration, u64)) -> (std::time::Duration, u64) {
1027
- (0..3)
1028
- .map(|_| run_once())
1029
- .min_by_key(|(elapsed, _)| *elapsed)
1030
- .unwrap()
1023
+ // can measure scheduler delay rather than retention work. The minimum
1024
+ // filters that one-sided noise, but only if every configuration is sampled
1025
+ // across the same stretch of time: sampling them one after another lets a
1026
+ // busy sibling inflate one configuration's every sample and not another's.
1027
+ // So each round runs all three, and each keeps its own best. Every sample
1028
+ // still performs the complete durable workload.
1029
+ let mut none = (std::time::Duration::MAX, 0u64);
1030
+ let mut small = (std::time::Duration::MAX, 0u64);
1031
+ let mut large = std::time::Duration::MAX;
1032
+ for _ in 0..9 {
1033
+ let sample = run(None);
1034
+ if sample.0 < none.0 { none = sample; }
1035
+ let sample = run(Some(5));
1036
+ if sample.0 < small.0 { small = sample; }
1037
+ large = large.min(run(Some(50)).0);
1031
1038
  }
1032
-
1033
- let (none_time, none_bytes) = sample(|| run(None));
1034
- let (small_time, small_bytes) = sample(|| run(Some(5)));
1035
- let (large_time, _) = sample(|| run(Some(50)));
1039
+ let (none_time, none_bytes) = none;
1040
+ let (small_time, small_bytes) = small;
1041
+ let large_time = large;
1036
1042
 
1037
1043
  assert!(
1038
1044
  large_time > small_time,
@@ -215,14 +215,26 @@ fn a_barrier_per_write_costs_more_than_none() {
215
215
  let directory = TempDir::new().unwrap();
216
216
  let workload = WORKLOADS[0];
217
217
 
218
- let flushed = run(directory.path(), workload, DurabilityMode::Flushed);
218
+ // A stall can only invert this ordering by inflating the cheap, flushed
219
+ // run, so take the best of several of those against one synced run. The
220
+ // minimum is the cost of the mode with scheduler contention taken out;
221
+ // repeating the slow synced run would only add load to sibling timing
222
+ // tests in this concurrently-run binary.
219
223
  let synced = run(directory.path(), workload, DurabilityMode::Synced);
224
+ let flushed = run(directory.path(), workload, DurabilityMode::Flushed);
225
+ let mut best_flushed = flushed.elapsed;
226
+ for sample in 0..4 {
227
+ // A fresh log per sample: a reused path would append to its history.
228
+ let sample_directory = directory.path().join(format!("flushed-{sample}"));
229
+ std::fs::create_dir_all(&sample_directory).unwrap();
230
+ let repeat = run(&sample_directory, workload, DurabilityMode::Flushed);
231
+ best_flushed = best_flushed.min(repeat.elapsed);
232
+ }
220
233
 
221
234
  assert!(
222
- synced.elapsed > flushed.elapsed,
223
- "a barrier per write must cost something: synced {:?} vs flushed {:?}",
224
- synced.elapsed,
225
- flushed.elapsed
235
+ synced.elapsed > best_flushed,
236
+ "a barrier per write must cost something: synced {:?} vs flushed {best_flushed:?}",
237
+ synced.elapsed
226
238
  );
227
239
 
228
240
  // Durability is the only thing that changed. The database is identical.
@@ -16,7 +16,7 @@ exclude = ["tests/", "target/"]
16
16
  # The wire types are the server's own: the request and response documents are defined once, in
17
17
  # `feltdb`, and both sides serialize the same structs. Exact, because the modules they come from are
18
18
  # not yet covered by `feltdb`'s compatibility policy; the workspace releases both at one version.
19
- feltdb = { version = "=0.2.1", path = "../feltdb" }
19
+ feltdb = { version = "=0.3.0", path = "../feltdb" }
20
20
  reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
21
21
  serde = { version = "1", features = ["derive"] }
22
22
  serde_json = "1"
@@ -24,7 +24,7 @@ hmac = "0.12"
24
24
  # Exact: the server uses `unstable-internals`, which carries no semver promise, so a release of
25
25
  # this crate is built against the one `feltdb` release it was tested with. The workspace shares one
26
26
  # version, so this moves with every release (a mismatch fails to resolve, it cannot drift silently).
27
- feltdb = { version = "=0.2.1", path = "../feltdb", features = ["unstable-internals"] }
27
+ feltdb = { version = "=0.3.0", path = "../feltdb", features = ["unstable-internals"] }
28
28
  futures-core = "0.3"
29
29
  rand = "0.8"
30
30
  ring = "0.17"
@@ -53,7 +53,12 @@ Implemented in this slice:
53
53
  - collection list/create/get/update/delete endpoints
54
54
  - `GET /events` as a server-sent event stream
55
55
  - durable, hashed API keys with namespace and `state:read`, `state:write`, or `events:read` scopes
56
- - `GET /metrics` request, authorization, state, and event counters
56
+ - `GET /metrics` request, authorization, state, and event counters (requires `operator:read`)
57
+ - `GET /v1/status` operator status: release identity, a write/fsync/read-back storage probe, the
58
+ last recorded backup and the last verified restore (requires `operator:read`)
59
+ - `/api/keys` listing (never secrets), issuing, `POST /api/keys/{id}/rotate` and revocation
60
+ (requires `keys:admin`; a caller can never delegate a scope it does not hold). See
61
+ [`docs/reference/managed-operator-surface.md`](../../docs/reference/managed-operator-surface.md).
57
62
  - authenticated `POST /sync/pull` and `POST /sync/push` operation exchange
58
63
  - durable, transitive operation history with content-hash verification, deduplication, and gap rejection
59
64
 
@@ -0,0 +1,511 @@
1
+ //! Authority, completion, and failure rules for runs the autonomous worker executes.
2
+ //!
3
+ //! Agent and workflow runs are created by an authenticated request and executed
4
+ //! later by the worker, which runs with the node's own authority. Without the
5
+ //! rules here, every capability a run invokes would read with that node
6
+ //! authority, and a run would end `completed` whenever a capability returned.
7
+ //!
8
+ //! - **Authority.** When a run is created, the server signs a grant that binds
9
+ //! the requesting principal's scopes to that one run ([`issue_run_authority`]).
10
+ //! The worker verifies it before executing ([`verify_run_authority`]) and
11
+ //! authorizes every collection read against it. The grant is signed with the
12
+ //! server's existing [`GrantSigner`], so a run record written or replayed by
13
+ //! anything other than the server — including a grant copied from another
14
+ //! run — is refused rather than trusted.
15
+ //! - **Completion.** An agent run completes only when criteria declared at
16
+ //! creation are satisfied by the result the runtime itself observed
17
+ //! ([`CompletionCriteria`]). The goal text is never evaluated. A run without
18
+ //! verifiable criteria ends `failed` with [`COMPLETION_UNVERIFIABLE`]; nothing
19
+ //! a client posts can complete a run.
20
+ //! - **Failure.** Permanent errors end the run at once; transient errors retry
21
+ //! at most [`max_run_attempts`] times, under the workload retry policy's bound.
22
+
23
+ use std::collections::BTreeSet;
24
+
25
+ use feltdb::authorization::{
26
+ Grant, GrantConstraints, GrantSigner, GrantStatus, SignedGrant, Subject,
27
+ AUTHORIZATION_CONTRACT_VERSION,
28
+ };
29
+ use feltdb::workload::WorkloadRetryPolicy;
30
+ use serde::{Deserialize, Serialize};
31
+ use serde_json::{json, Value};
32
+
33
+ /// The issuer recorded on every run authority grant.
34
+ pub const RUN_AUTHORITY_ISSUER: &str = "feltdb-run-authority";
35
+ /// How long a run's delegated authority stays valid after the run is created.
36
+ ///
37
+ /// A run that has not finished by then fails with [`AUTHORIZATION_DENIED`]
38
+ /// rather than executing on a delegation its caller may no longer intend.
39
+ pub const RUN_AUTHORITY_TTL_SECONDS: i64 = 24 * 60 * 60;
40
+
41
+ /// Run statuses written by the runtime.
42
+ pub const STATUS_READY: &str = "ready";
43
+ pub const STATUS_RUNNING: &str = "running";
44
+ pub const STATUS_COMPLETED: &str = "completed";
45
+ pub const STATUS_FAILED: &str = "failed";
46
+ /// Non-terminal annotations an external participant may set on an agent run.
47
+ pub const STATUS_DELIBERATING: &str = "deliberating";
48
+ pub const STATUS_ACTING: &str = "acting";
49
+
50
+ /// Stable failure codes stored in a failed run's `failure.code`.
51
+ pub const AUTHORIZATION_DENIED: &str = "AUTHORIZATION_DENIED";
52
+ pub const AGENT_NOT_FOUND: &str = "AGENT_NOT_FOUND";
53
+ pub const CAPABILITY_UNKNOWN: &str = "CAPABILITY_UNKNOWN";
54
+ pub const CAPABILITY_INPUT_INVALID: &str = "CAPABILITY_INPUT_INVALID";
55
+ pub const RETRY_EXHAUSTED: &str = "RETRY_EXHAUSTED";
56
+ pub const COMPLETION_CRITERIA_UNMET: &str = "COMPLETION_CRITERIA_UNMET";
57
+ pub const COMPLETION_UNVERIFIABLE: &str = "COMPLETION_UNVERIFIABLE";
58
+ pub const RUN_INVALID: &str = "RUN_INVALID";
59
+
60
+ /// Which kind of run a grant was issued for. It is part of the grant subject,
61
+ /// so an agent run's authority cannot be presented by a workflow run.
62
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
63
+ pub enum RunKind {
64
+ Agent,
65
+ Workflow,
66
+ }
67
+
68
+ impl RunKind {
69
+ fn subject(self, run_id: &str) -> Subject {
70
+ match self {
71
+ Self::Agent => Subject::Agent(format!("agent-run:{run_id}")),
72
+ Self::Workflow => Subject::Workload(format!("workflow-run:{run_id}")),
73
+ }
74
+ }
75
+ }
76
+
77
+ /// The principal a run acts for, as the server authenticated it.
78
+ #[derive(Debug, Clone, PartialEq, Eq)]
79
+ pub struct RunPrincipal {
80
+ pub id: String,
81
+ /// `service`, `worker`, or `human`, exactly as the authenticator decided.
82
+ pub kind: String,
83
+ pub scopes: BTreeSet<String>,
84
+ }
85
+
86
+ /// Sign the authority a run executes under.
87
+ pub fn issue_run_authority(
88
+ signer: &GrantSigner,
89
+ kind: RunKind,
90
+ run_id: &str,
91
+ tenant_id: &str,
92
+ principal: &RunPrincipal,
93
+ issued_at: i64,
94
+ ) -> Result<SignedGrant, String> {
95
+ let mut constraints = GrantConstraints::default();
96
+ constraints
97
+ .subject_attributes
98
+ .insert("principal_id".into(), principal.id.clone());
99
+ constraints
100
+ .subject_attributes
101
+ .insert("principal_kind".into(), principal.kind.clone());
102
+ signer
103
+ .sign(Grant {
104
+ contract_version: AUTHORIZATION_CONTRACT_VERSION,
105
+ id: format!("run-authority:{run_id}"),
106
+ issuer: Subject::Service(RUN_AUTHORITY_ISSUER.into()),
107
+ subject: kind.subject(run_id),
108
+ tenant_id: tenant_id.into(),
109
+ application_id: tenant_id.into(),
110
+ capabilities: principal.scopes.clone(),
111
+ resources: Vec::new(),
112
+ constraints,
113
+ issued_at,
114
+ expires_at: issued_at.saturating_add(RUN_AUTHORITY_TTL_SECONDS),
115
+ parent_grant: None,
116
+ revision: 1,
117
+ status: GrantStatus::Active,
118
+ })
119
+ .map_err(|error| error.message)
120
+ }
121
+
122
+ /// Verify a run's stored authority and return the principal it delegates.
123
+ ///
124
+ /// Every refusal is [`AUTHORIZATION_DENIED`] and permanent: retrying cannot
125
+ /// change a signature, a binding, or an expiry.
126
+ pub fn verify_run_authority(
127
+ signer: &GrantSigner,
128
+ stored: &Value,
129
+ kind: RunKind,
130
+ run_id: &str,
131
+ tenant_id: &str,
132
+ now: i64,
133
+ ) -> Result<RunPrincipal, RunFailure> {
134
+ let denied = |message: &str| RunFailure::permanent(AUTHORIZATION_DENIED, message);
135
+ if stored.is_null() {
136
+ return Err(denied("run carries no delegated authority"));
137
+ }
138
+ let signed: SignedGrant =
139
+ serde_json::from_value(stored.clone()).map_err(|_| denied("run authority is malformed"))?;
140
+ signer
141
+ .verify_signature(&signed)
142
+ .map_err(|_| denied("run authority signature is invalid"))?;
143
+ let grant = &signed.grant;
144
+ if grant.issuer != Subject::Service(RUN_AUTHORITY_ISSUER.into())
145
+ || grant.subject != kind.subject(run_id)
146
+ || grant.id != format!("run-authority:{run_id}")
147
+ {
148
+ return Err(denied("run authority was issued for a different run"));
149
+ }
150
+ if grant.tenant_id != tenant_id || grant.application_id != tenant_id {
151
+ return Err(denied("run authority belongs to a different tenant"));
152
+ }
153
+ if grant.status != GrantStatus::Active || now >= grant.expires_at || now < grant.issued_at {
154
+ return Err(denied("run authority is not current"));
155
+ }
156
+ let attribute = |name: &str| grant.constraints.subject_attributes.get(name).cloned();
157
+ let (Some(id), Some(kind)) = (attribute("principal_id"), attribute("principal_kind")) else {
158
+ return Err(denied("run authority names no principal"));
159
+ };
160
+ Ok(RunPrincipal {
161
+ id,
162
+ kind,
163
+ scopes: grant.capabilities.clone(),
164
+ })
165
+ }
166
+
167
+ /// A machine-checkable condition the runtime evaluates against the result it
168
+ /// observed. The set is closed: anything else is refused when the run starts.
169
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170
+ #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
171
+ pub enum CompletionCriteria {
172
+ /// The result is an array with at least `min` (and at most `max`) items.
173
+ ResultCount { min: usize, max: Option<usize> },
174
+ /// The result is an array with an item whose `field` equals `equals`.
175
+ ResultContains { field: String, equals: Value },
176
+ }
177
+
178
+ impl CompletionCriteria {
179
+ /// Reject criteria that would be satisfied by any outcome.
180
+ pub fn validate(&self) -> Result<(), String> {
181
+ match self {
182
+ Self::ResultCount { min, max } => {
183
+ if *min == 0 {
184
+ return Err("result_count requires min >= 1; a criterion every outcome satisfies verifies nothing".into());
185
+ }
186
+ if max.is_some_and(|max| max < *min) {
187
+ return Err("result_count max must be >= min".into());
188
+ }
189
+ }
190
+ Self::ResultContains { field, .. } => {
191
+ if field.is_empty() || field.len() > 128 {
192
+ return Err("result_contains requires a field name of 1..128 characters".into());
193
+ }
194
+ }
195
+ }
196
+ Ok(())
197
+ }
198
+
199
+ /// Whether `result`, as observed by the runtime, satisfies the criterion.
200
+ pub fn satisfied_by(&self, result: &Value) -> bool {
201
+ let Some(items) = result.as_array() else {
202
+ return false;
203
+ };
204
+ match self {
205
+ Self::ResultCount { min, max } => {
206
+ items.len() >= *min && max.is_none_or(|max| items.len() <= max)
207
+ }
208
+ Self::ResultContains { field, equals } => {
209
+ items.iter().any(|item| item.get(field) == Some(equals))
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /// Why a run did not complete. `retryable` is decided by the runtime, never by
216
+ /// the run record.
217
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218
+ pub struct RunFailure {
219
+ pub code: &'static str,
220
+ pub message: String,
221
+ pub retryable: bool,
222
+ }
223
+
224
+ impl RunFailure {
225
+ pub fn permanent(code: &'static str, message: impl Into<String>) -> Self {
226
+ Self {
227
+ code,
228
+ message: message.into(),
229
+ retryable: false,
230
+ }
231
+ }
232
+
233
+ pub fn to_record(&self, attempt: u64) -> Value {
234
+ json!({ "code": self.code, "message": self.message, "retryable": self.retryable, "attempt": attempt })
235
+ }
236
+ }
237
+
238
+ /// Classify a capability error by its HTTP status.
239
+ ///
240
+ /// A capability reads committed state and nothing else, so a client error
241
+ /// (bad input, missing record, refused authority) fails the same way on every
242
+ /// attempt and is permanent. Only a server-side error can succeed on retry.
243
+ pub fn classify_capability_error(status: u16, message: &str) -> RunFailure {
244
+ match status {
245
+ 401 | 403 => RunFailure::permanent(AUTHORIZATION_DENIED, message),
246
+ 404 => RunFailure::permanent(CAPABILITY_UNKNOWN, message),
247
+ 400..=499 => RunFailure::permanent(CAPABILITY_INPUT_INVALID, message),
248
+ _ => RunFailure {
249
+ code: "CAPABILITY_FAILED",
250
+ message: message.into(),
251
+ retryable: true,
252
+ },
253
+ }
254
+ }
255
+
256
+ /// The scopes a run may use: those granted when it was created that the key
257
+ /// still holds now. `*` on either side stands for the other side's set, so a
258
+ /// wildcard never widens a narrowed key, and an empty set authorizes nothing.
259
+ pub fn effective_scopes(
260
+ granted: &BTreeSet<String>,
261
+ current: &BTreeSet<String>,
262
+ ) -> BTreeSet<String> {
263
+ if granted.contains("*") {
264
+ current.clone()
265
+ } else if current.contains("*") {
266
+ granted.clone()
267
+ } else {
268
+ granted.intersection(current).cloned().collect()
269
+ }
270
+ }
271
+
272
+ /// The most attempts a run gets, shared with durable workloads.
273
+ pub fn max_run_attempts() -> u64 {
274
+ u64::from(WorkloadRetryPolicy::default().max_attempts)
275
+ }
276
+
277
+ /// Delay before attempt `next_attempt` (2 or later), exponential from the
278
+ /// workload policy's initial delay and capped at its maximum.
279
+ pub fn retry_delay_ms(next_attempt: u64) -> u64 {
280
+ let policy = WorkloadRetryPolicy::default();
281
+ let exponent = next_attempt.saturating_sub(2).min(20) as u32;
282
+ policy
283
+ .initial_delay_ms
284
+ .saturating_mul(2u64.saturating_pow(exponent))
285
+ .min(policy.max_delay_ms)
286
+ }
287
+
288
+ /// Collections reserved for the server. No capability reads them.
289
+ pub fn is_system_collection(collection: &str) -> bool {
290
+ collection.starts_with('_')
291
+ }
292
+
293
+ /// Collections the runtime alone writes. A raw write could otherwise set a
294
+ /// run's status or retry state directly, or — for durable work, which the
295
+ /// workload store loads from this collection at startup — inject a workload
296
+ /// whose authorization snapshot was never verified.
297
+ pub fn is_runtime_owned_collection(collection: &str) -> bool {
298
+ matches!(
299
+ collection,
300
+ "_flow_agent_runs" | "_flow_workflow_runs" | feltdb::workload::DURABLE_WORK_COLLECTION
301
+ )
302
+ }
303
+
304
+ pub fn is_terminal(status: &str) -> bool {
305
+ matches!(status, STATUS_COMPLETED | STATUS_FAILED)
306
+ }
307
+
308
+ /// Whether the worker may claim an agent run in `status`.
309
+ ///
310
+ /// External annotations (`deliberating`, `acting`) leave a run eligible, so a
311
+ /// client posting an observation cannot strand it outside the worker's reach.
312
+ pub fn agent_run_claimable(status: &str, lease_expired: bool) -> bool {
313
+ match status {
314
+ STATUS_READY | STATUS_DELIBERATING | STATUS_ACTING => true,
315
+ STATUS_RUNNING => lease_expired,
316
+ _ => false,
317
+ }
318
+ }
319
+
320
+ #[cfg(test)]
321
+ mod tests {
322
+ use super::*;
323
+
324
+ fn signer(seed: u8) -> GrantSigner {
325
+ GrantSigner::new("feltdb-primary-v1", vec![seed; 32]).unwrap()
326
+ }
327
+
328
+ fn principal() -> RunPrincipal {
329
+ RunPrincipal {
330
+ id: "key-1".into(),
331
+ kind: "service".into(),
332
+ scopes: ["agents:run".to_string(), "state:read".to_string()].into(),
333
+ }
334
+ }
335
+
336
+ fn issued(kind: RunKind, run: &str) -> Value {
337
+ serde_json::to_value(
338
+ issue_run_authority(&signer(7), kind, run, "tenant", &principal(), 1_000).unwrap(),
339
+ )
340
+ .unwrap()
341
+ }
342
+
343
+ #[test]
344
+ fn a_run_authority_round_trips_for_its_own_run() {
345
+ let verified = verify_run_authority(
346
+ &signer(7),
347
+ &issued(RunKind::Agent, "agent-1"),
348
+ RunKind::Agent,
349
+ "agent-1",
350
+ "tenant",
351
+ 1_001,
352
+ )
353
+ .unwrap();
354
+ assert_eq!(verified, principal());
355
+ }
356
+
357
+ #[test]
358
+ fn a_run_authority_is_refused_for_any_other_binding() {
359
+ let grant = issued(RunKind::Agent, "agent-1");
360
+ let refuse = |value: &Value, kind, run: &str, tenant: &str, now| {
361
+ let failure =
362
+ verify_run_authority(&signer(7), value, kind, run, tenant, now).unwrap_err();
363
+ assert_eq!(failure.code, AUTHORIZATION_DENIED);
364
+ assert!(!failure.retryable);
365
+ };
366
+ refuse(&grant, RunKind::Agent, "agent-2", "tenant", 1_001);
367
+ refuse(&grant, RunKind::Workflow, "agent-1", "tenant", 1_001);
368
+ refuse(&grant, RunKind::Agent, "agent-1", "other-tenant", 1_001);
369
+ refuse(
370
+ &grant,
371
+ RunKind::Agent,
372
+ "agent-1",
373
+ "tenant",
374
+ 1_000 + RUN_AUTHORITY_TTL_SECONDS,
375
+ );
376
+ refuse(&Value::Null, RunKind::Agent, "agent-1", "tenant", 1_001);
377
+ assert_eq!(
378
+ verify_run_authority(
379
+ &signer(8),
380
+ &grant,
381
+ RunKind::Agent,
382
+ "agent-1",
383
+ "tenant",
384
+ 1_001
385
+ )
386
+ .unwrap_err()
387
+ .code,
388
+ AUTHORIZATION_DENIED,
389
+ "a grant from another signer is refused"
390
+ );
391
+ }
392
+
393
+ #[test]
394
+ fn widening_a_signed_run_authority_breaks_its_signature() {
395
+ let mut grant = issued(RunKind::Agent, "agent-1");
396
+ grant["grant"]["capabilities"] = json!(["*"]);
397
+ assert!(verify_run_authority(
398
+ &signer(7),
399
+ &grant,
400
+ RunKind::Agent,
401
+ "agent-1",
402
+ "tenant",
403
+ 1_001
404
+ )
405
+ .is_err());
406
+ let mut grant = issued(RunKind::Agent, "agent-1");
407
+ grant["grant"]["constraints"]["subject_attributes"]["principal_id"] = json!("admin");
408
+ assert!(verify_run_authority(
409
+ &signer(7),
410
+ &grant,
411
+ RunKind::Agent,
412
+ "agent-1",
413
+ "tenant",
414
+ 1_001
415
+ )
416
+ .is_err());
417
+ }
418
+
419
+ #[test]
420
+ fn completion_criteria_verify_the_observed_result_only() {
421
+ let count = CompletionCriteria::ResultCount { min: 1, max: None };
422
+ assert!(count.satisfied_by(&json!([{ "id": "a" }])));
423
+ assert!(!count.satisfied_by(&json!([])));
424
+ assert!(!count.satisfied_by(&json!({ "items": [1] })));
425
+ let bounded = CompletionCriteria::ResultCount {
426
+ min: 1,
427
+ max: Some(1),
428
+ };
429
+ assert!(!bounded.satisfied_by(&json!([1, 2])));
430
+ let contains = CompletionCriteria::ResultContains {
431
+ field: "id".into(),
432
+ equals: json!("doc-1"),
433
+ };
434
+ assert!(contains.satisfied_by(&json!([{ "id": "doc-1" }])));
435
+ assert!(!contains.satisfied_by(&json!([{ "id": "doc-2" }])));
436
+ assert!(CompletionCriteria::ResultCount { min: 0, max: None }
437
+ .validate()
438
+ .is_err());
439
+ assert!(CompletionCriteria::ResultCount {
440
+ min: 2,
441
+ max: Some(1)
442
+ }
443
+ .validate()
444
+ .is_err());
445
+ assert!(
446
+ serde_json::from_value::<CompletionCriteria>(json!({ "kind": "goal_achieved" }))
447
+ .is_err()
448
+ );
449
+ assert!(serde_json::from_value::<CompletionCriteria>(
450
+ json!({ "kind": "result_count", "min": 1, "trust": true })
451
+ )
452
+ .is_err());
453
+ }
454
+
455
+ #[test]
456
+ fn effective_scopes_never_exceed_either_side() {
457
+ let set = |values: &[&str]| {
458
+ values
459
+ .iter()
460
+ .map(|v| v.to_string())
461
+ .collect::<BTreeSet<_>>()
462
+ };
463
+ let wide = set(&["*"]);
464
+ let reader = set(&["agents:run", "state:read"]);
465
+ let runner = set(&["agents:run"]);
466
+ assert_eq!(
467
+ effective_scopes(&wide, &runner),
468
+ runner,
469
+ "a wildcard grant narrows to the current key"
470
+ );
471
+ assert_eq!(
472
+ effective_scopes(&runner, &wide),
473
+ runner,
474
+ "a wildcard key does not widen the grant"
475
+ );
476
+ assert_eq!(effective_scopes(&reader, &runner), runner);
477
+ assert_eq!(effective_scopes(&wide, &wide), wide);
478
+ assert!(effective_scopes(&reader, &set(&[])).is_empty());
479
+ assert!(effective_scopes(&set(&[]), &wide).is_empty());
480
+ assert!(effective_scopes(&set(&["state:read"]), &set(&["state:write"])).is_empty());
481
+ }
482
+
483
+ #[test]
484
+ fn only_server_errors_are_retryable() {
485
+ assert!(!classify_capability_error(403, "denied").retryable);
486
+ assert_eq!(
487
+ classify_capability_error(404, "missing").code,
488
+ CAPABILITY_UNKNOWN
489
+ );
490
+ assert_eq!(
491
+ classify_capability_error(400, "bad").code,
492
+ CAPABILITY_INPUT_INVALID
493
+ );
494
+ assert!(classify_capability_error(500, "storage").retryable);
495
+ assert_eq!(max_run_attempts(), 3);
496
+ assert_eq!(retry_delay_ms(2), 1_000);
497
+ assert_eq!(retry_delay_ms(3), 2_000);
498
+ assert_eq!(retry_delay_ms(99), 300_000);
499
+ }
500
+
501
+ #[test]
502
+ fn terminal_runs_are_never_claimable() {
503
+ for status in [STATUS_COMPLETED, STATUS_FAILED] {
504
+ assert!(is_terminal(status));
505
+ assert!(!agent_run_claimable(status, true));
506
+ }
507
+ assert!(agent_run_claimable(STATUS_DELIBERATING, false));
508
+ assert!(!agent_run_claimable(STATUS_RUNNING, false));
509
+ assert!(agent_run_claimable(STATUS_RUNNING, true));
510
+ }
511
+ }
@@ -21,7 +21,7 @@ use crate::{
21
21
  auth::KeyStore, canonical_principals::CanonicalPrincipalAuthority,
22
22
  causal::CanonicalEventAuthority,
23
23
  clock::LeaseClock, cluster::ClusterStore,
24
- connections::ConnectionStore, content::ContentStore, identity::IdentityStore,
24
+ connections::ConnectionStore, console_accounts::ConsoleAccountStore, content::ContentStore, identity::IdentityStore,
25
25
  leases::LeaseStore, metrics::Metrics, portable_bundle::BundleStore,
26
26
  providers::ProviderStore, releases::ReleaseStore, sessions::SessionVerifier,
27
27
  tenancy::TenancyStore,
@@ -60,6 +60,12 @@ pub struct AppState {
60
60
  /// arbiter; this semaphore only bounds pre-commit working memory.
61
61
  pub state_transaction_admission: Arc<tokio::sync::Semaphore>,
62
62
  pub worker_enabled: bool,
63
+ /// Started with `--work-authority`: this node commits Durable Work even
64
+ /// with peers. See `is_work_authority`.
65
+ pub work_authority: bool,
66
+ /// A peer also reports being the Durable Work authority. Set by peer sync;
67
+ /// while set, this node refuses Durable Work writes (fail closed).
68
+ pub work_authority_conflict: Arc<std::sync::atomic::AtomicBool>,
63
69
  pub leases: LeaseStore,
64
70
  pub content: ContentStore,
65
71
  pub artifacts: ArtifactStore,
@@ -74,6 +80,8 @@ pub struct AppState {
74
80
  pub backup_artifacts: Arc<Vec<(String, PathBuf)>>,
75
81
  pub tenancy: TenancyStore,
76
82
  pub sessions: SessionVerifier,
83
+ /// Console (website) accounts; the only source of console user ids.
84
+ pub console_accounts: ConsoleAccountStore,
77
85
  pub principals: CanonicalPrincipalAuthority,
78
86
  pub connections: ConnectionStore,
79
87
  pub identities: IdentityStore,