field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit f69d08168d4d6278abb43a4864ec8e74167a514c
parent dd7c0ade89a0a6dca052f66f0dd37e2445fa9992
Author: triesap <tyson@radroots.org>
Date:   Sat, 12 Sep 2026 18:20:19 +0000

publication: Commit submission association atomically

- Bind immutable intents to the original composer revision
- Recover exact operation receipts before fresh policy capture
- Reject conflicting replay and preserve waiting media boundaries
- Verify crash recovery concurrency and native compatibility

Diffstat:
MTeraFFI/provenance.json | 54+++++++++++++++++++++++++++---------------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
MTeraFFI/source/aarch64-apple-ios-sim.json | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
MTeraFFI/source/aarch64-apple-ios.json | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcore/crates/tera_core/src/runtime/product_surface.rs | 8+++++---
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 6+++---
Mcore/crates/tera_core/src/runtime/product_surface/submission.rs | 20++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission/capture.rs | 6++----
Mcore/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs | 78++++++++++++++++--------------------------------------------------------------
Acore/crates/tera_core/src/runtime/product_surface/submission/commit.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs | 271+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs | 19++++++++++++++-----
Acore/crates/tera_core/src/runtime/product_surface/submission/intent.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission/repository.rs | 11++++++-----
Mcore/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs | 4+---
Mcore/crates/tera_core/src/runtime/product_surface/submission/test_support.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acore/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/fixtures/submission_intent_schema_v1.json | 36++++++++++++++++++++++++++++++++++++
Mrelease/provenance.json | 4++--
Mtest-fixtures/legacy-identifiers.v1.json | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
24 files changed, 1983 insertions(+), 212 deletions(-)

diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74824960, + "bytes": 74930824, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "b3dc43612a268870c96007622dce0965e0610b30a3f7fd37a1f21ccf88baf385" + "sha256": "84ffc4e6208ba56d66e4dd50e9754a5f601a02b2abe8b528b3348a41edbed2f1" }, { "bytes": 75137, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74891904, + "bytes": 74998704, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "2d57285bd84fcd1ccfe4e9964560cbb6954b7218072f218960a5cc1fbe164368" + "sha256": "4c43c5f3db07a01cee433e4b70153e2271182004886428e82ea2b0837b0f9f3a" }, { "bytes": 45118, @@ -77,34 +77,34 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 20960000, + "bytes": 20953872, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "6cc001b3583fe1cca19fa7a7366e35bf4c9d448345ea8ed1264b993e06a4e12f" + "sha256": "d8b29dffcd79a87320bed8d5c313279839785476feb5828b5ac1691f1f5d9598" }, { - "bytes": 74824960, + "bytes": 74930824, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "b3dc43612a268870c96007622dce0965e0610b30a3f7fd37a1f21ccf88baf385" + "sha256": "84ffc4e6208ba56d66e4dd50e9754a5f601a02b2abe8b528b3348a41edbed2f1" }, { - "bytes": 74891904, + "bytes": 74998704, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "2d57285bd84fcd1ccfe4e9964560cbb6954b7218072f218960a5cc1fbe164368" + "sha256": "4c43c5f3db07a01cee433e4b70153e2271182004886428e82ea2b0837b0f9f3a" }, { - "bytes": 84686, + "bytes": 87030, "path": "source/aarch64-apple-darwin.json", - "sha256": "859fdc632f844d8c6408c964f15219a9d1db66bd05260dec1476373b8e178b7e" + "sha256": "c997bbc68fb9a0dd98f16fd936c45a17b974e29adb1dfc370170f8a113991f0e" }, { - "bytes": 84530, + "bytes": 86874, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "9037f932d181c44626363f4e44dae9c29c8fcdcb3fe13f11b1bbc00bcdb1859b" + "sha256": "5af069739037277dd5b5f751fbdbf1b56422d569c0c3d5c192320c18f631cc78" }, { - "bytes": 84526, + "bytes": 86870, "path": "source/aarch64-apple-ios.json", - "sha256": "8b07b09c50c36f1d4ae360a024c74d9a31aa654a83bf753b0ca62e657c8eb0c8" + "sha256": "5ef0b7528f8c65cb403cf9631be1c577318303faf65ae20fda2b12497bf7a29d" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "f980edff71fa9e57d7bcfa417b3bb0ff776be01d" + "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74824960, + "bytes": 74930824, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "b3dc43612a268870c96007622dce0965e0610b30a3f7fd37a1f21ccf88baf385" + "sha256": "84ffc4e6208ba56d66e4dd50e9754a5f601a02b2abe8b528b3348a41edbed2f1" }, { "bytes": 75137, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74891904, + "bytes": 74998704, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "2d57285bd84fcd1ccfe4e9964560cbb6954b7218072f218960a5cc1fbe164368" + "sha256": "4c43c5f3db07a01cee433e4b70153e2271182004886428e82ea2b0837b0f9f3a" }, { "bytes": 592248, @@ -174,19 +174,19 @@ "sha256": "fa1cc3ee4d2ed28a8ff535e598de1b45dd2129a3260186f74c6b3d1a14069f83" }, { - "bytes": 84686, + "bytes": 87030, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "859fdc632f844d8c6408c964f15219a9d1db66bd05260dec1476373b8e178b7e" + "sha256": "c997bbc68fb9a0dd98f16fd936c45a17b974e29adb1dfc370170f8a113991f0e" }, { - "bytes": 84530, + "bytes": 86874, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "9037f932d181c44626363f4e44dae9c29c8fcdcb3fe13f11b1bbc00bcdb1859b" + "sha256": "5af069739037277dd5b5f751fbdbf1b56422d569c0c3d5c192320c18f631cc78" }, { - "bytes": 84526, + "bytes": 86870, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "8b07b09c50c36f1d4ae360a024c74d9a31aa654a83bf753b0ca62e657c8eb0c8" + "sha256": "5ef0b7528f8c65cb403cf9631be1c577318303faf65ae20fda2b12497bf7a29d" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "f980edff71fa9e57d7bcfa417b3bb0ff776be01d" -manifest_sha256 = "8d5e0e1cf044cb1d4ed1ccfd7ddb550590347d8f12350ae91b470a66514c1a8c" +source_tree = "e62cffbf482c8b38c170120dd9d4da63aab8c130" +manifest_sha256 = "4af8319178aa2945467331f9303a4b7538f7b78edf3080560a792607ddfb60c3" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -645,10 +645,10 @@ "sha256": "dff55e46521c26f5f0ece024453b55c15f132fa94f78e2cba3d141f123d4eca0" }, "core/crates/tera_core/src/runtime/product_surface.rs": { - "bytes": 7587, - "git_blob": "82c90eeb8d419354a09cf30eb60015a55820fc9f", + "bytes": 7767, + "git_blob": "6044a14cf25025ad46f2380a3c6e6af70bfbe91d", "mode": "100644", - "sha256": "28070e6a0a31bc68d47e31b9cef38f3231ada80a0dd8c3496155fe95592e8321" + "sha256": "14e1632185306b8254f9db212a1f449e49bd82c1a42af511e3cb6484d1f2aef4" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -801,10 +801,10 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 171907, - "git_blob": "dcabc95b601845caad80eafc1f43df2b47ea65c6", + "bytes": 171940, + "git_blob": "06b8856aa7de31c1bc38a1f5ab748c10667ef808", "mode": "100644", - "sha256": "dd9df1773f92e8d8291e6a270e65c8083cf94bb77ef2c35c6c6c63396567df13" + "sha256": "38c91467f2d90631cf943ae3878c2a70bcf6e006691c77031a9933bebea36307" }, "core/crates/tera_core/src/runtime/product_surface/outbox/inventory.rs": { "bytes": 8642, @@ -843,16 +843,22 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 4469, - "git_blob": "fd3bf4814c7652da421ffb58e132417adc260458", + "bytes": 5177, + "git_blob": "1c2a4ebad319a52c4841ce52846e75825bebc8be", "mode": "100644", - "sha256": "10dba026fab9f325e16b53548ef6c36142b6b787dab1bb4992841d9d4e0e04dd" + "sha256": "81d63a222ba087499e2169b58ab22469697949346a658781f25276e25d1906c8" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { + "bytes": 3189, + "git_blob": "70076f9ec21501893f4a7f6c36f1e68e68f2a677", + "mode": "100644", + "sha256": "27ce73e948e976b6d62da8cd0f53b20f98074b0aea6f6523f5685e0b2689494f" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture.rs": { - "bytes": 5479, - "git_blob": "252f6e268ae4285c1c642e263c6eb740f28b2158", + "bytes": 5310, + "git_blob": "ac334084ede1eccdd4fb0e9ae669650eda8598c2", "mode": "100644", - "sha256": "47d4de618b1789fee9b03bef65b47603b0f1d549509b484a9fab2c7f3259a9d9" + "sha256": "732d0f31698fd77a3ca2ebb5d7400e599af9e8a16505f306b2363495372f0f1a" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture/form.rs": { "bytes": 5358, @@ -879,16 +885,46 @@ "sha256": "6b7c433b52df37ea50499bf86a9046a8ae47bad6122dfa92712595b3e3d9fe1d" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs": { - "bytes": 9885, - "git_blob": "81c609cc27a574ebed30bebf47c545a75fed539d", + "bytes": 8518, + "git_blob": "472f9d0e22d0a320cbd9146c862e0bbba3580aa5", + "mode": "100644", + "sha256": "6c7481eb4a17a3064ec7672d4a1971f811f98ef8111068b6c2496ff9bbc202e7" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs": { + "bytes": 8732, + "git_blob": "f85e853b40a49191032281298bb8d4ffbfcf2d27", + "mode": "100644", + "sha256": "7512f1209c868ec98de71d7ae251d53ef7850b74b35cad5f6fbafb15b0822541" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs": { + "bytes": 8868, + "git_blob": "9984571196b191243caf409eedcd75814e548026", + "mode": "100644", + "sha256": "f79b74d1c467bf9bbfb213c3ec98a02c7b24622319d56aaf83cfab34d75cab81" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs": { + "bytes": 13449, + "git_blob": "cd210f824a2c962e740411dc4c782d39e982e020", "mode": "100644", - "sha256": "10cee00311f24141584103be7b664e9d4fe2fd2e7a2008d59ca69c445e598aae" + "sha256": "384365ffa9ce16b8cf73599359a47e89250c91b0051fe49230d058e86a9754d6" }, "core/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs": { - "bytes": 3438, - "git_blob": "55f8703ef8450d2ebdfd4f16d6587894917d2a1c", + "bytes": 3779, + "git_blob": "70524ca1ea66e703b18577c5fad19ddb588db54b", "mode": "100644", - "sha256": "c301666dd758b56ec282a48cb2c9093b01b02af210b0d04d82f9f5951ae29d9c" + "sha256": "23fab4dd866cf8d4acf87c9091c8858312dc7789d5fe08c250694a69a02248ed" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs": { + "bytes": 9736, + "git_blob": "67375586ef57ef51972988658104f31ddc44982f", + "mode": "100644", + "sha256": "e81f8cf7e413c55812cfd0a16e2434f74f585e91b9e5cc6e477d9c0432766b32" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs": { + "bytes": 7768, + "git_blob": "75452c5881181e8a86f5a06d104e2626671281e5", + "mode": "100644", + "sha256": "513320bd503d0f747edc5b72e4039bd51344c03ab82f73c870e5346e785b66e4" }, "core/crates/tera_core/src/runtime/product_surface/submission/record.rs": { "bytes": 5041, @@ -903,16 +939,16 @@ "sha256": "fb32e2eca072d2da3c9f77fb9c0fa16e423d0271553160c6d78aa47d58de80a2" }, "core/crates/tera_core/src/runtime/product_surface/submission/repository.rs": { - "bytes": 4923, - "git_blob": "d6dc389dcad54449437d02d8e7cef652520da155", + "bytes": 5025, + "git_blob": "241e89c3b5eec7e520a7844f6e5a80b44a58d655", "mode": "100644", - "sha256": "36305054f154bcdf2d8261214d389f4a6fb532fb309f2b7c14640b3f440cdd8d" + "sha256": "2531409bba3e04ade67c1b411bf26d96f14cc6b241229dd2ab468f14edc4fc1d" }, "core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs": { - "bytes": 8071, - "git_blob": "c6c56e091d4f7b6eec202b6b68aab82eea3fc767", + "bytes": 8041, + "git_blob": "98d15c4779d02813070543145c4cf89a7489c1ed", "mode": "100644", - "sha256": "7925464155cc45d6411abc54f64b4c6bc0043e4653ee52874d8e90e5124bc80a" + "sha256": "5d05ddf11b0ce9040f9221b7158a50374e4df6c344ce23faa1e14ce2d6c91122" }, "core/crates/tera_core/src/runtime/product_surface/submission/sqlite_tests.rs": { "bytes": 5247, @@ -921,10 +957,16 @@ "sha256": "dfd37bff667864d9133243900cc151cbf9fd423c67789fd19d9a401c4d9629a2" }, "core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs": { - "bytes": 1537, - "git_blob": "6f593c34ba7ef5b478b3fddacd597c5ba04efa58", + "bytes": 3240, + "git_blob": "32d45350ba89306f6e91df74dbcc3e3326461c64", "mode": "100644", - "sha256": "ec786993280bd6ed83d63d71d7913a1684a6cc41c0a1eaefd02059ab92e50d40" + "sha256": "b2ab79fd048704a991252f6f974d2f026388dbc418988619d333c6334b0a2397" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs": { + "bytes": 1384, + "git_blob": "a0a48378fc45eecc1fc06b3cad735b30c0abb10f", + "mode": "100644", + "sha256": "ec18505ac5efb7bfa1b6c351178aa7f6b15d8272d87dc567711acdd65ca372b7" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { "bytes": 140999, @@ -1382,6 +1424,12 @@ "mode": "100644", "sha256": "48ac3978641bb1485e9a4c7037533a5eefb1ad176e0eb43ead800d7e12c1f084" }, + "core/fixtures/submission_intent_schema_v1.json": { + "bytes": 2341, + "git_blob": "8ca94f29f0ae6d3493a1396e7319da0b3a2cb35b", + "mode": "100644", + "sha256": "9e9b21b186c816c7996e7a94fba802d58563dfaefa1ddc1f88a486daa05b25a6" + }, "core/fixtures/submission_reservation_schema_v1.json": { "bytes": 1400, "git_blob": "f54d623724bfe9dbae66da1d09273b90dbd78a35", @@ -1815,13 +1863,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 128410, - "git_blob": "5dc7e9d79eb9e3e8509f5683d0fc513d7c08a6e5", + "bytes": 130499, + "git_blob": "36677b124f56d9872a16c27963d447295300bd70", "mode": "100644", - "sha256": "ce1ba5a2231b01b7d3e2244633a00f52d15d97a424e9f688e784f3a4c699f383" + "sha256": "ccfb9eabc39cdd894036f4d8668c9099e850e741febef9c1f6ca615a3ea219b3" } }, "policy": "staged_inputs", - "tree": "f980edff71fa9e57d7bcfa417b3bb0ff776be01d" + "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -641,10 +641,10 @@ "sha256": "dff55e46521c26f5f0ece024453b55c15f132fa94f78e2cba3d141f123d4eca0" }, "core/crates/tera_core/src/runtime/product_surface.rs": { - "bytes": 7587, - "git_blob": "82c90eeb8d419354a09cf30eb60015a55820fc9f", + "bytes": 7767, + "git_blob": "6044a14cf25025ad46f2380a3c6e6af70bfbe91d", "mode": "100644", - "sha256": "28070e6a0a31bc68d47e31b9cef38f3231ada80a0dd8c3496155fe95592e8321" + "sha256": "14e1632185306b8254f9db212a1f449e49bd82c1a42af511e3cb6484d1f2aef4" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -797,10 +797,10 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 171907, - "git_blob": "dcabc95b601845caad80eafc1f43df2b47ea65c6", + "bytes": 171940, + "git_blob": "06b8856aa7de31c1bc38a1f5ab748c10667ef808", "mode": "100644", - "sha256": "dd9df1773f92e8d8291e6a270e65c8083cf94bb77ef2c35c6c6c63396567df13" + "sha256": "38c91467f2d90631cf943ae3878c2a70bcf6e006691c77031a9933bebea36307" }, "core/crates/tera_core/src/runtime/product_surface/outbox/inventory.rs": { "bytes": 8642, @@ -839,16 +839,22 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 4469, - "git_blob": "fd3bf4814c7652da421ffb58e132417adc260458", + "bytes": 5177, + "git_blob": "1c2a4ebad319a52c4841ce52846e75825bebc8be", "mode": "100644", - "sha256": "10dba026fab9f325e16b53548ef6c36142b6b787dab1bb4992841d9d4e0e04dd" + "sha256": "81d63a222ba087499e2169b58ab22469697949346a658781f25276e25d1906c8" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { + "bytes": 3189, + "git_blob": "70076f9ec21501893f4a7f6c36f1e68e68f2a677", + "mode": "100644", + "sha256": "27ce73e948e976b6d62da8cd0f53b20f98074b0aea6f6523f5685e0b2689494f" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture.rs": { - "bytes": 5479, - "git_blob": "252f6e268ae4285c1c642e263c6eb740f28b2158", + "bytes": 5310, + "git_blob": "ac334084ede1eccdd4fb0e9ae669650eda8598c2", "mode": "100644", - "sha256": "47d4de618b1789fee9b03bef65b47603b0f1d549509b484a9fab2c7f3259a9d9" + "sha256": "732d0f31698fd77a3ca2ebb5d7400e599af9e8a16505f306b2363495372f0f1a" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture/form.rs": { "bytes": 5358, @@ -875,16 +881,46 @@ "sha256": "6b7c433b52df37ea50499bf86a9046a8ae47bad6122dfa92712595b3e3d9fe1d" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs": { - "bytes": 9885, - "git_blob": "81c609cc27a574ebed30bebf47c545a75fed539d", + "bytes": 8518, + "git_blob": "472f9d0e22d0a320cbd9146c862e0bbba3580aa5", + "mode": "100644", + "sha256": "6c7481eb4a17a3064ec7672d4a1971f811f98ef8111068b6c2496ff9bbc202e7" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs": { + "bytes": 8732, + "git_blob": "f85e853b40a49191032281298bb8d4ffbfcf2d27", + "mode": "100644", + "sha256": "7512f1209c868ec98de71d7ae251d53ef7850b74b35cad5f6fbafb15b0822541" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs": { + "bytes": 8868, + "git_blob": "9984571196b191243caf409eedcd75814e548026", + "mode": "100644", + "sha256": "f79b74d1c467bf9bbfb213c3ec98a02c7b24622319d56aaf83cfab34d75cab81" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs": { + "bytes": 13449, + "git_blob": "cd210f824a2c962e740411dc4c782d39e982e020", "mode": "100644", - "sha256": "10cee00311f24141584103be7b664e9d4fe2fd2e7a2008d59ca69c445e598aae" + "sha256": "384365ffa9ce16b8cf73599359a47e89250c91b0051fe49230d058e86a9754d6" }, "core/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs": { - "bytes": 3438, - "git_blob": "55f8703ef8450d2ebdfd4f16d6587894917d2a1c", + "bytes": 3779, + "git_blob": "70524ca1ea66e703b18577c5fad19ddb588db54b", "mode": "100644", - "sha256": "c301666dd758b56ec282a48cb2c9093b01b02af210b0d04d82f9f5951ae29d9c" + "sha256": "23fab4dd866cf8d4acf87c9091c8858312dc7789d5fe08c250694a69a02248ed" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs": { + "bytes": 9736, + "git_blob": "67375586ef57ef51972988658104f31ddc44982f", + "mode": "100644", + "sha256": "e81f8cf7e413c55812cfd0a16e2434f74f585e91b9e5cc6e477d9c0432766b32" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs": { + "bytes": 7768, + "git_blob": "75452c5881181e8a86f5a06d104e2626671281e5", + "mode": "100644", + "sha256": "513320bd503d0f747edc5b72e4039bd51344c03ab82f73c870e5346e785b66e4" }, "core/crates/tera_core/src/runtime/product_surface/submission/record.rs": { "bytes": 5041, @@ -899,16 +935,16 @@ "sha256": "fb32e2eca072d2da3c9f77fb9c0fa16e423d0271553160c6d78aa47d58de80a2" }, "core/crates/tera_core/src/runtime/product_surface/submission/repository.rs": { - "bytes": 4923, - "git_blob": "d6dc389dcad54449437d02d8e7cef652520da155", + "bytes": 5025, + "git_blob": "241e89c3b5eec7e520a7844f6e5a80b44a58d655", "mode": "100644", - "sha256": "36305054f154bcdf2d8261214d389f4a6fb532fb309f2b7c14640b3f440cdd8d" + "sha256": "2531409bba3e04ade67c1b411bf26d96f14cc6b241229dd2ab468f14edc4fc1d" }, "core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs": { - "bytes": 8071, - "git_blob": "c6c56e091d4f7b6eec202b6b68aab82eea3fc767", + "bytes": 8041, + "git_blob": "98d15c4779d02813070543145c4cf89a7489c1ed", "mode": "100644", - "sha256": "7925464155cc45d6411abc54f64b4c6bc0043e4653ee52874d8e90e5124bc80a" + "sha256": "5d05ddf11b0ce9040f9221b7158a50374e4df6c344ce23faa1e14ce2d6c91122" }, "core/crates/tera_core/src/runtime/product_surface/submission/sqlite_tests.rs": { "bytes": 5247, @@ -917,10 +953,16 @@ "sha256": "dfd37bff667864d9133243900cc151cbf9fd423c67789fd19d9a401c4d9629a2" }, "core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs": { - "bytes": 1537, - "git_blob": "6f593c34ba7ef5b478b3fddacd597c5ba04efa58", + "bytes": 3240, + "git_blob": "32d45350ba89306f6e91df74dbcc3e3326461c64", "mode": "100644", - "sha256": "ec786993280bd6ed83d63d71d7913a1684a6cc41c0a1eaefd02059ab92e50d40" + "sha256": "b2ab79fd048704a991252f6f974d2f026388dbc418988619d333c6334b0a2397" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs": { + "bytes": 1384, + "git_blob": "a0a48378fc45eecc1fc06b3cad735b30c0abb10f", + "mode": "100644", + "sha256": "ec18505ac5efb7bfa1b6c351178aa7f6b15d8272d87dc567711acdd65ca372b7" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { "bytes": 140999, @@ -1378,6 +1420,12 @@ "mode": "100644", "sha256": "48ac3978641bb1485e9a4c7037533a5eefb1ad176e0eb43ead800d7e12c1f084" }, + "core/fixtures/submission_intent_schema_v1.json": { + "bytes": 2341, + "git_blob": "8ca94f29f0ae6d3493a1396e7319da0b3a2cb35b", + "mode": "100644", + "sha256": "9e9b21b186c816c7996e7a94fba802d58563dfaefa1ddc1f88a486daa05b25a6" + }, "core/fixtures/submission_reservation_schema_v1.json": { "bytes": 1400, "git_blob": "f54d623724bfe9dbae66da1d09273b90dbd78a35", @@ -1811,13 +1859,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 128410, - "git_blob": "5dc7e9d79eb9e3e8509f5683d0fc513d7c08a6e5", + "bytes": 130499, + "git_blob": "36677b124f56d9872a16c27963d447295300bd70", "mode": "100644", - "sha256": "ce1ba5a2231b01b7d3e2244633a00f52d15d97a424e9f688e784f3a4c699f383" + "sha256": "ccfb9eabc39cdd894036f4d8668c9099e850e741febef9c1f6ca615a3ea219b3" } }, "policy": "staged_inputs", - "tree": "f980edff71fa9e57d7bcfa417b3bb0ff776be01d" + "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -641,10 +641,10 @@ "sha256": "dff55e46521c26f5f0ece024453b55c15f132fa94f78e2cba3d141f123d4eca0" }, "core/crates/tera_core/src/runtime/product_surface.rs": { - "bytes": 7587, - "git_blob": "82c90eeb8d419354a09cf30eb60015a55820fc9f", + "bytes": 7767, + "git_blob": "6044a14cf25025ad46f2380a3c6e6af70bfbe91d", "mode": "100644", - "sha256": "28070e6a0a31bc68d47e31b9cef38f3231ada80a0dd8c3496155fe95592e8321" + "sha256": "14e1632185306b8254f9db212a1f449e49bd82c1a42af511e3cb6484d1f2aef4" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -797,10 +797,10 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 171907, - "git_blob": "dcabc95b601845caad80eafc1f43df2b47ea65c6", + "bytes": 171940, + "git_blob": "06b8856aa7de31c1bc38a1f5ab748c10667ef808", "mode": "100644", - "sha256": "dd9df1773f92e8d8291e6a270e65c8083cf94bb77ef2c35c6c6c63396567df13" + "sha256": "38c91467f2d90631cf943ae3878c2a70bcf6e006691c77031a9933bebea36307" }, "core/crates/tera_core/src/runtime/product_surface/outbox/inventory.rs": { "bytes": 8642, @@ -839,16 +839,22 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 4469, - "git_blob": "fd3bf4814c7652da421ffb58e132417adc260458", + "bytes": 5177, + "git_blob": "1c2a4ebad319a52c4841ce52846e75825bebc8be", "mode": "100644", - "sha256": "10dba026fab9f325e16b53548ef6c36142b6b787dab1bb4992841d9d4e0e04dd" + "sha256": "81d63a222ba087499e2169b58ab22469697949346a658781f25276e25d1906c8" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { + "bytes": 3189, + "git_blob": "70076f9ec21501893f4a7f6c36f1e68e68f2a677", + "mode": "100644", + "sha256": "27ce73e948e976b6d62da8cd0f53b20f98074b0aea6f6523f5685e0b2689494f" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture.rs": { - "bytes": 5479, - "git_blob": "252f6e268ae4285c1c642e263c6eb740f28b2158", + "bytes": 5310, + "git_blob": "ac334084ede1eccdd4fb0e9ae669650eda8598c2", "mode": "100644", - "sha256": "47d4de618b1789fee9b03bef65b47603b0f1d549509b484a9fab2c7f3259a9d9" + "sha256": "732d0f31698fd77a3ca2ebb5d7400e599af9e8a16505f306b2363495372f0f1a" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture/form.rs": { "bytes": 5358, @@ -875,16 +881,46 @@ "sha256": "6b7c433b52df37ea50499bf86a9046a8ae47bad6122dfa92712595b3e3d9fe1d" }, "core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs": { - "bytes": 9885, - "git_blob": "81c609cc27a574ebed30bebf47c545a75fed539d", + "bytes": 8518, + "git_blob": "472f9d0e22d0a320cbd9146c862e0bbba3580aa5", + "mode": "100644", + "sha256": "6c7481eb4a17a3064ec7672d4a1971f811f98ef8111068b6c2496ff9bbc202e7" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs": { + "bytes": 8732, + "git_blob": "f85e853b40a49191032281298bb8d4ffbfcf2d27", + "mode": "100644", + "sha256": "7512f1209c868ec98de71d7ae251d53ef7850b74b35cad5f6fbafb15b0822541" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs": { + "bytes": 8868, + "git_blob": "9984571196b191243caf409eedcd75814e548026", + "mode": "100644", + "sha256": "f79b74d1c467bf9bbfb213c3ec98a02c7b24622319d56aaf83cfab34d75cab81" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs": { + "bytes": 13449, + "git_blob": "cd210f824a2c962e740411dc4c782d39e982e020", "mode": "100644", - "sha256": "10cee00311f24141584103be7b664e9d4fe2fd2e7a2008d59ca69c445e598aae" + "sha256": "384365ffa9ce16b8cf73599359a47e89250c91b0051fe49230d058e86a9754d6" }, "core/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs": { - "bytes": 3438, - "git_blob": "55f8703ef8450d2ebdfd4f16d6587894917d2a1c", + "bytes": 3779, + "git_blob": "70524ca1ea66e703b18577c5fad19ddb588db54b", "mode": "100644", - "sha256": "c301666dd758b56ec282a48cb2c9093b01b02af210b0d04d82f9f5951ae29d9c" + "sha256": "23fab4dd866cf8d4acf87c9091c8858312dc7789d5fe08c250694a69a02248ed" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs": { + "bytes": 9736, + "git_blob": "67375586ef57ef51972988658104f31ddc44982f", + "mode": "100644", + "sha256": "e81f8cf7e413c55812cfd0a16e2434f74f585e91b9e5cc6e477d9c0432766b32" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs": { + "bytes": 7768, + "git_blob": "75452c5881181e8a86f5a06d104e2626671281e5", + "mode": "100644", + "sha256": "513320bd503d0f747edc5b72e4039bd51344c03ab82f73c870e5346e785b66e4" }, "core/crates/tera_core/src/runtime/product_surface/submission/record.rs": { "bytes": 5041, @@ -899,16 +935,16 @@ "sha256": "fb32e2eca072d2da3c9f77fb9c0fa16e423d0271553160c6d78aa47d58de80a2" }, "core/crates/tera_core/src/runtime/product_surface/submission/repository.rs": { - "bytes": 4923, - "git_blob": "d6dc389dcad54449437d02d8e7cef652520da155", + "bytes": 5025, + "git_blob": "241e89c3b5eec7e520a7844f6e5a80b44a58d655", "mode": "100644", - "sha256": "36305054f154bcdf2d8261214d389f4a6fb532fb309f2b7c14640b3f440cdd8d" + "sha256": "2531409bba3e04ade67c1b411bf26d96f14cc6b241229dd2ab468f14edc4fc1d" }, "core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs": { - "bytes": 8071, - "git_blob": "c6c56e091d4f7b6eec202b6b68aab82eea3fc767", + "bytes": 8041, + "git_blob": "98d15c4779d02813070543145c4cf89a7489c1ed", "mode": "100644", - "sha256": "7925464155cc45d6411abc54f64b4c6bc0043e4653ee52874d8e90e5124bc80a" + "sha256": "5d05ddf11b0ce9040f9221b7158a50374e4df6c344ce23faa1e14ce2d6c91122" }, "core/crates/tera_core/src/runtime/product_surface/submission/sqlite_tests.rs": { "bytes": 5247, @@ -917,10 +953,16 @@ "sha256": "dfd37bff667864d9133243900cc151cbf9fd423c67789fd19d9a401c4d9629a2" }, "core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs": { - "bytes": 1537, - "git_blob": "6f593c34ba7ef5b478b3fddacd597c5ba04efa58", + "bytes": 3240, + "git_blob": "32d45350ba89306f6e91df74dbcc3e3326461c64", "mode": "100644", - "sha256": "ec786993280bd6ed83d63d71d7913a1684a6cc41c0a1eaefd02059ab92e50d40" + "sha256": "b2ab79fd048704a991252f6f974d2f026388dbc418988619d333c6334b0a2397" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs": { + "bytes": 1384, + "git_blob": "a0a48378fc45eecc1fc06b3cad735b30c0abb10f", + "mode": "100644", + "sha256": "ec18505ac5efb7bfa1b6c351178aa7f6b15d8272d87dc567711acdd65ca372b7" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { "bytes": 140999, @@ -1378,6 +1420,12 @@ "mode": "100644", "sha256": "48ac3978641bb1485e9a4c7037533a5eefb1ad176e0eb43ead800d7e12c1f084" }, + "core/fixtures/submission_intent_schema_v1.json": { + "bytes": 2341, + "git_blob": "8ca94f29f0ae6d3493a1396e7319da0b3a2cb35b", + "mode": "100644", + "sha256": "9e9b21b186c816c7996e7a94fba802d58563dfaefa1ddc1f88a486daa05b25a6" + }, "core/fixtures/submission_reservation_schema_v1.json": { "bytes": 1400, "git_blob": "f54d623724bfe9dbae66da1d09273b90dbd78a35", @@ -1811,13 +1859,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 128410, - "git_blob": "5dc7e9d79eb9e3e8509f5683d0fc513d7c08a6e5", + "bytes": 130499, + "git_blob": "36677b124f56d9872a16c27963d447295300bd70", "mode": "100644", - "sha256": "ce1ba5a2231b01b7d3e2244633a00f52d15d97a424e9f688e784f3a4c699f383" + "sha256": "ccfb9eabc39cdd894036f4d8668c9099e850e741febef9c1f6ca615a3ea219b3" } }, "policy": "staged_inputs", - "tree": "f980edff71fa9e57d7bcfa417b3bb0ff776be01d" + "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" } } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -90,10 +90,12 @@ pub use settings::{ }; #[cfg(feature = "mobile-social")] pub use submission::{ - CapturedSubmission, SUBMISSION_RESERVATION_MAX_BYTES, SUBMISSION_RESERVATION_PAYLOAD_SCHEMA, + CapturedSubmission, SUBMISSION_INTENT_MAX_BYTES, SUBMISSION_INTENT_PAYLOAD_SCHEMA, + SUBMISSION_INTENT_SCHEMA_SHA256, SUBMISSION_INTENT_SCHEMA_VERSION, + SUBMISSION_RESERVATION_MAX_BYTES, SUBMISSION_RESERVATION_PAYLOAD_SCHEMA, SUBMISSION_RESERVATION_SCHEMA_SHA256, SUBMISSION_RESERVATION_SCHEMA_VERSION, - SubmissionCaptureError, SubmissionCommandId, SubmissionReservationError, - SubmissionReservationReceipt, SubmissionReservationRequest, + SubmissionCaptureError, SubmissionCommandId, SubmissionCommitError, SubmissionReceipt, + SubmissionReservationError, SubmissionReservationReceipt, SubmissionReservationRequest, }; #[cfg(feature = "mobile-social")] pub use today::{ diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -246,7 +246,7 @@ impl Phase1MediaPrerequisite { Ok(value) } - fn validate(&self) -> Result<(), Phase1DraftError> { + pub(super) fn validate(&self) -> Result<(), Phase1DraftError> { let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?; let hash = blob.hash_path().hash().to_string(); if self.local_reference.is_empty() @@ -379,7 +379,7 @@ impl Phase1CancellationPolicy { pub struct Phase1QueuePolicy { relay_urls: Vec<String>, satisfaction: Phase1RelaySatisfaction, - delivery_deadline_unix_ms: u64, + pub(super) delivery_deadline_unix_ms: u64, cancellation: Phase1CancellationPolicy, } @@ -400,7 +400,7 @@ impl Phase1QueuePolicy { Ok(value) } - fn materialize( + pub(super) fn materialize( &self, ) -> Result<(TargetSet, SatisfactionPolicy, CancellationPolicy), Phase1DraftError> { if self.delivery_deadline_unix_ms == 0 || self.relay_urls.is_empty() { diff --git a/core/crates/tera_core/src/runtime/product_surface/submission.rs b/core/crates/tera_core/src/runtime/product_surface/submission.rs @@ -2,11 +2,22 @@ //! Reservation does not authorize signing, delivery or strict publication. mod capture; +mod commit; +mod intent; mod record; mod repository; pub use capture::{CapturedSubmission, SubmissionCaptureError}; +pub use commit::{SubmissionCommitError, SubmissionReceipt}; +pub use intent::{ + SUBMISSION_INTENT_MAX_BYTES, SUBMISSION_INTENT_PAYLOAD_SCHEMA, SUBMISSION_INTENT_SCHEMA_SHA256, + SUBMISSION_INTENT_SCHEMA_VERSION, +}; +#[cfg(test)] +mod fault_store; #[cfg(test)] mod test_support; +#[cfg(test)] +mod transaction_test_support; use radroots_storage::{Error, authored_draft::AuthoredDraftId}; use serde::{Deserialize, Serialize}; @@ -125,11 +136,20 @@ pub struct SubmissionReservationReceipt { request: SubmissionReservationRequest, reservation_id: AuthoredDraftId, captured: ComposerDraft, + source: radroots_storage::authored_draft_submission::AuthoredDraftSource, reserved_at_unix_ms: u64, replayed: bool, } impl SubmissionReservationReceipt { + fn same_request(&self, other: &Self) -> bool { + self.request == other.request + && self.reservation_id == other.reservation_id + && self.captured == other.captured + && self.source == other.source + && self.reserved_at_unix_ms == other.reserved_at_unix_ms + } + pub fn request(&self) -> &SubmissionReservationRequest { &self.request } diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs b/core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs @@ -0,0 +1,81 @@ +use super::{Fault, FaultStore, Ordering}; +use radroots_storage::{ + Error, + atomic::AtomicCommitId, + authored::{AuthoredArtifact, AuthoredArtifactId, AuthoredOperation}, + authored_atomic::{ + AuthoredAtomicCommand, AuthoredAtomicOutcome, AuthoredAtomicReceipt, AuthoredAtomicStorage, + }, + authored_delivery::{AuthoredDeliveryPlan, AuthoredDeliveryPlanId}, + event::BoxFuture, + journal::OperationInstanceId, +}; + +impl<S: AuthoredAtomicStorage + ?Sized> AuthoredAtomicStorage for FaultStore<'_, S> { + fn execute_authored( + &self, + command: AuthoredAtomicCommand, + ) -> BoxFuture<'_, Result<AuthoredAtomicReceipt, Error>> { + Box::pin(async move { + let attempt = self.commits.fetch_add(1, Ordering::SeqCst); + if attempt == 0 && matches!(self.atomic_fault, Fault::BeforeCommit) { + return Err(Error::BackendUnavailable); + } + if let Fault::Race(barrier) = &self.atomic_fault { + barrier.wait().await; + } + let receipt = self.inner.execute_authored(command).await?; + if attempt == 0 { + match self.atomic_fault { + Fault::LostCallback => return Err(Error::BackendUnavailable), + Fault::WrongReceipt => { + let AuthoredAtomicOutcome::Submitted(value) = receipt.outcome() else { + panic!("expected submission"); + }; + let prepared = value.preparation(); + return AuthoredAtomicReceipt::from_durable_parts( + receipt.commit_id(), + receipt.digest(), + receipt.disposition(), + receipt.committed_at_unix_ms(), + AuthoredAtomicOutcome::Prepared { + operation: prepared.operation().clone(), + artifacts: prepared.artifacts().to_vec(), + delivery_plans: prepared.delivery_plans().to_vec(), + }, + ); + } + _ => {} + } + } + Ok(receipt) + }) + } + fn authored_receipt( + &self, + id: AtomicCommitId, + ) -> BoxFuture<'_, Result<Option<AuthoredAtomicReceipt>, Error>> { + if let Some(value) = &self.receipt_override { + return Box::pin(async { Ok(value.clone()) }); + } + self.inner.authored_receipt(id) + } + fn authored_operation( + &self, + id: OperationInstanceId, + ) -> BoxFuture<'_, Result<Option<AuthoredOperation>, Error>> { + self.inner.authored_operation(id) + } + fn authored_artifact( + &self, + id: AuthoredArtifactId, + ) -> BoxFuture<'_, Result<Option<AuthoredArtifact>, Error>> { + self.inner.authored_artifact(id) + } + fn authored_delivery_plan( + &self, + id: AuthoredDeliveryPlanId, + ) -> BoxFuture<'_, Result<Option<AuthoredDeliveryPlan>, Error>> { + self.inner.authored_delivery_plan(id) + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/capture.rs b/core/crates/tera_core/src/runtime/product_surface/submission/capture.rs @@ -72,9 +72,7 @@ impl CapturedSubmission { /// Compares semantic request identity, excluding the transient replay observation. pub fn same_request(&self, other: &Self) -> bool { - self.reservation.request() == other.reservation.request() - && self.reservation.captured() == other.reservation.captured() - && self.reservation.reserved_at_unix_ms() == other.reservation.reserved_at_unix_ms() + self.reservation.same_request(&other.reservation) && self.command == other.command && self.media == other.media && self.media_policy == other.media_policy @@ -82,7 +80,7 @@ impl CapturedSubmission { && self.plan == other.plan } - fn capture( + pub(super) fn capture( reservation: SubmissionReservationReceipt, policy: Phase1QueuePolicy, blossom: Option<&BlossomSlot>, diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs @@ -1,83 +1,37 @@ use super::super::test_support::*; use super::*; use crate::runtime::product_surface::{ - AddCommandType, ComposerDraft, ComposerEditSequence, ComposerFormInput, ComposerMediaInput, - ComposerPartialForm, Phase1CancellationPolicy, Phase1DraftEventTiming, Phase1RelaySatisfaction, -}; -use radroots_sdk::transport::{ - BlossomConfig, BlossomEndpointAuthority, BlossomHostKind, BlossomProfile, + AddCommandType, ComposerDraft, ComposerEditSequence, ComposerFormInput, ComposerPartialForm, + ComposerStorageRecord, Phase1DraftEventTiming, }; +use radroots_sdk::transport::BlossomConfig; #[path = "runtime_tests.rs"] mod runtime_tests; fn reservation(input: ComposerFormInput) -> SubmissionReservationReceipt { let request = request(); + let source = ComposerStorageRecord::initial( + request.composer_id(), + request.scope().clone(), + ComposerEditSequence::INITIAL, + ComposerPartialForm::new(input).unwrap(), + NOW, + ) + .unwrap(); SubmissionReservationReceipt { reservation_id: super::super::record::reservation_id(&request).unwrap(), - captured: ComposerDraft::new( - request.composer_id(), - request.expected_revision(), - request.scope().clone(), - ComposerEditSequence::INITIAL, - ComposerPartialForm::new(input).unwrap(), - ), + captured: source.draft().clone(), + source: radroots_storage::authored_draft_submission::AuthoredDraftSource::capture( + source.stored(), + ) + .unwrap(), request, reserved_at_unix_ms: NOW, replayed: false, } } -fn policy(relay: &str) -> Phase1QueuePolicy { - Phase1QueuePolicy::new( - vec![relay.into()], - Phase1RelaySatisfaction::AllAccepted, - NOW + 1000, - Phase1CancellationPolicy::LocalCooperative, - ) - .unwrap() -} - -fn blossom() -> BlossomSlot { - let slot = BlossomSlot::new(); - slot.configure(BlossomConfig::from_profile( - BlossomProfile::new( - BlossomHostKind::Simulator, - BlossomEndpointAuthority::LoopbackDevelopment, - "http://127.0.0.1:3000", - std::iter::empty::<&str>(), - ) - .unwrap(), - )) - .unwrap(); - slot -} - -fn input(kind: AddCommandType) -> ComposerFormInput { - let mut input = ComposerFormInput::empty(kind); - input.content = "PRIVATE harvest café".into(); - input -} - -fn photo() -> (ComposerMediaInput, Arc<[u8]>) { - let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); - bytes.extend_from_slice(&2u32.to_be_bytes()); - bytes.extend_from_slice(&2u32.to_be_bytes()); - ( - ComposerMediaInput { - opaque_reference: "media:harvest".into(), - sha256: radroots_blossom::Sha256::digest(&bytes).to_hex(), - media_type: "image/png".into(), - byte_size: bytes.len() as u64, - width: 2, - height: 2, - alt: "Harvest".into(), - prepared_at_unix_s: NOW / 1000, - }, - bytes.into(), - ) -} - #[test] fn all_five_families_capture_shared_plans_and_both_calendar_profiles() { let mut cases = vec![ diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/commit.rs b/core/crates/tera_core/src/runtime/product_surface/submission/commit.rs @@ -0,0 +1,250 @@ +//! One existing storage owner commits the full intent and source association. + +use std::sync::Arc; + +use radroots_storage::{ + Error, + atomic::AtomicCommitDisposition, + authored_atomic::{ + AuthoredAtomicCommand, AuthoredAtomicOutcome, AuthoredAtomicReceipt, AuthoredAtomicStorage, + }, + authored_draft::{AuthoredDraftId, AuthoredDraftStore}, + journal::OperationInstanceId, +}; + +use super::{ + CapturedSubmission, SubmissionCaptureError, SubmissionReservationError, + SubmissionReservationRequest, intent, repository::SubmissionRepository, +}; +use crate::{TeraRuntime, runtime::product_surface::ComposerPersistenceError}; + +#[cfg(test)] +#[path = "commit_sqlite_tests.rs"] +mod sqlite_tests; +#[cfg(test)] +#[path = "commit_tests.rs"] +mod tests; + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum SubmissionCommitError { + #[error(transparent)] + Capture(SubmissionCaptureError), + #[error(transparent)] + Reservation(SubmissionReservationError), + #[error("submission command conflicts with its committed request")] + IdempotencyConflict, + #[error("source composer revision changed before submission")] + RevisionConflict, + #[error("submission intent is invalid or exceeds its bounds")] + InvalidIntent, + #[error("submission record requires repair")] + CorruptRecord, + #[error("submission record schema is unsupported")] + UnsupportedSchema, + #[error("submission receipt is unconfirmed")] + InvalidReceipt, + #[error("submission storage failed: {0}")] + Storage(Error), +} + +impl From<Error> for SubmissionCommitError { + fn from(error: Error) -> Self { + match error { + Error::AtomicCommitConflict => Self::IdempotencyConflict, + Error::DraftRevisionConflict => Self::RevisionConflict, + error => Self::Storage(error), + } + } +} + +impl From<SubmissionReservationError> for SubmissionCommitError { + fn from(error: SubmissionReservationError) -> Self { + match error { + SubmissionReservationError::IdempotencyConflict => Self::IdempotencyConflict, + SubmissionReservationError::Source(ComposerPersistenceError::RevisionConflict) => { + Self::RevisionConflict + } + error => Self::Reservation(error), + } + } +} + +impl From<SubmissionCaptureError> for SubmissionCommitError { + fn from(error: SubmissionCaptureError) -> Self { + match error { + SubmissionCaptureError::Reservation(error) => error.into(), + error => Self::Capture(error), + } + } +} + +/// A durable local operation handle; it is not a signing or delivery receipt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SubmissionReceipt { + request: SubmissionReservationRequest, + intent_id: AuthoredDraftId, + operation_id: OperationInstanceId, + captured_at_unix_ms: u64, + committed_at_unix_ms: u64, + replayed: bool, +} + +impl SubmissionReceipt { + pub fn request(&self) -> &SubmissionReservationRequest { + &self.request + } + pub const fn intent_id(&self) -> AuthoredDraftId { + self.intent_id + } + pub const fn operation_id(&self) -> OperationInstanceId { + self.operation_id + } + pub const fn captured_at_unix_ms(&self) -> u64 { + self.captured_at_unix_ms + } + pub const fn committed_at_unix_ms(&self) -> u64 { + self.committed_at_unix_ms + } + pub const fn is_replay(&self) -> bool { + self.replayed + } +} + +type E = SubmissionCommitError; + +impl<S: AuthoredDraftStore + AuthoredAtomicStorage + ?Sized> SubmissionRepository<'_, S> { + pub(super) async fn commit( + &self, + captured: &CapturedSubmission, + ) -> Result<SubmissionReceipt, E> { + let reservation = self + .replay(captured.reservation().request()) + .await? + .ok_or(E::InvalidReceipt)?; + if !reservation.same_request(captured.reservation()) { + return Err(E::IdempotencyConflict); + } + let request = intent::IntentPayload::capture(captured)?; + let command = AuthoredAtomicCommand::PrepareFromDraft(Box::new(request)); + // The owner performs full replay comparison before the original source CAS. + // No prompt, media operation or network await enters this transaction. + let receipt = self.store.execute_authored(command.clone()).await?; + if !receipt.matches_command(&command) { + return Err(E::InvalidReceipt); + } + self.committed_receipt(captured.reservation().request(), receipt, false) + .await + } + + pub(super) async fn recover( + &self, + request: &SubmissionReservationRequest, + ) -> Result<Option<SubmissionReceipt>, E> { + let Some(receipt) = self + .store + .authored_receipt(intent::commit_id(request)) + .await? + else { + return Ok(None); + }; + self.committed_receipt(request, receipt, true) + .await + .map(Some) + } + + async fn committed_receipt( + &self, + request: &SubmissionReservationRequest, + receipt: AuthoredAtomicReceipt, + lookup: bool, + ) -> Result<SubmissionReceipt, E> { + if receipt.commit_id() != intent::commit_id(request) { + return Err(E::InvalidReceipt); + } + let AuthoredAtomicOutcome::Submitted(committed) = receipt.outcome() else { + return Err(E::InvalidReceipt); + }; + let reservation = self.replay(request).await?.ok_or(E::InvalidReceipt)?; + intent::IntentPayload::validate_committed(committed, &reservation)?; + if !receipt.matches_command(&AuthoredAtomicCommand::PrepareFromDraft(committed.clone())) { + return Err(E::InvalidReceipt); + } + Ok(SubmissionReceipt { + request: request.clone(), + intent_id: committed.intent().draft_id(), + operation_id: committed.preparation().operation().operation_id(), + captured_at_unix_ms: reservation.reserved_at_unix_ms(), + committed_at_unix_ms: receipt.committed_at_unix_ms(), + replayed: lookup || receipt.disposition() == AtomicCommitDisposition::Replay, + }) + } +} + +impl TeraRuntime { + /// Commits the exact owned capture. Changed capture/policy with the same ID conflicts. + pub async fn submission_commit( + &self, + captured: &CapturedSubmission, + ) -> Result<SubmissionReceipt, E> { + let _command = self + .lifecycle + .enter() + .map_err(SubmissionReservationError::from)?; + self.validate_submission_owner(captured.reservation().request())?; + let store = self + .client + .storage() + .map_err(|_| Error::BackendUnavailable)?; + SubmissionRepository { store }.commit(captured).await + } + + /// Recovers committed work without consulting current settings or requiring local media. + pub async fn submission_recover( + &self, + request: &SubmissionReservationRequest, + ) -> Result<Option<SubmissionReceipt>, E> { + let _command = self + .lifecycle + .enter() + .map_err(SubmissionReservationError::from)?; + self.validate_submission_owner(request)?; + let store = self + .client + .storage() + .map_err(|_| Error::BackendUnavailable)?; + SubmissionRepository { store }.recover(request).await + } + + /// Resolves the immutable saved request, then commits it before any publication effect. + /// Bytes materialize the source's fixed media metadata only when capture is still needed. + pub async fn submission_prepare( + &self, + request: &SubmissionReservationRequest, + media_bytes: Vec<Arc<[u8]>>, + ) -> Result<SubmissionReceipt, E> { + let _command = self + .lifecycle + .enter() + .map_err(SubmissionReservationError::from)?; + if let Some(receipt) = self.submission_recover(request).await? { + return Ok(receipt); + } + let captured = self.submission_capture(request, media_bytes).await?; + self.submission_commit(&captured).await + } + + fn validate_submission_owner(&self, request: &SubmissionReservationRequest) -> Result<(), E> { + let author = self + .store_public_key + .ok_or(SubmissionReservationError::Source( + ComposerPersistenceError::OwnerUnavailable, + ))?; + if author != request.scope().author() { + return Err(SubmissionReservationError::Source( + ComposerPersistenceError::ScopeMismatch, + ) + .into()); + } + Ok(()) + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs @@ -0,0 +1,271 @@ +use super::*; +use crate::runtime::{ + builder::RuntimeBuilder, + lifecycle::RuntimeLifecycleError, + product_surface::{ + AddCommandType, ComposerEditSequence, ComposerPartialForm, Phase1DraftError, + submission::test_support::*, + }, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, +}; +use radroots_sdk::transport::{ + RelayAccess, RelayEndpoint, RelayProfile, RelayProfileKind, RelayUrlPolicy, +}; + +async fn runtime(root: &std::path::Path, author: &str) -> TeraRuntime { + let config = MobileUserStoreConfig::from_encoded( + root, + author, + &hex::encode([1; 32]), + NOW, + ProtectedDataAvailability::Available, + ) + .unwrap(); + std::fs::create_dir_all(config.owner_directory()).unwrap(); + RuntimeBuilder::new(config) + .relay_profile( + RelayProfile::explicit( + RelayProfileKind::Simulator, + [RelayEndpoint::new( + "ws://127.0.0.1:19999", + RelayUrlPolicy::Local, + RelayAccess::ReadWrite, + ) + .unwrap()], + ) + .unwrap(), + ) + .blossom_config(radroots_sdk::transport::BlossomConfig::from_profile( + blossom().profile().unwrap(), + )) + .build() + .await + .unwrap() +} + +#[tokio::test] +async fn actual_sqlite_concurrent_prepare_replays_after_edits_settings_and_reconstruction() { + for media in [false, true] { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(root.path(), AUTHOR).await; + let request = request(); + let mut input = input(if media { + AddCommandType::CreatePhotoUpdate + } else { + AddCommandType::CreateUpdate + }); + let bytes = if media { + let (photo, bytes) = photo(); + input.media.push(photo); + vec![bytes] + } else { + vec![] + }; + let form = ComposerPartialForm::new(input).unwrap(); + runtime + .composer_create( + request.scope(), + request.composer_id(), + ComposerEditSequence::INITIAL, + form.clone(), + ) + .await + .unwrap(); + let (a, b) = tokio::join!( + runtime.submission_prepare(&request, bytes.clone()), + runtime.submission_prepare(&request, bytes) + ); + let a = a.unwrap(); + let b = b.unwrap(); + assert_eq!(a.operation_id(), b.operation_id()); + assert_eq!(a.intent_id(), b.intent_id()); + assert_ne!(a.is_replay(), b.is_replay()); + let later = runtime + .composer_save( + request.scope(), + request.composer_id(), + request.expected_revision(), + ComposerEditSequence::new(2).unwrap(), + ComposerPartialForm::new(super::super::test_support::input( + AddCommandType::CreateAsk, + )) + .unwrap(), + ) + .await + .unwrap(); + // A read-only profile makes a new capture impossible, but cannot invalidate the receipt. + runtime + .client + .configure_nostr( + RelayProfile::explicit( + RelayProfileKind::Simulator, + [RelayEndpoint::new( + "ws://127.0.0.1:19998", + RelayUrlPolicy::Local, + RelayAccess::ReadOnly, + ) + .unwrap()], + ) + .unwrap(), + ) + .unwrap(); + assert!( + runtime + .active_queue_policy(a.captured_at_unix_ms()) + .is_err() + ); + let replay = runtime.submission_prepare(&request, vec![]).await.unwrap(); + assert!(replay.is_replay()); + assert_eq!(replay.operation_id(), a.operation_id()); + assert_eq!( + runtime + .composer_list(request.scope(), 10, None) + .await + .unwrap() + .entries() + .len(), + 1 + ); + assert!(runtime.phase1_draft_heads(20).await.unwrap().is_empty()); + assert_eq!( + runtime + .phase1_draft_status(*a.intent_id().as_bytes()) + .await + .unwrap_err(), + Phase1DraftError::Corrupt + ); + assert_eq!( + runtime + .phase1_queue_draft( + *a.intent_id().as_bytes(), + 1, + policy("wss://relay.example"), + NOW + ) + .await + .unwrap_err(), + Phase1DraftError::Corrupt + ); + assert_eq!( + runtime + .phase1_cancel_draft(*a.intent_id().as_bytes(), 1, NOW) + .await + .unwrap_err(), + Phase1DraftError::Corrupt + ); + runtime.shutdown().await.unwrap(); + assert_eq!( + runtime.submission_recover(&request).await.unwrap_err(), + E::Reservation(SubmissionReservationError::Lifecycle( + RuntimeLifecycleError::Closed + )) + ); + drop(runtime); + let runtime = self::runtime(root.path(), AUTHOR).await; + // Simulates a caller lost after commit: no previous native handle and no materialized media. + let replay = runtime.submission_prepare(&request, vec![]).await.unwrap(); + assert!(replay.is_replay()); + assert_eq!(replay.operation_id(), a.operation_id()); + assert_eq!( + runtime + .composer_load(request.scope(), request.composer_id()) + .await + .unwrap(), + *later.draft() + ); + let mut foreign = request.clone(); + foreign.scope = scope(OTHER, "nearby"); + assert_eq!( + runtime.submission_recover(&foreign).await.unwrap_err(), + E::Reservation(SubmissionReservationError::Source( + ComposerPersistenceError::ScopeMismatch + )) + ); + runtime.shutdown().await.unwrap(); + drop(runtime); + let runtime = self::runtime(root.path(), OTHER).await; + assert!( + runtime + .submission_recover(&foreign) + .await + .unwrap() + .is_none() + ); + runtime + .composer_create( + foreign.scope(), + foreign.composer_id(), + ComposerEditSequence::INITIAL, + form, + ) + .await + .unwrap(); + let bytes = if media { vec![photo().1] } else { vec![] }; + let separate = runtime.submission_prepare(&foreign, bytes).await.unwrap(); + assert_ne!(a.operation_id(), separate.operation_id()); + assert_ne!(a.intent_id(), separate.intent_id()); + runtime.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn actual_sqlite_new_commit_loses_to_composer_edit_without_any_submission_record() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(root.path(), AUTHOR).await; + let request = request(); + runtime + .composer_create( + request.scope(), + request.composer_id(), + ComposerEditSequence::INITIAL, + ComposerPartialForm::new(input(AddCommandType::CreateUpdate)).unwrap(), + ) + .await + .unwrap(); + let captured = runtime.submission_capture(&request, vec![]).await.unwrap(); + runtime + .composer_save( + request.scope(), + request.composer_id(), + request.expected_revision(), + ComposerEditSequence::new(2).unwrap(), + ComposerPartialForm::new(input(AddCommandType::CreateAsk)).unwrap(), + ) + .await + .unwrap(); + assert_eq!( + runtime.submission_commit(&captured).await.unwrap_err(), + E::RevisionConflict + ); + assert!( + runtime + .submission_recover(&request) + .await + .unwrap() + .is_none() + ); + let store = runtime.client.storage().unwrap(); + assert!( + store + .authored_operation(intent::operation_id(&request).unwrap()) + .await + .unwrap() + .is_none() + ); + assert!( + store + .authored_draft_head(intent::intent_id(&request).unwrap()) + .await + .unwrap() + .is_none() + ); + assert_eq!( + store + .authored_draft_heads(*request.scope().author().as_bytes(), 20) + .await + .unwrap() + .len(), + 2 + ); + runtime.shutdown().await.unwrap(); +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs @@ -0,0 +1,383 @@ +use super::*; +use crate::runtime::product_surface::{ + ComposerRevision, SubmissionCommandId, submission::test_support::*, +}; +use radroots_storage::authored_draft::AuthoredDraftStage; + +use super::super::fault_store::{Fault, FaultStore}; +use super::super::transaction_test_support::capture; + +async fn assert_records<S: AuthoredDraftStore + AuthoredAtomicStorage + ?Sized>( + store: &S, + capture: &CapturedSubmission, + installed: bool, +) { + let request = capture.reservation().request(); + assert_eq!( + store + .authored_receipt(intent::commit_id(request)) + .await + .unwrap() + .is_some(), + installed + ); + assert_eq!( + store + .authored_draft_head(intent::intent_id(request).unwrap()) + .await + .unwrap() + .is_some(), + installed + ); + let operation = store + .authored_operation(intent::operation_id(request).unwrap()) + .await + .unwrap(); + assert_eq!(operation.is_some(), installed); + if let Some(operation) = operation { + for id in operation.artifact_ids() { + let artifact = store.authored_artifact(*id).await.unwrap().unwrap(); + assert_eq!( + artifact.signing_state(), + radroots_storage::authored::SigningState::Planned + ); + assert!(artifact.signed().is_none()); + assert_eq!( + artifact.admission_state(), + radroots_storage::authored::AdmissionState::Pending + ); + } + } + let source = store + .authored_draft_head(capture.reservation().source.draft_id()) + .await + .unwrap() + .unwrap(); + assert!(capture.reservation().source.matches(&source)); + assert_eq!( + store + .authored_draft_heads(*source.author(), 20) + .await + .unwrap() + .len(), + if installed { 3 } else { 2 } + ); +} + +#[tokio::test] +async fn racing_identical_commits_install_one_operation_with_exact_source_and_no_effects() { + for media in [false, true] { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let inner = client.storage().unwrap(); + let request = request(); + let captured = capture(inner, &request, media).await; + let mut store = FaultStore::new(inner, Fault::None); + store.atomic_fault = Fault::Race(tokio::sync::Barrier::new(2)); + let repo = SubmissionRepository { store: &store }; + let (a, b) = tokio::join!(repo.commit(&captured), repo.commit(&captured)); + let a = a.unwrap(); + let b = b.unwrap(); + assert_eq!(a.operation_id(), b.operation_id()); + assert_eq!(a.intent_id(), b.intent_id()); + assert_ne!(a.is_replay(), b.is_replay()); + assert_eq!(a.captured_at_unix_ms(), NOW); + assert!(a.committed_at_unix_ms() >= NOW); + assert_ne!(a.intent_id(), captured.reservation().reservation_id()); + assert_ne!(a.intent_id().as_bytes(), a.operation_id().as_bytes()); + assert_ne!(a.intent_id(), captured.reservation().source.draft_id()); + assert!(!format!("{a:?}").contains("PRIVATE")); + assert_records(inner, &captured, true).await; + let draft = inner + .authored_draft_head(a.intent_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + draft.stage(), + if media { + AuthoredDraftStage::MediaPreparing + } else { + AuthoredDraftStage::ReadyToSign + } + ); + assert_eq!( + draft.operation_id(), + if media { None } else { Some(a.operation_id()) } + ); + let recovered = repo.recover(&request).await.unwrap().unwrap(); + assert!(recovered.is_replay()); + assert_eq!(recovered.operation_id(), a.operation_id()); + assert_eq!(store.append_count(), 0); + } +} + +#[tokio::test] +async fn failed_or_lost_commit_acknowledgements_never_return_dangling_success() { + for media in [false, true] { + for fault in [ + Fault::BeforeCommit, + Fault::LostCallback, + Fault::WrongReceipt, + ] { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let inner = client.storage().unwrap(); + let request = request(); + let captured = capture(inner, &request, media).await; + let before = matches!(fault, Fault::BeforeCommit); + let wrong = matches!(fault, Fault::WrongReceipt); + let mut store = FaultStore::new(inner, Fault::None); + store.atomic_fault = fault; + let repo = SubmissionRepository { store: &store }; + assert_eq!( + repo.commit(&captured).await.unwrap_err(), + if wrong { + E::InvalidReceipt + } else { + E::Storage(Error::BackendUnavailable) + } + ); + assert_records(inner, &captured, !before).await; + assert_eq!(repo.recover(&request).await.unwrap().is_none(), before); + let retry = repo.commit(&captured).await.unwrap(); + assert_eq!(retry.is_replay(), !before); + assert_records(inner, &captured, true).await; + } + } +} + +#[tokio::test] +async fn full_changed_policy_conflicts_but_distinct_equal_actions_have_distinct_operations() { + for media in [false, true] { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let inner = client.storage().unwrap(); + let request = request(); + let captured = capture(inner, &request, media).await; + let repo = SubmissionRepository { store: inner }; + let original = repo.commit(&captured).await.unwrap(); + let bytes = if media { vec![photo().1] } else { vec![] }; + let slot = blossom(); + let changed = CapturedSubmission::capture( + captured.reservation().clone(), + policy("wss://new.example"), + Some(&slot), + bytes.clone(), + ) + .unwrap(); + assert_eq!( + repo.commit(&changed).await.unwrap_err(), + E::IdempotencyConflict + ); + if media { + slot.configure( + radroots_sdk::transport::BlossomConfig::from_profile(slot.profile().unwrap()) + .with_limits(1024 * 1024, 8192, 1) + .unwrap(), + ) + .unwrap(); + let changed = CapturedSubmission::capture( + captured.reservation().clone(), + captured.policy().clone(), + Some(&slot), + bytes.clone(), + ) + .unwrap(); + assert_eq!( + repo.commit(&changed).await.unwrap_err(), + E::IdempotencyConflict + ); + } + let mut new = request.clone(); + new.command_id = SubmissionCommandId::new([8; 16]).unwrap(); + let reservation = repo.reserve(&new, Some(NOW)).await.unwrap(); + let second = CapturedSubmission::capture( + reservation, + captured.policy().clone(), + Some(&blossom()), + bytes, + ) + .unwrap(); + assert_eq!(captured.plan(), second.plan()); + let second = repo.commit(&second).await.unwrap(); + assert_ne!(original.operation_id(), second.operation_id()); + assert_ne!(original.intent_id(), second.intent_id()); + for changed in [ + SubmissionReservationRequest::new( + request.command_id(), + scope(AUTHOR, "other"), + request.composer_id(), + request.expected_revision(), + ), + SubmissionReservationRequest::new( + request.command_id(), + request.scope().clone(), + request.composer_id(), + ComposerRevision::new(2).unwrap(), + ), + ] { + assert_eq!( + repo.recover(&changed).await.unwrap_err(), + E::IdempotencyConflict + ); + } + } +} + +#[tokio::test] +async fn original_composer_cas_rejects_first_commit_after_edit_but_preserves_committed_replay() { + for committed_first in [false, true] { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let inner = client.storage().unwrap(); + let request = request(); + let captured = capture(inner, &request, false).await; + let repo = SubmissionRepository { store: inner }; + if committed_first { + repo.commit(&captured).await.unwrap(); + } + let source = inner + .authored_draft_head(captured.reservation().source.draft_id()) + .await + .unwrap() + .unwrap(); + // Even a new revision with identical form bytes loses the original CAS. + let newer = source + .successor( + source.payload().to_vec(), + AuthoredDraftStage::Draft, + None, + NOW + 1, + ) + .unwrap(); + inner + .append_authored_draft(newer, Some(source.revision())) + .await + .unwrap(); + if committed_first { + assert!(repo.commit(&captured).await.unwrap().is_replay()); + assert!(repo.recover(&request).await.unwrap().unwrap().is_replay()); + } else { + assert_eq!( + repo.commit(&captured).await.unwrap_err(), + E::RevisionConflict + ); + assert!(repo.recover(&request).await.unwrap().is_none()); + assert!( + inner + .authored_operation(intent::operation_id(&request).unwrap()) + .await + .unwrap() + .is_none() + ); + assert!( + inner + .authored_draft_head(intent::intent_id(&request).unwrap()) + .await + .unwrap() + .is_none() + ); + } + } +} + +#[tokio::test] +async fn untrusted_receipt_and_historical_source_cannot_acknowledge_or_replace_work() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let inner = client.storage().unwrap(); + let request = request(); + let captured = capture(inner, &request, false).await; + let repo = SubmissionRepository { store: inner }; + let saved = repo.commit(&captured).await.unwrap(); + let receipt = inner + .authored_receipt(intent::commit_id(&request)) + .await + .unwrap() + .unwrap(); + let AuthoredAtomicOutcome::Submitted(original) = receipt.outcome() else { + panic!("expected submission") + }; + let prepared = original.preparation(); + let wrong = AuthoredAtomicReceipt::from_durable_parts( + receipt.commit_id(), + receipt.digest(), + receipt.disposition(), + receipt.committed_at_unix_ms(), + AuthoredAtomicOutcome::Prepared { + operation: prepared.operation().clone(), + artifacts: prepared.artifacts().to_vec(), + delivery_plans: prepared.delivery_plans().to_vec(), + }, + ) + .unwrap(); + let mut store = FaultStore::new(inner, Fault::None); + store.receipt_override = Some(Some(wrong)); + assert_eq!( + SubmissionRepository { store: &store } + .recover(&request) + .await + .unwrap_err(), + E::InvalidReceipt + ); + let mut different = request.clone(); + different.command_id = SubmissionCommandId::new([8; 16]).unwrap(); + store.receipt_override = Some(Some(receipt.clone())); + assert_eq!( + SubmissionRepository { store: &store } + .recover(&different) + .await + .unwrap_err(), + E::InvalidReceipt + ); + store.receipt_override = None; + let source = inner + .authored_draft_head(captured.reservation().source.draft_id()) + .await + .unwrap() + .unwrap(); + store.historical_override = Some( + radroots_storage::authored_draft::AuthoredDraft::initial( + source.draft_id(), + *source.author(), + source.payload_schema(), + b"corrupt source bytes".to_vec(), + source.stage(), + None, + NOW, + ) + .unwrap() + .with_scope(source.scope().unwrap()) + .unwrap(), + ); + assert_eq!( + SubmissionRepository { store: &store } + .recover(&request) + .await + .unwrap_err(), + E::Reservation(super::super::SubmissionReservationError::CorruptRecord) + ); + assert_eq!( + SubmissionRepository { store: &store } + .commit(&captured) + .await + .unwrap_err(), + E::Reservation(super::super::SubmissionReservationError::CorruptRecord) + ); + assert_eq!(store.commits.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(store.append_count(), 0); + assert_eq!( + repo.recover(&request) + .await + .unwrap() + .unwrap() + .operation_id(), + saved.operation_id() + ); +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs b/core/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs @@ -17,17 +17,23 @@ pub(super) enum Fault { Race(tokio::sync::Barrier), } -pub(super) struct FaultStore<'a> { - pub inner: &'a dyn AuthoredDraftStore, +pub(super) struct FaultStore<'a, S: ?Sized> { + pub inner: &'a S, + pub atomic_fault: Fault, + pub commits: AtomicUsize, + pub receipt_override: Option<Option<radroots_storage::authored_atomic::AuthoredAtomicReceipt>>, pub fault: Fault, pub appends: AtomicUsize, pub historical_override: Option<AuthoredDraft>, } -impl<'a> FaultStore<'a> { - pub fn new(inner: &'a dyn AuthoredDraftStore, fault: Fault) -> Self { +impl<'a, S: ?Sized> FaultStore<'a, S> { + pub fn new(inner: &'a S, fault: Fault) -> Self { Self { inner, + atomic_fault: Fault::None, + commits: AtomicUsize::new(0), + receipt_override: None, fault, appends: AtomicUsize::new(0), historical_override: None, @@ -38,7 +44,7 @@ impl<'a> FaultStore<'a> { } } -impl AuthoredDraftStore for FaultStore<'_> { +impl<S: AuthoredDraftStore + ?Sized> AuthoredDraftStore for FaultStore<'_, S> { fn query_authored_drafts( &self, query: AuthoredDraftQuery, @@ -108,3 +114,6 @@ impl AuthoredDraftStore for FaultStore<'_> { self.inner.authored_draft_heads(author, limit) } } + +#[path = "atomic_fault_store.rs"] +mod atomic; diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/intent.rs b/core/crates/tera_core/src/runtime/product_surface/submission/intent.rs @@ -0,0 +1,251 @@ +//! App-owned immutable capture, carried by the existing shared authored transaction. + +use radroots_event::contract::AuthorRole; +use radroots_event_codec::authoring::PlanWireV1; +use radroots_signing::{Actor, actor::ActorSource}; +use radroots_storage::{ + atomic::AtomicCommitId, + authored_atomic::PrepareAuthoredOperation, + authored_draft::{ + AUTHORED_DRAFT_PAYLOAD_MAX_BYTES, AuthoredDraft, AuthoredDraftId, AuthoredDraftRevision, + AuthoredDraftStage, + }, + authored_draft_submission::PrepareFromDraft, + journal::{IdempotencyKey, OperationInstanceId}, +}; +use radroots_sync::{PushRequest, policy::SyncId}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::{ + CapturedSubmission, SubmissionCommandId, SubmissionCommitError as E, + SubmissionReservationReceipt, SubmissionReservationRequest, +}; +use crate::runtime::product_surface::{ + AddCommandType, ComposerScope, Phase1MediaPrerequisite, Phase1MediaStage, Phase1QueuePolicy, +}; + +pub const SUBMISSION_INTENT_PAYLOAD_SCHEMA: &str = "tera.publication_intent.v1"; +pub const SUBMISSION_INTENT_SCHEMA_VERSION: u64 = 1; +pub const SUBMISSION_INTENT_SCHEMA_SHA256: &str = + "9e9b21b186c816c7996e7a94fba802d58563dfaefa1ddc1f88a486daa05b25a6"; +pub const SUBMISSION_INTENT_MAX_BYTES: usize = AUTHORED_DRAFT_PAYLOAD_MAX_BYTES; + +#[cfg(test)] +#[path = "intent_tests.rs"] +mod tests; + +#[derive(Deserialize)] +struct Header { + schema_version: u64, + schema_sha256: String, +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct IntentPayload { + schema_version: u64, + schema_sha256: String, + command_id: SubmissionCommandId, + scope: ComposerScope, + reservation_id: AuthoredDraftId, + command_type: AddCommandType, + plan_wire_json: Vec<u8>, + media: Vec<Phase1MediaPrerequisite>, + media_policy: Option<[u8; 32]>, + policy: Phase1QueuePolicy, +} + +pub(super) fn intent_id(request: &SubmissionReservationRequest) -> Result<AuthoredDraftId, E> { + AuthoredDraftId::new(derive_id(b"tera.publication_intent.v1\0", request)) + .map_err(|_| E::InvalidIntent) +} + +pub(super) fn operation_id( + request: &SubmissionReservationRequest, +) -> Result<OperationInstanceId, E> { + OperationInstanceId::new(derive_id(b"tera.submission_operation.v1\0", request)) + .map_err(|_| E::InvalidIntent) +} + +fn derive_id(domain: &[u8], request: &SubmissionReservationRequest) -> [u8; 16] { + let mut hash = Sha256::new(); + hash.update(domain); + hash.update(request.scope().author().as_bytes()); + hash.update(request.command_id().as_bytes()); + let mut id = [0; 16]; + id.copy_from_slice(&hash.finalize()[..16]); + id +} + +pub(super) fn commit_id(request: &SubmissionReservationRequest) -> AtomicCommitId { + PrepareFromDraft::commit_id_for( + request.scope().author().as_bytes(), + AtomicCommitId::new(*request.command_id().as_bytes()).expect("validated nonzero command"), + ) +} + +impl IntentPayload { + pub(super) fn capture(value: &CapturedSubmission) -> Result<PrepareFromDraft, E> { + let reservation = value.reservation(); + let payload = Self { + schema_version: SUBMISSION_INTENT_SCHEMA_VERSION, + schema_sha256: SUBMISSION_INTENT_SCHEMA_SHA256.to_owned(), + command_id: reservation.request().command_id(), + scope: reservation.request().scope().clone(), + reservation_id: reservation.reservation_id(), + command_type: value.command().command_type(), + plan_wire_json: PlanWireV1::from_plan(value.plan()) + .to_json() + .map_err(|_| E::InvalidIntent)?, + media: value.media().to_vec(), + media_policy: value.media_policy().map(|value| *value.as_bytes()), + policy: value.policy().clone(), + }; + let preparation = payload.preparation(reservation)?; + let bytes = serde_json::to_vec(&payload).map_err(|_| E::InvalidIntent)?; + if bytes.len() > SUBMISSION_INTENT_MAX_BYTES { + return Err(E::InvalidIntent); + } + let ready = payload.media.is_empty(); + let digest = Sha256::digest(&bytes).into(); + // This initial intent is installed by PrepareFromDraft, not the ordinary + // draft append API (whose initial constructor deliberately rejects ready). + let intent = AuthoredDraft::reconstruct( + intent_id(reservation.request())?, + AuthoredDraftRevision::INITIAL, + reservation.request().scope().author().into_bytes(), + SUBMISSION_INTENT_PAYLOAD_SCHEMA, + bytes, + digest, + if ready { + AuthoredDraftStage::ReadyToSign + } else { + AuthoredDraftStage::MediaPreparing + }, + ready.then_some(preparation.operation().operation_id()), + reservation.reserved_at_unix_ms(), + reservation.reserved_at_unix_ms(), + ) + .and_then(|intent| { + intent.with_scope( + reservation + .source + .scope() + .ok_or(radroots_storage::Error::InvalidAuthoredDraft)?, + ) + }) + .map_err(|_| E::InvalidIntent)?; + let constructor = if ready { + PrepareFromDraft::new + } else { + PrepareFromDraft::new_waiting + }; + constructor( + AtomicCommitId::new(*payload.command_id.as_bytes()).map_err(|_| E::InvalidIntent)?, + reservation.source.clone(), + intent, + preparation, + ) + .map_err(|_| E::InvalidIntent) + } + + pub(super) fn validate_committed( + request: &PrepareFromDraft, + reservation: &SubmissionReservationReceipt, + ) -> Result<(), E> { + request.validate().map_err(|_| E::InvalidReceipt)?; + let intent = request.intent(); + if intent.payload_schema() != SUBMISSION_INTENT_PAYLOAD_SCHEMA { + return Err(E::UnsupportedSchema); + } + if intent.payload().len() > SUBMISSION_INTENT_MAX_BYTES + || intent.draft_id() != intent_id(reservation.request())? + || intent.revision() != AuthoredDraftRevision::INITIAL + || request.source() != &reservation.source + || request.command_id().as_bytes() != reservation.request().command_id().as_bytes() + { + return Err(E::InvalidReceipt); + } + let header: Header = + serde_json::from_slice(intent.payload()).map_err(|_| E::CorruptRecord)?; + if header.schema_version != SUBMISSION_INTENT_SCHEMA_VERSION + || header.schema_sha256 != SUBMISSION_INTENT_SCHEMA_SHA256 + { + return Err(E::UnsupportedSchema); + } + let payload: Self = + serde_json::from_slice(intent.payload()).map_err(|_| E::CorruptRecord)?; + let preparation = payload.preparation(reservation)?; + let stage = if payload.media.is_empty() { + AuthoredDraftStage::ReadyToSign + } else { + AuthoredDraftStage::MediaPreparing + }; + if intent.stage() != stage || &preparation != request.preparation() { + return Err(E::InvalidReceipt); + } + Ok(()) + } + + fn preparation( + &self, + reservation: &SubmissionReservationReceipt, + ) -> Result<PrepareAuthoredOperation, E> { + let request = reservation.request(); + let input = reservation.captured().form().input(); + if self.command_id != request.command_id() + || &self.scope != request.scope() + || self.reservation_id != reservation.reservation_id() + || self.command_type != input.command_type + || self.media.len() != input.media.len() + || self.media.len() > 20 + || self.media.is_empty() != self.media_policy.is_none() + { + return Err(E::CorruptRecord); + } + for (media, input) in self.media.iter().zip(&input.media) { + media.validate().map_err(|_| E::CorruptRecord)?; + if media.stage() != Phase1MediaStage::Pending + || media.local_reference() != input.opaque_reference + || media.sha256() != input.sha256 + || media.media_type() != input.media_type + || media.byte_size() != input.byte_size + { + return Err(E::CorruptRecord); + } + } + let plan = PlanWireV1::from_json(&self.plan_wire_json) + .map_err(|_| E::CorruptRecord)? + .into_plan(); + if plan.author() != &request.scope().author() + || plan.created_at() != reservation.reserved_at_unix_ms() / 1000 + { + return Err(E::CorruptRecord); + } + let (targets, satisfaction, cancellation) = + self.policy.materialize().map_err(|_| E::CorruptRecord)?; + let actor = Actor::new( + request.scope().author(), + ActorSource::ExplicitPublicKey, + AuthorRole::ALL, + ) + .map_err(|_| E::InvalidIntent)?; + PushRequest::new( + SyncId::new(*operation_id(request)?.as_bytes()).map_err(|_| E::InvalidIntent)?, + IdempotencyKey::parse(format!( + "tera.submission.v1.{}", + hex::encode(request.command_id().as_bytes()) + )) + .map_err(|_| E::InvalidIntent)?, + actor, + plan, + targets, + satisfaction, + self.policy.delivery_deadline_unix_ms, + cancellation, + ) + .and_then(|request| request.authored_preparation(reservation.reserved_at_unix_ms())) + .map_err(|_| E::InvalidIntent) + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs @@ -0,0 +1,207 @@ +use super::super::{ + repository::SubmissionRepository, test_support::*, transaction_test_support::capture, +}; +use super::*; +use radroots_storage::authored_atomic::AuthoredAtomicOutcome; + +fn with_payload(original: &PrepareFromDraft, bytes: Vec<u8>, schema: &str) -> PrepareFromDraft { + let old = original.intent(); + let digest = Sha256::digest(&bytes).into(); + let draft = AuthoredDraft::reconstruct( + old.draft_id(), + old.revision(), + *old.author(), + schema, + bytes, + digest, + old.stage(), + old.operation_id(), + old.created_at_unix_ms(), + old.updated_at_unix_ms(), + ) + .unwrap() + .with_scope(old.scope().unwrap()) + .unwrap(); + let constructor = if old.operation_id().is_some() { + PrepareFromDraft::new + } else { + PrepareFromDraft::new_waiting + }; + constructor( + original.command_id(), + original.source().clone(), + draft, + original.preparation().clone(), + ) + .unwrap() +} + +#[tokio::test] +async fn descriptor_ids_and_original_source_are_stable_bounded_and_account_scoped() { + assert_eq!( + hex::encode(Sha256::digest(include_bytes!( + "../../../../../../fixtures/submission_intent_schema_v1.json" + ))), + SUBMISSION_INTENT_SCHEMA_SHA256 + ); + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let store = client.storage().unwrap(); + let request = request(); + let captured = capture(store, &request, false).await; + let original = IntentPayload::capture(&captured).unwrap(); + assert_eq!(original.source(), &captured.reservation().source); + assert_ne!( + original.source().draft_id(), + captured.reservation().reservation_id() + ); + IntentPayload::validate_committed(&original, captured.reservation()).unwrap(); + let mut different = request.clone(); + different.scope = scope(OTHER, "nearby"); + assert_ne!(intent_id(&request).unwrap(), intent_id(&different).unwrap()); + assert_ne!( + operation_id(&request).unwrap(), + operation_id(&different).unwrap() + ); + assert_ne!(commit_id(&request), commit_id(&different)); + different.scope = scope(AUTHOR, "other"); + assert_eq!(intent_id(&request).unwrap(), intent_id(&different).unwrap()); + assert_eq!( + operation_id(&request).unwrap(), + operation_id(&different).unwrap() + ); + let mut bytes = original.intent().payload().to_vec(); + bytes.resize(SUBMISSION_INTENT_MAX_BYTES, b' '); + let at_limit = with_payload(&original, bytes.clone(), SUBMISSION_INTENT_PAYLOAD_SCHEMA); + IntentPayload::validate_committed(&at_limit, captured.reservation()).unwrap(); + bytes.push(b' '); + let digest = Sha256::digest(&bytes).into(); + let old = original.intent(); + assert!( + AuthoredDraft::reconstruct( + old.draft_id(), + old.revision(), + *old.author(), + old.payload_schema(), + bytes, + digest, + old.stage(), + old.operation_id(), + old.created_at_unix_ms(), + old.updated_at_unix_ms() + ) + .is_err() + ); +} + +#[tokio::test] +async fn malformed_and_self_inconsistent_intents_fail_before_any_success_receipt() { + for media in [false, true] { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let store = client.storage().unwrap(); + let captured = capture(store, &request(), media).await; + let original = IntentPayload::capture(&captured).unwrap(); + let base: serde_json::Value = serde_json::from_slice(original.intent().payload()).unwrap(); + let mut variants = Vec::new(); + for (field, value) in [ + ("schema_version", serde_json::json!(2)), + ("schema_sha256", serde_json::json!("wrong")), + ("unknown", serde_json::json!(true)), + ("command_id", serde_json::json!([9; 16].to_vec())), + ("reservation_id", serde_json::json!([9; 16].to_vec())), + ("command_type", serde_json::json!("create_event")), + ("plan_wire_json", serde_json::json!([123u8, 125].to_vec())), + ] { + let mut changed = base.clone(); + changed[field] = value; + variants.push(serde_json::to_vec(&changed).unwrap()); + } + let text = String::from_utf8(original.intent().payload().to_vec()).unwrap(); + variants.push(format!("{{\"schema_version\":1,{}", &text[1..]).into_bytes()); + variants.push(b"{truncated".to_vec()); + let mut changed = base.clone(); + changed["policy"]["delivery_deadline_unix_ms"] = serde_json::json!(NOW + 9999); + variants.push(serde_json::to_vec(&changed).unwrap()); + let mut changed = base.clone(); + changed["policy"]["unknown"] = serde_json::json!(true); + variants.push(serde_json::to_vec(&changed).unwrap()); + if media { + let mut changed = base.clone(); + changed["media"][0]["unknown"] = serde_json::json!(true); + variants.push(serde_json::to_vec(&changed).unwrap()); + let mut changed = base.clone(); + changed["media"] = serde_json::json!([]); + variants.push(serde_json::to_vec(&changed).unwrap()); + let mut changed = base.clone(); + changed["media_policy"] = serde_json::Value::Null; + variants.push(serde_json::to_vec(&changed).unwrap()); + let mut changed = base.clone(); + changed["media"][0]["byte_size"] = serde_json::json!(1); + variants.push(serde_json::to_vec(&changed).unwrap()); + } + for bytes in variants { + let changed = with_payload(&original, bytes, SUBMISSION_INTENT_PAYLOAD_SCHEMA); + assert!(IntentPayload::validate_committed(&changed, captured.reservation()).is_err()); + } + assert_eq!( + IntentPayload::validate_committed( + &with_payload( + &original, + original.intent().payload().to_vec(), + "future.schema" + ), + captured.reservation() + ), + Err(E::UnsupportedSchema) + ); + } +} + +#[tokio::test] +async fn original_receipt_stays_valid_after_waiting_intent_head_advances() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let store = client.storage().unwrap(); + let request = request(); + let captured = capture(store, &request, true).await; + let repo = SubmissionRepository { store }; + let receipt = repo.commit(&captured).await.unwrap(); + let head = store + .authored_draft_head(receipt.intent_id()) + .await + .unwrap() + .unwrap(); + let next = head + .successor( + head.payload().to_vec(), + AuthoredDraftStage::MediaUploading, + None, + NOW + 1, + ) + .unwrap(); + store + .append_authored_draft(next, Some(head.revision())) + .await + .unwrap(); + let replay = repo.recover(&request).await.unwrap().unwrap(); + assert_eq!(receipt.intent_id(), replay.intent_id()); + assert_eq!(receipt.operation_id(), replay.operation_id()); + assert!(replay.is_replay()); + let stored = store + .authored_receipt(commit_id(&request)) + .await + .unwrap() + .unwrap(); + let AuthoredAtomicOutcome::Submitted(original) = stored.outcome() else { + panic!("wrong receipt") + }; + assert_eq!( + original.intent().stage(), + AuthoredDraftStage::MediaPreparing + ); + assert_eq!(original.intent().revision(), AuthoredDraftRevision::INITIAL); +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/repository.rs b/core/crates/tera_core/src/runtime/product_surface/submission/repository.rs @@ -14,8 +14,8 @@ use crate::{ runtime::product_surface::{ComposerPersistenceError as SourceError, ComposerStorageRecord}, }; -struct SubmissionRepository<'a> { - store: &'a dyn AuthoredDraftStore, +pub(super) struct SubmissionRepository<'a, S: ?Sized> { + pub(super) store: &'a S, } #[cfg(test)] @@ -25,8 +25,8 @@ mod sqlite_tests; #[path = "repository_tests.rs"] mod tests; -impl SubmissionRepository<'_> { - async fn replay( +impl<S: AuthoredDraftStore + ?Sized> SubmissionRepository<'_, S> { + pub(super) async fn replay( &self, request: &SubmissionReservationRequest, ) -> Result<Option<SubmissionReservationReceipt>, E> { @@ -65,12 +65,13 @@ impl SubmissionRepository<'_> { request: request.clone(), reservation_id: stored.draft_id(), captured, + source: wire.source, reserved_at_unix_ms: stored.created_at_unix_ms(), replayed, }) } - async fn reserve( + pub(super) async fn reserve( &self, request: &SubmissionReservationRequest, observed_time: Option<u64>, diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs @@ -2,9 +2,7 @@ use super::*; use crate::runtime::product_surface::submission::test_support::*; use crate::runtime::product_surface::{ComposerId, ComposerRevision, SubmissionCommandId}; -#[path = "fault_store.rs"] -mod fault_store; -use fault_store::{Fault, FaultStore}; +use super::super::fault_store::{Fault, FaultStore}; #[tokio::test] async fn concurrent_equivalent_reservations_reuse_one_winner_despite_different_clocks() { diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs b/core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs @@ -1,7 +1,8 @@ use super::*; use crate::runtime::product_surface::{ - AddCommandType, ComposerEditSequence, ComposerFormInput, ComposerPartialForm, - ComposerStorageRecord, LocalNetworkId, + AddCommandType, ComposerEditSequence, ComposerFormInput, ComposerMediaInput, + ComposerPartialForm, ComposerStorageRecord, LocalNetworkId, Phase1CancellationPolicy, + Phase1QueuePolicy, Phase1RelaySatisfaction, }; use radroots_identity::PublicKey; @@ -43,3 +44,58 @@ pub(super) fn request() -> SubmissionReservationRequest { ComposerRevision::INITIAL, ) } + +use radroots_sdk::transport::{ + BlossomConfig, BlossomEndpointAuthority, BlossomHostKind, BlossomProfile, BlossomSlot, +}; +use std::sync::Arc; + +pub(super) fn policy(relay: &str) -> Phase1QueuePolicy { + Phase1QueuePolicy::new( + vec![relay.into()], + Phase1RelaySatisfaction::AllAccepted, + NOW + 1000, + Phase1CancellationPolicy::LocalCooperative, + ) + .unwrap() +} + +pub(super) fn blossom() -> BlossomSlot { + let slot = BlossomSlot::new(); + slot.configure(BlossomConfig::from_profile( + BlossomProfile::new( + BlossomHostKind::Simulator, + BlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3000", + std::iter::empty::<&str>(), + ) + .unwrap(), + )) + .unwrap(); + slot +} + +pub(super) fn input(kind: AddCommandType) -> ComposerFormInput { + let mut input = ComposerFormInput::empty(kind); + input.content = "PRIVATE harvest café".into(); + input +} + +pub(super) fn photo() -> (ComposerMediaInput, Arc<[u8]>) { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&2u32.to_be_bytes()); + bytes.extend_from_slice(&2u32.to_be_bytes()); + ( + ComposerMediaInput { + opaque_reference: "media:harvest".into(), + sha256: radroots_blossom::Sha256::digest(&bytes).to_hex(), + media_type: "image/png".into(), + byte_size: bytes.len() as u64, + width: 2, + height: 2, + alt: "Harvest".into(), + prepared_at_unix_s: NOW / 1000, + }, + bytes.into(), + ) +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs b/core/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs @@ -0,0 +1,50 @@ +use super::{ + CapturedSubmission, SubmissionReservationRequest, repository::SubmissionRepository, + test_support::*, +}; +use crate::runtime::product_surface::{ + AddCommandType, ComposerEditSequence, ComposerPartialForm, ComposerStorageRecord, +}; +use radroots_storage::authored_draft::AuthoredDraftStore; + +pub(super) async fn capture<S: AuthoredDraftStore + ?Sized>( + store: &S, + request: &SubmissionReservationRequest, + media: bool, +) -> CapturedSubmission { + let mut input = input(if media { + AddCommandType::CreatePhotoUpdate + } else { + AddCommandType::CreateUpdate + }); + let bytes = if media { + let (photo, bytes) = photo(); + input.media.push(photo); + vec![bytes] + } else { + vec![] + }; + let source = ComposerStorageRecord::initial( + request.composer_id(), + request.scope().clone(), + ComposerEditSequence::INITIAL, + ComposerPartialForm::new(input).unwrap(), + NOW, + ) + .unwrap(); + store + .append_authored_draft(source.into_stored(), None) + .await + .unwrap(); + let reservation = SubmissionRepository { store } + .reserve(request, Some(NOW)) + .await + .unwrap(); + CapturedSubmission::capture( + reservation, + policy("wss://relay.example"), + Some(&blossom()), + bytes, + ) + .unwrap() +} diff --git a/core/fixtures/submission_intent_schema_v1.json b/core/fixtures/submission_intent_schema_v1.json @@ -0,0 +1,36 @@ +{ + "schema": "tera.publication_intent.schema_descriptor.v1", + "payload_schema": "tera.publication_intent.v1", + "schema_version": 1, + "encoding": "utf8_json", + "unknown_fields": "reject", + "payload_fields": ["schema_version", "schema_sha256", "command_id", "scope", "reservation_id", "command_type", "plan_wire_json", "media", "media_policy", "policy"], + "bounds": { + "payload_bytes": 4194304, + "media_items": 20, + "media_policy_bytes": 32, + "timestamp_ms_max": 9223372036854775807 + }, + "source": "The original immutable composer envelope carried by PrepareFromDraft and the existing reservation; never substitute the reservation for composer CAS.", + "plan": "Exact validated shared PlanWire at the reserved author and time, equal to the pure shared preparation.", + "media": "Captured byte-verified metadata and complete configuration fingerprint; initial entries are pending. No bytes, credentials, transport or signer handle are serialized.", + "policy": "Exact canonical targets, satisfaction, deadline and cancellation captured before commit.", + "envelope": { + "owner": "AuthoredDraft", + "revision": 1, + "author": "captured stable account", + "scope": "existing composer scope digest", + "without_media": "ready_to_sign with the exact prepared operation association", + "with_media": "media_preparing with no draft operation association", + "timestamps": "equal reserved time no earlier than the original source" + }, + "identity": { + "input": "author_32_bytes followed by random command_id_16_bytes; no content hash or session generation", + "derivation": "first 16 bytes of sha256(domain_with_zero_terminator, input); reject zero", + "intent_domain": "tera.publication_intent.v1", + "operation_domain": "tera.submission_operation.v1", + "receipt": "existing PrepareFromDraft::commit_id_for stable account and command key" + }, + "transaction": "Existing single-owner PrepareFromDraft installs intent, operation, planned artifact, delivery plan and complete association receipts atomically. Exact committed replay precedes source CAS; conflicting replay is rejected.", + "authority": "Local immutable intent only. No signer or network inside the transaction; media progress and native operation advancement are separate consumers. Legacy draft mutators reject this distinct schema." +} diff --git a/release/provenance.json b/release/provenance.json @@ -2,7 +2,7 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "fa1cc3ee4d2ed28a8ff535e598de1b45dd2129a3260186f74c6b3d1a14069f83", - "ffi_provenance_sha256": "8d5e0e1cf044cb1d4ed1ccfd7ddb550590347d8f12350ae91b470a66514c1a8c", + "ffi_provenance_sha256": "4af8319178aa2945467331f9303a4b7538f7b78edf3080560a792607ddfb60c3", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "181465ab175193b361bf543db0b141c64fb03fa3439ce8142a69f5c56734bd3e", @@ -22,7 +22,7 @@ "lib_revision": "ac392da942896a6b676f063929af16971e201b0e", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "f980edff71fa9e57d7bcfa417b3bb0ff776be01d", + "tera_ffi_source_tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130", "xcode_package_lock_sha256": "c7f41934ea25f7a287bdc4f3a6ecabbf09a3a0bdd0f5a3e58183f355ca814096" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -4071,7 +4071,7 @@ "count": 1 }, { - "path": "core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs", + "path": "core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs", "count": 1 }, { @@ -4189,6 +4189,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/today.rs", "count": 6 }, @@ -4287,6 +4291,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/today.rs", "count": 2 }, @@ -4601,10 +4609,26 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs", + "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs", + "count": 6 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs", + "count": 3 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs", "count": 5 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/test_support.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/today.rs", "count": 4 }, @@ -4691,6 +4715,10 @@ "count": 6 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs", + "count": 1 + }, + { "path": "core/crates/tera_ffi/Cargo.toml", "count": 1 }, @@ -4770,10 +4798,34 @@ }, { "path": "core/crates/tera_core/src/runtime/product_surface/submission.rs", + "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/capture/tests.rs", "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/commit_tests.rs", + "count": 4 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/fault_store.rs", + "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs", + "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs", "count": 1 }, { @@ -4789,6 +4841,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/transaction_test_support.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/today.rs", "count": 12 }, @@ -4851,6 +4907,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/today.rs", "count": 1 },