@feltdb/core 0.7.3 → 0.8.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 (147) hide show
  1. package/README.md +25 -0
  2. package/dist/application-development.d.ts +78 -0
  3. package/dist/application-development.d.ts.map +1 -0
  4. package/dist/application-development.js +153 -0
  5. package/dist/application-manifest.d.ts +6 -1
  6. package/dist/application-manifest.d.ts.map +1 -1
  7. package/dist/application-manifest.js +10 -3
  8. package/dist/canonical-application.d.ts +27 -0
  9. package/dist/canonical-application.d.ts.map +1 -0
  10. package/dist/canonical-application.js +46 -0
  11. package/dist/cli/ai-model-config.js +97 -0
  12. package/dist/cli/cli.js +1 -1
  13. package/dist/cli/commands.js +664 -120
  14. package/dist/cli/index.js +1 -1
  15. package/dist/cli/managed-environment.js +6 -0
  16. package/dist/cli/managed-project.js +6 -0
  17. package/dist/cli/proposal-client.js +63 -0
  18. package/dist/cli/publish-engine.js +165 -0
  19. package/dist/cli/source-sync.js +183 -0
  20. package/dist/create/cli.js +11 -2
  21. package/dist/create/create.js +38 -16
  22. package/dist/create/managed-account.js +82 -11
  23. package/dist/create/package-versions.js +1 -1
  24. package/dist/create/server-source/crates/feltdb/src/application.rs +41 -1
  25. package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +84 -6
  26. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
  27. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
  28. package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
  29. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
  30. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
  31. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
  32. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
  33. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
  34. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
  35. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +111 -3
  36. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
  37. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
  38. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1960 -143
  39. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
  40. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
  41. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
  42. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
  43. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +195 -13
  44. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
  45. package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
  46. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
  47. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
  48. package/dist/create/template/default-project/README.md +57 -0
  49. package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
  50. package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
  51. package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
  52. package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
  53. package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
  54. package/dist/create/template/default-project/feltdb.flow +174 -0
  55. package/dist/create/template/default-project/src/App.tsx +20 -0
  56. package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
  57. package/dist/create/template/default-project/src/feltdb.ts +87 -0
  58. package/dist/create/template/default-project/src/index.tsx +14 -0
  59. package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
  60. package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
  61. package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
  62. package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
  63. package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
  64. package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
  65. package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
  66. package/dist/create/template/default-project/src/styles-application.css +21 -0
  67. package/dist/create/template/default-project/src/styles.css +1 -0
  68. package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
  69. package/dist/create/template/default-project/workflows/invitation.ts +30 -0
  70. package/dist/create/template/default-project/workflows/project-created.ts +2 -0
  71. package/dist/db.d.ts +49 -1
  72. package/dist/db.d.ts.map +1 -1
  73. package/dist/db.js +77 -0
  74. package/dist/error-codes.d.ts +20 -1
  75. package/dist/error-codes.d.ts.map +1 -1
  76. package/dist/error-codes.js +25 -0
  77. package/dist/flowspec.d.ts +1 -0
  78. package/dist/flowspec.d.ts.map +1 -1
  79. package/dist/flowspec.js +66 -11
  80. package/dist/http-client.d.ts +135 -14
  81. package/dist/http-client.d.ts.map +1 -1
  82. package/dist/http-client.js +79 -31
  83. package/dist/http-db.d.ts +15 -2
  84. package/dist/http-db.d.ts.map +1 -1
  85. package/dist/http-db.js +113 -5
  86. package/dist/index-core.d.ts +5 -0
  87. package/dist/index-core.d.ts.map +1 -1
  88. package/dist/index-core.js +5 -0
  89. package/dist/module-intent.d.ts +37 -0
  90. package/dist/module-intent.d.ts.map +1 -0
  91. package/dist/module-intent.js +81 -0
  92. package/dist/module.d.ts +76 -0
  93. package/dist/module.d.ts.map +1 -0
  94. package/dist/module.js +61 -0
  95. package/dist/proposal.d.ts +161 -0
  96. package/dist/proposal.d.ts.map +1 -0
  97. package/dist/proposal.js +232 -0
  98. package/dist/studio/app.d.ts +5 -1
  99. package/dist/studio/app.d.ts.map +1 -1
  100. package/dist/studio/components/ApplicationDesigner.d.ts +2 -1
  101. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  102. package/dist/studio/components/AskFeltDB.d.ts +21 -0
  103. package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
  104. package/dist/studio/components/ContractInspector.d.ts +7 -0
  105. package/dist/studio/components/ContractInspector.d.ts.map +1 -0
  106. package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
  107. package/dist/studio/components/ManagedInstancePanel.js +1 -5
  108. package/dist/studio/components/ModuleExplorer.d.ts +6 -0
  109. package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
  110. package/dist/studio/components/ProposalBrowser.d.ts +12 -0
  111. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
  112. package/dist/studio/components/StateExplorer.d.ts +6 -7
  113. package/dist/studio/components/StateExplorer.d.ts.map +1 -1
  114. package/dist/studio/components/index.d.ts +4 -0
  115. package/dist/studio/components/index.d.ts.map +1 -1
  116. package/dist/studio/components/index.js +4 -4
  117. package/dist/studio/components-CNSNrmA9.js +2787 -0
  118. package/dist/studio/index.js +214 -157
  119. package/dist/studio/studio.css +1 -1
  120. package/dist/studio/utils/index.d.ts +2 -0
  121. package/dist/studio/utils/index.d.ts.map +1 -1
  122. package/dist/studio/utils/index.js +14 -12
  123. package/dist/studio/utils/managed-instance.d.ts +0 -4
  124. package/dist/studio/utils/managed-instance.d.ts.map +1 -1
  125. package/dist/studio/utils/managed-instance.js +0 -4
  126. package/dist/studio/utils/proposal-api.d.ts +53 -0
  127. package/dist/studio/utils/proposal-api.d.ts.map +1 -0
  128. package/dist/studio/utils/proposal-api.js +91 -0
  129. package/dist/studio/utils/service-api.d.ts +81 -0
  130. package/dist/studio/utils/service-api.d.ts.map +1 -0
  131. package/dist/studio/utils/service-api.js +51 -0
  132. package/dist/studio-app/assets/dist-CCOk39Uc.js +1 -0
  133. package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-CSB6KVgw.js} +1 -1
  134. package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
  135. package/dist/studio-app/assets/index-Bncji3aN.js +43 -0
  136. package/dist/studio-app/assets/{index-C1GWyazR.css → index-yazaWMUN.css} +1 -1
  137. package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
  138. package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -0
  139. package/dist/studio-app/index.html +2 -2
  140. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  141. package/dist/workspace/local-development-authority.d.ts +3 -0
  142. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  143. package/dist/workspace/local-development-authority.js +124 -2
  144. package/package.json +1 -1
  145. package/dist/studio/components-Dxuhrv8_.js +0 -1832
  146. package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
  147. package/dist/studio-app/assets/index-B5tnmvSD.js +0 -28
