-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy pathcommit.rs
More file actions
2597 lines (2383 loc) · 109 KB
/
Copy pathcommit.rs
File metadata and controls
2597 lines (2383 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Describe changes / mutations to data
use crate::{
agents::{decode_base64, encode_base64},
datatype::DataType,
errors::AtomicResult,
urls,
values::SubResource,
Atom, Resource, Storelike, Subject, Value,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use urls::SIGNER;
/// The `resource_new`, `resource_old` and `commit_resource` fields are only created if the Commit is persisted.
/// When the Db is only notifying other of changes (e.g. if a new Message was added to a ChatRoom), these fields are not created.
/// When deleting a resource, the `resource_new` field is None.
#[derive(Clone, Debug)]
pub struct CommitResponse {
pub commit: Commit,
pub commit_resource: Resource,
pub resource_new: Option<Resource>,
pub resource_old: Option<Resource>,
pub add_atoms: Vec<Atom>,
pub remove_atoms: Vec<Atom>,
/// The property URLs that were changed by this commit's Loro update.
pub changed_props: HashSet<String>,
/// Optional transport/source identity for echo suppression.
pub source_id: Option<String>,
}
impl CommitResponse {
/// The authorization relevance of this commit — which authority-defining
/// facts it establishes or mutates. See [`crate::hierarchy::AuthImpact`].
pub fn auth_impact(&self) -> crate::hierarchy::AuthImpact {
crate::hierarchy::classify_auth_impact(
&self.changed_props,
self.commit.is_genesis == Some(true),
self.commit.destroy.unwrap_or(false),
)
}
}
pub struct CommitApplied {
/// The resource before the Commit was applied
pub resource_old: Resource,
/// The modified resources where the commit has been applied to
pub resource_new: Resource,
/// The atoms that should be added to the store (for updating indexes)
pub add_atoms: Vec<Atom>,
/// The atoms that should be removed from the store (for updating indexes)
pub remove_atoms: Vec<Atom>,
/// The property URLs that were changed by this commit's Loro update.
pub changed_props: HashSet<String>,
/// True when importing the commit's `loroUpdate` actually advanced the
/// doc's oplog — i.e. the ops were new. False means every op was already
/// present (an idempotent replay), so producing no state change is
/// expected and correct, not a silent LWW loss.
pub imported_new_ops: bool,
}
#[derive(Clone, Debug)]
/// Describes options for applying a Commit.
/// Skip the checks you don't need to get better performance, or if you want to break the rules a little.
pub struct CommitOpts {
/// Makes sure all `required` properties are present.
pub validate_schema: bool,
/// Checks the public key and the signature of the Commit.
pub validate_signature: bool,
/// Checks whether the Commit isn't too old, or has been created in the future.
pub validate_timestamp: bool,
/// Checks whether the creator of the Commit has the rights to edit the Resource.
pub validate_rights: bool,
/// Checks whether the previous Commit applied to the resource matches the one mentioned in the Commit/
/// This makes sure that the Commit is not applied twice, or that the one creating it had a faulty state.
pub validate_previous_commit: bool,
/// Detects commits whose Loro update's writes silently lost LWW against
/// the stored state — i.e. the client's Loro doc wasn't seeded from the
/// server's current state, so its ops are concurrent with stored ops and
/// get dropped by Loro's conflict resolution. When this happens, the
/// commit would "succeed" but the server-visible state wouldn't reflect
/// the client's intent. With this enabled, we reject such commits so the
/// client can refetch and retry.
///
/// Turn off for true multi-peer sync (mesh/Iroh) where concurrent writes
/// are expected and LWW is the correct resolution.
pub validate_loro_causality: bool,
/// Updates the indexes in the Store. Is a bit more costly.
pub update_index: bool,
/// For who the right checks will be perormed. If empty, the signer of the Commit will be used.
pub validate_for_agent: Option<String>,
/// Optional transport/source identity for echo suppression.
pub source_id: Option<String>,
}
impl CommitOpts {
pub fn no_validations_no_index() -> Self {
Self {
validate_schema: false,
validate_signature: false,
validate_timestamp: false,
validate_rights: false,
validate_previous_commit: false,
validate_loro_causality: false,
update_index: false,
validate_for_agent: None,
source_id: None,
}
}
}
/// A Commit is a set of changes to a Resource.
/// Use CommitBuilder if you're programmatically constructing a Delta.
#[derive(Clone, Serialize)]
pub struct Commit {
/// The subject URL that is to be modified by this Delta
#[serde(rename = "https://atomicdata.dev/properties/subject")]
pub subject: Subject,
/// The date it was created, as a unix timestamp
#[serde(rename = "https://atomicdata.dev/properties/createdAt")]
pub created_at: i64,
/// The URL of the one signing this Commit
#[serde(rename = "https://atomicdata.dev/properties/signer")]
pub signer: Subject,
/// A Loro CRDT binary update for the entire resource document
#[serde(rename = "https://atomicdata.dev/properties/loroUpdate")]
pub loro_update: Option<Vec<u8>>,
/// If set to true, deletes the entire resource
#[serde(rename = "https://atomicdata.dev/properties/destroy")]
pub destroy: Option<bool>,
/// Base64 encoded signature of the JSON serialized Commit
#[serde(rename = "https://atomicdata.dev/properties/signature")]
pub signature: Option<String>,
/// The previously applied commit to this Resource.
#[serde(rename = "https://atomicdata.dev/properties/previousCommit")]
pub previous_commit: Option<String>,
/// Whether this is the first commit for a Resource.
#[serde(rename = "https://atomicdata.dev/properties/isGenesis")]
pub is_genesis: Option<bool>,
/// The URL of the Commit
pub url: Option<String>,
}
impl std::fmt::Debug for Commit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Commit")
.field("subject", &self.subject)
.field("created_at", &self.created_at)
.field("signer", &self.signer)
.field(
"loro_update",
&self
.loro_update
.as_ref()
.map(|v| format!("<{} bytes>", v.len())),
)
.field("destroy", &self.destroy)
.field("signature", &self.signature)
.field("previous_commit", &self.previous_commit)
.field("is_genesis", &self.is_genesis)
.field("url", &self.url)
.finish()
}
}
impl Commit {
/// Throws an error if the parent is set to itself
pub fn check_for_circular_parents(&self) -> AtomicResult<()> {
// Check if the Loro update contains a parent property that matches the subject.
if let Some(loro_bytes) = &self.loro_update {
let doc = crate::loro::AtomicLoroDoc::from_snapshot(loro_bytes).or_else(|_| {
let doc = crate::loro::AtomicLoroDoc::new();
doc.import_update(loro_bytes)?;
Ok::<_, crate::errors::AtomicError>(doc)
})?;
if let Some(parent) = doc.get_string_property(urls::PARENT) {
if parent == self.subject {
return Err("Circular parent reference".into());
}
}
}
Ok(())
}
/// Rejects a Loro update that writes a property only the server itself may
/// assign. `internalId` is the motivating case: it identifies the blob a
/// File resource resolves to, assigned once by `/upload`; letting a client
/// overwrite it on an existing resource would repoint that resource at an
/// arbitrary (or nonexistent) blob.
///
/// Decodes the update in isolation (same approach as the semantic-no-op
/// check above) to see which properties THIS update writes — works
/// cleanly for snapshots, may miss a pure delta that only touches
/// properties not present in this update's own ops.
pub fn check_server_managed_properties(&self) -> AtomicResult<()> {
const SERVER_MANAGED_PROPERTIES: &[&str] = &[urls::INTERNAL_ID];
let Some(loro_bytes) = &self.loro_update else {
return Ok(());
};
let doc = crate::loro::AtomicLoroDoc::new();
let _ = doc.import_update(loro_bytes);
let written = doc.get_all_properties();
for prop in SERVER_MANAGED_PROPERTIES {
if written.contains_key(*prop) {
return Err(format!(
"Property '{}' is managed by the server and cannot be set directly.",
prop
)
.into());
}
}
Ok(())
}
pub fn validate_previous_commit(
&self,
resource_old: &Resource,
subject_url: &str,
) -> AtomicResult<()> {
let commit = self;
if let Ok(last_commit_val) = resource_old.get(urls::LAST_COMMIT) {
let last_commit = last_commit_val.to_string();
if let Some(prev_commit) = commit.previous_commit.clone() {
// TODO: try auto merge
if last_commit != prev_commit {
return Err(format!(
"previousCommit mismatch. Had lastCommit '{}' in Resource {}, but got in Commit '{}'. Perhaps you created the Commit based on an outdated version of the Resource.",
last_commit, subject_url, prev_commit,
)
.into());
}
} else {
return Err(format!("Missing `previousCommit`. Resource {} already exists, and it has a `lastCommit` field, so a `previousCommit` field is required in your Commit.", commit.subject).into());
}
} else {
// If there is no lastCommit in the Resource, we'll accept the Commit.
tracing::warn!("No `lastCommit` in Resource. This can be a bug, or it could be that the resource was never properly updated.");
}
Ok(())
}
/// Creates a new Commit with a `did:ad` Subject.
/// The ID of the Subject is the signature of the Commit.
pub async fn create_did(
mut commit_builder: CommitBuilder,
agent: &crate::agents::Agent,
store: &impl Storelike,
) -> AtomicResult<Commit> {
let now = crate::utils::now();
// Create a temporary commit with empty signature and subject
// The subject is needed for serialization, but it will be removed for the signature check (and thus creation)
let temp_subject = "did:ad:genesis".to_string();
commit_builder.subject = temp_subject.clone().into();
// Race-free rights: stamp the resource's `drive` at genesis so a child's
// rights check can consult the (stable) drive grant directly instead of
// walking a parent chain that may not be materialized yet under
// concurrent creation (the parent-before-child 401 race). The drive is
// the parent's drive, or the parent itself when the parent is a drive
// root. Top-level resources (no parent) ARE their own drive — skip.
if !commit_builder.set.contains_key(urls::DRIVE_PROP) {
if let Some(parent_val) = commit_builder.set.get(urls::PARENT).cloned() {
let parent_subject = crate::Subject::from(parent_val.to_string());
if let Ok(parent_res) = store.get_resource(&parent_subject).await {
let drive = match parent_res.get(urls::DRIVE_PROP) {
Ok(d) => d.to_string(),
Err(_) => parent_subject.to_string(),
};
commit_builder.set.insert(
urls::DRIVE_PROP.into(),
crate::values::Value::AtomicUrl(drive.into()),
);
}
}
}
// ---- Self-verifying genesis certificate ----
// The resource's identity (DID) is the agent's Ed25519 signature over a
// compact binary cert (signer, createdAt, nonce, parent, drive), stored
// inline as the immutable `genesis` propval. This makes authorship +
// identity verifiable offline, with no commit fetch. The cert — NOT the
// commit — is what the DID is derived from. See
// `planning/genesis-self-verifying.md`.
let private_key = agent.private_key.clone().ok_or("No private key in agent")?;
let signer_pubkey: [u8; 32] = crate::agents::decode_base64(&agent.public_key)?
.try_into()
.map_err(|_| "Agent public key must be 32 bytes for the genesis certificate")?;
let mut nonce = [0u8; 16];
{
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut nonce);
}
let parent = commit_builder
.set
.get(urls::PARENT)
.map(|v| v.to_string())
.unwrap_or_default();
let drive = commit_builder
.set
.get(urls::DRIVE_PROP)
.map(|v| v.to_string())
.unwrap_or_default();
let cert = crate::genesis::GenesisCert {
signer_pubkey,
created_at: now,
nonce,
state_hash: None,
parent,
drive,
};
let cert_b64 = crate::agents::encode_base64(&cert.encode());
let genesis_signature = cert.sign(&private_key)?;
let did = crate::genesis::GenesisCert::subject_for_signature(&genesis_signature);
// Build the loro snapshot WITH the `genesis` propval — whether or not a
// loro_update was pre-set (e.g. by `save_remote`). The cert rides inline.
let doc = crate::loro::AtomicLoroDoc::new();
if let Some(update) = &commit_builder.loro_update {
doc.import_update(update)?;
} else {
for (prop, val) in &commit_builder.set {
doc.set_property(prop, val)?;
}
for prop in &commit_builder.remove {
doc.remove_property(prop)?;
}
}
doc.set_property(urls::GENESIS, &crate::values::Value::String(cert_b64))?;
let loro_update = Some(doc.export_snapshot());
let mut commit = Commit {
subject: temp_subject.into(),
signer: agent.subject.clone(),
loro_update,
destroy: Some(commit_builder.destroy),
created_at: now,
previous_commit: None,
is_genesis: Some(true),
signature: None,
url: None,
};
// The commit also carries a CONTENT signature (authorship of the initial
// state) — distinct from the cert signature that mints the DID. Genesis
// commits serialize without the subject, so deriving the subject from the
// cert below does not affect this signature.
let stringified = commit
.serialize_deterministically_json_ad(store)
.await
.map_err(|e| format!("Failed serializing commit: {}", e))?;
let signature =
sign_message(&stringified, &private_key, &agent.public_key).map_err(|e| {
format!(
"Failed to sign message for new did:ad commit with agent {}: {}",
agent.subject, e
)
})?;
commit.signature = Some(signature);
commit.subject = did.into();
Ok(commit)
}
/// Check if the Commit's signature matches the signer's public key.
pub async fn validate_signature(&self, store: &impl Storelike) -> AtomicResult<()> {
let commit = self;
let signature = match commit.signature.as_ref() {
Some(sig) => sig,
None => return Err("No signature set".into()),
};
let signer_subject = store.normalize_subject(&commit.signer);
// For agent DIDs, the public key IS the DID — extract directly.
let pubkey_b64 = if commit.signer.is_agent_did() {
commit
.signer
.as_str()
.strip_prefix("did:ad:agent:")
.ok_or("Invalid did:ad:agent signer")?
.to_string()
} else if let Ok(resource) = store.get_resource(&signer_subject).await {
resource.get(urls::PUBLIC_KEY)?.to_string()
} else if let crate::Subject::Internal { url, .. } = &signer_subject {
// Legacy HTTP agents: extract key from URL path
let path = url.path();
if path.starts_with("/agents/") {
path.strip_prefix("/agents/").unwrap().to_string()
} else {
return Err(format!("Signer {} not found in store", commit.signer).into());
}
} else {
return Err(format!(
"Signer {} not found and cannot extract public key",
commit.signer
)
.into());
};
let agent_pubkey = decode_base64(&pubkey_b64)?;
let stringified_commit = commit.serialize_deterministically_json_ad(store).await?;
let pubkey_bytes: [u8; 32] = agent_pubkey
.try_into()
.map_err(|_| "Ed25519 public key must be 32 bytes")?;
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|e| format!("Invalid public key: {}", e))?;
let signature_bytes = decode_base64(signature)?;
let sig_bytes: [u8; 64] = signature_bytes
.try_into()
.map_err(|_| "Ed25519 signature must be 64 bytes")?;
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
use ed25519_dalek::Verifier;
verifying_key
.verify(stringified_commit.as_bytes(), &sig)
.map_err(|_e| {
format!(
"Incorrect signature for Commit. This could be due to an error during signing or serialization of the commit. Compare this to the serialized commit in the server: {}",
stringified_commit,
)
})?;
// For a genesis DID resource, identity is verified one of two ways
// (dual-accept, during the migration to self-verifying certs):
//
// 1. SELF-VERIFYING CERTIFICATE (preferred): the subject `did:ad:<sig>`
// is the agent's signature over a compact binary `GenesisCert`,
// carried inline as the `genesis` propval. Verify the cert and that
// its signer is this commit's signer. Server-minted resources take
// this path (see planning/genesis-self-verifying.md).
//
// 2. LEGACY commit-signature DID: the subject `did:ad:<sig>` is the
// agent's signature over the genesis *commit* itself. Browser-minted
// resources still take this path until the client mints certs.
//
// The discriminator is the explicit `is_genesis: true` flag — NOT
// `previous_commit.is_none()`, which is also true for destroy and other
// non-genesis commits. Agent DIDs (did:ad:agent:{pubkey}) are
// identity-based and exempt.
if commit.is_genesis == Some(true)
&& commit.subject.is_did()
&& !commit.subject.is_agent_did()
{
let subject_val = commit
.subject
.as_str()
.strip_prefix("did:ad:")
.ok_or("Invalid did:ad subject")?;
let cert_b64 = commit
.loro_update
.as_ref()
.and_then(|u| crate::Resource::genesis_cert_b64_from_loro_update(u));
if let Some(cert_b64) = cert_b64 {
// Path 1: self-verifying genesis certificate.
let cert_bytes = decode_base64(&cert_b64)?;
let cert = crate::genesis::GenesisCert::decode(&cert_bytes)?;
cert.verify(subject_val)?;
if cert.signer_pubkey != pubkey_bytes {
return Err(
"Genesis certificate signer does not match the commit signer".into(),
);
}
} else if subject_val != signature {
// Path 2: legacy commit-signature DID.
return Err(format!(
"Invalid did:ad subject. Expected 'did:ad:{}' but got '{}'",
signature, commit.subject
)
.into());
}
}
Ok(())
}
/// Performs the checks specified in CommitOpts and constructs a new Resource.
/// Warning: Does not save the new resource to the Store - doet not delete if it `destroy: true`.
/// Use [Storelike::apply_commit] to save the resource to the Store.
pub async fn validate_and_build_response(
self,
opts: &CommitOpts,
store: &impl Storelike,
) -> AtomicResult<CommitResponse> {
let commit = self;
let subject = commit.subject.clone();
if subject.is_did() && subject.as_str().starts_with("did:ad:") {
let pure_id = subject.pure_id();
let b64_part = if subject.is_agent_did() {
pure_id.strip_prefix("did:ad:agent:")
} else if subject.is_commit_did() {
pure_id.strip_prefix("did:ad:commit:")
} else {
pure_id.strip_prefix("did:ad:")
}
.ok_or("Invalid DID format")?;
let decoded = crate::agents::decode_base64(b64_part)
.map_err(|_| "Invalid DID: not valid base64")?;
let expected_len = if subject.is_agent_did() { 32 } else { 64 };
if decoded.len() != expected_len {
return Err(format!(
"Invalid DID: expected {} bytes, got {}. DID subjects cannot contain a path.",
expected_len,
decoded.len()
)
.into());
}
}
let subject_url = match &subject {
Subject::Internal { url, .. } => url.clone(),
Subject::External(u) => u.clone(),
Subject::Did { url, .. } => url.clone(),
};
if subject_url.query().is_some() {
return Err("Subject URL cannot have query parameters".into());
}
if opts.validate_signature {
commit.validate_signature(store).await?;
}
if opts.validate_timestamp {
commit.validate_timestamp()?;
}
commit.check_for_circular_parents()?;
// Create a new resource if it doesn't exist yet.
// For agent DIDs, get_resource() returns a synthetic "just-in-time" agent
// even when no data is stored. Detect this by checking for a lastCommit —
// a real stored resource always has one after its genesis commit.
let (resource_old, is_new) = match store.get_resource(&commit.subject.clone()).await {
Ok(rs) => {
let is_synthetic_agent =
commit.subject.is_agent_did() && rs.get(urls::LAST_COMMIT).is_err();
if is_synthetic_agent {
// Treat synthetic fallback agents as non-existent so genesis
// commits work and the Loro doc is built from scratch.
(
Resource::new(store.normalize_subject(&commit.subject.clone()).to_string()),
true,
)
} else {
(rs, false)
}
}
Err(_) => (
Resource::new(store.normalize_subject(&commit.subject.clone()).to_string()),
true,
),
};
if let Some(explicit_genesis) = commit.is_genesis {
if explicit_genesis && !is_new {
return Err(format!(
"Commit for {} has is_genesis: true, but the resource already exists.",
commit.subject
)
.into());
}
if !explicit_genesis && is_new {
return Err(format!(
"Commit for {} has is_genesis: false, but the resource does not exist yet.",
commit.subject
)
.into());
}
}
// `previous_commit` is recorded on every commit for audit / history
// navigation, but it is NOT a validation gate. Concurrency is handled
// by the Loro CRDT itself: each commit's `loro_update` carries the
// op's peer-scoped Lamport clock, and concurrent edits merge
// deterministically — there is no single linear chain to enforce.
//
// The previous behaviour ("commit's `previousCommit` must equal the
// resource's current `lastCommit`") was a Git-style optimistic-
// concurrency check that fought the CRDT semantics: under any real
// concurrent edit (two peers committing without seeing each other),
// one of them would be rejected even though Loro could merge them
// perfectly. It also produced a leaky wire-protocol invariant — the
// client had to round-trip `lastCommit` through every code path or
// its next commit would 500.
//
// The is-genesis distinction below stays — that's about identity
// (subject = signature), not ordering.
let _ = opts.validate_previous_commit;
// Reject commits that carry no Loro update and aren't a destroy.
// Loro is the single source of truth for all user data; a commit
// without it cannot change any searchable state. Previously, such
// commits (typically legacy `set`/`push` bodies from old client code)
// appeared to succeed but left the resource un-indexed — the search
// index read from propvals, which only get materialized when Loro
// imports fire. A destroy commit is the one exception.
let is_destroy = commit.destroy.unwrap_or(false);
if commit.loro_update.is_none() && !is_destroy {
return Err(format!(
"Commit for {} has no `loroUpdate` and is not a destroy. Loro \
is required for all state-changing commits — legacy `set` / \
`push` / `remove` maps are not applied. Please upgrade the \
client to send Loro updates.",
commit.subject
)
.into());
}
// `validate_rights` is only false for commits the server builds and signs
// itself (e.g. Resource::save from a handler like /upload) - those are
// trusted. Anything that reaches here with `validate_rights: true` came in
// as a signed Commit from a client, however privileged, and must not be able
// to set properties the server alone is supposed to manage.
if opts.validate_rights {
commit.check_server_managed_properties()?;
}
let mut applied = commit
.apply_changes(resource_old.clone())
.await
.map_err(|e| {
format!(
"Error applying changes to Resource {}. {}",
commit.subject, e
)
})?;
// NOTE: `createdAt` / `createdBy` are server-managed creation metadata
// (materialized from the genesis oplog change). We do NOT reject commits
// that carry them: the materialized values round-trip back to clients in
// JSON-AD, so a later edit legitimately re-sends them (e.g. saving an
// agent's name). Rejecting broke those saves. Forge-resistance is the
// job of the genesis certificate (`planning/genesis-self-verifying.md`),
// where identity metadata is signed into the DID, not a settable propval.
// Causality guard: a commit with a non-trivial loroUpdate that
// produces ZERO net state change.
//
// Two cases look identical at the projection level but mean opposite
// things, so they must be distinguished:
//
// 1. Idempotent replay — the commit's ops are already in the doc's
// oplog (importing the update did not advance the version
// vector). Re-applying it changed nothing because there was
// nothing new to apply. This is correct and safe — Loro
// deduplicates ops by ID — so ACCEPT. The browser outbox relies
// on this when it retransmits a commit the server already has.
//
// 2. Silent LWW loss — the commit's ops ARE new (the VV advanced)
// but lost last-writer-wins against stored state, contributing
// nothing. Happens when the client's Loro doc was not seeded
// from the server's state (fresh peer ID, concurrent writes).
// REJECT so the silent data loss surfaces.
//
// Exemptions:
// - destroy commits (no Loro merge to evaluate).
// - tiny/empty loroUpdate (client didn't really try to write).
// - genesis commits (is_new): no stored state to lose to.
if opts.validate_loro_causality
&& !is_new
&& !commit.destroy.unwrap_or(false)
&& commit.loro_update.as_ref().map(|b| b.len()).unwrap_or(0) > 16
&& applied.add_atoms.is_empty()
&& applied.remove_atoms.is_empty()
{
if !applied.imported_new_ops {
// Case 1: every op was already present — idempotent replay.
tracing::debug!(
subject = %commit.subject,
"[causality-guard] accepting idempotent replay (ops already in oplog)"
);
} else {
// The ops were new but produced no atom change. Decode the
// incoming update in isolation to see what the client
// INTENDED to write. Works cleanly for snapshots; may be
// empty for pure deltas.
let incoming_intent = commit
.loro_update
.as_ref()
.map(|bytes| {
let doc = crate::loro::AtomicLoroDoc::new();
let _ = doc.import_update(bytes);
doc.get_all_properties()
})
.unwrap_or_default();
let merged_doc = applied.resource_new.build_state_doc()?;
let merged_state = merged_doc.get_all_properties();
// Semantic no-op: the client re-set values that already match
// stored state (e.g. a UI flow calls `set(x, v)` with the
// current `v`, then saves). New ops, but no real change —
// accept rather than reject.
//
// `lastCommit` and `createdAt` are SERVER-MANAGED in the
// client snapshot: the client's `setLastCommitValue` writes
// its own view of the latest commit (whichever commit it
// last received via WS). With concurrent peers (e.g. two
// tabs on the same agent), the server's `lastCommit`
// races ahead of the client's between Tab A's save landing
// and Tab B's snapshot export. Comparing those values is
// guaranteed to mismatch under concurrent writes and
// produce a spurious reject — the client never *intended*
// to write that value, it's just a side-effect of how
// `applyIncoming` stores commit metadata in the Loro doc.
// Skip both in the all-match check; only user-controlled
// properties need to round-trip cleanly for the guard to
// mean what its name claims.
let server_managed: &[&str] = &[crate::urls::LAST_COMMIT, crate::urls::CREATED_AT];
// Empty `incoming_intent` means the loroUpdate didn't write
// any *propvals* — but Loro docs can carry non-propval state
// (TipTap document body via `loro-prosemirror` containers,
// canvas stroke trees, etc.) that lives outside the
// `properties` map this guard reads from. Rejecting on
// empty intent would block every document/canvas content
// edit (`documents.spec.ts:25` regression — heading inserts
// never reach the server). The fact that `imported_new_ops`
// was true here proves the commit *did* contribute work to
// the Loro doc; we just can't observe it through the
// propval projection. Accept and trust Loro CRDT.
let all_match = if incoming_intent.is_empty() {
true
} else {
incoming_intent.iter().all(|(key, incoming_val)| {
if server_managed.contains(&key.as_str()) {
return true;
}
merged_state.get(key).is_some_and(|mv| mv == incoming_val)
})
};
if all_match {
tracing::debug!(
subject = %commit.subject,
keys = ?incoming_intent.keys().collect::<Vec<_>>(),
empty_intent = incoming_intent.is_empty(),
"[causality-guard] accepting commit (propval intent is empty or matches stored state)"
);
} else {
tracing::warn!(
subject = %commit.subject,
loro_bytes = commit.loro_update.as_ref().map(|b| b.len()).unwrap_or(0),
incoming_intent = ?incoming_intent,
merged_state = ?merged_state,
"[causality-guard] rejecting commit with non-trivial loroUpdate that produced no state changes (silent LWW loss)"
);
return Err(format!(
"Commit's Loro update produced no state changes — its writes were \
silently dropped by LWW against stored state. The client's Loro doc \
wasn't seeded from the server's current state. Refetch the resource \
and retry the commit. subject={} incoming_intent={:?} merged_state_keys={:?}",
commit.subject,
incoming_intent
.iter()
.map(|(k, v)| format!("{k} = {v:?}"))
.collect::<Vec<_>>(),
merged_state.keys().collect::<Vec<_>>(),
)
.into());
}
}
}
if opts.validate_rights {
let signer_str = commit.signer.to_string();
let validate_for = opts.validate_for_agent.as_ref().unwrap_or(&signer_str);
if is_new {
crate::hierarchy::check_append(store, &applied.resource_new, &validate_for.into())
.await?;
// F11 (planning/unified-sync.md): this subject just passed a
// rights-checked genesis — if it was previously destroyed
// (and thus tombstoned to stop bulk-sync from resurrecting
// it), that invariant is now stale. Clear it so this
// legitimate re-create isn't invisible to future
// `SYNC_PUSH`/`SYNC_VV` bulk-sync with other replicas
// (`is_tombstoned` would otherwise keep skipping it there
// forever). No-op if there was nothing to clear.
store.clear_tombstone(commit.subject.as_str());
// For new DID resources, grant the signer explicit write access so future
// commits don't need drive-level rights. Agents are excluded because they
// already have self-write via their subject matching the agent check.
if matches!(applied.resource_new.get_subject(), Subject::Did { .. }) {
let is_agent = applied
.resource_new
.get(urls::IS_A)
.ok()
.and_then(|v| v.to_subjects(None).ok())
.unwrap_or_default()
.iter()
.any(|c| c == urls::AGENT);
if !is_agent {
let mut writers: Vec<String> = applied
.resource_new
.get(urls::WRITE)
.ok()
.and_then(|v| v.to_subjects(None).ok())
.unwrap_or_default();
if !writers.contains(&signer_str) {
writers.push(signer_str.clone());
applied
.resource_new
.set_unsafe(urls::WRITE.into(), writers.into())?;
}
}
}
} else {
// This should use the _old_ resource, not the new one, as the new one might maliciously give itself write rights.
crate::hierarchy::check_write(store, &resource_old, &validate_for.into()).await?;
}
// `drive` is a rights shortcut: `check_rights` consults it *before* it
// walks the parent chain. It must therefore always agree with the
// current parent, and must be derived here rather than trusted from
// the client. Re-derive it at genesis and on any commit that moves the
// resource — otherwise a resource moved out of a publicly readable
// drive keeps that drive's grants and stays publicly readable from its
// new, private home.
//
// Deriving it also covers creation paths that never stamped it — a
// guest replying in a drive shared with them — which the commit fan-out
// needs in order to route to the owning drive's subscribers. See
// planning/commit-fanout-drive-isolation.md.
let parent_changed = applied.changed_props.iter().any(|p| p == urls::PARENT);
if is_new || parent_changed {
if let Ok(parent_val) = applied.resource_new.get(urls::PARENT) {
let parent_subject = crate::Subject::from(parent_val.to_string());
// If the parent isn't materialized here we cannot derive the
// drive. Leave whatever was stamped rather than clearing it.
if let Ok(parent_res) = store.get_resource(&parent_subject).await {
let drive = match parent_res.get(urls::DRIVE_PROP) {
Ok(d) => d.to_string(),
Err(_) => parent_subject.to_string(),
};
applied.resource_new.set_unsafe(
urls::DRIVE_PROP.into(),
crate::values::Value::AtomicUrl(drive.into()),
)?;
}
}
}
// Managed admission gate. No-op under the default OpenPolicy, so
// self-hosted / FOSS is unaffected. On a managed node, the drive this
// commit belongs to must be enrolled (allowlist + quota), with a
// bootstrap grace so a freshly-created drive can sync while its
// enrollment propagates. Agents (`did:ad:agent:…`) are exempt — they
// are outside the enrollment model, which is exactly what a naïve
// drive check got wrong before.
//
// The exemption MUST be keyed on the commit's own subject structure
// (`is_agent_did`), never on a claimed `IS_A` value: `IS_A` is an
// ordinary, fully client-controlled property with no required-props
// gate on the `Agent` class, so checking it here would let any client
// skip the gate for arbitrary data by tagging it `IS_A: [Agent]`.
{
let res = &applied.resource_new;
let is_agent = commit.subject.is_agent_did();
if !is_agent {
// The drive this resource belongs to: its `drive` stamp, or
// (a drive root / top-level resource) its own subject.
let drive_subject = res
.get(urls::DRIVE_PROP)
.map(|v| v.to_string())
.unwrap_or_else(|_| res.get_subject().to_string());
match store.sync_policy().admit_decision(&drive_subject) {
crate::sync::policy::AdmitDecision::Admitted => {}
crate::sync::policy::AdmitDecision::NotEnrolled => {
return Err(format!(
"Drive {drive_subject} is not enrolled for sync on this node."
)
.into());
}
crate::sync::policy::AdmitDecision::OverQuota => {
return Err(format!(
"Drive {drive_subject} has reached its storage quota on this node."
)
.into());
}
}
}
}
};
// Check if all required props are there
if opts.validate_schema {
applied.resource_new.check_required_props(store).await?;
}
let commit_resource: Resource = commit.into_resource(store).await?;
// Set the `lastCommit` to the newly created Commit
applied
.resource_new
.set(
urls::LAST_COMMIT.to_string(),
Value::AtomicUrl(commit_resource.get_subject().clone()),
store,
)
.await?;
let destroyed = commit.destroy.unwrap_or(false);
Ok(CommitResponse {
commit,
add_atoms: applied.add_atoms,
remove_atoms: applied.remove_atoms,
commit_resource,
resource_new: if destroyed {
None
} else {
Some(applied.resource_new)
},
resource_old: if is_new {
None
} else {
Some(applied.resource_old)
},
changed_props: applied.changed_props,
source_id: opts.source_id.clone(),
})
}
/// Checks if the Commit has been created in the future or if it is expired.
#[tracing::instrument(skip_all)]
pub fn validate_timestamp(&self) -> AtomicResult<()> {
crate::utils::check_timestamp_in_past(self.created_at, ACCEPTABLE_TIME_DIFFERENCE)
}
/// Applies the Loro CRDT update and/or destroy to the Resource.
/// Returns the diff as atoms for index updates, plus the set of changed property URLs.
#[tracing::instrument(skip_all)]
pub async fn apply_changes(&self, mut resource: Resource) -> AtomicResult<CommitApplied> {
let resource_unedited = resource.clone();
let mut remove_atoms: Vec<Atom> = Vec::new();
let mut add_atoms: Vec<Atom> = Vec::new();
let mut changed_props: HashSet<String> = HashSet::new();
let mut imported_new_ops = false;
if let Some(loro_update_bytes) = &self.loro_update {
// Seed from the current resource state when no snapshot exists yet so
// older resources can still apply snapshot/delta updates correctly.
let loro_doc = resource.build_state_doc()?;
// Whether the import actually advances the oplog tells idempotent
// replay (every op already present → VV unchanged) apart from a
// genuine new write. See the causality guard in `apply_commit`.
let vv_before = loro_doc.oplog_vv_map();
// Import the update and compute the property-level diff for indexing
let diff = loro_doc
.import_update_with_diff(loro_update_bytes, &resource.get_subject().to_string())?;
imported_new_ops = loro_doc.oplog_vv_map() != vv_before;
// Track which properties changed
for atom in &diff.add_atoms {
changed_props.insert(atom.property.clone());
}
for atom in &diff.remove_atoms {
changed_props.insert(atom.property.clone());
}
add_atoms.extend(diff.add_atoms);
remove_atoms.extend(diff.remove_atoms);
// Rebuild the materialized resource state from the merged Loro doc so
// deleted properties disappear from propvals as well.
resource.apply_state_doc(loro_doc)?;
}
// Remove all atoms from index if destroy
if let Some(destroy) = self.destroy {
if destroy {
for atom in resource.to_atoms().into_iter() {
remove_atoms.push(atom);
}
}
}
Ok(CommitApplied {
resource_old: resource_unedited,
resource_new: resource,
add_atoms,
remove_atoms,
changed_props,
imported_new_ops,
})
}
/// Converts a Resource of a Commit into a Commit
pub fn from_resource(resource: Resource) -> AtomicResult<Commit> {