@feltdb/core 0.9.0 → 0.10.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 (105) hide show
  1. package/dist/authority-scope.d.ts +2 -0
  2. package/dist/authority-scope.d.ts.map +1 -1
  3. package/dist/authority-scope.js +7 -2
  4. package/dist/cli/commands.js +19 -21
  5. package/dist/cli/development-session.js +16 -0
  6. package/dist/cli/studio-artifact.js +33 -0
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
  9. package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
  10. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
  11. package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
  12. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
  13. package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
  14. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
  15. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
  16. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  17. package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
  18. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
  19. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
  20. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
  21. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
  22. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
  23. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
  24. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
  25. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
  26. package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
  27. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
  28. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
  29. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/main.rs +443 -77
  32. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
  33. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
  34. package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
  35. package/dist/db.d.ts +14 -7
  36. package/dist/db.d.ts.map +1 -1
  37. package/dist/db.js +50 -26
  38. package/dist/deployment.d.ts +2 -0
  39. package/dist/deployment.d.ts.map +1 -1
  40. package/dist/deployment.js +4 -2
  41. package/dist/embedded-transaction.d.ts.map +1 -1
  42. package/dist/embedded-transaction.js +4 -0
  43. package/dist/error-codes.d.ts +12 -0
  44. package/dist/error-codes.d.ts.map +1 -1
  45. package/dist/error-codes.js +20 -2
  46. package/dist/feltdb.d.ts +4 -0
  47. package/dist/feltdb.d.ts.map +1 -1
  48. package/dist/file-db.d.ts +10 -0
  49. package/dist/file-db.d.ts.map +1 -1
  50. package/dist/file-db.js +18 -0
  51. package/dist/http-db.d.ts +7 -0
  52. package/dist/http-db.d.ts.map +1 -1
  53. package/dist/http-db.js +83 -15
  54. package/dist/index-core.d.ts +1 -0
  55. package/dist/index-core.d.ts.map +1 -1
  56. package/dist/index-core.js +1 -0
  57. package/dist/operation-admission.d.ts +2 -0
  58. package/dist/operation-admission.d.ts.map +1 -1
  59. package/dist/public-contract.d.ts +112 -0
  60. package/dist/public-contract.d.ts.map +1 -0
  61. package/dist/public-contract.js +125 -0
  62. package/dist/studio/app.d.ts +23 -0
  63. package/dist/studio/app.d.ts.map +1 -1
  64. package/dist/studio/components/ContractInspector.d.ts +5 -0
  65. package/dist/studio/components/ContractInspector.d.ts.map +1 -1
  66. package/dist/studio/components/SettingsPanel.d.ts +4 -1
  67. package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
  68. package/dist/studio/components/index.js +4 -4
  69. package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
  70. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  71. package/dist/studio/control-plane/Instance.d.ts +10 -0
  72. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  73. package/dist/studio/control-plane/Operations.d.ts +3 -0
  74. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  75. package/dist/studio/control-plane/State.d.ts.map +1 -1
  76. package/dist/studio/control-plane/index.d.ts +4 -2
  77. package/dist/studio/control-plane/index.d.ts.map +1 -1
  78. package/dist/studio/control-plane/index.js +2 -2
  79. package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
  80. package/dist/studio/index.js +96 -75
  81. package/dist/studio/utils/api.d.ts +1 -1
  82. package/dist/studio/utils/api.d.ts.map +1 -1
  83. package/dist/studio/utils/control-plane-api.d.ts +12 -1
  84. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  85. package/dist/studio/utils/control-plane-api.js +1 -1
  86. package/dist/studio/utils/index.js +16 -16
  87. package/dist/studio/utils/service-api.d.ts +8 -0
  88. package/dist/studio/utils/service-api.d.ts.map +1 -1
  89. package/dist/studio/utils/service-api.js +15 -12
  90. package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
  92. package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
  93. package/dist/studio-app/feltdb-studio.json +6 -0
  94. package/dist/studio-app/index.html +1 -1
  95. package/dist/transaction.d.ts +9 -1
  96. package/dist/transaction.d.ts.map +1 -1
  97. package/dist/transaction.js +6 -2
  98. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  99. package/dist/workspace/local-development-authority.d.ts +8 -0
  100. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  101. package/dist/workspace/local-development-authority.js +136 -1
  102. package/package.json +2 -2
  103. package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
  104. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  105. package/dist/studio-app/assets/index-CjELc-ul.js +0 -30