@@ -1,5 +1,20 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ function loadEnvironment(file) {
4
+ const values = {};
5
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
6
+ const line = raw.trim();
7
+ if (!line || line.startsWith('#'))
8
+ continue;
9
+ const separator = line.indexOf('=');
10
+ if (separator > 0)
11
+ values[line.slice(0, separator)] = line.slice(separator + 1);
12
+ }
13
+ return values;
14
+ }
15
+ function managedStateFile(projectDir) {
16
+ return path.join(projectDir, '.feltdb', 'managed.json');
17
+ }
3
18
  async function responseJson(response, fallback) {
4
19
  const body = await response.json().catch(() => ({}));
5
20
  if (!response.ok)
@@ -43,26 +58,82 @@ export async function configureManagedAccount(options) {
43
58
  // Provision through the account origin that issued the human session. The
44
59
  // resulting key is still used against apiUrl, but setup must not require a
45
60
  // browser session to be trusted by every managed data-plane hostname.
46
- const key = await responseJson(await request(`${sessionOrigin}/api/keys`, {
61
+ const controlKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
62
+ method: 'POST', headers,
63
+ body: JSON.stringify({
64
+ id: `${options.applicationName}-control`,
65
+ namespace: application.namespace || options.namespace,
66
+ application_id: application.id,
67
+ scopes: [
68
+ 'application:read', 'application:write', 'application:revision:create',
69
+ 'application:revision:read', 'application:revision:promote',
70
+ 'application:environment:read', 'application:environment:write',
71
+ ],
72
+ }),
73
+ }), 'Could not create the managed control-plane key');
74
+ if (!controlKey.secret)
75
+ throw new Error('Managed key service did not return the control-plane credential');
76
+ const dataKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
47
77
  method: 'POST', headers,
48
78
  body: JSON.stringify({
49
- id: `${options.applicationName}-cli`,
50
- namespace: options.namespace,
51
- scopes: ['state:read', 'state:write', 'events:read'],
79
+ id: `${options.applicationName}-runtime`,
80
+ namespace: application.namespace || options.namespace,
81
+ application_id: application.id,
82
+ scopes: [
83
+ 'application:read', 'application:revision:read',
84
+ 'state:read', 'state:write', 'events:read',
85
+ 'workflows:run', 'agents:run', 'capabilities:run',
86
+ ],
52
87
  }),
53
- }), 'Could not create the managed application key');
54
- if (!key.secret)
55
- throw new Error('Managed key service did not return an application secret');
88
+ }), 'Could not create the managed application data key');
89
+ if (!dataKey.secret)
90
+ throw new Error('Managed key service did not return the application data credential');
91
+ try {
92
+ await responseJson(await request(`${apiUrl}/api/applications/${encodeURIComponent(application.id)}/revisions`, {
93
+ headers: { Authorization: `Bearer ${controlKey.secret}` },
94
+ }), 'Managed control-plane credential was created but is not accepted by the managed API');
95
+ }
96
+ catch (error) {
97
+ await Promise.allSettled([controlKey.id, dataKey.id].filter(Boolean).map(id => request(`${sessionOrigin}/api/keys/${encodeURIComponent(id)}`, { method: 'DELETE', headers })));
98
+ throw error;
99
+ }
56
100
  const environment = [
57
101
  '# Generated by create-feltdb managed setup. Do not commit this file.',
58
102
  `VITE_FELTDB_MANAGED_URL=${apiUrl}`,
59
- `VITE_FELTDB_MANAGED_API_KEY=${key.secret}`,
60
- `VITE_FELTDB_MANAGED_TENANT_ID=${tenant.id}`,
61
103
  `VITE_FELTDB_MANAGED_APPLICATION_ID=${application.id}`,
62
- `VITE_FELTDB_MANAGED_NAMESPACE=${application.namespace || options.namespace}`,
63
- 'VITE_FELTDB_MANAGED_ENVIRONMENT=production',
104
+ '# Control-plane credential. Never prefix this with VITE_ or expose it to browser code.',
105
+ `FELTDB_MANAGED_CONTROL_API_KEY=${controlKey.secret}`,
106
+ '# Server/deployment data-plane credential. Never prefix this with VITE_.',
107
+ `FELTDB_TOKEN=${dataKey.secret}`,
64
108
  '',
65
109
  ].join('\n');
