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 a3fc1df6044f802a67b88df2765df63749ab5b1d
parent f69d08168d4d6278abb43a4864ec8e74167a514c
Author: triesap <tyson@radroots.org>
Date:   Sat, 12 Sep 2026 18:57:49 +0000

publication: Advance exact scoped submission operations

- Validate immutable receipts and current operation bindings
- Reuse the existing bounded signing and delivery phases
- Keep waiting media inert and composer editing independent
- Verify loopback execution duplicate waits and SQLite recovery

Diffstat:
MTeraFFI/provenance.json | 54+++++++++++++++++++++++++++---------------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MTeraFFI/source/aarch64-apple-ios-sim.json | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MTeraFFI/source/aarch64-apple-ios.json | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcore/crates/tera_core/src/runtime/product_surface.rs | 5+++--
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 55+++++++------------------------------------------------
Acore/crates/tera_core/src/runtime/product_surface/outbox/advance.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission.rs | 9+++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission/commit.rs | 7+++++--
Mcore/crates/tera_core/src/runtime/product_surface/submission/intent.rs | 10+++++++++-
Acore/crates/tera_core/src/runtime/product_surface/submission/operation.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrelease/provenance.json | 4++--
Mtest-fixtures/legacy-identifiers.v1.json | 40++++++++++++++++++++++++++++++++++++++++
18 files changed, 1205 insertions(+), 141 deletions(-)

diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74930824, + "bytes": 74943480, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "84ffc4e6208ba56d66e4dd50e9754a5f601a02b2abe8b528b3348a41edbed2f1" + "sha256": "c4cf3b9883f8c6422c45daaeecd6009dc4f6ae5d04176fcd683bb9b7ebc04288" }, { "bytes": 75137, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74998704, + "bytes": 75010008, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "4c43c5f3db07a01cee433e4b70153e2271182004886428e82ea2b0837b0f9f3a" + "sha256": "5084a5d56c57b49840feaadeba814eb44606328097ca0669e75aa22d5a290bd2" }, { "bytes": 45118, @@ -77,34 +77,34 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 20953872, + "bytes": 20953600, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "d8b29dffcd79a87320bed8d5c313279839785476feb5828b5ac1691f1f5d9598" + "sha256": "9e476f34d2d1b203c2e94d80929dc223035202f2d734b0b2428643001b526382" }, { - "bytes": 74930824, + "bytes": 74943480, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "84ffc4e6208ba56d66e4dd50e9754a5f601a02b2abe8b528b3348a41edbed2f1" + "sha256": "c4cf3b9883f8c6422c45daaeecd6009dc4f6ae5d04176fcd683bb9b7ebc04288" }, { - "bytes": 74998704, + "bytes": 75010008, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "4c43c5f3db07a01cee433e4b70153e2271182004886428e82ea2b0837b0f9f3a" + "sha256": "5084a5d56c57b49840feaadeba814eb44606328097ca0669e75aa22d5a290bd2" }, { - "bytes": 87030, + "bytes": 88816, "path": "source/aarch64-apple-darwin.json", - "sha256": "c997bbc68fb9a0dd98f16fd936c45a17b974e29adb1dfc370170f8a113991f0e" + "sha256": "8c12071a51dc16bdb8cba0bd620a1d3fc7e886110305b44b06cad7da9283f427" }, { - "bytes": 86874, + "bytes": 88660, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "5af069739037277dd5b5f751fbdbf1b56422d569c0c3d5c192320c18f631cc78" + "sha256": "a9c6f572a7cce6fcc8c0ce5b0c005a2d40a74bb80a9947072b3a5c382d356a94" }, { - "bytes": 86870, + "bytes": 88656, "path": "source/aarch64-apple-ios.json", - "sha256": "5ef0b7528f8c65cb403cf9631be1c577318303faf65ae20fda2b12497bf7a29d" + "sha256": "aa8891f70d4f87550a90481dc1c876bf1b75d3de6e050393c70750ad72027300" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" + "tree": "18a7a18d56a4fb953cd53e12f99afee48c7e6e41" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74930824, + "bytes": 74943480, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "84ffc4e6208ba56d66e4dd50e9754a5f601a02b2abe8b528b3348a41edbed2f1" + "sha256": "c4cf3b9883f8c6422c45daaeecd6009dc4f6ae5d04176fcd683bb9b7ebc04288" }, { "bytes": 75137, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74998704, + "bytes": 75010008, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "4c43c5f3db07a01cee433e4b70153e2271182004886428e82ea2b0837b0f9f3a" + "sha256": "5084a5d56c57b49840feaadeba814eb44606328097ca0669e75aa22d5a290bd2" }, { "bytes": 592248, @@ -174,19 +174,19 @@ "sha256": "fa1cc3ee4d2ed28a8ff535e598de1b45dd2129a3260186f74c6b3d1a14069f83" }, { - "bytes": 87030, + "bytes": 88816, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "c997bbc68fb9a0dd98f16fd936c45a17b974e29adb1dfc370170f8a113991f0e" + "sha256": "8c12071a51dc16bdb8cba0bd620a1d3fc7e886110305b44b06cad7da9283f427" }, { - "bytes": 86874, + "bytes": 88660, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "5af069739037277dd5b5f751fbdbf1b56422d569c0c3d5c192320c18f631cc78" + "sha256": "a9c6f572a7cce6fcc8c0ce5b0c005a2d40a74bb80a9947072b3a5c382d356a94" }, { - "bytes": 86870, + "bytes": 88656, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "5ef0b7528f8c65cb403cf9631be1c577318303faf65ae20fda2b12497bf7a29d" + "sha256": "aa8891f70d4f87550a90481dc1c876bf1b75d3de6e050393c70750ad72027300" } ], "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 = "e62cffbf482c8b38c170120dd9d4da63aab8c130" -manifest_sha256 = "4af8319178aa2945467331f9303a4b7538f7b78edf3080560a792607ddfb60c3" +source_tree = "18a7a18d56a4fb953cd53e12f99afee48c7e6e41" +manifest_sha256 = "d33dcc83c3ec328d8d8bc13ee92fbe5b553bbbeecb15fcd2eb7d0e34a17fede4" 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": 7767, - "git_blob": "6044a14cf25025ad46f2380a3c6e6af70bfbe91d", + "bytes": 7824, + "git_blob": "d47c550c5f18f5ef4d106004287ece34b8df4ad7", "mode": "100644", - "sha256": "14e1632185306b8254f9db212a1f449e49bd82c1a42af511e3cb6484d1f2aef4" + "sha256": "74cc8b81313265d09456dc0efe619e49fcd7a5d7962055215cf8e31618cb1f1b" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -801,10 +801,16 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 171940, - "git_blob": "06b8856aa7de31c1bc38a1f5ab748c10667ef808", + "bytes": 170329, + "git_blob": "696260720ef6a54c2111d7b975fb85ac45abb41d", "mode": "100644", - "sha256": "38c91467f2d90631cf943ae3878c2a70bcf6e006691c77031a9933bebea36307" + "sha256": "48cc3be9a8a1356f2cde36816329955fd20bb930c01e75d2243d82dbe8ed3d0b" + }, + "core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs": { + "bytes": 2011, + "git_blob": "8fec7500c5f7b6298ee6c80abe2e409cb2b0b8f7", + "mode": "100644", + "sha256": "ffba0183c2bb06d8b6a4b29b79dfd1b3e179d6b209913dda3013c9c7732b77c4" }, "core/crates/tera_core/src/runtime/product_surface/outbox/inventory.rs": { "bytes": 8642, @@ -843,10 +849,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 5177, - "git_blob": "1c2a4ebad319a52c4841ce52846e75825bebc8be", + "bytes": 5404, + "git_blob": "aca536a36c4a9f5cebad5f206ecb66e17d7c392c", "mode": "100644", - "sha256": "81d63a222ba087499e2169b58ab22469697949346a658781f25276e25d1906c8" + "sha256": "70c51fbd3abb0c743df1e2840a59e484d8fdc3cf7dd08a2534c26d8e5e9e1bd7" }, "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { "bytes": 3189, @@ -891,10 +897,10 @@ "sha256": "6c7481eb4a17a3064ec7672d4a1971f811f98ef8111068b6c2496ff9bbc202e7" }, "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs": { - "bytes": 8732, - "git_blob": "f85e853b40a49191032281298bb8d4ffbfcf2d27", + "bytes": 8777, + "git_blob": "c595715d62f17d1730ebbd9c39b388350267ea8f", "mode": "100644", - "sha256": "7512f1209c868ec98de71d7ae251d53ef7850b74b35cad5f6fbafb15b0822541" + "sha256": "1449145ce3f995c740902c6de070865d4ecb3443eba0f8afd64eca6c4fa1c36b" }, "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs": { "bytes": 8868, @@ -915,10 +921,10 @@ "sha256": "23fab4dd866cf8d4acf87c9091c8858312dc7789d5fe08c250694a69a02248ed" }, "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs": { - "bytes": 9736, - "git_blob": "67375586ef57ef51972988658104f31ddc44982f", + "bytes": 9935, + "git_blob": "8a8367a4d9df09700af6eb152dda91646886f6eb", "mode": "100644", - "sha256": "e81f8cf7e413c55812cfd0a16e2434f74f585e91b9e5cc6e477d9c0432766b32" + "sha256": "44459a1fc1819d83dbf5355cfa01442e832a8607d9c4dd5a210fdf51b2d3757f" }, "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs": { "bytes": 7768, @@ -926,6 +932,36 @@ "mode": "100644", "sha256": "513320bd503d0f747edc5b72e4039bd51344c03ab82f73c870e5346e785b66e4" }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation.rs": { + "bytes": 5956, + "git_blob": "025015f05b1674ac462f71a333e5781111704a5f", + "mode": "100644", + "sha256": "178be957fd0b899de4f3dbecb31fc5e41faeb2a83b9cec329747d628bc49d104" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs": { + "bytes": 4726, + "git_blob": "b0d9c843be0156d125f99900ec342a23d97dc70a", + "mode": "100644", + "sha256": "1d9f0935dd2b36d97b3d553754c6f12d2c536df9225c997cc416cb2bf6a84497" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs": { + "bytes": 3771, + "git_blob": "bca321ca015c4e8e85f954b9cef4fe1fbfce6fb3", + "mode": "100644", + "sha256": "34053a01079a939b54615f9c39e9f97314f9741bb1ff70fd309257c24ba00acc" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs": { + "bytes": 6267, + "git_blob": "a4a3aa744a9b25ab693cc9cde426c086adbf023c", + "mode": "100644", + "sha256": "0c5e6561cb50ab0c05d5001a3e8e8d59cba0cb51961bd8efd8fc36b86f698d8a" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs": { + "bytes": 10339, + "git_blob": "553a05930f80af78c8d519ad87a64e0ab1746a3b", + "mode": "100644", + "sha256": "442655af58a6de128bb9a2f38be6b7173d12ea602d8565ea7dca1d15e681213e" + }, "core/crates/tera_core/src/runtime/product_surface/submission/record.rs": { "bytes": 5041, "git_blob": "0b1a1d54477249f2ccfa0ec7955de5378340a528", @@ -1863,13 +1899,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 130499, - "git_blob": "36677b124f56d9872a16c27963d447295300bd70", + "bytes": 131954, + "git_blob": "32349030113731123547695834e580cf8d6b3b3c", "mode": "100644", - "sha256": "ccfb9eabc39cdd894036f4d8668c9099e850e741febef9c1f6ca615a3ea219b3" + "sha256": "e8f0034a49cddca52703bae646f2f88b06331195572902b9b728cde64e1a4ffa" } }, "policy": "staged_inputs", - "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" + "tree": "18a7a18d56a4fb953cd53e12f99afee48c7e6e41" } } 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": 7767, - "git_blob": "6044a14cf25025ad46f2380a3c6e6af70bfbe91d", + "bytes": 7824, + "git_blob": "d47c550c5f18f5ef4d106004287ece34b8df4ad7", "mode": "100644", - "sha256": "14e1632185306b8254f9db212a1f449e49bd82c1a42af511e3cb6484d1f2aef4" + "sha256": "74cc8b81313265d09456dc0efe619e49fcd7a5d7962055215cf8e31618cb1f1b" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -797,10 +797,16 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 171940, - "git_blob": "06b8856aa7de31c1bc38a1f5ab748c10667ef808", + "bytes": 170329, + "git_blob": "696260720ef6a54c2111d7b975fb85ac45abb41d", "mode": "100644", - "sha256": "38c91467f2d90631cf943ae3878c2a70bcf6e006691c77031a9933bebea36307" + "sha256": "48cc3be9a8a1356f2cde36816329955fd20bb930c01e75d2243d82dbe8ed3d0b" + }, + "core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs": { + "bytes": 2011, + "git_blob": "8fec7500c5f7b6298ee6c80abe2e409cb2b0b8f7", + "mode": "100644", + "sha256": "ffba0183c2bb06d8b6a4b29b79dfd1b3e179d6b209913dda3013c9c7732b77c4" }, "core/crates/tera_core/src/runtime/product_surface/outbox/inventory.rs": { "bytes": 8642, @@ -839,10 +845,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 5177, - "git_blob": "1c2a4ebad319a52c4841ce52846e75825bebc8be", + "bytes": 5404, + "git_blob": "aca536a36c4a9f5cebad5f206ecb66e17d7c392c", "mode": "100644", - "sha256": "81d63a222ba087499e2169b58ab22469697949346a658781f25276e25d1906c8" + "sha256": "70c51fbd3abb0c743df1e2840a59e484d8fdc3cf7dd08a2534c26d8e5e9e1bd7" }, "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { "bytes": 3189, @@ -887,10 +893,10 @@ "sha256": "6c7481eb4a17a3064ec7672d4a1971f811f98ef8111068b6c2496ff9bbc202e7" }, "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs": { - "bytes": 8732, - "git_blob": "f85e853b40a49191032281298bb8d4ffbfcf2d27", + "bytes": 8777, + "git_blob": "c595715d62f17d1730ebbd9c39b388350267ea8f", "mode": "100644", - "sha256": "7512f1209c868ec98de71d7ae251d53ef7850b74b35cad5f6fbafb15b0822541" + "sha256": "1449145ce3f995c740902c6de070865d4ecb3443eba0f8afd64eca6c4fa1c36b" }, "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs": { "bytes": 8868, @@ -911,10 +917,10 @@ "sha256": "23fab4dd866cf8d4acf87c9091c8858312dc7789d5fe08c250694a69a02248ed" }, "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs": { - "bytes": 9736, - "git_blob": "67375586ef57ef51972988658104f31ddc44982f", + "bytes": 9935, + "git_blob": "8a8367a4d9df09700af6eb152dda91646886f6eb", "mode": "100644", - "sha256": "e81f8cf7e413c55812cfd0a16e2434f74f585e91b9e5cc6e477d9c0432766b32" + "sha256": "44459a1fc1819d83dbf5355cfa01442e832a8607d9c4dd5a210fdf51b2d3757f" }, "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs": { "bytes": 7768, @@ -922,6 +928,36 @@ "mode": "100644", "sha256": "513320bd503d0f747edc5b72e4039bd51344c03ab82f73c870e5346e785b66e4" }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation.rs": { + "bytes": 5956, + "git_blob": "025015f05b1674ac462f71a333e5781111704a5f", + "mode": "100644", + "sha256": "178be957fd0b899de4f3dbecb31fc5e41faeb2a83b9cec329747d628bc49d104" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs": { + "bytes": 4726, + "git_blob": "b0d9c843be0156d125f99900ec342a23d97dc70a", + "mode": "100644", + "sha256": "1d9f0935dd2b36d97b3d553754c6f12d2c536df9225c997cc416cb2bf6a84497" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs": { + "bytes": 3771, + "git_blob": "bca321ca015c4e8e85f954b9cef4fe1fbfce6fb3", + "mode": "100644", + "sha256": "34053a01079a939b54615f9c39e9f97314f9741bb1ff70fd309257c24ba00acc" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs": { + "bytes": 6267, + "git_blob": "a4a3aa744a9b25ab693cc9cde426c086adbf023c", + "mode": "100644", + "sha256": "0c5e6561cb50ab0c05d5001a3e8e8d59cba0cb51961bd8efd8fc36b86f698d8a" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs": { + "bytes": 10339, + "git_blob": "553a05930f80af78c8d519ad87a64e0ab1746a3b", + "mode": "100644", + "sha256": "442655af58a6de128bb9a2f38be6b7173d12ea602d8565ea7dca1d15e681213e" + }, "core/crates/tera_core/src/runtime/product_surface/submission/record.rs": { "bytes": 5041, "git_blob": "0b1a1d54477249f2ccfa0ec7955de5378340a528", @@ -1859,13 +1895,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 130499, - "git_blob": "36677b124f56d9872a16c27963d447295300bd70", + "bytes": 131954, + "git_blob": "32349030113731123547695834e580cf8d6b3b3c", "mode": "100644", - "sha256": "ccfb9eabc39cdd894036f4d8668c9099e850e741febef9c1f6ca615a3ea219b3" + "sha256": "e8f0034a49cddca52703bae646f2f88b06331195572902b9b728cde64e1a4ffa" } }, "policy": "staged_inputs", - "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" + "tree": "18a7a18d56a4fb953cd53e12f99afee48c7e6e41" } } 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": 7767, - "git_blob": "6044a14cf25025ad46f2380a3c6e6af70bfbe91d", + "bytes": 7824, + "git_blob": "d47c550c5f18f5ef4d106004287ece34b8df4ad7", "mode": "100644", - "sha256": "14e1632185306b8254f9db212a1f449e49bd82c1a42af511e3cb6484d1f2aef4" + "sha256": "74cc8b81313265d09456dc0efe619e49fcd7a5d7962055215cf8e31618cb1f1b" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -797,10 +797,16 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 171940, - "git_blob": "06b8856aa7de31c1bc38a1f5ab748c10667ef808", + "bytes": 170329, + "git_blob": "696260720ef6a54c2111d7b975fb85ac45abb41d", "mode": "100644", - "sha256": "38c91467f2d90631cf943ae3878c2a70bcf6e006691c77031a9933bebea36307" + "sha256": "48cc3be9a8a1356f2cde36816329955fd20bb930c01e75d2243d82dbe8ed3d0b" + }, + "core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs": { + "bytes": 2011, + "git_blob": "8fec7500c5f7b6298ee6c80abe2e409cb2b0b8f7", + "mode": "100644", + "sha256": "ffba0183c2bb06d8b6a4b29b79dfd1b3e179d6b209913dda3013c9c7732b77c4" }, "core/crates/tera_core/src/runtime/product_surface/outbox/inventory.rs": { "bytes": 8642, @@ -839,10 +845,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 5177, - "git_blob": "1c2a4ebad319a52c4841ce52846e75825bebc8be", + "bytes": 5404, + "git_blob": "aca536a36c4a9f5cebad5f206ecb66e17d7c392c", "mode": "100644", - "sha256": "81d63a222ba087499e2169b58ab22469697949346a658781f25276e25d1906c8" + "sha256": "70c51fbd3abb0c743df1e2840a59e484d8fdc3cf7dd08a2534c26d8e5e9e1bd7" }, "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { "bytes": 3189, @@ -887,10 +893,10 @@ "sha256": "6c7481eb4a17a3064ec7672d4a1971f811f98ef8111068b6c2496ff9bbc202e7" }, "core/crates/tera_core/src/runtime/product_surface/submission/commit.rs": { - "bytes": 8732, - "git_blob": "f85e853b40a49191032281298bb8d4ffbfcf2d27", + "bytes": 8777, + "git_blob": "c595715d62f17d1730ebbd9c39b388350267ea8f", "mode": "100644", - "sha256": "7512f1209c868ec98de71d7ae251d53ef7850b74b35cad5f6fbafb15b0822541" + "sha256": "1449145ce3f995c740902c6de070865d4ecb3443eba0f8afd64eca6c4fa1c36b" }, "core/crates/tera_core/src/runtime/product_surface/submission/commit_sqlite_tests.rs": { "bytes": 8868, @@ -911,10 +917,10 @@ "sha256": "23fab4dd866cf8d4acf87c9091c8858312dc7789d5fe08c250694a69a02248ed" }, "core/crates/tera_core/src/runtime/product_surface/submission/intent.rs": { - "bytes": 9736, - "git_blob": "67375586ef57ef51972988658104f31ddc44982f", + "bytes": 9935, + "git_blob": "8a8367a4d9df09700af6eb152dda91646886f6eb", "mode": "100644", - "sha256": "e81f8cf7e413c55812cfd0a16e2434f74f585e91b9e5cc6e477d9c0432766b32" + "sha256": "44459a1fc1819d83dbf5355cfa01442e832a8607d9c4dd5a210fdf51b2d3757f" }, "core/crates/tera_core/src/runtime/product_surface/submission/intent_tests.rs": { "bytes": 7768, @@ -922,6 +928,36 @@ "mode": "100644", "sha256": "513320bd503d0f747edc5b72e4039bd51344c03ab82f73c870e5346e785b66e4" }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation.rs": { + "bytes": 5956, + "git_blob": "025015f05b1674ac462f71a333e5781111704a5f", + "mode": "100644", + "sha256": "178be957fd0b899de4f3dbecb31fc5e41faeb2a83b9cec329747d628bc49d104" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs": { + "bytes": 4726, + "git_blob": "b0d9c843be0156d125f99900ec342a23d97dc70a", + "mode": "100644", + "sha256": "1d9f0935dd2b36d97b3d553754c6f12d2c536df9225c997cc416cb2bf6a84497" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs": { + "bytes": 3771, + "git_blob": "bca321ca015c4e8e85f954b9cef4fe1fbfce6fb3", + "mode": "100644", + "sha256": "34053a01079a939b54615f9c39e9f97314f9741bb1ff70fd309257c24ba00acc" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs": { + "bytes": 6267, + "git_blob": "a4a3aa744a9b25ab693cc9cde426c086adbf023c", + "mode": "100644", + "sha256": "0c5e6561cb50ab0c05d5001a3e8e8d59cba0cb51961bd8efd8fc36b86f698d8a" + }, + "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs": { + "bytes": 10339, + "git_blob": "553a05930f80af78c8d519ad87a64e0ab1746a3b", + "mode": "100644", + "sha256": "442655af58a6de128bb9a2f38be6b7173d12ea602d8565ea7dca1d15e681213e" + }, "core/crates/tera_core/src/runtime/product_surface/submission/record.rs": { "bytes": 5041, "git_blob": "0b1a1d54477249f2ccfa0ec7955de5378340a528", @@ -1859,13 +1895,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 130499, - "git_blob": "36677b124f56d9872a16c27963d447295300bd70", + "bytes": 131954, + "git_blob": "32349030113731123547695834e580cf8d6b3b3c", "mode": "100644", - "sha256": "ccfb9eabc39cdd894036f4d8668c9099e850e741febef9c1f6ca615a3ea219b3" + "sha256": "e8f0034a49cddca52703bae646f2f88b06331195572902b9b728cde64e1a4ffa" } }, "policy": "staged_inputs", - "tree": "e62cffbf482c8b38c170120dd9d4da63aab8c130" + "tree": "18a7a18d56a4fb953cd53e12f99afee48c7e6e41" } } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -94,8 +94,9 @@ pub use submission::{ 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, SubmissionCommitError, SubmissionReceipt, - SubmissionReservationError, SubmissionReservationReceipt, SubmissionReservationRequest, + SubmissionCaptureError, SubmissionCommandId, SubmissionCommitError, SubmissionOperationError, + SubmissionOperationStatus, 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 @@ -46,6 +46,7 @@ use super::{ }; use crate::runtime::TeraRuntime; +mod advance; #[path = "outbox/inventory.rs"] mod inventory; pub use inventory::{ @@ -2586,52 +2587,7 @@ impl TeraRuntime { return Err(Phase1DraftError::RevisionConflict); } let request = push_request(&head)?; - let operation_id = request.operation_id(); - let sync = self.sync()?; - let mut status = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - - if matches!( - status.artifact().signing_state(), - SigningState::Planned | SigningState::Retryable - ) { - sync.sign_prepared(request) - .await - .map_err(|_| Phase1DraftError::Operation)?; - status = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - } - if status.artifact().signing_state() == SigningState::Signed - && matches!( - status.artifact().admission_state(), - AdmissionState::Pending | AdmissionState::Retryable - ) - { - sync.admit_signed(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)?; - status = sync - .push_status(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)? - .ok_or(Phase1DraftError::Corrupt)?; - } - if status.artifact().admission_state().is_admitted() - && matches!( - status.delivery_plan().state(), - AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable - ) - { - sync.deliver_push(operation_id) - .await - .map_err(|_| Phase1DraftError::Operation)?; - } + self.advance_push_request(request).await?; self.draft_status_from(head).await } @@ -3126,7 +3082,7 @@ impl TeraRuntime { .map_err(|_| Phase1DraftError::Storage) } - fn sync(&self) -> Result<radroots_sdk::sync::Operations<'_>, Phase1DraftError> { + pub(super) fn sync(&self) -> Result<radroots_sdk::sync::Operations<'_>, Phase1DraftError> { self.client .sync() .map_err(|_| Phase1DraftError::OperationUnavailable)? @@ -3302,7 +3258,10 @@ const fn valid_media_transition(previous: Phase1MediaStage, next: Phase1MediaSta } } -fn aggregate_state(draft: &AuthoredDraft, push: Option<&PushStatus>) -> Phase1OutboxState { +pub(super) fn aggregate_state( + draft: &AuthoredDraft, + push: Option<&PushStatus>, +) -> Phase1OutboxState { if draft.stage() == AuthoredDraftStage::Cancelled { return Phase1OutboxState::Cancelled; } diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs b/core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs @@ -0,0 +1,58 @@ +//! Shared bounded effect phases for validated legacy and scoped operation requests. + +use super::*; + +impl TeraRuntime { + pub(in crate::runtime::product_surface) async fn advance_push_request( + &self, + request: PushRequest, + ) -> Result<(), Phase1DraftError> { + let operation_id = request.operation_id(); + let sync = self.sync()?; + let mut status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + + if matches!( + status.artifact().signing_state(), + SigningState::Planned | SigningState::Retryable + ) { + sync.sign_prepared(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if status.artifact().signing_state() == SigningState::Signed + && matches!( + status.artifact().admission_state(), + AdmissionState::Pending | AdmissionState::Retryable + ) + { + sync.admit_signed(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if status.artifact().admission_state().is_admitted() + && matches!( + status.delivery_plan().state(), + AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable + ) + { + sync.deliver_push(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + Ok(()) + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission.rs b/core/crates/tera_core/src/runtime/product_surface/submission.rs @@ -4,6 +4,8 @@ mod capture; mod commit; mod intent; +mod operation; +mod operation_load; mod record; mod repository; pub use capture::{CapturedSubmission, SubmissionCaptureError}; @@ -12,9 +14,16 @@ pub use intent::{ SUBMISSION_INTENT_MAX_BYTES, SUBMISSION_INTENT_PAYLOAD_SCHEMA, SUBMISSION_INTENT_SCHEMA_SHA256, SUBMISSION_INTENT_SCHEMA_VERSION, }; +pub use operation::{SubmissionOperationError, SubmissionOperationStatus}; #[cfg(test)] mod fault_store; #[cfg(test)] +mod operation_recovery_tests; +#[cfg(test)] +mod operation_test_support; +#[cfg(test)] +mod operation_tests; +#[cfg(test)] mod test_support; #[cfg(test)] mod transaction_test_support; 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 @@ -152,7 +152,7 @@ impl<S: AuthoredDraftStore + AuthoredAtomicStorage + ?Sized> SubmissionRepositor .map(Some) } - async fn committed_receipt( + pub(super) async fn committed_receipt( &self, request: &SubmissionReservationRequest, receipt: AuthoredAtomicReceipt, @@ -233,7 +233,10 @@ impl TeraRuntime { self.submission_commit(&captured).await } - fn validate_submission_owner(&self, request: &SubmissionReservationRequest) -> Result<(), E> { + pub(super) fn validate_submission_owner( + &self, + request: &SubmissionReservationRequest, + ) -> Result<(), E> { let author = self .store_public_key .ok_or(SubmissionReservationError::Source( 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 @@ -192,6 +192,15 @@ impl IntentPayload { &self, reservation: &SubmissionReservationReceipt, ) -> Result<PrepareAuthoredOperation, E> { + self.push_request(reservation)? + .authored_preparation(reservation.reserved_at_unix_ms()) + .map_err(|_| E::InvalidIntent) + } + + pub(super) fn push_request( + &self, + reservation: &SubmissionReservationReceipt, + ) -> Result<PushRequest, E> { let request = reservation.request(); let input = reservation.captured().form().input(); if self.command_id != request.command_id() @@ -245,7 +254,6 @@ impl IntentPayload { 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/operation.rs b/core/crates/tera_core/src/runtime/product_surface/submission/operation.rs @@ -0,0 +1,177 @@ +//! Scoped operation state stays separate from the still-editable composer. + +use radroots_storage::{ + Error, + authored_draft::{AuthoredDraft, AuthoredDraftStage}, +}; +use radroots_sync::PushStatus; + +use super::{ + SubmissionCommitError, SubmissionReceipt, SubmissionReservationRequest, intent, + operation_load::LoadedOperation, repository::SubmissionRepository, +}; +use crate::{ + TeraRuntime, + runtime::product_surface::{ + Phase1DraftError, Phase1OutboxState, outbox, phase1_operation_now_unix_ms, + }, +}; + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum SubmissionOperationError { + #[error(transparent)] + Submission(#[from] SubmissionCommitError), + #[error(transparent)] + Operation(#[from] Phase1DraftError), + #[error("submission operation was not committed")] + NotFound, + #[error("submission prerequisites are incomplete")] + PrerequisitesPending, + #[error("submission operation requires repair")] + Corrupt, +} + +impl From<Error> for SubmissionOperationError { + fn from(error: Error) -> Self { + Self::Submission(error.into()) + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct SubmissionOperationStatus { + receipt: SubmissionReceipt, + intent: AuthoredDraft, + push: PushStatus, +} + +impl std::fmt::Debug for SubmissionOperationStatus { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SubmissionOperationStatus") + .field("state", &self.state()) + .finish_non_exhaustive() + } +} + +impl SubmissionOperationStatus { + pub fn receipt(&self) -> &SubmissionReceipt { + &self.receipt + } + pub fn intent(&self) -> &AuthoredDraft { + &self.intent + } + pub fn push(&self) -> &PushStatus { + &self.push + } + pub fn state(&self) -> Phase1OutboxState { + let push = (self.intent.stage() == AuthoredDraftStage::Queued).then_some(&self.push); + outbox::aggregate_state(&self.intent, push) + } +} + +type E = SubmissionOperationError; + +impl TeraRuntime { + pub async fn submission_operation_status( + &self, + request: &SubmissionReservationRequest, + ) -> Result<SubmissionOperationStatus, E> { + let _command = self.lifecycle.enter().map_err(Phase1DraftError::from)?; + let (loaded, push) = self.load_submission_operation(request).await?; + Ok(SubmissionOperationStatus { + receipt: loaded.receipt, + intent: loaded.head, + push, + }) + } + + /// Associates only the already committed ready operation; invokes no signer or transport. + pub async fn submission_queue( + &self, + request: &SubmissionReservationRequest, + expected_revision: u64, + ) -> Result<SubmissionOperationStatus, E> { + let _command = self.lifecycle.enter().map_err(Phase1DraftError::from)?; + self.validate_submission_owner(request)?; + let _admission = self + .mutations + .draft(*intent::intent_id(request)?.as_bytes())?; + let (mut loaded, _) = self.load_submission_operation(request).await?; + self.queue_submission_loaded(&mut loaded, expected_revision) + .await?; + self.submission_operation_status(request).await + } + + /// Advances the captured operation through at most one existing bounded delivery attempt. + pub async fn submission_advance( + &self, + request: &SubmissionReservationRequest, + expected_revision: u64, + ) -> Result<SubmissionOperationStatus, E> { + let _command = self.lifecycle.enter().map_err(Phase1DraftError::from)?; + self.validate_submission_owner(request)?; + let _admission = self + .mutations + .draft(*intent::intent_id(request)?.as_bytes())?; + let (mut loaded, _) = self.load_submission_operation(request).await?; + self.queue_submission_loaded(&mut loaded, expected_revision) + .await?; + self.advance_push_request(loaded.request).await?; + self.submission_operation_status(request).await + } + + async fn load_submission_operation( + &self, + request: &SubmissionReservationRequest, + ) -> Result<(LoadedOperation, PushStatus), E> { + self.validate_submission_owner(request)?; + let store = self + .client + .storage() + .map_err(|_| Error::BackendUnavailable)?; + let loaded = SubmissionRepository { store } + .load_operation(request) + .await?; + let push = self + .sync()? + .push_status(loaded.request.operation_id()) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(E::Corrupt)?; + loaded.validate_push(&push)?; + Ok((loaded, push)) + } + + async fn queue_submission_loaded( + &self, + loaded: &mut LoadedOperation, + expected_revision: u64, + ) -> Result<(), E> { + if loaded.head.revision().get() != expected_revision { + return Err(Phase1DraftError::RevisionConflict.into()); + } + match loaded.head.stage() { + AuthoredDraftStage::Queued => return Ok(()), + AuthoredDraftStage::ReadyToSign => {} + _ => return Err(E::PrerequisitesPending), + } + let next = loaded.head.successor( + loaded.head.payload().to_vec(), + AuthoredDraftStage::Queued, + Some(loaded.receipt.operation_id()), + phase1_operation_now_unix_ms()?.max(loaded.head.updated_at_unix_ms()), + )?; + let store = self + .client + .storage() + .map_err(|_| Error::BackendUnavailable)?; + let receipt = store + .append_authored_draft(next.clone(), Some(loaded.head.revision())) + .await?; + if receipt.draft() != &next { + return Err(E::Corrupt); + } + loaded.head = next; + Ok(()) + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs b/core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs @@ -0,0 +1,120 @@ +//! Validate the immutable transaction before using any mutable operation head. + +use radroots_storage::{ + authored_atomic::{AuthoredAtomicOutcome, AuthoredAtomicStorage, PrepareAuthoredOperation}, + authored_draft::{AuthoredDraft, AuthoredDraftStage, AuthoredDraftStore}, +}; +use radroots_sync::{PushRequest, PushStatus}; + +use super::{ + SubmissionReceipt, SubmissionReservationRequest, + intent::{self, IntentPayload}, + operation::SubmissionOperationError as E, + repository::SubmissionRepository, +}; + +pub(super) struct LoadedOperation { + pub receipt: SubmissionReceipt, + pub head: AuthoredDraft, + pub request: PushRequest, + preparation: PrepareAuthoredOperation, +} + +impl<S: AuthoredDraftStore + AuthoredAtomicStorage + ?Sized> SubmissionRepository<'_, S> { + pub(super) async fn load_operation( + &self, + request: &SubmissionReservationRequest, + ) -> Result<LoadedOperation, E> { + let atomic = self + .store + .authored_receipt(intent::commit_id(request)) + .await? + .ok_or(E::NotFound)?; + let receipt = self + .committed_receipt(request, atomic.clone(), true) + .await?; + let AuthoredAtomicOutcome::Submitted(original) = atomic.outcome() else { + return Err(E::Corrupt); + }; + let reservation = self + .replay(request) + .await + .map_err(super::SubmissionCommitError::from)? + .ok_or(E::Corrupt)?; + let payload: IntentPayload = + serde_json::from_slice(original.intent().payload()).map_err(|_| E::Corrupt)?; + let push = payload.push_request(&reservation)?; + let head = self + .store + .authored_draft_head(receipt.intent_id()) + .await? + .ok_or(E::Corrupt)?; + validate_head(original.intent(), &head)?; + Ok(LoadedOperation { + receipt, + head, + request: push, + preparation: original.preparation().clone(), + }) + } +} + +fn validate_head(original: &AuthoredDraft, head: &AuthoredDraft) -> Result<(), E> { + head.validate().map_err(|_| E::Corrupt)?; + if head.draft_id() != original.draft_id() + || head.author() != original.author() + || head.scope() != original.scope() + || head.payload_schema() != original.payload_schema() + || head.payload() != original.payload() + || head.operation_id() != original.operation_id() + || head.created_at_unix_ms() != original.created_at_unix_ms() + || head.updated_at_unix_ms() < original.updated_at_unix_ms() + || head.revision() < original.revision() + || (head.revision() == original.revision() && head != original) + { + return Err(E::Corrupt); + } + // Media progression is admitted separately by the scoped prerequisite adapter. + // A forged stage cannot turn the immutable waiting snapshot into signing authority. + match original.stage() { + AuthoredDraftStage::ReadyToSign + if matches!( + head.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) => + { + Ok(()) + } + AuthoredDraftStage::MediaPreparing if head == original => Ok(()), + _ => Err(E::Corrupt), + } +} + +impl LoadedOperation { + pub(super) fn validate_push(&self, push: &PushStatus) -> Result<(), E> { + let [artifact] = self.preparation.artifacts() else { + return Err(E::Corrupt); + }; + let [delivery] = self.preparation.delivery_plans() else { + return Err(E::Corrupt); + }; + if push.operation().operation_id() != self.receipt.operation_id() + || push.operation().artifact_ids() != self.preparation.operation().artifact_ids() + || push.operation().created_at_unix_ms() + != self.preparation.operation().created_at_unix_ms() + || push.artifact().artifact_id() != artifact.artifact_id() + || push.artifact().operation_id() != artifact.operation_id() + || push.artifact().ordinal() != artifact.ordinal() + || push.artifact().origin() != artifact.origin() + || push.artifact().plan() != artifact.plan() + || push.artifact().created_at_unix_ms() != artifact.created_at_unix_ms() + || push.delivery_plan().plan_id() != delivery.plan_id() + || push.delivery_plan().artifact_id() != delivery.artifact_id() + || push.delivery_plan().intent() != delivery.intent() + || push.delivery_plan().created_at_unix_ms() != delivery.created_at_unix_ms() + { + return Err(E::Corrupt); + } + Ok(()) + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs @@ -0,0 +1,103 @@ +use std::{sync::atomic::Ordering, time::Duration}; + +use radroots_storage::{authored::SigningState, authored_draft::AuthoredDraftStage}; + +use super::{operation_test_support::*, test_support::*, *}; + +#[tokio::test] +async fn abandoned_signer_wait_keeps_exact_sqlite_operation_for_restart() { + let root = tempfile::tempdir().unwrap(); + let signer = CountingSigner::new(); + signer.pause.store(true, Ordering::SeqCst); + let runtime = runtime(Some(root.path()), signer.clone(), "ws://127.0.0.1:19999").await; + let request = request(); + prepare(&runtime, &request, false).await; + let original = runtime.submission_operation_status(&request).await.unwrap(); + let task = { + let runtime = runtime.clone(); + let request = request.clone(); + tokio::spawn(async move { runtime.submission_advance(&request, 1).await }) + }; + tokio::time::timeout(Duration::from_secs(5), signer.entered.notified()) + .await + .unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let waiting = runtime.submission_operation_status(&request).await.unwrap(); + assert_eq!( + waiting.receipt().operation_id(), + original.receipt().operation_id() + ); + assert!(waiting.push().artifact().signing_claim().is_some()); + assert!(waiting.push().artifact().signed().is_none()); + assert!(waiting.push().delivery_plan().attempts().is_empty()); + runtime.shutdown().await.unwrap(); + drop(runtime); + let reopened = self::runtime(Some(root.path()), signer.clone(), "ws://127.0.0.1:19998").await; + let status = reopened + .submission_operation_status(&request) + .await + .unwrap(); + assert_eq!(status, waiting); + assert!( + reopened + .submission_prepare(&request, vec![]) + .await + .unwrap() + .is_replay() + ); + assert_eq!(signer.count(), 1); + reopened.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn forged_waiting_association_is_rejected_before_any_signer_callback() { + for sqlite in [false, true] { + let root = tempfile::tempdir().unwrap(); + let signer = CountingSigner::new(); + let runtime = runtime( + sqlite.then_some(root.path()), + signer.clone(), + "ws://127.0.0.1:19999", + ) + .await; + let request = request(); + prepare(&runtime, &request, true).await; + let original = runtime.submission_operation_status(&request).await.unwrap(); + let forged = original + .intent() + .successor( + original.intent().payload().to_vec(), + AuthoredDraftStage::ReadyToSign, + Some(original.receipt().operation_id()), + original.intent().updated_at_unix_ms() + 1, + ) + .unwrap(); + runtime + .client + .storage() + .unwrap() + .append_authored_draft(forged, Some(original.intent().revision())) + .await + .unwrap(); + assert_eq!( + runtime.submission_advance(&request, 2).await.unwrap_err(), + SubmissionOperationError::Corrupt + ); + assert_eq!(signer.count(), 0); + assert_eq!(signer.statuses.load(Ordering::SeqCst), 0); + let push = runtime + .sync() + .unwrap() + .push_status( + radroots_sync::policy::SyncId::new(*original.receipt().operation_id().as_bytes()) + .unwrap(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(push.artifact().signing_state(), SigningState::Planned); + assert!(push.delivery_plan().attempts().is_empty()); + runtime.shutdown().await.unwrap(); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs b/core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs @@ -0,0 +1,204 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use radroots_nostr::{key::SecretKey, signing::LocalSigner}; +use radroots_sdk::{ + ClientBuilder, + transport::{RelayAccess, RelayEndpoint, RelayProfile, RelayProfileKind, RelayUrlPolicy}, +}; +use radroots_signing::{Signer, signer::BoxFuture}; +use tokio::{net::TcpListener, sync::Notify}; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +use super::{SubmissionReservationRequest, test_support::*}; +use crate::{ + TeraRuntime, + runtime::{ + builder::RuntimeBuilder, + product_surface::{AddCommandType, ComposerEditSequence, ComposerPartialForm}, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }, +}; + +pub(super) struct CountingSigner { + inner: LocalSigner, + pub calls: AtomicUsize, + pub statuses: AtomicUsize, + pub pause: AtomicBool, + pub entered: Notify, + pub resume: Notify, +} + +impl CountingSigner { + pub fn new() -> Arc<Self> { + Arc::new(Self { + inner: LocalSigner::new( + SecretKey::parse( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap(), + ) + .unwrap(), + calls: AtomicUsize::new(0), + statuses: AtomicUsize::new(0), + pause: AtomicBool::new(false), + entered: Notify::new(), + resume: Notify::new(), + }) + } + pub fn count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +impl Signer for CountingSigner { + fn status( + &self, + ) -> BoxFuture<'_, Result<radroots_signing::SignerStatus, radroots_signing::Error>> { + self.statuses.fetch_add(1, Ordering::SeqCst); + self.inner.status() + } + fn sign( + &self, + request: radroots_signing::SignRequest, + ) -> BoxFuture<'_, Result<radroots_signing::SignReceipt, radroots_signing::Error>> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.pause.swap(false, Ordering::SeqCst) { + self.entered.notify_one(); + self.resume.notified().await; + } + self.inner.sign(request).await + }) + } +} + +pub(super) fn profile(url: &str) -> RelayProfile { + RelayProfile::explicit( + RelayProfileKind::Simulator, + [RelayEndpoint::new(url, RelayUrlPolicy::Local, RelayAccess::ReadWrite).unwrap()], + ) + .unwrap() +} + +pub(super) async fn runtime( + root: Option<&std::path::Path>, + signer: Arc<CountingSigner>, + relay: &str, +) -> Arc<TeraRuntime> { + let blossom = + radroots_sdk::transport::BlossomConfig::from_profile(blossom().profile().unwrap()); + Arc::new(if let Some(root) = root { + 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) + .signer(signer) + .relay_profile(profile(relay)) + .blossom_config(blossom) + .build() + .await + .unwrap() + } else { + TeraRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(scope(AUTHOR, "nearby").author()), + None, + Some(signer), + Some(profile(relay)), + Some(blossom), + ) + .unwrap() + }) +} + +pub(super) async fn prepare( + runtime: &TeraRuntime, + request: &SubmissionReservationRequest, + media: bool, +) { + 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![] + }; + runtime + .composer_create( + request.scope(), + request.composer_id(), + ComposerEditSequence::INITIAL, + ComposerPartialForm::new(input).unwrap(), + ) + .await + .unwrap(); + runtime.submission_prepare(request, bytes).await.unwrap(); +} + +pub(super) async fn relay() -> (String, tokio::task::JoinHandle<serde_json::Value>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(15), async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + while let Some(message) = socket.next().await { + let message = message.unwrap(); + if let Message::Text(text) = message { + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + if value[0] == "EVENT" { + let event = value[1].clone(); + socket + .send(Message::Text( + serde_json::json!(["OK", event["id"], true, ""]) + .to_string() + .into(), + )) + .await + .unwrap(); + return event; + } + } + } + panic!("relay closed without authored event"); + }) + .await + .expect("bounded loopback relay") + }); + (url, task) +} + +pub(super) fn assert_redacted(status: &super::SubmissionOperationStatus) { + let debug = format!("{status:?}"); + for private in [ + "PRIVATE", + "harvest", + "wire_json", + "payload", + "127.0.0.1", + "nearby", + AUTHOR, + ] { + assert!( + !debug.contains(private), + "operation diagnostics must redact captured data" + ); + } + assert!(!debug.contains(&format!("{:?}", status.intent().payload()))); +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs @@ -0,0 +1,274 @@ +use std::{sync::atomic::Ordering, time::Duration}; + +use radroots_storage::{authored::SigningState, authored_draft::AuthoredDraftStage}; + +use super::{operation_test_support::*, repository::SubmissionRepository, test_support::*, *}; +use crate::runtime::product_surface::{ + AddCommandType, ComposerEditSequence, ComposerPartialForm, Phase1DraftError, Phase1OutboxState, +}; + +#[tokio::test] +async fn scoped_operation_memory_and_sqlite_execute_original_preparation_once() { + for sqlite in [false, true] { + let root = tempfile::tempdir().unwrap(); + let signer = CountingSigner::new(); + let (url, server) = relay().await; + let runtime = runtime(sqlite.then_some(root.path()), signer.clone(), &url).await; + let request = request(); + prepare(&runtime, &request, false).await; + let initial = runtime.submission_operation_status(&request).await.unwrap(); + assert_redacted(&initial); + assert_eq!(initial.state(), Phase1OutboxState::ReadyToSign); + assert_eq!( + initial.push().artifact().signing_state(), + SigningState::Planned + ); + assert_eq!(signer.count(), 0); + let frozen = initial.intent().payload().to_vec(); + let later = runtime + .composer_save( + request.scope(), + request.composer_id(), + request.expected_revision(), + ComposerEditSequence::new(2).unwrap(), + ComposerPartialForm::new(input(AddCommandType::CreateAsk)).unwrap(), + ) + .await + .unwrap(); + let loaded = SubmissionRepository { + store: runtime.client.storage().unwrap(), + } + .load_operation(&request) + .await + .unwrap(); + // Ordinary Sync prepare replays the composite's receipt despite a later clock. + let replay = runtime + .sync() + .unwrap() + .prepare_push(loaded.request) + .await + .unwrap(); + assert!(replay.is_replay()); + assert_eq!( + replay.operation().operation_id(), + initial.receipt().operation_id() + ); + let done = runtime.submission_advance(&request, 1).await.unwrap(); + assert_redacted(&done); + assert_eq!(done.state(), Phase1OutboxState::Complete); + assert_eq!(done.intent().payload(), frozen); + assert_eq!( + done.receipt().operation_id(), + initial.receipt().operation_id() + ); + assert_eq!(signer.count(), 1); + let sent = server.await.unwrap(); + assert_eq!(sent["content"], "PRIVATE harvest café"); + assert_eq!( + sent["id"], + hex::encode( + done.push() + .artifact() + .signed() + .unwrap() + .event() + .id() + .as_bytes() + ) + ); + assert_eq!(done.push().delivery_plan().attempt_count(), 1); + let replay = runtime + .submission_advance(&request, done.intent().revision().get()) + .await + .unwrap(); + assert_eq!(replay, done); + assert_eq!(signer.count(), 1); + assert_eq!( + runtime + .composer_load(request.scope(), request.composer_id()) + .await + .unwrap(), + *later.draft() + ); + let identity = *done.receipt().operation_id().as_bytes(); + runtime.shutdown().await.unwrap(); + drop(runtime); + if sqlite { + let reopened = + self::runtime(Some(root.path()), signer.clone(), "ws://127.0.0.1:19998").await; + let recovered = reopened + .submission_operation_status(&request) + .await + .unwrap(); + assert_eq!(recovered.state(), Phase1OutboxState::Complete); + assert_eq!(recovered.receipt().operation_id().as_bytes(), &identity); + assert_eq!(recovered.intent().payload(), frozen); + let replay = reopened + .submission_advance(&request, recovered.intent().revision().get()) + .await + .unwrap(); + assert_eq!(replay, recovered); + assert_eq!(signer.count(), 1); + reopened.shutdown().await.unwrap(); + } + } +} + +#[tokio::test] +async fn scoped_operation_waiting_scope_missing_and_stale_requests_have_no_effects() { + for sqlite in [false, true] { + let root = tempfile::tempdir().unwrap(); + let signer = CountingSigner::new(); + let runtime = runtime( + sqlite.then_some(root.path()), + signer.clone(), + "ws://127.0.0.1:19999", + ) + .await; + let request = request(); + assert_eq!( + runtime + .submission_operation_status(&request) + .await + .unwrap_err(), + SubmissionOperationError::NotFound + ); + prepare(&runtime, &request, true).await; + let original = runtime.submission_operation_status(&request).await.unwrap(); + assert_eq!( + original.intent().stage(), + AuthoredDraftStage::MediaPreparing + ); + for queue in [false, true] { + let error = if queue { + runtime.submission_queue(&request, 1).await.unwrap_err() + } else { + runtime.submission_advance(&request, 1).await.unwrap_err() + }; + assert_eq!(error, SubmissionOperationError::PrerequisitesPending); + } + assert_eq!( + runtime.submission_advance(&request, 2).await.unwrap_err(), + SubmissionOperationError::Operation(Phase1DraftError::RevisionConflict) + ); + for foreign in [scope(OTHER, "nearby"), scope(AUTHOR, "different")] { + let request = SubmissionReservationRequest::new( + request.command_id(), + foreign, + request.composer_id(), + request.expected_revision(), + ); + assert!(runtime.submission_advance(&request, 1).await.is_err()); + } + assert_eq!( + runtime.submission_operation_status(&request).await.unwrap(), + original + ); + assert_eq!(signer.count(), 0); + assert!(original.push().delivery_plan().attempts().is_empty()); + // Shared storage permits waiting progress, but this consumer rejects an altered capture. + let mut payload: serde_json::Value = + serde_json::from_slice(original.intent().payload()).unwrap(); + payload["policy"]["delivery_deadline_unix_ms"] = serde_json::json!(2_000_000_000_000u64); + let next = original + .intent() + .successor( + serde_json::to_vec(&payload).unwrap(), + AuthoredDraftStage::MediaPreparing, + None, + original.intent().updated_at_unix_ms() + 1, + ) + .unwrap(); + runtime + .client + .storage() + .unwrap() + .append_authored_draft(next, Some(original.intent().revision())) + .await + .unwrap(); + assert_eq!( + runtime.submission_advance(&request, 2).await.unwrap_err(), + SubmissionOperationError::Corrupt + ); + assert_eq!(signer.count(), 0); + assert!( + runtime + .submission_recover(&request) + .await + .unwrap() + .is_some() + ); + runtime.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn scoped_operation_queue_is_effect_free_and_uses_frozen_policy_after_settings_change() { + let signer = CountingSigner::new(); + let runtime = runtime(None, signer.clone(), "ws://127.0.0.1:19999").await; + let request = request(); + prepare(&runtime, &request, false).await; + let before = runtime.submission_operation_status(&request).await.unwrap(); + runtime + .client + .configure_nostr(profile("ws://127.0.0.1:19998")) + .unwrap(); + let queued = runtime.submission_queue(&request, 1).await.unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(queued.push(), before.push()); + assert_eq!(queued.intent().payload(), before.intent().payload()); + assert_eq!(signer.count(), 0); + assert_eq!( + runtime.submission_queue(&request, 1).await.unwrap_err(), + SubmissionOperationError::Operation(Phase1DraftError::RevisionConflict) + ); + assert_eq!(runtime.submission_queue(&request, 2).await.unwrap(), queued); + runtime.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn scoped_operation_slow_signer_blocks_duplicate_operation_but_allows_editing() { + let signer = CountingSigner::new(); + signer.pause.store(true, Ordering::SeqCst); + let (url, server) = relay().await; + let runtime = runtime(None, signer.clone(), &url).await; + let request = request(); + prepare(&runtime, &request, false).await; + let task = { + let runtime = runtime.clone(); + let request = request.clone(); + tokio::spawn(async move { runtime.submission_advance(&request, 1).await }) + }; + tokio::time::timeout(Duration::from_secs(5), signer.entered.notified()) + .await + .unwrap(); + assert_eq!( + runtime.submission_advance(&request, 2).await.unwrap_err(), + SubmissionOperationError::Operation(Phase1DraftError::OperationInProgress) + ); + let later = runtime + .composer_save( + request.scope(), + request.composer_id(), + request.expected_revision(), + ComposerEditSequence::new(2).unwrap(), + ComposerPartialForm::new(input(AddCommandType::CreateAsk)).unwrap(), + ) + .await + .unwrap(); + signer.resume.notify_one(); + assert_eq!( + task.await.unwrap().unwrap().state(), + Phase1OutboxState::Complete + ); + assert_eq!(server.await.unwrap()["content"], "PRIVATE harvest café"); + assert_eq!(signer.count(), 1); + assert_eq!( + runtime + .composer_load(request.scope(), request.composer_id()) + .await + .unwrap(), + *later.draft() + ); + runtime.shutdown().await.unwrap(); +} 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": "4af8319178aa2945467331f9303a4b7538f7b78edf3080560a792607ddfb60c3", + "ffi_provenance_sha256": "d33dcc83c3ec328d8d8bc13ee92fbe5b553bbbeecb15fcd2eb7d0e34a17fede4", "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": "e62cffbf482c8b38c170120dd9d4da63aab8c130", + "tera_ffi_source_tree": "18a7a18d56a4fb953cd53e12f99afee48c7e6e41", "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 @@ -4515,6 +4515,10 @@ { "path": "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs", "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs", + "count": 1 } ] }, @@ -4621,6 +4625,10 @@ "count": 3 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs", + "count": 2 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/repository_tests.rs", "count": 5 }, @@ -4719,6 +4727,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs", + "count": 6 + }, + { "path": "core/crates/tera_ffi/Cargo.toml", "count": 1 }, @@ -4829,6 +4841,22 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/record.rs", "count": 2 }, @@ -4911,6 +4939,18 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_load.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_recovery_tests.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/today.rs", "count": 1 },