@@ -0,0 +1,1093 @@
1
+ //! Canonical principal authority and one-way legacy import.
2
+ //!
3
+ //! This module is deliberately narrower than the public principal API. It is
4
+ //! the first 0.10A migration primitive: legacy JSON is accepted only as input,
5
+ //! while every accepted record is committed to FeltDB transaction history.
6
+
7
+ use crate::{
8
+ authorization::{authorize, AuthorizationRequest, Subject},
9
+ principals::{PrincipalAuditEvent, PrincipalKind, PrincipalRecord, PrincipalStatus},
10
+ tenancy::TenancyStore,
11
+ };
12
+ use feltdb::{application::ApplicationStore, AtomicMutation, AtomicPrecondition, FeltDb};
13
+ use rand::{distributions::Alphanumeric, Rng};
14
+ use serde::{Deserialize, Serialize};
15
+ use serde_json::{json, Value};
16
+ use sha2::{Digest, Sha256};
17
+ use std::{fs, path::Path};
18
+
19
+ pub const PRINCIPAL_COLLECTION: &str = "system_principals";
20
+ pub const PRINCIPAL_AUDIT_COLLECTION: &str = "system_principal_authorization_decisions";
21
+ pub const APPLICATION_OWNERSHIP_COLLECTION: &str = "system_application_ownership";
22
+ pub const APPLICATION_OWNERSHIP_MIGRATION_VERSION: &str =
23
+ "application-ownership-backfill/v1";
24
+
25
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26
+ pub struct CanonicalApplicationOwnership {
27
+ pub application_id: String,
28
+ pub tenant_id: String,
29
+ pub source_authority: String,
30
+ pub source_record_id: String,
31
+ pub source_digest: String,
32
+ pub authorization_reference: String,
33
+ pub authorized_by: String,
34
+ pub authorized_at: String,
35
+ pub migration_version: String,
36
+ pub reason: String,
37
+ }
38
+
39
+ impl CanonicalApplicationOwnership {
40
+ fn is_valid_for(&self, application_id: &str) -> bool {
41
+ self.application_id == application_id
42
+ && !self.tenant_id.trim().is_empty()
43
+ && !self.source_authority.trim().is_empty()
44
+ && !self.source_record_id.trim().is_empty()
45
+ && self.source_digest.len() == 71
46
+ && self.source_digest.starts_with("sha256:")
47
+ && self.source_digest[7..]
48
+ .bytes()
49
+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
50
+ && !self.authorization_reference.trim().is_empty()
51
+ && !self.authorized_by.trim().is_empty()
52
+ && !self.authorized_at.trim().is_empty()
53
+ && self.migration_version == APPLICATION_OWNERSHIP_MIGRATION_VERSION
54
+ && !self.reason.trim().is_empty()
55
+ }
56
+ }
57
+
58
+ #[derive(Debug, Clone, Serialize, Deserialize)]
59
+ pub struct CanonicalPrincipal {
60
+ pub tenant_id: String,
61
+ pub application_id: String,
62
+ pub principal: PrincipalRecord,
63
+ pub source_digest: String,
64
+ }
65
+
66
+ #[derive(Debug, Clone, Serialize, Deserialize)]
67
+ struct CanonicalPrincipalAudit {
68
+ tenant_id: String,
69
+ application_id: String,
70
+ event: PrincipalAuditEvent,
71
+ source_digest: String,
72
+ }
73
+
74
+ #[derive(Debug, Default, Serialize, Deserialize)]
75
+ struct LegacyPrincipalRecords {
76
+ #[serde(default)]
77
+ principals: Vec<PrincipalRecord>,
78
+ #[serde(default)]
79
+ audit: Vec<PrincipalAuditEvent>,
80
+ }
81
+
82
+ #[derive(Debug, Clone, PartialEq, Eq)]
83
+ pub struct PrincipalMigrationEvidence {
84
+ pub principals: usize,
85
+ pub audit_events: usize,
86
+ pub verified_existing: usize,
87
+ pub committed: usize,
88
+ }
89
+
90
+ /// Resolve principal scope only from canonical application ownership facts.
91
+ /// Tenancy registration and committed application-definition records are
92
+ /// independent observations of the same relationship and must agree.
93
+ pub fn resolve_principal_tenant(
94
+ application_id: &str,
95
+ tenancy: &TenancyStore,
96
+ applications: &ApplicationStore,
97
+ db: &FeltDb,
98
+ ) -> Result<String, String> {
99
+ let mut ownership_candidates = applications.tenant_ownership_candidates(application_id);
100
+ if let Some(value) = db
101
+ .get_value(&application_ownership_key(application_id))
102
+ .map_err(|error| error.to_string())?
103
+ {
104
+ let ownership: CanonicalApplicationOwnership = serde_json::from_value(value).map_err(|_| {
105
+ format!(
106
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:INVALID_OWNERSHIP:{application_id}"
107
+ )
108
+ })?;
109
+ if !ownership.is_valid_for(application_id) {
110
+ return Err(format!(
111
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:INVALID_OWNERSHIP:{application_id}"
112
+ ));
113
+ }
114
+ ownership_candidates.push(ownership.tenant_id);
115
+ }
116
+ let tenant = resolve_principal_tenant_candidates(
117
+ application_id,
118
+ tenancy.application_tenant_candidates(application_id),
119
+ ownership_candidates,
120
+ )?;
121
+ if !tenancy.tenant_exists(&tenant) {
122
+ return Err(format!(
123
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:MISSING_TENANT:{application_id}:{tenant}"
124
+ ));
125
+ }
126
+ Ok(tenant)
127
+ }
128
+
129
+ pub fn application_ownership_key(application_id: &str) -> String {
130
+ format!("{APPLICATION_OWNERSHIP_COLLECTION}:{application_id}")
131
+ }
132
+
133
+ fn resolve_principal_tenant_candidates(
134
+ application_id: &str,
135
+ tenancy_candidates: Vec<String>,
136
+ application_candidates: Vec<String>,
137
+ ) -> Result<String, String> {
138
+ let candidates = tenancy_candidates
139
+ .into_iter()
140
+ .chain(application_candidates)
141
+ .filter(|tenant| !tenant.trim().is_empty())
142
+ .collect::<std::collections::BTreeSet<_>>();
143
+ match candidates.len() {
144
+ 0 => Err(format!(
145
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:MISSING_OWNERSHIP:{application_id}"
146
+ )),
147
+ 1 => Ok(candidates.into_iter().next().expect("one candidate")),
148
+ _ => Err(format!(
149
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:AMBIGUOUS_OWNERSHIP:{application_id}"
150
+ )),
151
+ }
152
+ }
153
+
154
+ #[derive(Clone)]
155
+ pub struct CanonicalPrincipalAuthority {
156
+ db: FeltDb,
157
+ }
158
+
159
+ impl CanonicalPrincipalAuthority {
160
+ pub fn new(db: FeltDb) -> Self {
161
+ Self { db }
162
+ }
163
+
164
+ pub fn get(
165
+ &self,
166
+ tenant_id: &str,
167
+ application_id: &str,
168
+ principal_id: &str,
169
+ ) -> Result<Option<CanonicalPrincipal>, String> {
170
+ self.db
171
+ .get_collection_record(
172
+ PRINCIPAL_COLLECTION,
173
+ &principal_key(tenant_id, application_id, principal_id),
174
+ )
175
+ .map_err(|error| error.to_string())?
176
+ .map(|row| serde_json::from_value(row.value).map_err(|error| error.to_string()))
177
+ .transpose()
178
+ }
179
+
180
+ pub fn list_canonical(
181
+ &self,
182
+ tenant_id: &str,
183
+ application_id: &str,
184
+ ) -> Result<Vec<CanonicalPrincipal>, String> {
185
+ let mut records = self
186
+ .db
187
+ .query_collection(PRINCIPAL_COLLECTION, None, |row| {
188
+ row.value.get("tenant_id").and_then(Value::as_str) == Some(tenant_id)
189
+ && row.value.get("application_id").and_then(Value::as_str)
190
+ == Some(application_id)
191
+ })
192
+ .map_err(|error| error.to_string())?
193
+ .into_iter()
194
+ .map(|row| serde_json::from_value(row.value).map_err(|error| error.to_string()))
195
+ .collect::<Result<Vec<_>, _>>()?;
196
+ records
197
+ .sort_by(|left: &CanonicalPrincipal, right| left.principal.id.cmp(&right.principal.id));
198
+ Ok(records)
199
+ }
200
+
201
+ pub fn list(
202
+ &self,
203
+ actor: &str,
204
+ tenancy: &TenancyStore,
205
+ application_id: &str,
206
+ ) -> Result<Vec<PrincipalRecord>, String> {
207
+ let tenant_id = application_context(tenancy, application_id)?;
208
+ if !tenancy.authorize_application(actor, &tenant_id, application_id, "applications:read") {
209
+ return Err("forbidden".into());
210
+ }
211
+ Ok(self
212
+ .list_canonical(&tenant_id, application_id)?
213
+ .into_iter()
214
+ .map(|value| value.principal)
215
+ .collect())
216
+ }
217
+
218
+ pub fn create(
219
+ &self,
220
+ actor: &str,
221
+ tenancy: &TenancyStore,
222
+ application_id: &str,
223
+ kind: PrincipalKind,
224
+ name: &str,
225
+ ) -> Result<PrincipalRecord, String> {
226
+ let tenant_id = application_context(tenancy, application_id)?;
227
+ let required = match kind {
228
+ PrincipalKind::Agent => "agents:write",
229
+ PrincipalKind::Connection => "connections:write",
230
+ _ => "applications:write",
231
+ };
232
+ if !tenancy.authorize_application(actor, &tenant_id, application_id, required) {
233
+ return Err("forbidden".into());
234
+ }
235
+ let prefix = match kind {
236
+ PrincipalKind::Agent => "agent",
237
+ PrincipalKind::Workload => "workload",
238
+ PrincipalKind::Service => "service",
239
+ PrincipalKind::Worker => "worker",
240
+ PrincipalKind::Connection => "connection",
241
+ };
242
+ let principal = PrincipalRecord {
243
+ id: runtime_id(prefix),
244
+ application_id: application_id.into(),
245
+ kind,
246
+ name: name.into(),
247
+ version: 1,
248
+ status: PrincipalStatus::Active,
249
+ created_by: actor.into(),
250
+ created_at: now(),
251
+ capabilities: vec![],
252
+ parent_id: None,
253
+ worker_id: None,
254
+ started_at: None,
255
+ completed_at: None,
256
+ };
257
+ self.commit_principal(
258
+ &tenant_id,
259
+ &principal,
260
+ None,
261
+ PrincipalAuditEvent {
262
+ event: "principal.created".into(),
263
+ actor: actor.into(),
264
+ principal_id: principal.id.clone(),
265
+ application_id: application_id.into(),
266
+ capability: None,
267
+ decision: None,
268
+ at: now(),
269
+ },
270
+ )?;
271
+ Ok(principal)
272
+ }
273
+
274
+ pub fn grant(
275
+ &self,
276
+ actor: &str,
277
+ tenancy: &TenancyStore,
278
+ principal_id: &str,
279
+ capability: &str,
280
+ ) -> Result<PrincipalRecord, String> {
281
+ if capability == "secrets:plaintext" {
282
+ return Err("plaintext secret access cannot be granted".into());
283
+ }
284
+ let (tenant_id, mut principal, sequence) = self.find(principal_id)?;
285
+ if actor == principal_id
286
+ || !tenancy.authorize_application(
287
+ actor,
288
+ &tenant_id,
289
+ &principal.application_id,
290
+ "applications:write",
291
+ )
292
+ {
293
+ return Err("forbidden".into());
294
+ }
295
+ if !principal.capabilities.iter().any(|held| held == capability) {
296
+ principal.capabilities.push(capability.into());
297
+ principal.version += 1;
298
+ }
299
+ self.commit_principal(
300
+ &tenant_id,
301
+ &principal,
302
+ Some(sequence),
303
+ PrincipalAuditEvent {
304
+ event: "principal.capability_granted".into(),
305
+ actor: actor.into(),
306
+ principal_id: principal.id.clone(),
307
+ application_id: principal.application_id.clone(),
308
+ capability: Some(capability.into()),
309
+ decision: None,
310
+ at: now(),
311
+ },
312
+ )?;
313
+ Ok(principal)
314
+ }
315
+
316
+ pub fn authorize(&self, principal_id: &str, application_id: &str, capability: &str) -> bool {
317
+ let Ok((tenant_id, principal, _)) = self.find(principal_id) else {
318
+ return false;
319
+ };
320
+ let active = matches!(
321
+ principal.status,
322
+ PrincipalStatus::Active | PrincipalStatus::Running
323
+ ) && principal.application_id == application_id;
324
+ let allowed = active
325
+ && capability != "secrets:plaintext"
326
+ && authorize(&AuthorizationRequest {
327
+ subject: &kind_subject(&principal),
328
+ tenant_id: &tenant_id,
329
+ application_id: Some(application_id),
330
+ capability,
331
+ direct_grants: &principal.capabilities,
332
+ memberships: &[],
333
+ roles: &[],
334
+ })
335
+ .allowed;
336
+ if self
337
+ .commit_audit(
338
+ &tenant_id,
339
+ PrincipalAuditEvent {
340
+ event: "principal.authorized".into(),
341
+ actor: principal_id.into(),
342
+ principal_id: principal_id.into(),
343
+ application_id: application_id.into(),
344
+ capability: Some(capability.into()),
345
+ decision: Some(allowed),
346
+ at: now(),
347
+ },
348
+ )
349
+ .is_err()
350
+ {
351
+ return false;
352
+ }
353
+ allowed
354
+ }
355
+
356
+ pub fn create_workload(
357
+ &self,
358
+ actor: &str,
359
+ tenancy: &TenancyStore,
360
+ agent_id: &str,
361
+ ) -> Result<PrincipalRecord, String> {
362
+ let (tenant_id, agent, _) = self.find(agent_id)?;
363
+ if agent.kind != PrincipalKind::Agent {
364
+ return Err("agent not found".into());
365
+ }
366
+ if !tenancy.authorize_application(
367
+ actor,
368
+ &tenant_id,
369
+ &agent.application_id,
370
+ "workloads:execute",
371
+ ) && !tenancy.authorize_application(
372
+ actor,
373
+ &tenant_id,
374
+ &agent.application_id,
375
+ "applications:write",
376
+ ) {
377
+ return Err("forbidden".into());
378
+ }
379
+ let principal = PrincipalRecord {
380
+ id: runtime_id("workload"),
381
+ application_id: agent.application_id.clone(),
382
+ kind: PrincipalKind::Workload,
383
+ name: format!("{} execution", agent.name),
384
+ version: agent.version,
385
+ status: PrincipalStatus::Running,
386
+ created_by: actor.into(),
387
+ created_at: now(),
388
+ capabilities: agent.capabilities,
389
+ parent_id: Some(agent.id),
390
+ worker_id: None,
391
+ started_at: Some(now()),
392
+ completed_at: None,
393
+ };
394
+ self.commit_principal(
395
+ &tenant_id,
396
+ &principal,
397
+ None,
398
+ PrincipalAuditEvent {
399
+ event: "workload.created".into(),
400
+ actor: actor.into(),
401
+ principal_id: principal.id.clone(),
402
+ application_id: principal.application_id.clone(),
403
+ capability: None,
404
+ decision: None,
405
+ at: now(),
406
+ },
407
+ )?;
408
+ Ok(principal)
409
+ }
410
+
411
+ pub fn assign_worker(
412
+ &self,
413
+ actor: &str,
414
+ tenancy: &TenancyStore,
415
+ workload_id: &str,
416
+ worker_id: &str,
417
+ ) -> Result<(), String> {
418
+ let (tenant_id, mut workload, sequence) = self.find(workload_id)?;
419
+ let (worker_tenant, worker, _) = self.find(worker_id)?;
420
+ if workload.kind != PrincipalKind::Workload {
421
+ return Err("workload not found".into());
422
+ }
423
+ if worker.kind != PrincipalKind::Worker {
424
+ return Err("worker not found".into());
425
+ }
426
+ if worker_tenant != tenant_id || worker.application_id != workload.application_id {
427
+ return Err("cross-application worker assignment denied".into());
428
+ }
429
+ if !tenancy.authorize_application(
430
+ actor,
431
+ &tenant_id,
432
+ &workload.application_id,
433
+ "applications:write",
434
+ ) {
435
+ return Err("forbidden".into());
436
+ }
437
+ workload.worker_id = Some(worker_id.into());
438
+ workload.version += 1;
439
+ self.commit_principal(
440
+ &tenant_id,
441
+ &workload,
442
+ Some(sequence),
443
+ PrincipalAuditEvent {
444
+ event: "workload.worker_assigned".into(),
445
+ actor: actor.into(),
446
+ principal_id: workload.id.clone(),
447
+ application_id: workload.application_id.clone(),
448
+ capability: None,
449
+ decision: None,
450
+ at: now(),
451
+ },
452
+ )
453
+ }
454
+
455
+ pub fn worker_can_execute(&self, worker_id: &str, workload_id: &str) -> bool {
456
+ self.find(workload_id).is_ok_and(|(_, workload, _)| {
457
+ workload.kind == PrincipalKind::Workload
458
+ && workload.worker_id.as_deref() == Some(worker_id)
459
+ && workload.status == PrincipalStatus::Running
460
+ })
461
+ }
462
+
463
+ pub fn execution_snapshot(
464
+ &self,
465
+ worker_id: &str,
466
+ workload_id: &str,
467
+ ) -> Option<(String, String, Vec<String>)> {
468
+ let (_, workload, _) = self.find(workload_id).ok()?;
469
+ (workload.kind == PrincipalKind::Workload
470
+ && workload.worker_id.as_deref() == Some(worker_id)
471
+ && workload.status == PrincipalStatus::Running)
472
+ .then(|| (workload.application_id, workload.id, workload.capabilities))
473
+ }
474
+
475
+ pub fn audit_for(
476
+ &self,
477
+ actor: &str,
478
+ tenancy: &TenancyStore,
479
+ application_id: &str,
480
+ principal_id: Option<&str>,
481
+ limit: usize,
482
+ ) -> Result<Vec<PrincipalAuditEvent>, String> {
483
+ let tenant_id = application_context(tenancy, application_id)?;
484
+ if !tenancy.authorize_application(actor, &tenant_id, application_id, "applications:read") {
485
+ return Err("forbidden".into());
486
+ }
487
+ let mut events = self
488
+ .db
489
+ .query_collection(PRINCIPAL_AUDIT_COLLECTION, None, |row| {
490
+ row.value.get("tenant_id").and_then(Value::as_str) == Some(&tenant_id)
491
+ && row.value.get("application_id").and_then(Value::as_str)
492
+ == Some(application_id)
493
+ && principal_id.is_none_or(|id| {
494
+ row.value
495
+ .get("event")
496
+ .and_then(|event| event.get("principal_id"))
497
+ .and_then(Value::as_str)
498
+ == Some(id)
499
+ })
500
+ })
501
+ .map_err(|error| error.to_string())?
502
+ .into_iter()
503
+ .map(|row| {
504
+ serde_json::from_value::<CanonicalPrincipalAudit>(row.value)
505
+ .map(|value| value.event)
506
+ .map_err(|error| error.to_string())
507
+ })
508
+ .collect::<Result<Vec<_>, _>>()?;
509
+ events.sort_by_key(|event| std::cmp::Reverse(event.at));
510
+ events.truncate(limit);
511
+ Ok(events)
512
+ }
513
+
514
+ /// Import a legacy file in one direction only.
515
+ ///
516
+ /// The caller resolves application-to-tenant ownership from the canonical
517
+ /// tenancy authority. Existing identical records are verified. An identity
518
+ /// whose canonical digest differs is a hard conflict and is never replaced.
519
+ pub fn migrate_legacy_file(
520
+ &self,
521
+ path: &Path,
522
+ tenant_for_application: impl Fn(&str) -> Result<String, String>,
523
+ ) -> Result<PrincipalMigrationEvidence, String> {
524
+ let bytes = fs::read(path).map_err(|error| error.to_string())?;
525
+ let legacy: LegacyPrincipalRecords =
526
+ serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
527
+ let mut evidence = PrincipalMigrationEvidence {
528
+ principals: legacy.principals.len(),
529
+ audit_events: legacy.audit.len(),
530
+ verified_existing: 0,
531
+ committed: 0,
532
+ };
533
+
534
+ for principal in legacy.principals {
535
+ let tenant_id = tenant_for_application(&principal.application_id)?;
536
+ let source_digest = digest(&principal)?;
537
+ let canonical = CanonicalPrincipal {
538
+ tenant_id: tenant_id.clone(),
539
+ application_id: principal.application_id.clone(),
540
+ principal: principal.clone(),
541
+ source_digest: source_digest.clone(),
542
+ };
543
+ let key = principal_key(&tenant_id, &principal.application_id, &principal.id);
544
+ if let Some(existing) =
545
+ self.get(&tenant_id, &principal.application_id, &principal.id)?
546
+ {
547
+ if existing.source_digest != source_digest
548
+ || digest(&existing.principal)? != source_digest
549
+ {
550
+ return Err(format!("PRINCIPAL_MIGRATION_CONFLICT:{}", principal.id));
551
+ }
552
+ evidence.verified_existing += 1;
553
+ continue;
554
+ }
555
+ let transaction_id = migration_transaction_id(
556
+ "principal",
557
+ &tenant_id,
558
+ &principal.application_id,
559
+ &source_digest,
560
+ );
561
+ self.commit_insert(
562
+ &transaction_id,
563
+ PRINCIPAL_COLLECTION,
564
+ &key,
565
+ serde_json::to_value(&canonical).map_err(|error| error.to_string())?,
566
+ json!({
567
+ "kind": "principal_migration",
568
+ "subject": principal.created_by,
569
+ "tenant": tenant_id,
570
+ "application": principal.application_id,
571
+ "principal_id": principal.id,
572
+ "agent_id": (principal.kind == PrincipalKind::Agent).then(|| principal.id.as_str()),
573
+ "delegator_id": principal.parent_id.as_deref().unwrap_or(&principal.created_by),
574
+ "operation_id": transaction_id,
575
+ "source_digest": source_digest,
576
+ }),
577
+ )?;
578
+ evidence.committed += 1;
579
+ }
580
+
581
+ for (position, event) in legacy.audit.into_iter().enumerate() {
582
+ let tenant_id = tenant_for_application(&event.application_id)?;
583
+ let source_digest = digest(&event)?;
584
+ let key = audit_key(
585
+ &tenant_id,
586
+ &event.application_id,
587
+ &event.principal_id,
588
+ &source_digest,
589
+ );
590
+ let value = CanonicalPrincipalAudit {
591
+ tenant_id: tenant_id.clone(),
592
+ application_id: event.application_id.clone(),
593
+ event: event.clone(),
594
+ source_digest: source_digest.clone(),
595
+ };
596
+ if let Some(existing) = self
597
+ .db
598
+ .get_collection_record(PRINCIPAL_AUDIT_COLLECTION, &key)
599
+ .map_err(|error| error.to_string())?
600
+ {
601
+ let existing: CanonicalPrincipalAudit =
602
+ serde_json::from_value(existing.value).map_err(|error| error.to_string())?;
603
+ if existing.source_digest != source_digest {
604
+ return Err(format!(
605
+ "PRINCIPAL_AUDIT_MIGRATION_CONFLICT:{}:{}",
606
+ event.principal_id, position
607
+ ));
608
+ }
609
+ evidence.verified_existing += 1;
610
+ continue;
611
+ }
612
+ self.commit_insert(
613
+ &migration_transaction_id(
614
+ "principal_audit",
615
+ &tenant_id,
616
+ &event.application_id,
617
+ &source_digest,
618
+ ),
619
+ PRINCIPAL_AUDIT_COLLECTION,
620
+ &key,
621
+ serde_json::to_value(value).map_err(|error| error.to_string())?,
622
+ json!({
623
+ "kind": "principal_audit_migration",
624
+ "subject": event.actor,
625
+ "tenant": tenant_id,
626
+ "application": event.application_id,
627
+ "principal_id": event.principal_id,
628
+ "source_digest": source_digest,
629
+ }),
630
+ )?;
631
+ evidence.committed += 1;
632
+ }
633
+ Ok(evidence)
634
+ }
635
+
636
+ fn find(&self, principal_id: &str) -> Result<(String, PrincipalRecord, u64), String> {
637
+ let mut rows = self
638
+ .db
639
+ .query_collection(PRINCIPAL_COLLECTION, Some(2), |row| {
640
+ row.value
641
+ .get("principal")
642
+ .and_then(|principal| principal.get("id"))
643
+ .and_then(Value::as_str)
644
+ == Some(principal_id)
645
+ })
646
+ .map_err(|error| error.to_string())?;
647
+ if rows.len() > 1 {
648
+ return Err(format!("ambiguous principal identity: {principal_id}"));
649
+ }
650
+ let row = rows
651
+ .pop()
652
+ .ok_or_else(|| "principal not found".to_string())?;
653
+ let sequence = row
654
+ .operation
655
+ .as_ref()
656
+ .map(|operation| operation.sequence)
657
+ .ok_or_else(|| "canonical principal has no operation identity".to_string())?;
658
+ let value: CanonicalPrincipal =
659
+ serde_json::from_value(row.value).map_err(|error| error.to_string())?;
660
+ Ok((value.tenant_id, value.principal, sequence))
661
+ }
662
+
663
+ fn commit_principal(
664
+ &self,
665
+ tenant_id: &str,
666
+ principal: &PrincipalRecord,
667
+ expected_version: Option<u64>,
668
+ event: PrincipalAuditEvent,
669
+ ) -> Result<(), String> {
670
+ let transaction_id = runtime_id("principal_tx");
671
+ let principal_key = principal_key(tenant_id, &principal.application_id, &principal.id);
672
+ let audit_key = format!(
673
+ "{}/{}/{}/{}",
674
+ tenant_id, principal.application_id, principal.id, transaction_id
675
+ );
676
+ let canonical = CanonicalPrincipal {
677
+ tenant_id: tenant_id.into(),
678
+ application_id: principal.application_id.clone(),
679
+ principal: principal.clone(),
680
+ source_digest: digest(principal)?,
681
+ };
682
+ let audit = CanonicalPrincipalAudit {
683
+ tenant_id: tenant_id.into(),
684
+ application_id: principal.application_id.clone(),
685
+ event: event.clone(),
686
+ source_digest: digest(&event)?,
687
+ };
688
+ self.db
689
+ .apply_atomic_transaction(
690
+ &transaction_id,
691
+ None,
692
+ &[
693
+ AtomicPrecondition {
694
+ capability: PRINCIPAL_COLLECTION.into(),
695
+ key: principal_key.clone(),
696
+ expected_version,
697
+ },
698
+ AtomicPrecondition {
699
+ capability: PRINCIPAL_AUDIT_COLLECTION.into(),
700
+ key: audit_key.clone(),
701
+ expected_version: None,
702
+ },
703
+ ],
704
+ &[
705
+ AtomicMutation {
706
+ capability: PRINCIPAL_COLLECTION.into(),
707
+ key: principal_key,
708
+ value: Some(
709
+ serde_json::to_value(canonical).map_err(|error| error.to_string())?,
710
+ ),
711
+ },
712
+ AtomicMutation {
713
+ capability: PRINCIPAL_AUDIT_COLLECTION.into(),
714
+ key: audit_key,
715
+ value: Some(
716
+ serde_json::to_value(audit).map_err(|error| error.to_string())?,
717
+ ),
718
+ },
719
+ ],
720
+ Some(json!({
721
+ "kind": event.event,
722
+ "subject": event.actor,
723
+ "tenant": tenant_id,
724
+ "application": principal.application_id,
725
+ "principal_id": principal.id,
726
+ })),
727
+ )
728
+ .map(|_| ())
729
+ .map_err(|error| error.to_string())
730
+ }
731
+
732
+ fn commit_audit(&self, tenant_id: &str, event: PrincipalAuditEvent) -> Result<(), String> {
733
+ let transaction_id = runtime_id("principal_decision_tx");
734
+ let key = format!(
735
+ "{}/{}/{}/{}",
736
+ tenant_id, event.application_id, event.principal_id, transaction_id
737
+ );
738
+ let audit = CanonicalPrincipalAudit {
739
+ tenant_id: tenant_id.into(),
740
+ application_id: event.application_id.clone(),
741
+ source_digest: digest(&event)?,
742
+ event: event.clone(),
743
+ };
744
+ self.commit_insert(
745
+ &transaction_id,
746
+ PRINCIPAL_AUDIT_COLLECTION,
747
+ &key,
748
+ serde_json::to_value(audit).map_err(|error| error.to_string())?,
749
+ json!({
750
+ "kind": event.event,
751
+ "subject": event.actor,
752
+ "tenant": tenant_id,
753
+ "application": event.application_id,
754
+ "principal_id": event.principal_id,
755
+ "delegator_id": event.actor,
756
+ "operation_id": transaction_id,
757
+ }),
758
+ )
759
+ }
760
+
761
+ fn commit_insert(
762
+ &self,
763
+ transaction_id: &str,
764
+ collection: &str,
765
+ key: &str,
766
+ value: Value,
767
+ audit: Value,
768
+ ) -> Result<(), String> {
769
+ self.db
770
+ .apply_atomic_transaction(
771
+ transaction_id,
772
+ None,
773
+ &[AtomicPrecondition {
774
+ capability: collection.into(),
775
+ key: key.into(),
776
+ expected_version: None,
777
+ }],
778
+ &[AtomicMutation {
779
+ capability: collection.into(),
780
+ key: key.into(),
781
+ value: Some(value),
782
+ }],
783
+ Some(audit),
784
+ )
785
+ .map(|_| ())
786
+ .map_err(|error| error.to_string())
787
+ }
788
+ }
789
+
790
+ fn principal_key(tenant: &str, application: &str, principal: &str) -> String {
791
+ format!("{tenant}/{application}/{principal}")
792
+ }
793
+
794
+ fn application_context(tenancy: &TenancyStore, application_id: &str) -> Result<String, String> {
795
+ tenancy
796
+ .application_tenant(application_id)
797
+ .ok_or_else(|| "application not found".into())
798
+ }
799
+
800
+ fn kind_subject(record: &PrincipalRecord) -> Subject {
801
+ match record.kind {
802
+ PrincipalKind::Agent => Subject::Agent(record.id.clone()),
803
+ PrincipalKind::Workload => Subject::Workload(record.id.clone()),
804
+ PrincipalKind::Service => Subject::Service(record.id.clone()),
805
+ PrincipalKind::Worker => Subject::Worker(record.id.clone()),
806
+ PrincipalKind::Connection => Subject::Connection(record.id.clone()),
807
+ }
808
+ }
809
+
810
+ fn runtime_id(prefix: &str) -> String {
811
+ let value: String = rand::thread_rng()
812
+ .sample_iter(&Alphanumeric)
813
+ .take(24)
814
+ .map(char::from)
815
+ .collect();
816
+ format!("{prefix}_{value}")
817
+ }
818
+
819
+ fn now() -> u64 {
820
+ std::time::SystemTime::now()
821
+ .duration_since(std::time::UNIX_EPOCH)
822
+ .unwrap_or_default()
823
+ .as_secs()
824
+ }
825
+
826
+ fn audit_key(tenant: &str, application: &str, principal: &str, digest: &str) -> String {
827
+ format!("{tenant}/{application}/{principal}/{}", &digest[7..])
828
+ }
829
+
830
+ fn digest(value: &impl Serialize) -> Result<String, String> {
831
+ let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?;
832
+ Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
833
+ }
834
+
835
+ fn migration_transaction_id(kind: &str, tenant: &str, application: &str, digest: &str) -> String {
836
+ let scoped = Sha256::digest(format!("{kind}\0{tenant}\0{application}\0{digest}").as_bytes());
837
+ format!("migrate_{kind}_{scoped:x}")
838
+ }
839
+
840
+ #[cfg(test)]
841
+ mod tests {
842
+ use super::*;
843
+ use crate::principals::{PrincipalKind, PrincipalStatus};
844
+ use std::time::{SystemTime, UNIX_EPOCH};
845
+
846
+ fn path(label: &str) -> std::path::PathBuf {
847
+ std::env::temp_dir().join(format!(
848
+ "feltdb-canonical-principal-{label}-{}-{}",
849
+ std::process::id(),
850
+ SystemTime::now()
851
+ .duration_since(UNIX_EPOCH)
852
+ .unwrap()
853
+ .as_nanos()
854
+ ))
855
+ }
856
+
857
+ fn principal() -> PrincipalRecord {
858
+ PrincipalRecord {
859
+ id: "agent_stable".into(),
860
+ application_id: "app_a".into(),
861
+ kind: PrincipalKind::Agent,
862
+ name: "Stable Agent".into(),
863
+ version: 3,
864
+ status: PrincipalStatus::Active,
865
+ created_by: "principal_delegator".into(),
866
+ created_at: 100,
867
+ capabilities: vec!["state:read".into()],
868
+ parent_id: None,
869
+ worker_id: None,
870
+ started_at: None,
871
+ completed_at: None,
872
+ }
873
+ }
874
+
875
+ #[test]
876
+ fn migration_is_idempotent_and_survives_deleting_the_source() {
877
+ let db_path = path("db");
878
+ let legacy_path = path("principals.json");
879
+ let records = LegacyPrincipalRecords {
880
+ principals: vec![principal()],
881
+ audit: vec![],
882
+ };
883
+ fs::write(&legacy_path, serde_json::to_vec(&records).unwrap()).unwrap();
884
+
885
+ let authority = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
886
+ let first = authority
887
+ .migrate_legacy_file(&legacy_path, |_| Ok("tenant_a".into()))
888
+ .unwrap();
889
+ assert_eq!(first.committed, 1);
890
+ let second = authority
891
+ .migrate_legacy_file(&legacy_path, |_| Ok("tenant_a".into()))
892
+ .unwrap();
893
+ assert_eq!(second.verified_existing, 1);
894
+ fs::remove_file(&legacy_path).unwrap();
895
+ drop(authority);
896
+
897
+ let reopened = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
898
+ let recovered = reopened
899
+ .get("tenant_a", "app_a", "agent_stable")
900
+ .unwrap()
901
+ .unwrap();
902
+ assert_eq!(recovered.principal.created_by, "principal_delegator");
903
+ assert_eq!(recovered.principal.version, 3);
904
+ }
905
+
906
+ #[test]
907
+ fn migration_detects_identity_conflicts() {
908
+ let db_path = path("conflict-db");
909
+ let legacy_path = path("conflict-principals.json");
910
+ let records = LegacyPrincipalRecords {
911
+ principals: vec![principal()],
912
+ audit: vec![],
913
+ };
914
+ fs::write(&legacy_path, serde_json::to_vec(&records).unwrap()).unwrap();
915
+ let authority = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
916
+ authority
917
+ .migrate_legacy_file(&legacy_path, |_| Ok("tenant_a".into()))
918
+ .unwrap();
919
+ let mut changed = principal();
920
+ changed.name = "Different".into();
921
+ fs::write(
922
+ &legacy_path,
923
+ serde_json::to_vec(&LegacyPrincipalRecords {
924
+ principals: vec![changed],
925
+ audit: vec![],
926
+ })
927
+ .unwrap(),
928
+ )
929
+ .unwrap();
930
+ let error = authority
931
+ .migrate_legacy_file(&legacy_path, |_| Ok("tenant_a".into()))
932
+ .unwrap_err();
933
+ assert_eq!(error, "PRINCIPAL_MIGRATION_CONFLICT:agent_stable");
934
+ }
935
+
936
+ #[test]
937
+ fn principal_tenant_resolution_accepts_one_canonical_owner() {
938
+ assert_eq!(
939
+ resolve_principal_tenant_candidates(
940
+ "app_pKEN4gJ0lvQV4D2ZWa",
941
+ vec![],
942
+ vec!["tenant_a".into(), "tenant_a".into()],
943
+ )
944
+ .unwrap(),
945
+ "tenant_a"
946
+ );
947
+ assert_eq!(
948
+ resolve_principal_tenant_candidates(
949
+ "app_a",
950
+ vec!["tenant_a".into()],
951
+ vec!["tenant_a".into()],
952
+ )
953
+ .unwrap(),
954
+ "tenant_a"
955
+ );
956
+ }
957
+
958
+ #[test]
959
+ fn principal_missing_tenant_and_application_fail_closed() {
960
+ let error = resolve_principal_tenant_candidates("app_missing", vec![], vec![])
961
+ .unwrap_err();
962
+ assert_eq!(
963
+ error,
964
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:MISSING_OWNERSHIP:app_missing"
965
+ );
966
+ }
967
+
968
+ #[test]
969
+ fn principal_ambiguous_tenant_and_cross_tenant_reference_fail_closed() {
970
+ for (tenancy, applications) in [
971
+ (vec!["tenant_a".into(), "tenant_b".into()], vec![]),
972
+ (vec!["tenant_a".into()], vec!["tenant_b".into()]),
973
+ ] {
974
+ let error = resolve_principal_tenant_candidates("app_a", tenancy, applications)
975
+ .unwrap_err();
976
+ assert_eq!(
977
+ error,
978
+ "PRINCIPAL_TENANT_MIGRATION_CONFLICT:AMBIGUOUS_OWNERSHIP:app_a"
979
+ );
980
+ }
981
+ }
982
+
983
+ #[test]
984
+ fn production_shaped_application_definition_resolves_principal_scope() {
985
+ let db_path = path("production-shaped-db");
986
+ let legacy_path = path("production-shaped-principals.json");
987
+ let tenancy_path = path("production-shaped-tenancy.json");
988
+ let applications_path = path("production-shaped-applications.json");
989
+ let tenancy = TenancyStore::load(&tenancy_path).unwrap();
990
+ let tenant = tenancy
991
+ .create_tenant("principal_owner", "Production tenant")
992
+ .unwrap();
993
+ let applications = ApplicationStore::load(&applications_path).unwrap();
994
+ let draft = applications
995
+ .create_draft(
996
+ &tenant.id,
997
+ "app_pKEN4gJ0lvQV4D2ZWa",
998
+ "Sanitized production-shaped application",
999
+ "principal_owner",
1000
+ None,
1001
+ )
1002
+ .unwrap();
1003
+ applications
1004
+ .commit(
1005
+ &tenant.id,
1006
+ "app_pKEN4gJ0lvQV4D2ZWa",
1007
+ &draft.draft_id,
1008
+ "principal_owner",
1009
+ )
1010
+ .unwrap();
1011
+ let mut record = principal();
1012
+ record.application_id = "app_pKEN4gJ0lvQV4D2ZWa".into();
1013
+ fs::write(
1014
+ &legacy_path,
1015
+ serde_json::to_vec(&LegacyPrincipalRecords {
1016
+ principals: vec![record],
1017
+ audit: vec![],
1018
+ })
1019
+ .unwrap(),
1020
+ )
1021
+ .unwrap();
1022
+ let authority = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
1023
+ authority
1024
+ .migrate_legacy_file(&legacy_path, |application_id| {
1025
+ resolve_principal_tenant(application_id, &tenancy, &applications, &authority.db)
1026
+ })
1027
+ .unwrap();
1028
+ fs::remove_file(&legacy_path).unwrap();
1029
+ drop(authority);
1030
+ let reopened = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
1031
+ assert!(reopened
1032
+ .get(
1033
+ &tenant.id,
1034
+ "app_pKEN4gJ0lvQV4D2ZWa",
1035
+ "agent_stable"
1036
+ )
1037
+ .unwrap()
1038
+ .is_some());
1039
+ }
1040
+
1041
+ #[test]
1042
+ fn production_mutations_and_worker_validation_survive_cold_restart() {
1043
+ let db_path = path("production-db");
1044
+ let tenancy_path = path("production-tenancy.json");
1045
+ let tenancy = TenancyStore::load(&tenancy_path).unwrap();
1046
+ let tenant = tenancy.create_tenant("owner", "Canonical").unwrap();
1047
+ let application = tenancy
1048
+ .create_application("owner", &tenant.id, "Application")
1049
+ .unwrap();
1050
+ let authority = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
1051
+ let agent = authority
1052
+ .create(
1053
+ "owner",
1054
+ &tenancy,
1055
+ &application.id,
1056
+ PrincipalKind::Agent,
1057
+ "Agent",
1058
+ )
1059
+ .unwrap();
1060
+ authority
1061
+ .grant("owner", &tenancy, &agent.id, "state:read")
1062
+ .unwrap();
1063
+ let work = authority
1064
+ .create_workload("owner", &tenancy, &agent.id)
1065
+ .unwrap();
1066
+ let worker = authority
1067
+ .create(
1068
+ "owner",
1069
+ &tenancy,
1070
+ &application.id,
1071
+ PrincipalKind::Worker,
1072
+ "Worker",
1073
+ )
1074
+ .unwrap();
1075
+ authority
1076
+ .assign_worker("owner", &tenancy, &work.id, &worker.id)
1077
+ .unwrap();
1078
+ assert!(authority.authorize(&agent.id, &application.id, "state:read"));
1079
+ drop(authority);
1080
+
1081
+ let reopened = CanonicalPrincipalAuthority::new(FeltDb::open(&db_path).unwrap());
1082
+ assert!(reopened.authorize(&agent.id, &application.id, "state:read"));
1083
+ assert!(reopened.worker_can_execute(&worker.id, &work.id));
1084
+ assert_eq!(
1085
+ reopened.execution_snapshot(&worker.id, &work.id).unwrap().0,
1086
+ application.id
1087
+ );
1088
+ assert!(!reopened
1089
+ .audit_for("owner", &tenancy, &application.id, Some(&agent.id), 20)
1090
+ .unwrap()
1091
+ .is_empty());
1092
+ }
1093
+ }