66
110
  fs.writeFileSync(path.join(options.projectDir, '.env.local'), environment, { mode: 0o600 });
111
+ fs.mkdirSync(path.dirname(managedStateFile(options.projectDir)), { recursive: true });
112
+ fs.writeFileSync(managedStateFile(options.projectDir), `${JSON.stringify({
113
+ url: apiUrl,
114
+ tenantId: tenant.id,
115
+ applicationId: application.id,
116
+ namespace: application.namespace || options.namespace,
117
+ environment: 'production',
118
+ }, null, 2)}\n`, { mode: 0o600 });
67
119
  return { tenant, application, apiUrl };
68
120
  }
121
+ export async function finalizeManagedProvisioning(options) {
122
+ const publishedFile = path.join(options.projectDir, '.feltdb', 'last-published.json');
123
+ if (!fs.existsSync(publishedFile))
124
+ throw new Error('Managed provisioning did not produce .feltdb/last-published.json');
125
+ const published = JSON.parse(fs.readFileSync(publishedFile, 'utf8'));
126
+ if (!published.revisionId)
127
+ throw new Error('Managed provisioning did not promote an application revision');
128
+ const environmentFile = path.join(options.projectDir, '.env.local');
129
+ const environment = loadEnvironment(environmentFile);
130
+ const managedFile = managedStateFile(options.projectDir);
131
+ const managed = JSON.parse(fs.readFileSync(managedFile, 'utf8'));
132
+ const request = options.fetchImpl || fetch;
133
+ const discovery = await responseJson(await request(`${managed.url}/v1/application?application_id=${encodeURIComponent(managed.applicationId)}&environment=${encodeURIComponent(managed.environment)}`, { headers: { Authorization: `Bearer ${environment.FELTDB_TOKEN}` } }), 'Managed application data credential is not accepted by the promoted runtime');
134
+ if (discovery.revision_id !== published.revisionId) {
135
+ throw new Error('Managed runtime revision does not match the revision promoted during provisioning');
136
+ }
137
+ fs.writeFileSync(managedFile, `${JSON.stringify({ ...managed, revisionId: published.revisionId }, null, 2)}\n`, { mode: 0o600 });
138
+ return { revisionId: published.revisionId };
139
+ }
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.7.3';
3
+ export const FELTDB_PACKAGE_VERSION = '0.8.0';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -132,7 +132,10 @@ pub struct CapabilityBinding {
132
132
  pub name: String,
133
133
  #[serde(default)]
134
134
  pub provider: Option<String>,
135
+ #[serde(default = "public_capability_visibility")]
136
+ pub visibility: String,
135
137
  }
