create-feltdb 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,7 +75,7 @@ use feltdb_server::{
75
75
  authenticated_principal::AuthenticatedPrincipal,
76
76
  authorization::{authorize, AuthorizationRequest, Subject},
77
77
  backup::{
78
- create_bundle, create_bundle_with_virtual, restore_bundle, select_bundle_at,
78
+ restore_bundle, select_bundle_at,
79
79
  server_artifacts, verify_bundle,
80
80
  },
81
81
  causal::{
@@ -601,6 +601,14 @@ struct CanonicalTransactionRequest {
601
601
  environment: String,
602
602
  transaction: TransactionRequest,
603
603
  }
604
+ #[derive(Deserialize)]
605
+ struct CanonicalReadBasisRequest {
606
+ application_id: String,
607
+ revision_id: String,
608
+ #[serde(default = "production_environment")]
609
+ environment: String,
610
+ reads: feltdb::ReadSet,
611
+ }
604
612
  fn production_environment() -> String {
605
613
  "production".into()
606
614
  }
@@ -1207,6 +1215,7 @@ async fn push_sync(
1207
1215
  ),
1208
1216
  operations: vec![operation.operation.clone()],
1209
1217
  preconditions: vec![],
1218
+ fences: vec![],
1210
1219
  };
1211
1220
  let _transaction_permit = state
1212
1221
  .state_transaction_admission
@@ -4620,12 +4629,15 @@ fn runtime_inventory(
4620
4629
  .filter(|value| value.status == ConnectionStatus::Active)
4621
4630
  .map(|value| value.id)
4622
4631
  .collect();
4623
- let snapshot = state.db.export_snapshot()?;
4632
+ // Every managed request resolves its contract through here, so this reads
4633
+ // only the capability registry's own rows. A whole-database snapshot would
4634
+ // clone, sort, and hash every tenant's state per request.
4624
4635
  let mut capabilities = BTreeSet::new();
4625
- for row in snapshot
4626
- .rows
4636
+ for row in state
4637
+ .db
4638
+ .list_collection("_flow_capabilities")?
4627
4639
  .iter()
4628
- .filter(|row| row.capability == "_flow_capabilities" && !row.deleted)
4640
+ .filter(|row| !row.deleted)
4629
4641
  {
4630
4642
  if let Some(name) = row.value.get("name").and_then(Value::as_str) {
4631
4643
  capabilities.insert(name.to_string());
@@ -4653,6 +4665,8 @@ fn revision_contract(
4653
4665
  revision_id: &str,
4654
4666
  environment: &str,
4655
4667
  ) -> Result<ApplicationRuntimeContract, ApiError> {
4668
+ let _span =
4669
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::ContractResolution);
4656
4670
  let tenant = application_scope(state, actor, application_id, "application:revision:read")?;
4657
4671
  let revision = state
4658
4672
  .applications
@@ -7209,6 +7223,7 @@ async fn run_runtime_action(
7209
7223
  if_version,
7210
7224
  }],
7211
7225
  preconditions: vec![],
7226
+ fences: vec![],
7212
7227
  };
7213
7228
  let write_policy = contract
7214
7229
  .policies
@@ -7658,11 +7673,34 @@ async fn execute_canonical_query(
7658
7673
  &input.revision_id,
7659
7674
  &input.environment,
7660
7675
  )?;
7676
+ let authorization = if feltdb::state_contract::is_substrate_collection(&input.query.collection)
7677
+ {
7678
+ let mut authorization = state_authorization_legacy(
7679
+ &principal,
7680
+ &tenant,
7681
+ &input.application_id,
7682
+ &contract.revision_id,
7683
+ "state:denied",
7684
+ );
7685
+ if application_scope(
7686
+ &state,
7687
+ &principal.key_id,
7688
+ &input.application_id,
7689
+ "composition:read",
7690
+ )
7691
+ .is_ok()
7692
+ {
7693
+ authorization.capabilities.insert("composition:read".into());
7694
+ }
7695
+ authorization
7696
+ } else {
7697
+ state_authorization(&principal, &contract, &input.query.collection, "read")
7698
+ };
7661
7699
  let context = feltdb::state_contract::begin_query_read(
7662
7700
  &state.db,
7663
7701
  &contract.state.schema,
7664
7702
  &contract.environment.state_namespace,
7665
- state_authorization(&principal, &contract, &input.query.collection, "read"),
7703
+ authorization,
7666
7704
  )
7667
7705
  .map_err(state_contract_error)?;
7668
7706
 
@@ -7885,6 +7923,76 @@ async fn execute_revision_recovery(
7885
7923
  Ok(Json(response))
7886
7924
  }
7887
7925
 
7926
+ /// Whether `principal` may read every record of `collection` — the condition
7927
+ /// for a read basis, captured or committed, to cover it.
7928
+ ///
7929
+ /// A token digests every row it covers, and a refused fence reveals that one
7930
+ /// of them changed. Both are reads, so both require the right to read the whole
7931
+ /// collection: `state:read` under a read policy that does not depend on the
7932
+ /// record (none, or `authenticated`), or `composition:read` for a substrate
7933
+ /// collection. Owner, member, and self policies filter rows per actor; a read
7934
+ /// basis over such a collection is refused rather than digested.
7935
+ fn read_basis_readable(
7936
+ state: &AppState,
7937
+ principal: &Principal,
7938
+ contract: &ApplicationRuntimeContract,
7939
+ collection: &str,
7940
+ ) -> bool {
7941
+ if feltdb::state_contract::is_substrate_collection(collection) {
7942
+ return application_scope(
7943
+ state,
7944
+ &principal.key_id,
7945
+ &contract.application_id,
7946
+ "composition:read",
7947
+ )
7948
+ .is_ok();
7949
+ }
7950
+ if !state_authorization(principal, contract, collection, "read")
7951
+ .capabilities
7952
+ .contains("state:read")
7953
+ {
7954
+ return false;
7955
+ }
7956
+ matches!(
7957
+ matching_state_policy(contract, collection)
7958
+ .and_then(|policy| policy.read.as_deref())
7959
+ .and_then(PolicySubject::from_str),
7960
+ None | Some(PolicySubject::Authenticated)
7961
+ )
7962
+ }
7963
+
7964
+ /// Capture a read basis over the application's state namespace.
7965
+ async fn capture_canonical_read_basis(
7966
+ State(state): State<AppState>,
7967
+ Extension(principal): Extension<Principal>,
7968
+ headers: HeaderMap,
7969
+ Json(input): Json<CanonicalReadBasisRequest>,
7970
+ ) -> Result<Json<Value>, ApiError> {
7971
+ let tenant = application_scope(
7972
+ &state,
7973
+ &principal.key_id,
7974
+ &input.application_id,
7975
+ "state:read",
7976
+ )?;
7977
+ require_bound_tenant(&headers, &tenant)?;
7978
+ let contract = revision_contract(
7979
+ &state,
7980
+ &principal.key_id,
7981
+ &input.application_id,
7982
+ &input.revision_id,
7983
+ &input.environment,
7984
+ )?;
7985
+ let basis = feltdb::state_contract::capture_read_basis(
7986
+ &state.db,
7987
+ &contract.state.schema,
7988
+ &contract.environment.state_namespace,
7989
+ &input.reads,
7990
+ &|collection| read_basis_readable(&state, &principal, &contract, collection),
7991
+ )
7992
+ .map_err(state_contract_error)?;
7993
+ Ok(Json(json!(basis)))
7994
+ }
7995
+
7888
7996
  async fn execute_canonical_transaction(
7889
7997
  State(state): State<AppState>,
7890
7998
  Extension(principal): Extension<Principal>,
@@ -7921,14 +8029,49 @@ async fn execute_canonical_transaction(
7921
8029
  input.transaction.revision_id = contract.revision_id.clone();
7922
8030
  input.transaction.schema_version = contract.state.schema.schema_version;
7923
8031
  input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
7924
- let collections = input
8032
+ let (substrate, collections): (Vec<String>, Vec<String>) = input
7925
8033
  .transaction
7926
8034
  .operations
7927
8035
  .iter()
7928
8036
  .map(|operation| operation.collection.clone())
7929
- .collect::<Vec<_>>();
7930
- let (authorized, write_policies) =
7931
- authorize_transaction_collections(&principal, &contract, &tenant, &collections);
8037
+ .partition(|collection| feltdb::state_contract::is_substrate_collection(collection));
8038
+ let (authorized, write_policies) = if collections.is_empty() && !substrate.is_empty() {
8039
+ (tenant == contract.tenant_id, BTreeMap::new())
8040
+ } else {
8041
+ authorize_transaction_collections(&principal, &contract, &tenant, &collections)
8042
+ };
8043
+ // Substrate records are written only under the dedicated capability.
8044
+ let substrate_authorized = substrate.is_empty()
8045
+ || application_scope(
8046
+ &state,
8047
+ &principal.key_id,
8048
+ &input.application_id,
8049
+ "composition:write",
8050
+ )
8051
+ .is_ok();
8052
+ // A fence is a read of everything it covers: the committer must be able to
8053
+ // read all of it, exactly as capturing it required.
8054
+ for fence in &input.transaction.fences {
8055
+ for collection in fence
8056
+ .reads
8057
+ .records
8058
+ .iter()
8059
+ .map(|record| record.collection.as_str())
8060
+ .chain(
8061
+ fence
8062
+ .reads
8063
+ .predicates
8064
+ .iter()
8065
+ .map(|predicate| predicate.collection.as_str()),
8066
+ )
8067
+ {
8068
+ if !read_basis_readable(&state, &principal, &contract, collection) {
8069
+ return Err(state_contract_error(
8070
+ feltdb::state_contract::read_basis_denied(collection),
8071
+ ));
8072
+ }
8073
+ }
8074
+ }
7932
8075
  input.transaction.authorization = state_authorization_legacy(
7933
8076
  &principal,
7934
8077
  &tenant,
@@ -7940,6 +8083,13 @@ async fn execute_canonical_transaction(
7940
8083
  "state:denied"
7941
8084
  },
7942
8085
  );
8086
+ if authorized && substrate_authorized && !substrate.is_empty() {
8087
+ input
8088
+ .transaction
8089
+ .authorization
8090
+ .capabilities
8091
+ .insert("composition:write".into());
8092
+ }
7943
8093
  let _transaction_permit = state
7944
8094
  .state_transaction_admission
7945
8095
  .acquire()
@@ -8529,6 +8679,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8529
8679
  if manage_backup()? {
8530
8680
  return Ok(());
8531
8681
  }
8682
+ if manage_doctor()? {
8683
+ return Ok(());
8684
+ }
8532
8685
  if manage_application_runtime()? {
8533
8686
  return Ok(());
8534
8687
  }
@@ -9033,6 +9186,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9033
9186
  "/v1/transactions",
9034
9187
  axum::routing::post(execute_canonical_transaction),
9035
9188
  )
9189
+ .route(
9190
+ "/v1/transactions/read-basis",
9191
+ axum::routing::post(capture_canonical_read_basis),
9192
+ )
9036
9193
  .route(
9037
9194
  "/v1/revision/recover",
9038
9195
  axum::routing::post(execute_revision_recovery),
@@ -9309,6 +9466,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9309
9466
  axum::routing::delete(delete_connection_control),
9310
9467
  )
9311
9468
  .route("/transactions", axum::routing::post(commit_transaction))
9469
+ .route(
9470
+ "/transactions/read-basis",
9471
+ axum::routing::post(capture_read_basis),
9472
+ )
9312
9473
  .route("/query", axum::routing::post(execute_bounded_query))
9313
9474
  .route("/revision", get(get_revision))
9314
9475
  .route(
@@ -9628,6 +9789,8 @@ async fn authenticate(
9628
9789
  || path == "/v1/state/conflicts"
9629
9790
  || path == "/v1/state/reconciliation/plan"
9630
9791
  || path == "/v1/experimental/semantic-decisions"
9792
+ || path == "/transactions/read-basis"
9793
+ || path == "/v1/transactions/read-basis"
9631
9794
  {
9632
9795
  // Describing state is reading it. These are POSTs because they take
9633
9796
  // three revision identities and a plan, not because they change
@@ -9989,8 +10152,16 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
9989
10152
  match outcome {
9990
10153
  Ok(Ok(Ok(health))) => {
9991
10154
  let ready = health.is_nominal();
10155
+ let recovering = matches!(
10156
+ state.cluster.proposal().map(|value| value.phase),
10157
+ Some(ProposalPhase::Preparing | ProposalPhase::Prepared)
10158
+ );
9992
10159
  let body = Json(json!({
9993
10160
  "ready": ready,
10161
+ // The explicit health model. A running server has already
10162
+ // refused a corrupt or incompatible log at open, so `corrupt`
10163
+ // is reported offline by `feltdb-server doctor`, never here.
10164
+ "state": if !ready { "degraded" } else if recovering { "recovering" } else { "healthy" },
9994
10165
  "storage": health.storage.label(),
9995
10166
  "durable_format": health.durable_format.to_string(),
9996
10167
  "probe_ms": waited_ms,
@@ -10006,6 +10177,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
10006
10177
  StatusCode::SERVICE_UNAVAILABLE,
10007
10178
  Json(json!({
10008
10179
  "ready": false,
10180
+ "state": "unavailable",
10009
10181
  "error": error.to_string(),
10010
10182
  "code": "STORAGE_UNAVAILABLE",
10011
10183
  "probe_ms": waited_ms,
@@ -10016,6 +10188,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
10016
10188
  StatusCode::SERVICE_UNAVAILABLE,
10017
10189
  Json(json!({
10018
10190
  "ready": false,
10191
+ "state": "unavailable",
10019
10192
  "error": error.to_string(),
10020
10193
  "code": "STORAGE_PROBE_FAILED",
10021
10194
  "probe_ms": waited_ms,
@@ -10026,6 +10199,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
10026
10199
  StatusCode::SERVICE_UNAVAILABLE,
10027
10200
  Json(json!({
10028
10201
  "ready": false,
10202
+ "state": "unavailable",
10029
10203
  "error": "storage probe exceeded the server deadline",
10030
10204
  "code": "STORAGE_STALLED",
10031
10205
  "probe_ms": waited_ms,
@@ -10370,7 +10544,6 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
10370
10544
  if arguments.get(1).map(String::as_str) != Some("backup") {
10371
10545
  return Ok(false);
10372
10546
  }
10373
- let command = arguments.get(2).map(String::as_str).unwrap_or("");
10374
10547
  let option = |name: &str| {
10375
10548
  arguments
10376
10549
  .iter()
@@ -10378,27 +10551,105 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
10378
10551
  .and_then(|index| arguments.get(index + 1))
10379
10552
  .cloned()
10380
10553
  };
10554
+ let json_output = arguments.iter().any(|value| value == "--json");
10555
+ // `backup <path>` is `backup create --output <path>`; `backup verify <path>`
10556
+ // is `backup verify --archive <path>`. The flag forms remain supported.
10557
+ let positional = |from: usize| {
10558
+ let mut index = from;
10559
+ while let Some(value) = arguments.get(index) {
10560
+ if value == "--json" {
10561
+ index += 1;
10562
+ } else if value.starts_with("--") {
10563
+ index += 2;
10564
+ } else {
10565
+ return Some(value.clone());
10566
+ }
10567
+ }
10568
+ None
10569
+ };
10570
+ let (command, target) = match arguments.get(2).map(String::as_str) {
10571
+ Some(command @ ("create" | "verify" | "restore")) => (command, positional(3)),
10572
+ Some(value) if !value.starts_with("--") => ("create", Some(value.to_string())),
10573
+ _ if option("--output").is_some() && option("--archive").is_none() => ("create", None),
10574
+ _ => ("", None),
10575
+ };
10576
+ let usage = "usage: feltdb-server backup [create] <output> | backup verify <archive> | backup restore --archive <archive> --output <directory> [--at-ms timestamp] (options: --data path --keys path --audit path --json)";
10577
+ let started = Instant::now();
10578
+ let emit = |report: &feltdb_server::backup::BackupReport, human: String| {
10579
+ if json_output {
10580
+ println!("{}", serde_json::to_string_pretty(report).unwrap_or_default());
10581
+ } else if report.status == "ok" {
10582
+ println!("{human}");
10583
+ } else {
10584
+ eprintln!(
10585
+ "Backup {} failed: {} ({})",
10586
+ report.operation,
10587
+ report.path,
10588
+ report.error.as_deref().unwrap_or("unknown error")
10589
+ );
10590
+ }
10591
+ };
10381
10592
  match command {
10382
10593
  "create" => {
10383
10594
  let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
10384
10595
  let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
10385
10596
  let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
10386
- let output = PathBuf::from(option("--output").ok_or("backup create requires --output")?);
10387
- let manifest = create_bundle(&output, &server_artifacts(&data, &keys, &audit))?;
10388
- println!("Backup created: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len());
10597
+ let output = option("--output").or(target).ok_or("backup create requires an output path")?;
10598
+ let output = feltdb_server::backup::normalize_path(std::path::Path::new(&output))?;
10599
+ let data = feltdb_server::backup::normalize_path(&data)?;
10600
+ if !data.exists() {
10601
+ return Err(format!("backup source does not exist: {}", data.display()).into());
10602
+ }
10603
+ let source = feltdb_server::backup::BackupSource {
10604
+ data: Some(data.display().to_string()),
10605
+ state_sha256: None,
10606
+ server_version: Some(env!("CARGO_PKG_VERSION").into()),
10607
+ durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10608
+ };
10609
+ feltdb_server::backup::create_bundle_with_source(&output, &server_artifacts(&data, &keys, &audit), &[], source)?;
10610
+ // A backup is reported as successful only after it verifies
10611
+ // independently of the code path that wrote it.
10612
+ let mut report = feltdb_server::backup::verification_report(&output, started, "create");
10613
+ if report.status == "ok" {
10614
+ if let Err(error) = verify_restorable(&output) {
10615
+ report.status = "failed".into();
10616
+ report.verification = "failed".into();
10617
+ report.error = Some(error.to_string());
10618
+ }
10619
+ }
10620
+ let human = format!("Backup created: {} (id {}, format {}, {} files, {} bytes, verified, {} ms)", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, report.duration_ms);
10621
+ emit(&report, human);
10622
+ if report.status != "ok" {
10623
+ std::process::exit(1);
10624
+ }
10389
10625
  }
10390
10626
  "verify" => {
10391
- let archive = PathBuf::from(option("--archive").ok_or("backup verify requires --archive")?);
10392
- let manifest = verify_bundle(&archive)?;
10393
- println!("Backup verified: {} (format {}, {} files)", archive.display(), manifest.format, manifest.entries.len());
10627
+ let archive = option("--archive").or(target).ok_or("backup verify requires an archive path")?;
10628
+ let mut report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
10629
+ let mut restorable = None;
10630
+ if report.status == "ok" {
10631
+ match verify_restorable(std::path::Path::new(&report.path)) {
10632
+ Ok(detail) => restorable = Some(detail),
10633
+ Err(error) => {
10634
+ report.status = "failed".into();
10635
+ report.verification = "failed".into();
10636
+ report.error = Some(error.to_string());
10637
+ }
10638
+ }
10639
+ }
10640
+ let human = format!("Backup verified: {} (id {}, format {}, {} files, {} bytes{})", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, restorable.map(|detail| format!(", {detail}")).unwrap_or_default());
10641
+ emit(&report, human);
10642
+ if report.status != "ok" {
10643
+ std::process::exit(1);
10644
+ }
10394
10645
  }
10395
10646
  "restore" => {
10396
- let archive = PathBuf::from(option("--archive").ok_or("backup restore requires --archive")?);
10647
+ let archive = PathBuf::from(option("--archive").or(target).ok_or("backup restore requires --archive")?);
10397
10648
  let archive = match option("--at-ms") {
10398
10649
  Some(value) => select_bundle_at(&archive, value.parse().map_err(|_| "backup restore --at-ms must be an unsigned millisecond timestamp")?)?,
10399
10650
  None => archive,
10400
10651
  };
10401
- let output = PathBuf::from(option("--output").ok_or("backup restore requires --output")?);
10652
+ let output = feltdb_server::backup::normalize_path(std::path::Path::new(&option("--output").ok_or("backup restore requires --output")?))?;
10402
10653
  let manifest = restore_bundle(&archive, &output)?;
10403
10654
  let snapshot_path = output.join("files/database.snapshot.json");
10404
10655
  if snapshot_path.exists() {
@@ -10406,13 +10657,199 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
10406
10657
  let database = FeltDb::open(output.join("files/state.log"))?;
10407
10658
  database.install_snapshot(snapshot)?;
10408
10659
  }
10409
- println!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len());
10660
+ let mut report = feltdb_server::backup::verification_report(&archive, started, "restore");
10661
+ report.path = output.display().to_string();
10662
+ emit(&report, format!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len()));
10663
+ if report.status != "ok" {
10664
+ std::process::exit(1);
10665
+ }
10410
10666
  }
10411
- _ => return Err("usage: feltdb-server backup <create|verify|restore> [--data path] [--keys path] [--audit path] --output directory | --archive directory [--at-ms timestamp]".into()),
10667
+ _ => return Err(usage.into()),
10412
10668
  }
10413
10669
  Ok(true)
10414
10670
  }
10415
10671
 
10672
+ /// `feltdb-server doctor` — what an operator needs to know, offline.
10673
+ ///
10674
+ /// Answers, for a data path: is the database healthy; is the durable log
10675
+ /// valid; what state/version is it in; is recovery pending; can a backup be
10676
+ /// created; can that backup be restored; which server and format versions are
10677
+ /// active. It never opens the live files: the state is bundled exactly as
10678
+ /// `backup create` would, and every probe — open, read, write — runs against a
10679
+ /// private restored copy. `--backup <archive>` additionally verifies an
10680
+ /// existing bundle.
10681
+ ///
10682
+ /// The health state is one of `healthy`, `recovering`, `degraded`,
10683
+ /// `unavailable`, or `corrupt`, and `healthy` requires every probe to pass:
10684
+ /// a readable durable log in a supported format, a clean replay, a working
10685
+ /// read, a working write, and a restorable backup.
10686
+ fn manage_doctor() -> Result<bool, Box<dyn std::error::Error>> {
10687
+ let arguments: Vec<String> = std::env::args().collect();
10688
+ if arguments.get(1).map(String::as_str) != Some("doctor") {
10689
+ return Ok(false);
10690
+ }
10691
+ let option = |name: &str| {
10692
+ arguments
10693
+ .iter()
10694
+ .position(|value| value == name)
10695
+ .and_then(|index| arguments.get(index + 1))
10696
+ .cloned()
10697
+ };
10698
+ let json_output = arguments.iter().any(|value| value == "--json");
10699
+ let data = feltdb_server::backup::normalize_path(&PathBuf::from(
10700
+ option("--data").unwrap_or_else(|| "./data/feltdb.log".into()),
10701
+ ))?;
10702
+ let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
10703
+ let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
10704
+
10705
+ let mut checks: Vec<Value> = Vec::new();
10706
+ let mut check = |name: &str, ok: bool, detail: String| {
10707
+ checks.push(json!({ "name": name, "status": if ok { "PASS" } else { "FAIL" }, "detail": detail }));
10708
+ ok
10709
+ };
10710
+ let scratch = std::env::temp_dir().join(format!(
10711
+ "feltdb-doctor-{}-{}",
10712
+ std::process::id(),
10713
+ rand::random::<u64>()
10714
+ ));
10715
+ let bundle = scratch.join("bundle");
10716
+ let restored = scratch.join("restored");
10717
+ let mut state = "healthy";
10718
+ let mut facts = json!({
10719
+ "data": data.display().to_string(),
10720
+ "server_version": env!("CARGO_PKG_VERSION"),
10721
+ "supported_durable_format": feltdb::DURABLE_FORMAT_VERSION,
10722
+ "backup_format": feltdb_server::backup::BACKUP_FORMAT,
10723
+ });
10724
+
10725
+ if !check("data present", data.exists(), data.display().to_string()) {
10726
+ state = "unavailable";
10727
+ } else {
10728
+ std::fs::create_dir_all(&scratch)?;
10729
+ let source = feltdb_server::backup::BackupSource {
10730
+ data: Some(data.display().to_string()),
10731
+ state_sha256: None,
10732
+ server_version: Some(env!("CARGO_PKG_VERSION").into()),
10733
+ durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10734
+ };
10735
+ let bundled = feltdb_server::backup::create_bundle_with_source(&bundle, &server_artifacts(&data, &keys, &audit), &[], source);
10736
+ if !check("backup can be created", bundled.is_ok(), bundled.as_ref().map(|manifest| format!("{} files", manifest.entries.len())).unwrap_or_else(|error| error.to_string())) {
10737
+ state = "unavailable";
10738
+ } else {
10739
+ let restorable = restore_bundle(&bundle, &restored);
10740
+ check("backup can be restored", restorable.is_ok(), restorable.as_ref().map(|_| "restored to a private copy".to_string()).unwrap_or_else(|error| error.to_string()));
10741
+ match FeltDb::open(restored.join("files/state.log")) {
10742
+ Err(error) => {
10743
+ let corrupt = matches!(error, feltdb::FlowError::CorruptLogLine(_) | feltdb::FlowError::IncompatibleFormat(_));
10744
+ check("durable log valid", false, error.to_string());
10745
+ state = if corrupt { "corrupt" } else { "unavailable" };
10746
+ }
10747
+ Ok(database) => {
10748
+ let health = database.health();
10749
+ facts["durable_format"] = json!(health.durable_format.to_string());
10750
+ facts["storage"] = json!(health.storage.label());
10751
+ facts["recovery"] = json!(health.recovery.to_string());
10752
+ facts["sequence"] = json!(database.sequence().unwrap_or(0));
10753
+ check("durable log valid", true, format!("durable format {}", health.durable_format));
10754
+ if !check("replay clean", health.storage.is_clean(), health.recovery.to_string()) {
10755
+ state = "degraded";
10756
+ }
10757
+ let read = database.list_collection_page("_flow_capabilities", None, 1);
10758
+ if !check("read path", read.is_ok(), read.as_ref().map(|_| "bounded read succeeded".to_string()).unwrap_or_else(|error| error.to_string())) {
10759
+ state = "unavailable";
10760
+ }
10761
+ let probe_key = "_feltdb_doctor:probe";
10762
+ let wrote = database
10763
+ .insert(probe_key, json!({ "probe": true }))
10764
+ .and_then(|_| database.get_value(probe_key))
10765
+ .map(|value| value.is_some());
10766
+ if !check("mutation path", matches!(wrote, Ok(true)), match &wrote { Ok(true) => "write and read-back on the private copy".into(), Ok(false) => "write was not readable".into(), Err(error) => error.to_string() }) {
10767
+ state = "unavailable";
10768
+ }
10769
+ }
10770
+ }
10771
+ }
10772
+ if let Ok(cluster) = ClusterStore::load(data.with_extension("cluster.json"), Vec::new()) {
10773
+ let pending = matches!(cluster.proposal().map(|value| value.phase), Some(ProposalPhase::Preparing | ProposalPhase::Prepared));
10774
+ facts["pending_recovery"] = json!(pending);
10775
+ check("no pending membership recovery", !pending, if pending { "a membership change is mid-flight".into() } else { "none".into() });
10776
+ if pending && state == "healthy" {
10777
+ state = "recovering";
10778
+ }
10779
+ }
10780
+ }
10781
+ if let Some(archive) = option("--backup") {
10782
+ let started = Instant::now();
10783
+ let report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
10784
+ let restorable = if report.status == "ok" { verify_restorable(std::path::Path::new(&report.path)).map_err(|error| error.to_string()) } else { Err(report.error.clone().unwrap_or_default()) };
10785
+ check("supplied backup restorable", restorable.is_ok(), restorable.unwrap_or_else(|error| error));
10786
+ facts["backup"] = json!(report);
10787
+ }
10788
+ let _ = std::fs::remove_dir_all(&scratch);
10789
+ let healthy_requires_all = checks.iter().all(|entry| entry["status"] == "PASS");
10790
+ if state == "healthy" && !healthy_requires_all {
10791
+ state = "degraded";
10792
+ }
10793
+ let report = json!({ "command": "doctor", "state": state, "facts": facts, "checks": checks });
10794
+ if json_output {
10795
+ println!("{}", serde_json::to_string_pretty(&report)?);
10796
+ } else {
10797
+ println!("FeltDB doctor: {state}");
10798
+ for entry in report["checks"].as_array().into_iter().flatten() {
10799
+ println!(" {} {}: {}", if entry["status"] == "PASS" { "✓" } else { "✗" }, entry["name"].as_str().unwrap_or(""), entry["detail"].as_str().unwrap_or(""));
10800
+ }
10801
+ }
10802
+ if state != "healthy" {
10803
+ std::process::exit(1);
10804
+ }
10805
+ Ok(true)
10806
+ }
10807
+
10808
+ /// Prove a bundle restores into a database this build can open.
10809
+ ///
10810
+ /// Checksums prove the bytes are the ones recorded; they cannot prove the
10811
+ /// recorded bytes are a FeltDB database in a format this build understands.
10812
+ /// This restores into a private temporary directory, opens it, and reads it —
10813
+ /// the same path `backup restore` and a subsequent server start take — and
10814
+ /// never touches the bundle or any live database.
10815
+ fn verify_restorable(archive: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
10816
+ let manifest = verify_bundle(archive)?;
10817
+ let has_log = manifest.entries.iter().any(|entry| entry.path == "files/state.log");
10818
+ let has_snapshot = manifest
10819
+ .entries
10820
+ .iter()
10821
+ .any(|entry| entry.path == "files/database.snapshot.json");
10822
+ if !has_log && !has_snapshot {
10823
+ return Err("backup holds no authoritative state (neither state.log nor database.snapshot.json)".into());
10824
+ }
10825
+ let scratch = std::env::temp_dir().join(format!(
10826
+ "feltdb-backup-verify-{}-{}",
10827
+ std::process::id(),
10828
+ rand::random::<u64>()
10829
+ ));
10830
+ let restored = scratch.join("restored");
10831
+ let result = (|| -> Result<String, Box<dyn std::error::Error>> {
10832
+ std::fs::create_dir_all(&scratch)?;
10833
+ restore_bundle(archive, &restored)?;
10834
+ let database = FeltDb::open(restored.join("files/state.log"))?;
10835
+ if has_snapshot {
10836
+ let snapshot: DatabaseSnapshot = serde_json::from_slice(&std::fs::read(
10837
+ restored.join("files/database.snapshot.json"),
10838
+ )?)?;
10839
+ database.install_snapshot(snapshot)?;
10840
+ }
10841
+ let health = database.health();
10842
+ Ok(format!(
10843
+ "restorable: durable format {}, storage {}, sequence {}",
10844
+ health.durable_format,
10845
+ health.storage.label(),
10846
+ database.sequence()?
10847
+ ))
10848
+ })();
10849
+ let _ = std::fs::remove_dir_all(&scratch);
10850
+ result
10851
+ }
10852
+
10416
10853
  fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
10417
10854
  let arguments: Vec<String> = std::env::args().collect();
10418
10855
  if arguments.get(1).map(String::as_str) != Some("app") {
@@ -10515,12 +10952,24 @@ async fn create_online_backup(
10515
10952
  .cloned()
10516
10953
  .collect();
10517
10954
  let output = PathBuf::from(request.output);
10955
+ let source = feltdb_server::backup::BackupSource {
10956
+ data: state
10957
+ .backup_artifacts
10958
+ .iter()
10959
+ .find(|(label, _)| label == "state.log")
10960
+ .and_then(|(_, path)| feltdb_server::backup::normalize_path(path).ok())
10961
+ .map(|path| path.display().to_string()),
10962
+ state_sha256: None,
10963
+ server_version: Some(env!("CARGO_PKG_VERSION").into()),
10964
+ durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10965
+ };
10518
10966
  let manifest = tokio::task::spawn_blocking(move || {
10519
10967
  let _guard = guard;
10520
- create_bundle_with_virtual(
10968
+ feltdb_server::backup::create_bundle_with_source(
10521
10969
  &output,
10522
10970
  &artifacts,
10523
10971
  &[("database.snapshot.json".to_string(), snapshot_bytes)],
10972
+ source,
10524
10973
  )
10525
10974
  })
10526
10975
  .await
@@ -13584,6 +14033,28 @@ struct AtomicTxRequest {
13584
14033
  /// exactly as before.
13585
14034
  #[serde(default)]
13586
14035
  preconditions: Vec<AtomicTxPreconditionRequest>,
14036
+ /// Read bases this authority issued (`POST /transactions/read-basis`),
14037
+ /// re-evaluated inside the commit lock. Absent means no fence.
14038
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
14039
+ fences: Vec<feltdb::ReadBasis>,
14040
+ }
14041
+
14042
+ /// Capture a read basis: what the authority holds, now, for a set of records
14043
+ /// and equality predicates. Reading, so it requires `state:read`.
14044
+ async fn capture_read_basis(
14045
+ State(state): State<AppState>,
14046
+ Json(reads): Json<feltdb::ReadSet>,
14047
+ ) -> Result<Json<feltdb::ReadBasis>, ApiError> {
14048
+ state
14049
+ .db
14050
+ .read_basis(&reads)
14051
+ .map(Json)
14052
+ .map_err(|error| match error {
14053
+ FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
14054
+ ApiError(StatusCode::BAD_REQUEST, message)
14055
+ }
14056
+ other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
14057
+ })
13587
14058
  }
13588
14059
 
13589
14060
  #[derive(Serialize)]
@@ -13645,6 +14116,8 @@ async fn commit_transaction(
13645
14116
  let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
13646
14117
  let mut seen = std::collections::HashSet::new();
13647
14118
 
14119
+ let fences = request.fences.clone();
14120
+
13648
14121
  // Transaction-level preconditions first, on records the transaction need
13649
14122
  // not write at all.
13650
14123
  for condition in &request.preconditions {
@@ -13722,12 +14195,13 @@ async fn commit_transaction(
13722
14195
  let operations = mutations.len();
13723
14196
  let commit = state
13724
14197
  .db
13725
- .apply_atomic_transaction_guarded(
14198
+ .apply_atomic_transaction_fenced(
13726
14199
  &request.transaction_id,
13727
14200
  Some(&payload_hash),
13728
14201
  None,
13729
14202
  &[],
13730
14203
  &record_preconditions,
14204
+ &fences,
13731
14205
  &mutations,
13732
14206
  None,
13733
14207
  )
@@ -13747,6 +14221,9 @@ async fn commit_transaction(
13747
14221
  "failure": *failure,
13748
14222
  }),
13749
14223
  ),
14224
+ FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
14225
+ ApiError(StatusCode::BAD_REQUEST, message)
14226
+ }
13750
14227
  FlowError::CapabilityError(message)
13751
14228
  if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH:") =>
13752
14229
  {