-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy pathcommit.rs
More file actions
2196 lines (2017 loc) · 90.4 KB
/
Copy pathcommit.rs
File metadata and controls
2196 lines (2017 loc) · 90.4 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>,
}
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(())
}
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();
let loro_update = if let Some(update) = commit_builder.loro_update {
Some(update)
} else if !commit_builder.set.is_empty() || !commit_builder.remove.is_empty() {
let doc = crate::loro::AtomicLoroDoc::new();
for (prop, val) in &commit_builder.set {
doc.set_property(prop, val)?;
}
for prop in &commit_builder.remove {
doc.remove_property(prop)?;
}
Some(doc.export_snapshot())
} else {
None
};
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,
};
// Serialize without subject
let stringified = commit
.serialize_deterministically_json_ad(store)
.await
.map_err(|e| format!("Failed serializing commit: {}", e))?;
let private_key = agent.private_key.clone().ok_or("No private key in agent")?;
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.clone());
let did = format!("did:ad:{}", 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 genesis resource commits, the subject is DERIVED from the
// signature (did:ad:{signature}), so the two must match. The
// discriminator is the explicit `is_genesis: true` flag — NOT
// `previous_commit.is_none()`, which is also true for destroy
// commits and any other non-genesis commit now that we no longer
// require previousCommit chaining for validation.
// 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")?;
if subject_val != signature {
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());
}
let mut applied = commit
.apply_changes(resource_old.clone())
.await
.map_err(|e| {
format!(
"Error applying changes to Resource {}. {}",
commit.subject, e
)
})?;
// 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];
let all_match = !incoming_intent.is_empty()
&& 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<_>>(),
"[causality-guard] accepting semantic no-op commit (values match 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?;
// 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?;
}
};
// 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> {
let subject = resource.get(urls::SUBJECT)?.to_string();
let created_at = resource.get(urls::CREATED_AT)?.to_int()?;
let signer = resource.get(SIGNER)?.to_string();
let loro_update = match resource.get(urls::LORO_UPDATE) {
Ok(Value::LoroDoc(bin)) => Some(bin.clone()),
_ => None,
};
let destroy = match resource.get(urls::DESTROY) {
Ok(found) => Some(found.to_bool()?),
Err(_) => None,
};
let previous_commit = match resource.get(urls::PREVIOUS_COMMIT) {
Ok(found) => Some(found.to_string()),
Err(_) => None,
};
let is_genesis = match resource.get(urls::IS_GENESIS) {
Ok(found) => Some(found.to_bool()?),
Err(_) => None,
};
let signature = resource.get(urls::SIGNATURE)?.to_string();
let url = Some(resource.get_subject().to_string());
Ok(Commit {
subject: subject.into(),
created_at,
signer: signer.into(),
loro_update,
destroy,
previous_commit,
is_genesis,
signature: Some(signature),
url,
})
}
/// Converts the Commit into a Resource with Atomic Values.
/// Creates an identifier using the server_url
/// Works for both Signed and Unsigned Commits
#[tracing::instrument(skip_all)]
pub async fn into_resource(&self, store: &impl Storelike) -> AtomicResult<Resource> {
let commit_subject = match self.signature.as_ref() {
Some(sig) => format!("did:ad:commit:{}", sig),
None => {
let now = crate::utils::now();
format!("internal:/commitsUnsigned/{}", now)
}
};
// `new_instance(COMMIT, …)` already set `isA: Commit`, so the
// resource is `is_native()` from here on: every `set_unsafe`
// below takes the propval-only branch and never materializes a Loro
// state doc. That is exactly what keeps the commit's `loroUpdate`
// (its signed payload) from being re-derived as a doc snapshot.
let mut resource = Resource::new_instance(urls::COMMIT, store).await?;
resource.set_subject(commit_subject);
resource.set_unsafe(
urls::SUBJECT.into(),
Value::new(self.subject.as_str(), &DataType::AtomicUrl)?,
)?;
let classes = vec![urls::COMMIT.to_string()];
resource.set_unsafe(urls::IS_A.into(), classes.into())?;
resource.set_unsafe(
urls::CREATED_AT.into(),
Value::new(&self.created_at.to_string(), &DataType::Timestamp)?,
)?;
resource.set_unsafe(
SIGNER.into(),
Value::new(self.signer.as_str(), &DataType::AtomicUrl)?,
)?;
if let Some(destroy) = self.destroy {
if destroy {
resource.set_unsafe(urls::DESTROY.into(), true.into())?;
}
}
if let Some(previous_commit) = &self.previous_commit {
resource.set_unsafe(
urls::PREVIOUS_COMMIT.into(),
Value::AtomicUrl(previous_commit.clone().into()),
)?;
}
if let Some(is_genesis) = self.is_genesis {
resource.set_unsafe(urls::IS_GENESIS.into(), is_genesis.into())?;
}
if let Some(loro_update) = &self.loro_update {
if !loro_update.is_empty() {
resource.set_unsafe(
urls::LORO_UPDATE.into(),
Value::LoroDoc(loro_update.clone()),
)?;
}
}
resource.set_unsafe(
SIGNER.into(),
Value::new(self.signer.as_str(), &DataType::AtomicUrl)?,
)?;
if let Some(signature) = &self.signature {
resource.set_unsafe(urls::SIGNATURE.into(), signature.clone().into())?;
}
Ok(resource)
}
pub fn get_subject(&self) -> &Subject {
&self.subject
}
/// Generates a deterministic serialized JSON-AD representation of the Commit.
/// Removes the signature from the object before serializing, since this function is used to check if the signature is correct.
#[tracing::instrument(skip_all)]
pub async fn serialize_deterministically_json_ad(
&self,
store: &impl Storelike,
) -> AtomicResult<String> {
let mut commit_resource = self.into_resource(store).await?;
// A deterministic serialization should not contain the hash (signature), since that would influence the hash.
commit_resource.remove_propval(urls::SIGNATURE)?;
let is_genesis_flag = self.is_genesis == Some(true);
let has_previous = self.previous_commit.is_some();
// The is_genesis flag is what distinguishes signing conventions
// (genesis signs without `subject`; non-genesis signs with it),
// so the two states must be internally consistent — but
// `previous_commit` is no longer treated as a validation gate;
// it's recorded as a propval for audit/history only.
if is_genesis_flag && has_previous {
return Err(format!(
"Commit has is_genesis=true but also has a previous_commit ({}). A genesis commit cannot have a predecessor.",
self.previous_commit.as_ref().unwrap()
).into());
}
// For genesis commits the subject is derived from the signature, so it
// must not be part of the signed bytes (circular dependency).
// is_genesis stays in the bytes so both sides sign/verify the same content.
if is_genesis_flag {
commit_resource.remove_propval(urls::SUBJECT)?;
}
let json_obj = crate::serialize::propvals_to_json_ad_map(
commit_resource.get_propvals(),
None,
&store
.get_base_domain()
.unwrap_or_else(|| "internal".to_string()),
false,
)?;
let json = serde_jcs::to_string(&json_obj)
.map_err(|e| format!("Failed to serialize Commit: {}", e))?;
Ok(json)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommitBuilderJSON {
pub subject: String,
pub loro_update: Option<String>,
pub destroy: bool,
pub previous_commit: Option<String>,
}
/// Use this for creating Commits.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommitBuilder {
/// The subject URL that is to be modified by this Delta.
pub subject: Subject,
/// Property changes accumulated on the server side.
/// These get converted to a Loro update at sign time.
set: std::collections::HashMap<String, Value>,
/// Properties to remove. Converted to Loro operations at sign time.
remove: HashSet<String>,
/// A Loro CRDT binary update (from client). Takes precedence over set/remove.
loro_update: Option<Vec<u8>>,
/// If set to true, deletes the entire resource
destroy: bool,
/// The previous Commit that was applied to the target resource (the subject) of this Commit.
previous_commit: Option<String>,
/// Whether this is a genesis commit (the first commit for a DID resource).
pub is_genesis: bool,
}
impl CommitBuilder {
/// Start constructing a Commit.
pub fn new(subject: Subject) -> Self {
CommitBuilder {
subject,
set: HashMap::new(),
remove: HashSet::new(),
loro_update: None,
destroy: false,
previous_commit: None,
is_genesis: false,
}
}
pub fn from_commit_builder_json(commit_builder_json: CommitBuilderJSON) -> AtomicResult<Self> {
let mut commit_builder = CommitBuilder::new(commit_builder_json.subject.into());
commit_builder.destroy(commit_builder_json.destroy);
if let Some(loro_b64) = commit_builder_json.loro_update {
let bin = crate::agents::decode_base64(&loro_b64)
.map_err(|e| format!("Invalid base64 in loro_update: {e}"))?;
commit_builder.set_loro_update(bin);
}
Ok(commit_builder)
}
/// Creates the Commit and signs it using a signature.
/// Does not send it - see [atomic_lib::client::post_commit].
/// Private key is the base64 encoded pkcs8 for the signer.
/// Sets the `previousCommit` using the `lastCommit`.
/// Returns true if this builder has any pending change that would
/// produce a non-empty commit. Used by callers (`Resource::save`,
/// `Resource::save_locally`) to skip a sign+apply round-trip when
/// the caller asked to "save" a resource that hasn't been touched —
/// `apply_commit` would otherwise reject the resulting empty commit
/// with "no `loroUpdate` and is not a destroy", which surfaces as a
/// hard error from idiomatic test code like
/// `Resource::new_generate_subject(&store).save_locally(&store)`.
pub fn has_changes(&self) -> bool {
!self.set.is_empty()
|| !self.remove.is_empty()
|| self.loro_update.is_some()
|| self.destroy
}
pub async fn sign(
mut self,
agent: &crate::agents::Agent,
store: &impl Storelike,
resource: &Resource,
) -> AtomicResult<Commit> {
if let Ok(last) = resource.get(urls::LAST_COMMIT) {
self.previous_commit = Some(last.to_string());
}
// If the resource has a live Loro doc but no snapshot was eagerly
// exported to the commit builder, export it now (single export).
// Skip when `set`/`remove` are pending — sign_at must merge those onto
// `existing_loro_snapshot`. Exporting the live doc here would freeze a
// stale snapshot and ignore commitbuilder.set (e.g. gallery folderId).
if self.loro_update.is_none() && self.set.is_empty() && self.remove.is_empty() {
if let Some(snapshot) = resource.export_open_state() {
self.loro_update = Some(snapshot);
}
}
// Pass the resource's existing Loro snapshot so sign_at can build
// incremental updates on top of it instead of creating a detached doc.
//
// Prefer the in-memory Loro doc over the persisted `loroUpdate` propval.
// `push_list_item` (strokes) updates the live doc but not the propval
// until after save. Using the propval here drops stroke edits when