138
+ fn public_capability_visibility() -> String { "public".into() }
136
139
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137
140
  pub struct SecretReference {
138
141
  pub binding: String,
@@ -269,6 +272,9 @@ pub struct ApplicationManifest {
269
272
  pub agents: Vec<AgentDefinition>,
270
273
  #[serde(default)]
271
274
  pub capabilities: Vec<CapabilityBinding>,
275
+ /// Resolved, provider-neutral module semantic contracts. Secret values are forbidden.
276
+ #[serde(default)]
277
+ pub modules: Vec<serde_json::Value>,
272
278
  #[serde(default)]
273
279
  pub connections: Vec<ConnectionBinding>,
274
280
  #[serde(default)]
@@ -310,6 +316,7 @@ impl ApplicationManifest {
310
316
  schedules: vec![],
311
317
  agents: vec![],
312
318
  capabilities: vec![],
319
+ modules: vec![],
313
320
  connections: vec![],
314
321
  policies: vec![],
315
322
  ui: UiApplicationModel::default(),
@@ -368,7 +375,14 @@ pub struct ValidationReport {
368
375
  }
369
376
 
370
377
  fn is_valid_policy_subject(subject: &str) -> bool {
371
- matches!(subject, "authenticated" | "owner" | "member")
378
+ subject.split('|').map(str::trim).all(|term| {
379
+ matches!(term, "authenticated" | "owner" | "member")
380
+ || term.strip_prefix("self(").and_then(|value| value.strip_suffix(')')).is_some_and(|field| {
381
+ let mut characters = field.chars();
382
+ characters.next().is_some_and(|value| value == '_' || value.is_ascii_alphabetic())
383
+ && characters.all(|value| value == '_' || value.is_ascii_alphanumeric())
384
+ })
385
+ })
372
386
  }
373
387
 
374
388
  fn unique(
@@ -540,6 +554,11 @@ pub fn validate_manifest(
540
554
  .iter()
541
555
  .map(|v| v.name.as_str())
542
556
  .collect();
557
+ for capability in &manifest.capabilities {
558
+ if !matches!(capability.visibility.as_str(), "public" | "agent" | "workflow" | "internal") {
559
+ issues.push(issue("semantic", format!("capabilities.{}.visibility", capability.name), "visibility must be public, agent, workflow, or internal"));
560
+ }
561
+ }
543
562
  let workflows: BTreeSet<_> = manifest.workflows.iter().map(|v| v.name.as_str()).collect();
544
563
  let queries: BTreeSet<_> = manifest.queries.iter().map(|v| v.name.as_str()).collect();
545
564
  let bindings: BTreeSet<_> = manifest
@@ -718,6 +737,11 @@ pub fn validate_manifest(
718
737
  }
719
738
  }
720
739
  }
740
+ for (index, module) in manifest.modules.iter().enumerate() {
741
+ if contains_plaintext_secret(module) || module.get("value").is_some() {
742
+ issues.push(issue("security", format!("modules.{index}"), "module contracts may declare secret requirements but never secret values"));
743
+ }
744
+ }
721
745
  for environment in &manifest.environments {
722
746
  if environment.configuration.iter().any(|(key, value)| {
723
747
  matches!(
@@ -853,6 +877,7 @@ fn sorted(mut manifest: ApplicationManifest) -> ApplicationManifest {
853
877
  agent.capabilities.sort();
854
878
  }
855
879
  sort_name!(manifest.capabilities);
880
+ manifest.modules.sort_by(|a, b| a.get("id").and_then(Value::as_str).cmp(&b.get("id").and_then(Value::as_str)));
856
881
  sort_name!(manifest.connections);
857
882
  sort_name!(manifest.policies);
858
883
  sort_name!(manifest.ui.bindings);
@@ -2468,6 +2493,21 @@ mod tests {
2468
2493
  );
2469
2494
  }
2470
2495
 
2496
+ #[test]
2497
+ fn policy_with_identity_and_membership_composition_is_valid() {
2498
+ let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
2499
+ manifest.policies.push(PolicyDefinition {
2500
+ name: "InvitationPolicy".into(),
2501
+ resource: "Invitation".into(),
2502
+ read: Some("self(user) | member".into()),
2503
+ write: Some("member".into()),
2504
+ capabilities: vec![],
2505
+ });
2506
+
2507
+ let report = validate_manifest(&manifest, "tenant", "app", None);
2508
+ assert!(report.valid, "composed identity policy should be valid: {:?}", report.issues);
2509
+ }
2510
+
2471
2511
  #[test]
2472
2512
  fn policy_with_invalid_subject_rejected() {
2473
2513
  let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
@@ -283,6 +283,10 @@ pub enum AuthorityError {
283
283
  provided: FencingToken,
284
284
  current: FencingToken,
285
285
  },
286
+ QuorumUnavailable {
287
+ live: usize,
288
+ required: usize,
289
+ },
286
290
  Io(String),
287
291
  }
288
292
 
@@ -296,6 +300,12 @@ impl std::fmt::Display for AuthorityError {
296
300
  Self::StaleToken { provided, current } => {
297
301
  write!(f, "stale token {provided}, current is {current}")
298
302
  }
303
+ Self::QuorumUnavailable { live, required } => {
304
+ write!(
305
+ f,
306
+ "authority quorum unavailable: {live} live, {required} required"
307
+ )
308
+ }
299
309
  Self::Io(msg) => write!(f, "authority store: {msg}"),
300
310
  }
301
311
  }
@@ -308,6 +318,20 @@ impl AuthorityStore {
308
318
  cluster_id: &str,
309
319
  local_node_id: &str,
310
320
  local_priority: u32,
321
+ ) -> Result<Self, AuthorityError> {
322
+ Self::open_with_failure_detector(path, cluster_id, local_node_id, local_priority, 1_000, 5)
323
+ }
324
+
325
+ /// Open with an explicitly configured failure detector. Production keeps
326
+ /// the conservative default above; process-level acceptance tests use a
327
+ /// shorter interval so failure is event-driven without multi-second sleeps.
328
+ pub fn open_with_failure_detector<P: AsRef<Path>>(
329
+ path: P,
330
+ cluster_id: &str,
331
+ local_node_id: &str,
332
+ local_priority: u32,
333
+ heartbeat_interval_ms: u64,
334
+ failure_threshold: u32,
311
335
  ) -> Result<Self, AuthorityError> {
312
336
  let path = path.as_ref().to_path_buf();
313
337
  let state = if path.exists() {
@@ -323,7 +347,7 @@ impl AuthorityStore {
323
347
  Ok(Self {
324
348
  path,
325
349
  state,
326
- heartbeats: HeartbeatState::new(1000, 5), // 1s heartbeat, 5 missed = failure
350
+ heartbeats: HeartbeatState::new(heartbeat_interval_ms, failure_threshold),
327
351
  })
328
352
  }
329
353
 
@@ -333,8 +357,8 @@ impl AuthorityStore {
333
357
  std::fs::create_dir_all(parent).map_err(|e| AuthorityError::Io(e.to_string()))?;
334
358
  }
335
359
  let temp = self.path.with_extension("tmp");
336
- let bytes =
337
- serde_json::to_vec_pretty(&self.state).map_err(|e| AuthorityError::Io(e.to_string()))?;
360
+ let bytes = serde_json::to_vec_pretty(&self.state)
361
+ .map_err(|e| AuthorityError::Io(e.to_string()))?;
338
362
  {
339
363
  use std::io::Write;
340
364
  let mut file =
@@ -416,7 +440,9 @@ impl AuthorityStore {
416
440
 
417
441
  let mut record =
418
442
  AuthorityRecord::new(self.state.local_node_id.clone(), new_token, previous);
419
- record.acknowledgements.insert(self.state.local_node_id.clone());
443
+ record
444
+ .acknowledgements
445
+ .insert(self.state.local_node_id.clone());
420
446
 
421
447
  self.state.current_authority = Some(record);
422
448
  self.state.local_role = AuthorityRole::Authority;
@@ -512,6 +538,31 @@ impl AuthorityStore {
512
538
  Ok(())
513
539
  }
514
540
 
541
+ /// Validate an authoritative write against both its fencing term and a
542
+ /// live quorum. A partitioned former authority may not have observed the
543
+ /// replacement's newer token yet, so fencing alone is insufficient until
544
+ /// healing; expiry of its quorum evidence closes that split-brain window.
545
+ pub fn validate_quorum_write(
546
+ &self,
547
+ token: FencingToken,
548
+ required_quorum: usize,
549
+ ) -> Result<(), AuthorityError> {
550
+ self.validate_write(Some(token))?;
551
+ let live = 1 + self
552
+ .heartbeats
553
+ .last_seen
554
+ .keys()
555
+ .filter(|node_id| !self.heartbeats.is_failed(node_id))
556
+ .count();
557
+ if live < required_quorum {
558
+ return Err(AuthorityError::QuorumUnavailable {
559
+ live,
560
+ required: required_quorum,
561
+ });
562
+ }
563
+ Ok(())
564
+ }
565
+
515
566
  /// Step down from authority role (voluntary).
516
567
  pub fn step_down(&mut self) -> Result<(), AuthorityError> {
517
568
  if self.state.local_role == AuthorityRole::Authority {
@@ -746,13 +797,40 @@ mod tests {
746
797
  assert!(heartbeats.is_failed("A"));
747
798
  }
748
799
 
800
+ #[test]
801
+ fn partitioned_authority_loses_write_quorum() {
802
+ let dir = tempfile::tempdir().unwrap();
803
+ let mut store = AuthorityStore::open_with_failure_detector(
804
+ dir.path().join("authority.json"),
805
+ "test-cluster",
806
+ "A",
807
+ 10,
808
+ 1,
809
+ 1,
810
+ )
811
+ .expect("open authority store");
812
+ let token = match store.claim_authority().expect("claim") {
813
+ ClaimResult::Granted { token } => token,
814
+ _ => panic!("authority claim was not granted"),
815
+ };
816
+ store.heartbeat("B");
817
+ assert!(store.validate_quorum_write(token, 2).is_ok());
818
+ store.heartbeats.last_seen.insert("B".to_string(), 0);
819
+ assert!(matches!(
820
+ store.validate_quorum_write(token, 2),
821
+ Err(AuthorityError::QuorumUnavailable {
822
+ live: 1,
823
+ required: 2
824
+ })
825
+ ));
826
+ }
827
+
749
828
  #[test]
750
829
  fn recovery_from_fenced() {
751
830
  let dir = tempfile::tempdir().unwrap();
752
831
  let path = dir.path().join("authority.json");
753
832
 
754
- let mut store =
755
- AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
833
+ let mut store = AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
756
834
  store.claim_authority().expect("claim");
757
835
 
758
836
  // Get fenced