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 e8c0c43489d82ca44b5a3ace5df2e068ddbd0410
parent ed076f0dea6334260ed82bf83cb38edc98d39c4c
Author: triesap <tyson@radroots.org>
Date:   Thu, 10 Sep 2026 14:11:50 +0000

compose: Enforce revision-checked durable saves

- Create and load scoped composers through the existing runtime owner
- Require owner CAS and newer edit sequences for durable save receipts
- Test concurrency, restart, overflow, scope and failed receipt boundaries
- Regenerate exact native artifacts and qualify standalone consumers

Diffstat:
MTeraFFI/provenance.json | 54+++++++++++++++++++++++++++---------------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 50++++++++++++++++++++++++++++++++++----------------
MTeraFFI/source/aarch64-apple-ios-sim.json | 50++++++++++++++++++++++++++++++++++----------------
MTeraFFI/source/aarch64-apple-ios.json | 50++++++++++++++++++++++++++++++++++----------------
Mcore/crates/tera_core/src/runtime/product_surface.rs | 5+++--
Mcore/crates/tera_core/src/runtime/product_surface/composer.rs | 2++
Mcore/crates/tera_core/src/runtime/product_surface/composer/identity.rs | 5+++++
Acore/crates/tera_core/src/runtime/product_surface/composer/repository.rs | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/composer/storage.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mrelease/provenance.json | 4++--
Mtest-fixtures/legacy-identifiers.v1.json | 24++++++++++++++++++++++++
14 files changed, 1058 insertions(+), 99 deletions(-)

diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74225840, + "bytes": 74246648, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "0d90f00e64d20a779c3b1708a9fb945193a3fe42a13a95042eb33657b6f2cafc" + "sha256": "7288406baa0faede8308128c0c4fd2ebe63b9b2f2cabc3f0de2b6cb2c69b857f" }, { "bytes": 71509, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74296656, + "bytes": 74314640, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "5ae7484efae0b205ab449db35ee8e2c75c2a64792efd526b1dcae5f5fcc51743" + "sha256": "9f2273445919d3c83b18d7ef9ed9be74d9d36b16e87714992af30eed7964f0fd" }, { "bytes": 42544, @@ -77,34 +77,34 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 20612096, + "bytes": 20614432, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "1f6f20b535b3789c409258cd1c1cb73c5ab1a50929abad00118b1be9cf24b58e" + "sha256": "6504bbed1c2067bcb9def19b061128e84e7f359745f158f10054c4cf96ba1494" }, { - "bytes": 74225840, + "bytes": 74246648, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "0d90f00e64d20a779c3b1708a9fb945193a3fe42a13a95042eb33657b6f2cafc" + "sha256": "7288406baa0faede8308128c0c4fd2ebe63b9b2f2cabc3f0de2b6cb2c69b857f" }, { - "bytes": 74296656, + "bytes": 74314640, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "5ae7484efae0b205ab449db35ee8e2c75c2a64792efd526b1dcae5f5fcc51743" + "sha256": "9f2273445919d3c83b18d7ef9ed9be74d9d36b16e87714992af30eed7964f0fd" }, { - "bytes": 74677, + "bytes": 75570, "path": "source/aarch64-apple-darwin.json", - "sha256": "0a45a688f1e1faacce0a38e71990f13ea2061258ec23d858a7f7481ade4285a3" + "sha256": "9488bd4f54e22790cfeefd0ae591a56f277d63b00f81a5bf5e610112f76986a5" }, { - "bytes": 74521, + "bytes": 75414, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "6336b22885385b8ad0a4fefe58b5fc2c97fe054c08b65ed247462ad8f7d78387" + "sha256": "4f702b0f060978b5aa89be09141f4bc44f3fcf706f528a4d5475fcf342954dcd" }, { - "bytes": 74517, + "bytes": 75410, "path": "source/aarch64-apple-ios.json", - "sha256": "9a24669e914ecce38ca5a151fe66b0970ef6e01f8d8fa080ec04debb504dc076" + "sha256": "b79cf17894cf3d4677bd7eb1b62eefbc33898d3ddd70c1358e24a85f9cb6d18f" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "e0ba029e9f889064f46953f7033508d9ea2e48cf" + "tree": "ea52e196d8cec300a827b65f3c28bcd0c08f38a9" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74225840, + "bytes": 74246648, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "0d90f00e64d20a779c3b1708a9fb945193a3fe42a13a95042eb33657b6f2cafc" + "sha256": "7288406baa0faede8308128c0c4fd2ebe63b9b2f2cabc3f0de2b6cb2c69b857f" }, { "bytes": 71509, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 74296656, + "bytes": 74314640, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "5ae7484efae0b205ab449db35ee8e2c75c2a64792efd526b1dcae5f5fcc51743" + "sha256": "9f2273445919d3c83b18d7ef9ed9be74d9d36b16e87714992af30eed7964f0fd" }, { "bytes": 521598, @@ -174,19 +174,19 @@ "sha256": "e2e025ee9a29182970f52f53593796afd154cdfb09aba197474577103dcb4ad6" }, { - "bytes": 74677, + "bytes": 75570, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "0a45a688f1e1faacce0a38e71990f13ea2061258ec23d858a7f7481ade4285a3" + "sha256": "9488bd4f54e22790cfeefd0ae591a56f277d63b00f81a5bf5e610112f76986a5" }, { - "bytes": 74521, + "bytes": 75414, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "6336b22885385b8ad0a4fefe58b5fc2c97fe054c08b65ed247462ad8f7d78387" + "sha256": "4f702b0f060978b5aa89be09141f4bc44f3fcf706f528a4d5475fcf342954dcd" }, { - "bytes": 74517, + "bytes": 75410, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "9a24669e914ecce38ca5a151fe66b0970ef6e01f8d8fa080ec04debb504dc076" + "sha256": "b79cf17894cf3d4677bd7eb1b62eefbc33898d3ddd70c1358e24a85f9cb6d18f" } ], "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 = "e0ba029e9f889064f46953f7033508d9ea2e48cf" -manifest_sha256 = "2cc3c61abb397437ace14ca9d9aec9da57eee00064152a8c6bd4faac7b7536ec" +source_tree = "ea52e196d8cec300a827b65f3c28bcd0c08f38a9" +manifest_sha256 = "ac4c1d2d93d0fee6cfb4ceb3c3e75e3ac6afaf4c382bf435c484d831c0d532ad" 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": 6919, - "git_blob": "c6b9a5fa1e0f1ba53b90630fb49e0383ad740a92", + "bytes": 6970, + "git_blob": "b7a2eb1b8d9bdaa9b99526a3c3fdf7459ff62be0", "mode": "100644", - "sha256": "d49d1d7e3ecbda3239183045e4171d2aa736398c099cac9038353a64e8137352" + "sha256": "6d2ce1e54af1291fbdff356ab788478490fe757fa668a8bc7e683e8297373637" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -681,10 +681,10 @@ "sha256": "3ec65d1cb2d94740396137d03920b212afb74692dba11729e4ce5bb3c5a2a789" }, "core/crates/tera_core/src/runtime/product_surface/composer.rs": { - "bytes": 2087, - "git_blob": "84ae67d8e260d987d51298a357c735da7294c959", + "bytes": 2172, + "git_blob": "36aafd0e0b14998aced2f8363afa714f62b6a8db", "mode": "100644", - "sha256": "d8955b5998e3471b8a4e55b934138ea76fe96d6803355c69b977f06a81893e4e" + "sha256": "2d3251f4b337466b811a53a2e4a061e00e947cf07cf78b59ff878d4f453e0223" }, "core/crates/tera_core/src/runtime/product_surface/composer/form.rs": { "bytes": 6734, @@ -693,16 +693,34 @@ "sha256": "0c69e3cc7674807537ad1f29667e474a3483e14160859da728c2db477a5ab7b1" }, "core/crates/tera_core/src/runtime/product_surface/composer/identity.rs": { - "bytes": 4004, - "git_blob": "f212f48aedc7612cf4cf7441f115e51e52b26c6f", + "bytes": 4207, + "git_blob": "e75825371f77c0dda83704e3cc0d87935e4728ff", "mode": "100644", - "sha256": "3dc9c2fc93fa3f368de6717dbe85406c240a71f05e7d94784fc4f2553b53897b" + "sha256": "db2014f5d22eb19d4f8ca07daea7e19c33b9741663429c10e27e05737d352dc6" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository.rs": { + "bytes": 7679, + "git_blob": "c97293d2ac983746148e1243033ef6c81cd2ada6", + "mode": "100644", + "sha256": "ddc6a098dfd7aacce09097f4cd9894e13c78277d8a85d3184092bdb177cb0060" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs": { + "bytes": 7276, + "git_blob": "09a4c261ab1abd31c5294413f5d69229cb49c253", + "mode": "100644", + "sha256": "29cd33d6dd8a45d8c86ace0f1f4cd48b1caf123159589a434079dd60a202c55d" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs": { + "bytes": 11486, + "git_blob": "fc137e3cf247eca8be14dbbde72574c2b79f671d", + "mode": "100644", + "sha256": "73af5646cfbd35de35c0a5b3b7d035a53c6f684b13f6452027919db118e85b80" }, "core/crates/tera_core/src/runtime/product_surface/composer/storage.rs": { - "bytes": 7272, - "git_blob": "0a3417af19a294f9cb2bc120be59186de8f95cb7", + "bytes": 8518, + "git_blob": "0ae7ad06e738acc10592bef7cea221fc3361e228", "mode": "100644", - "sha256": "271f6a8dd894ff10a371173f2b098ad4cd9413ab0d9c28c95fe34bb4045e1b69" + "sha256": "6f5a128bf9f0b779651ede5983d614b3dd27cb521a5e69f39a5166e282381338" }, "core/crates/tera_core/src/runtime/product_surface/composer/storage_sqlite_tests.rs": { "bytes": 6768, @@ -1605,13 +1623,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 122410, - "git_blob": "f0e450953eab508af6345f09c4ae7cc73ff446e2", + "bytes": 123270, + "git_blob": "2ea1d8cb75e05b44800bfd042354387945eee102", "mode": "100644", - "sha256": "1da17843897d19a73571f9675276909d114d6730c5ae6ff5b44ae6af19a81494" + "sha256": "a84c98a9acb0abde8880e6d4aa0f25ce5c759f0d3de632cbff600e420eeedb08" } }, "policy": "staged_inputs", - "tree": "e0ba029e9f889064f46953f7033508d9ea2e48cf" + "tree": "ea52e196d8cec300a827b65f3c28bcd0c08f38a9" } } 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": 6919, - "git_blob": "c6b9a5fa1e0f1ba53b90630fb49e0383ad740a92", + "bytes": 6970, + "git_blob": "b7a2eb1b8d9bdaa9b99526a3c3fdf7459ff62be0", "mode": "100644", - "sha256": "d49d1d7e3ecbda3239183045e4171d2aa736398c099cac9038353a64e8137352" + "sha256": "6d2ce1e54af1291fbdff356ab788478490fe757fa668a8bc7e683e8297373637" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -677,10 +677,10 @@ "sha256": "3ec65d1cb2d94740396137d03920b212afb74692dba11729e4ce5bb3c5a2a789" }, "core/crates/tera_core/src/runtime/product_surface/composer.rs": { - "bytes": 2087, - "git_blob": "84ae67d8e260d987d51298a357c735da7294c959", + "bytes": 2172, + "git_blob": "36aafd0e0b14998aced2f8363afa714f62b6a8db", "mode": "100644", - "sha256": "d8955b5998e3471b8a4e55b934138ea76fe96d6803355c69b977f06a81893e4e" + "sha256": "2d3251f4b337466b811a53a2e4a061e00e947cf07cf78b59ff878d4f453e0223" }, "core/crates/tera_core/src/runtime/product_surface/composer/form.rs": { "bytes": 6734, @@ -689,16 +689,34 @@ "sha256": "0c69e3cc7674807537ad1f29667e474a3483e14160859da728c2db477a5ab7b1" }, "core/crates/tera_core/src/runtime/product_surface/composer/identity.rs": { - "bytes": 4004, - "git_blob": "f212f48aedc7612cf4cf7441f115e51e52b26c6f", + "bytes": 4207, + "git_blob": "e75825371f77c0dda83704e3cc0d87935e4728ff", "mode": "100644", - "sha256": "3dc9c2fc93fa3f368de6717dbe85406c240a71f05e7d94784fc4f2553b53897b" + "sha256": "db2014f5d22eb19d4f8ca07daea7e19c33b9741663429c10e27e05737d352dc6" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository.rs": { + "bytes": 7679, + "git_blob": "c97293d2ac983746148e1243033ef6c81cd2ada6", + "mode": "100644", + "sha256": "ddc6a098dfd7aacce09097f4cd9894e13c78277d8a85d3184092bdb177cb0060" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs": { + "bytes": 7276, + "git_blob": "09a4c261ab1abd31c5294413f5d69229cb49c253", + "mode": "100644", + "sha256": "29cd33d6dd8a45d8c86ace0f1f4cd48b1caf123159589a434079dd60a202c55d" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs": { + "bytes": 11486, + "git_blob": "fc137e3cf247eca8be14dbbde72574c2b79f671d", + "mode": "100644", + "sha256": "73af5646cfbd35de35c0a5b3b7d035a53c6f684b13f6452027919db118e85b80" }, "core/crates/tera_core/src/runtime/product_surface/composer/storage.rs": { - "bytes": 7272, - "git_blob": "0a3417af19a294f9cb2bc120be59186de8f95cb7", + "bytes": 8518, + "git_blob": "0ae7ad06e738acc10592bef7cea221fc3361e228", "mode": "100644", - "sha256": "271f6a8dd894ff10a371173f2b098ad4cd9413ab0d9c28c95fe34bb4045e1b69" + "sha256": "6f5a128bf9f0b779651ede5983d614b3dd27cb521a5e69f39a5166e282381338" }, "core/crates/tera_core/src/runtime/product_surface/composer/storage_sqlite_tests.rs": { "bytes": 6768, @@ -1601,13 +1619,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 122410, - "git_blob": "f0e450953eab508af6345f09c4ae7cc73ff446e2", + "bytes": 123270, + "git_blob": "2ea1d8cb75e05b44800bfd042354387945eee102", "mode": "100644", - "sha256": "1da17843897d19a73571f9675276909d114d6730c5ae6ff5b44ae6af19a81494" + "sha256": "a84c98a9acb0abde8880e6d4aa0f25ce5c759f0d3de632cbff600e420eeedb08" } }, "policy": "staged_inputs", - "tree": "e0ba029e9f889064f46953f7033508d9ea2e48cf" + "tree": "ea52e196d8cec300a827b65f3c28bcd0c08f38a9" } } 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": 6919, - "git_blob": "c6b9a5fa1e0f1ba53b90630fb49e0383ad740a92", + "bytes": 6970, + "git_blob": "b7a2eb1b8d9bdaa9b99526a3c3fdf7459ff62be0", "mode": "100644", - "sha256": "d49d1d7e3ecbda3239183045e4171d2aa736398c099cac9038353a64e8137352" + "sha256": "6d2ce1e54af1291fbdff356ab788478490fe757fa668a8bc7e683e8297373637" }, "core/crates/tera_core/src/runtime/product_surface/authoring.rs": { "bytes": 11282, @@ -677,10 +677,10 @@ "sha256": "3ec65d1cb2d94740396137d03920b212afb74692dba11729e4ce5bb3c5a2a789" }, "core/crates/tera_core/src/runtime/product_surface/composer.rs": { - "bytes": 2087, - "git_blob": "84ae67d8e260d987d51298a357c735da7294c959", + "bytes": 2172, + "git_blob": "36aafd0e0b14998aced2f8363afa714f62b6a8db", "mode": "100644", - "sha256": "d8955b5998e3471b8a4e55b934138ea76fe96d6803355c69b977f06a81893e4e" + "sha256": "2d3251f4b337466b811a53a2e4a061e00e947cf07cf78b59ff878d4f453e0223" }, "core/crates/tera_core/src/runtime/product_surface/composer/form.rs": { "bytes": 6734, @@ -689,16 +689,34 @@ "sha256": "0c69e3cc7674807537ad1f29667e474a3483e14160859da728c2db477a5ab7b1" }, "core/crates/tera_core/src/runtime/product_surface/composer/identity.rs": { - "bytes": 4004, - "git_blob": "f212f48aedc7612cf4cf7441f115e51e52b26c6f", + "bytes": 4207, + "git_blob": "e75825371f77c0dda83704e3cc0d87935e4728ff", "mode": "100644", - "sha256": "3dc9c2fc93fa3f368de6717dbe85406c240a71f05e7d94784fc4f2553b53897b" + "sha256": "db2014f5d22eb19d4f8ca07daea7e19c33b9741663429c10e27e05737d352dc6" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository.rs": { + "bytes": 7679, + "git_blob": "c97293d2ac983746148e1243033ef6c81cd2ada6", + "mode": "100644", + "sha256": "ddc6a098dfd7aacce09097f4cd9894e13c78277d8a85d3184092bdb177cb0060" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs": { + "bytes": 7276, + "git_blob": "09a4c261ab1abd31c5294413f5d69229cb49c253", + "mode": "100644", + "sha256": "29cd33d6dd8a45d8c86ace0f1f4cd48b1caf123159589a434079dd60a202c55d" + }, + "core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs": { + "bytes": 11486, + "git_blob": "fc137e3cf247eca8be14dbbde72574c2b79f671d", + "mode": "100644", + "sha256": "73af5646cfbd35de35c0a5b3b7d035a53c6f684b13f6452027919db118e85b80" }, "core/crates/tera_core/src/runtime/product_surface/composer/storage.rs": { - "bytes": 7272, - "git_blob": "0a3417af19a294f9cb2bc120be59186de8f95cb7", + "bytes": 8518, + "git_blob": "0ae7ad06e738acc10592bef7cea221fc3361e228", "mode": "100644", - "sha256": "271f6a8dd894ff10a371173f2b098ad4cd9413ab0d9c28c95fe34bb4045e1b69" + "sha256": "6f5a128bf9f0b779651ede5983d614b3dd27cb521a5e69f39a5166e282381338" }, "core/crates/tera_core/src/runtime/product_surface/composer/storage_sqlite_tests.rs": { "bytes": 6768, @@ -1601,13 +1619,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 122410, - "git_blob": "f0e450953eab508af6345f09c4ae7cc73ff446e2", + "bytes": 123270, + "git_blob": "2ea1d8cb75e05b44800bfd042354387945eee102", "mode": "100644", - "sha256": "1da17843897d19a73571f9675276909d114d6730c5ae6ff5b44ae6af19a81494" + "sha256": "a84c98a9acb0abde8880e6d4aa0f25ce5c759f0d3de632cbff600e420eeedb08" } }, "policy": "staged_inputs", - "tree": "e0ba029e9f889064f46953f7033508d9ea2e48cf" + "tree": "ea52e196d8cec300a827b65f3c28bcd0c08f38a9" } } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -36,8 +36,9 @@ pub use composer::{ COMPOSER_CONTENT_MAX_BYTES, COMPOSER_FORM_MAX_BYTES, COMPOSER_MEDIA_MAX, COMPOSER_PAYLOAD_SCHEMA, COMPOSER_SCHEMA_SHA256, COMPOSER_SCHEMA_VERSION, COMPOSER_TEXT_MAX_BYTES, ComposerDraft, ComposerEditSequence, ComposerError, ComposerFormInput, - ComposerId, ComposerMediaInput, ComposerPartialForm, ComposerRevision, ComposerScope, - ComposerStorageError, ComposerStorageRecord, + ComposerId, ComposerMediaInput, ComposerPartialForm, ComposerPersistenceError, + ComposerRevision, ComposerSaveReceipt, ComposerScope, ComposerStorageError, + ComposerStorageRecord, }; pub use context::{ ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError, diff --git a/core/crates/tera_core/src/runtime/product_surface/composer.rs b/core/crates/tera_core/src/runtime/product_surface/composer.rs @@ -2,6 +2,7 @@ mod form; mod identity; +mod repository; mod storage; pub use form::{ @@ -9,6 +10,7 @@ pub use form::{ COMPOSER_TEXT_MAX_BYTES, ComposerFormInput, ComposerMediaInput, ComposerPartialForm, }; pub use identity::{ComposerEditSequence, ComposerId, ComposerRevision, ComposerScope}; +pub use repository::{ComposerPersistenceError, ComposerSaveReceipt}; pub use storage::{ COMPOSER_PAYLOAD_SCHEMA, COMPOSER_SCHEMA_SHA256, COMPOSER_SCHEMA_VERSION, ComposerStorageError, ComposerStorageRecord, diff --git a/core/crates/tera_core/src/runtime/product_surface/composer/identity.rs b/core/crates/tera_core/src/runtime/product_surface/composer/identity.rs @@ -11,6 +11,11 @@ use crate::runtime::product_surface::LocalNetworkId; pub struct ComposerId(AuthoredDraftId); impl ComposerId { + /// Reserves editing identity before the first save, independently of operation IDs. + pub fn generate() -> Result<Self, ComposerError> { + Self::new(*uuid::Uuid::new_v4().as_bytes()) + } + pub fn new(bytes: [u8; 16]) -> Result<Self, ComposerError> { AuthoredDraftId::new(bytes) .map(Self) diff --git a/core/crates/tera_core/src/runtime/product_surface/composer/repository.rs b/core/crates/tera_core/src/runtime/product_surface/composer/repository.rs @@ -0,0 +1,232 @@ +//! Application save policy over the existing owner; no SQL, signer or network port. + +use super::{ + ComposerDraft, ComposerEditSequence, ComposerId, ComposerPartialForm, ComposerRevision, + ComposerScope, ComposerStorageError, ComposerStorageRecord, +}; +use crate::{TeraRuntime, runtime::lifecycle::RuntimeLifecycleError}; +use radroots_storage::{ + Error, + authored_draft::{AuthoredDraftId, AuthoredDraftStore, DraftAppendDisposition}, +}; + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ComposerPersistenceError { + #[error(transparent)] + Lifecycle(#[from] RuntimeLifecycleError), + #[error("composer store identity is unavailable")] + OwnerUnavailable, + #[error("composer belongs to a different account or context")] + ScopeMismatch, + #[error("composer was not found")] + NotFound, + #[error("composer revision conflicts with durable state")] + RevisionConflict, + #[error("composer edit sequence does not advance durable editing")] + EditSequenceConflict, + #[error("composer revision cannot advance")] + RevisionOverflow, + #[error("composer save receipt does not match the requested revision")] + InvalidReceipt, + #[error(transparent)] + Record(ComposerStorageError), + #[error("composer storage failed: {0}")] + Storage(Error), +} + +impl From<ComposerStorageError> for ComposerPersistenceError { + fn from(error: ComposerStorageError) -> Self { + match error { + ComposerStorageError::ScopeMismatch => Self::ScopeMismatch, + other => Self::Record(other), + } + } +} + +impl From<Error> for ComposerPersistenceError { + fn from(error: Error) -> Self { + match error { + Error::DraftRevisionConflict => Self::RevisionConflict, + Error::DraftNotFound => Self::NotFound, + Error::CorruptAuthoredDraft => Self::Record(ComposerStorageError::CorruptRecord), + other => Self::Storage(other), + } + } +} + +/// Acknowledges exactly one owner-committed revision, possibly an exact replay. +/// Later revisions can exist by callback time; callers must retain edit-order guards. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComposerSaveReceipt { + draft: ComposerDraft, + replayed: bool, +} + +impl ComposerSaveReceipt { + pub fn draft(&self) -> &ComposerDraft { + &self.draft + } + pub const fn is_replay(&self) -> bool { + self.replayed + } +} + +struct ComposerRepository<'a> { + store: &'a dyn AuthoredDraftStore, + scope: &'a ComposerScope, +} + +impl ComposerRepository<'_> { + async fn load( + &self, + id: ComposerId, + ) -> Result<ComposerStorageRecord, ComposerPersistenceError> { + let id = AuthoredDraftId::new(*id.as_bytes()) + .map_err(|_| ComposerStorageError::InvalidRecord)?; + let stored = self + .store + .authored_draft_head(id) + .await? + .ok_or(ComposerPersistenceError::NotFound)?; + if stored.draft_id() != id { + return Err(ComposerStorageError::CorruptRecord.into()); + } + Ok(ComposerStorageRecord::decode(stored, self.scope)?) + } + + async fn create( + &self, + id: ComposerId, + edit_sequence: ComposerEditSequence, + form: ComposerPartialForm, + now_unix_ms: u64, + ) -> Result<ComposerSaveReceipt, ComposerPersistenceError> { + let candidate = ComposerStorageRecord::initial( + id, + self.scope.clone(), + edit_sequence, + form, + now_unix_ms, + )?; + self.commit(candidate, None).await + } + + async fn save( + &self, + id: ComposerId, + expected_revision: ComposerRevision, + edit_sequence: ComposerEditSequence, + form: ComposerPartialForm, + now_unix_ms: u64, + ) -> Result<ComposerSaveReceipt, ComposerPersistenceError> { + let current = self.load(id).await?; + if current.draft().revision() != expected_revision { + return Err(ComposerPersistenceError::RevisionConflict); + } + if edit_sequence <= current.draft().edit_sequence() { + return Err(ComposerPersistenceError::EditSequenceConflict); + } + expected_revision + .next() + .map_err(|_| ComposerPersistenceError::RevisionOverflow)?; + let candidate = current.successor(edit_sequence, form, now_unix_ms)?; + self.commit(candidate, Some(current.stored().revision())) + .await + } + + async fn commit( + &self, + candidate: ComposerStorageRecord, + expected: Option<radroots_storage::authored_draft::AuthoredDraftRevision>, + ) -> Result<ComposerSaveReceipt, ComposerPersistenceError> { + let receipt = self + .store + .append_authored_draft(candidate.stored().clone(), expected) + .await?; + if receipt.draft() != candidate.stored() { + return Err(ComposerPersistenceError::InvalidReceipt); + } + Ok(ComposerSaveReceipt { + draft: candidate.draft().clone(), + replayed: receipt.disposition() == DraftAppendDisposition::Replay, + }) + } +} + +impl TeraRuntime { + /// Creates local editing under a previously reserved ID; no publish authority is acquired. + pub async fn composer_create( + &self, + scope: &ComposerScope, + id: ComposerId, + edit_sequence: ComposerEditSequence, + form: ComposerPartialForm, + ) -> Result<ComposerSaveReceipt, ComposerPersistenceError> { + let _command = self.lifecycle.enter()?; + self.composer_repository(scope)? + .create(id, edit_sequence, form, now_unix_ms()?) + .await + } + + /// Loads exactly the selected ID and stable account/context without repairing bytes. + pub async fn composer_load( + &self, + scope: &ComposerScope, + id: ComposerId, + ) -> Result<ComposerDraft, ComposerPersistenceError> { + let _command = self.lifecycle.enter()?; + Ok(self + .composer_repository(scope)? + .load(id) + .await? + .draft() + .clone()) + } + + /// Saves a newer edit with owner CAS, returning only its exact durable acknowledgement. + pub async fn composer_save( + &self, + scope: &ComposerScope, + id: ComposerId, + expected_revision: ComposerRevision, + edit_sequence: ComposerEditSequence, + form: ComposerPartialForm, + ) -> Result<ComposerSaveReceipt, ComposerPersistenceError> { + let _command = self.lifecycle.enter()?; + self.composer_repository(scope)? + .save(id, expected_revision, edit_sequence, form, now_unix_ms()?) + .await + } + + fn composer_repository<'a>( + &'a self, + scope: &'a ComposerScope, + ) -> Result<ComposerRepository<'a>, ComposerPersistenceError> { + let author = self + .store_public_key + .ok_or(ComposerPersistenceError::OwnerUnavailable)?; + if author != scope.author() { + return Err(ComposerPersistenceError::ScopeMismatch); + } + let store = self + .client + .storage() + .map_err(|_| ComposerPersistenceError::Storage(Error::BackendUnavailable))?; + Ok(ComposerRepository { store, scope }) + } +} + +fn now_unix_ms() -> Result<u64, ComposerStorageError> { + u64::try_from(chrono::Utc::now().timestamp_millis()) + .ok() + .filter(|time| *time > 0) + .ok_or(ComposerStorageError::InvalidTimestamp) +} + +#[cfg(test)] +#[path = "repository_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "repository_sqlite_tests.rs"] +mod sqlite_tests; diff --git a/core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs b/core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs @@ -0,0 +1,239 @@ +use super::{ + tests::{AUTHOR, form, scope}, + *, +}; +use crate::runtime::product_surface::LocalNetworkId; +use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, +}; +use radroots_identity::PublicKey; +use radroots_storage::authored_draft::{AuthoredDraftStage, AuthoredDraftStore}; + +#[tokio::test] +async fn concurrent_sdk_sqlite_saves_keep_one_revision_winner_and_reopen_exactly() { + let root = tempfile::tempdir().unwrap(); + let config = MobileUserStoreConfig::from_encoded( + root.path(), + AUTHOR, + &"02".repeat(32), + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .unwrap(); + std::fs::create_dir_all(config.owner_directory()).unwrap(); + let runtime = RuntimeBuilder::new(config.clone()).build().await.unwrap(); + let selected = scope(); + let id = ComposerId::generate().unwrap(); + let initial = runtime + .composer_create( + &selected, + id, + ComposerEditSequence::INITIAL, + form("\n unfinished café "), + ) + .await + .unwrap(); + assert_eq!(initial.draft().id(), id); + assert_eq!(initial.draft().scope(), &selected); + let (left, right) = tokio::join!( + runtime.composer_save( + &selected, + id, + ComposerRevision::INITIAL, + ComposerEditSequence::new(2).unwrap(), + form("left") + ), + runtime.composer_save( + &selected, + id, + ComposerRevision::INITIAL, + ComposerEditSequence::new(3).unwrap(), + form("right") + ), + ); + let outcomes = [left, right]; + assert_eq!(outcomes.iter().filter(|value| value.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|value| matches!(value, Err(ComposerPersistenceError::RevisionConflict))) + .count(), + 1 + ); + let winner = outcomes.into_iter().find_map(Result::ok).unwrap(); + assert_eq!(winner.draft().revision().get(), 2); + assert_eq!( + runtime.composer_load(&selected, id).await.unwrap(), + *winner.draft() + ); + let saved = runtime + .composer_save( + &selected, + id, + winner.draft().revision(), + ComposerEditSequence::new(10).unwrap(), + form("newest 1.\n2026-"), + ) + .await + .unwrap(); + assert_eq!(saved.draft().revision().get(), 3); + assert_eq!(saved.draft().edit_sequence().get(), 10); + let foreign = ComposerScope::new( + PublicKey::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + .unwrap(), + selected.local_network().clone(), + ); + let other_context = ComposerScope::new( + selected.author(), + LocalNetworkId::new("other".into()).unwrap(), + ); + for other in [&foreign, &other_context] { + assert_eq!( + runtime.composer_load(other, id).await.unwrap_err(), + ComposerPersistenceError::ScopeMismatch + ); + assert_eq!( + runtime + .composer_save( + other, + id, + saved.draft().revision(), + ComposerEditSequence::new(11).unwrap(), + form("foreign") + ) + .await + .unwrap_err(), + ComposerPersistenceError::ScopeMismatch + ); + } + let uncreated = ComposerId::generate().unwrap(); + assert_eq!( + runtime + .composer_create( + &foreign, + uncreated, + ComposerEditSequence::INITIAL, + form("foreign") + ) + .await + .unwrap_err(), + ComposerPersistenceError::ScopeMismatch + ); + assert_eq!( + runtime + .composer_load(&selected, uncreated) + .await + .unwrap_err(), + ComposerPersistenceError::NotFound + ); + let stored = AuthoredDraftStore::authored_draft_head( + runtime.client.storage().unwrap(), + AuthoredDraftId::new(*id.as_bytes()).unwrap(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.stage(), AuthoredDraftStage::Draft); + assert!(stored.operation_id().is_none()); + runtime.shutdown().await.unwrap(); + assert_eq!( + runtime.composer_load(&selected, id).await.unwrap_err(), + ComposerPersistenceError::Lifecycle(RuntimeLifecycleError::Closed) + ); + assert_eq!( + runtime + .composer_create( + &selected, + uncreated, + ComposerEditSequence::INITIAL, + form("closed") + ) + .await + .unwrap_err(), + ComposerPersistenceError::Lifecycle(RuntimeLifecycleError::Closed) + ); + assert_eq!( + runtime + .composer_save( + &selected, + id, + saved.draft().revision(), + ComposerEditSequence::new(11).unwrap(), + form("closed") + ) + .await + .unwrap_err(), + ComposerPersistenceError::Lifecycle(RuntimeLifecycleError::Closed) + ); + drop(runtime); + let runtime = RuntimeBuilder::new(config).build().await.unwrap(); + assert_eq!( + runtime.sdk_storage_status().await.unwrap().backend, + "sqlite" + ); + assert_eq!( + runtime.composer_load(&selected, id).await.unwrap(), + *saved.draft() + ); + let store = runtime.client.storage().unwrap(); + assert_eq!( + AuthoredDraftStore::authored_draft_head(store, stored.draft_id()) + .await + .unwrap() + .as_ref(), + Some(&stored) + ); + let original = AuthoredDraftStore::authored_draft_revision( + store, + stored.draft_id(), + radroots_storage::authored_draft::AuthoredDraftRevision::INITIAL, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + ComposerStorageRecord::decode(original, &selected) + .unwrap() + .draft(), + initial.draft() + ); + runtime.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn missing_runtime_owner_cannot_create_read_or_save_a_composer() { + let runtime = TeraRuntime::test_memory().unwrap(); + let selected = scope(); + let id = ComposerId::generate().unwrap(); + assert_eq!( + runtime + .composer_create( + &selected, + id, + ComposerEditSequence::INITIAL, + form("missing owner") + ) + .await + .unwrap_err(), + ComposerPersistenceError::OwnerUnavailable + ); + assert_eq!( + runtime.composer_load(&selected, id).await.unwrap_err(), + ComposerPersistenceError::OwnerUnavailable + ); + assert_eq!( + runtime + .composer_save( + &selected, + id, + ComposerRevision::INITIAL, + ComposerEditSequence::new(2).unwrap(), + form("missing owner") + ) + .await + .unwrap_err(), + ComposerPersistenceError::OwnerUnavailable + ); + runtime.shutdown().await.unwrap(); +} diff --git a/core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs b/core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs @@ -0,0 +1,367 @@ +use super::*; +use crate::runtime::product_surface::{AddCommandType, ComposerFormInput, LocalNetworkId}; +use radroots_identity::PublicKey; +use radroots_storage::{ + authored_draft::{AuthoredDraft, AuthoredDraftRevision, DraftAppendReceipt}, + authored_draft_query::{AuthoredDraftPage, AuthoredDraftQuery}, + event::BoxFuture, +}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub(super) const AUTHOR: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const NOW: u64 = 1_800_000_000_000; +pub(super) fn scope() -> ComposerScope { + ComposerScope::new( + PublicKey::from_hex(AUTHOR).unwrap(), + LocalNetworkId::new("nearby".into()).unwrap(), + ) +} +pub(super) fn form(content: &str) -> ComposerPartialForm { + let mut input = ComposerFormInput::empty(AddCommandType::CreateFoodAvailability); + input.content = content.into(); + input.price_amount = Some("-".into()); + input.event_start_date = Some("2026-09-".into()); + ComposerPartialForm::new(input).unwrap() +} +fn sequence(value: u64) -> ComposerEditSequence { + ComposerEditSequence::new(value).unwrap() +} + +#[tokio::test] +async fn a_different_record_from_the_owner_cannot_be_read_or_saved_under_the_requested_id() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let selected = scope(); + let id = ComposerId::new([1; 16]).unwrap(); + let unrelated = ComposerStorageRecord::initial( + ComposerId::new([2; 16]).unwrap(), + selected.clone(), + sequence(1), + form("unrelated"), + NOW, + ) + .unwrap(); + let store = FaultStore { + inner: client.storage().unwrap(), + fault: AppendFault::None, + head_override: Some(unrelated.into_stored()), + appends: AtomicUsize::new(0), + }; + let repo = ComposerRepository { + store: &store, + scope: &selected, + }; + let expected = ComposerPersistenceError::Record(ComposerStorageError::CorruptRecord); + assert_eq!(repo.load(id).await.unwrap_err(), expected); + assert_eq!( + repo.save( + id, + ComposerRevision::INITIAL, + sequence(2), + form("unsaved"), + NOW + 1 + ) + .await + .unwrap_err(), + expected + ); + assert_eq!(store.appends.load(Ordering::SeqCst), 0); +} + +enum AppendFault { + None, + BeforeCommit, + WrongReceipt, +} +struct FaultStore<'a> { + inner: &'a dyn AuthoredDraftStore, + fault: AppendFault, + head_override: Option<AuthoredDraft>, + appends: AtomicUsize, +} +impl AuthoredDraftStore for FaultStore<'_> { + fn query_authored_drafts( + &self, + query: AuthoredDraftQuery, + ) -> BoxFuture<'_, Result<AuthoredDraftPage, Error>> { + self.inner.query_authored_drafts(query) + } + fn append_authored_draft( + &self, + draft: AuthoredDraft, + expected: Option<AuthoredDraftRevision>, + ) -> BoxFuture<'_, Result<DraftAppendReceipt, Error>> { + Box::pin(async move { + self.appends.fetch_add(1, Ordering::SeqCst); + match self.fault { + AppendFault::None => self.inner.append_authored_draft(draft, expected).await, + AppendFault::BeforeCommit => Err(Error::BackendUnavailable), + AppendFault::WrongReceipt => Ok(DraftAppendReceipt::new( + draft.successor( + draft.payload().to_vec(), + draft.stage(), + draft.operation_id(), + draft.updated_at_unix_ms() + 1, + )?, + DraftAppendDisposition::Inserted, + )), + } + }) + } + fn authored_draft_head( + &self, + id: AuthoredDraftId, + ) -> BoxFuture<'_, Result<Option<AuthoredDraft>, Error>> { + Box::pin(async move { + if let Some(head) = &self.head_override { + return Ok(Some(head.clone())); + } + self.inner.authored_draft_head(id).await + }) + } + fn authored_draft_revision( + &self, + id: AuthoredDraftId, + revision: AuthoredDraftRevision, + ) -> BoxFuture<'_, Result<Option<AuthoredDraft>, Error>> { + self.inner.authored_draft_revision(id, revision) + } + fn authored_draft_heads( + &self, + author: [u8; 32], + limit: u16, + ) -> BoxFuture<'_, Result<Vec<AuthoredDraft>, Error>> { + self.inner.authored_draft_heads(author, limit) + } +} + +#[tokio::test] +async fn explicit_owner_replay_is_a_historical_receipt_and_never_regresses_the_head() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let selected = scope(); + let repo = ComposerRepository { + store: client.storage().unwrap(), + scope: &selected, + }; + let id = ComposerId::generate().unwrap(); + let initial = repo + .create(id, sequence(1), form("PRIVATE initial"), NOW) + .await + .unwrap(); + assert!(!initial.is_replay()); + let next = repo + .save( + id, + ComposerRevision::INITIAL, + sequence(8), + form("PRIVATE changed"), + NOW + 1, + ) + .await + .unwrap(); + assert_eq!(next.draft().revision().get(), 2); + let replay = repo + .create(id, sequence(1), form("PRIVATE initial"), NOW) + .await + .unwrap(); + assert!(replay.is_replay()); + assert_eq!(replay.draft(), initial.draft()); + assert_eq!(repo.load(id).await.unwrap().draft(), next.draft()); + assert_eq!( + repo.create(id, sequence(1), form("different"), NOW) + .await + .unwrap_err(), + ComposerPersistenceError::RevisionConflict + ); + assert_eq!( + repo.save( + id, + ComposerRevision::INITIAL, + sequence(9), + form("stale"), + NOW + 2 + ) + .await + .unwrap_err(), + ComposerPersistenceError::RevisionConflict + ); + assert!(!format!("{replay:?}").contains("PRIVATE")); +} + +#[tokio::test] +async fn owner_failure_or_mismatched_receipt_never_acknowledges_or_changes_saved_work() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let selected = scope(); + let inner = client.storage().unwrap(); + let repo = ComposerRepository { + store: inner, + scope: &selected, + }; + let id = ComposerId::generate().unwrap(); + repo.create(id, sequence(1), form("original"), NOW) + .await + .unwrap(); + let original = repo.load(id).await.unwrap(); + for (fault, expected) in [ + ( + AppendFault::BeforeCommit, + ComposerPersistenceError::Storage(Error::BackendUnavailable), + ), + ( + AppendFault::WrongReceipt, + ComposerPersistenceError::InvalidReceipt, + ), + ] { + let store = FaultStore { + inner, + fault, + head_override: None, + appends: AtomicUsize::new(0), + }; + let repo = ComposerRepository { + store: &store, + scope: &selected, + }; + assert_eq!( + repo.save( + id, + ComposerRevision::INITIAL, + sequence(2), + form("unsaved"), + NOW + 1 + ) + .await + .unwrap_err(), + expected + ); + assert_eq!(repo.load(id).await.unwrap().stored(), original.stored()); + let unsaved = ComposerId::generate().unwrap(); + assert_eq!( + repo.create(unsaved, sequence(1), form("unsaved new"), NOW) + .await + .unwrap_err(), + expected + ); + assert_eq!( + repo.load(unsaved).await.unwrap_err(), + ComposerPersistenceError::NotFound + ); + assert_eq!(store.appends.load(Ordering::SeqCst), 2); + } +} + +#[tokio::test] +async fn maximum_revision_and_nonadvancing_edit_sequences_fail_before_append() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let selected = scope(); + let id = ComposerId::generate().unwrap(); + let initial = + ComposerStorageRecord::initial(id, selected.clone(), sequence(7), form("original"), NOW) + .unwrap(); + let mut envelope = serde_json::to_value(initial.stored()).unwrap(); + envelope["revision"] = serde_json::json!(ComposerRevision::MAX); + let maximum: AuthoredDraft = serde_json::from_value(envelope).unwrap(); + let maximum_edit = ComposerStorageRecord::initial( + id, + selected.clone(), + sequence(u64::MAX), + form("original"), + NOW, + ) + .unwrap(); + for (head, expected, edit, error) in [ + ( + maximum, + ComposerRevision::new(ComposerRevision::MAX).unwrap(), + sequence(8), + ComposerPersistenceError::RevisionOverflow, + ), + ( + initial.stored().clone(), + ComposerRevision::INITIAL, + sequence(7), + ComposerPersistenceError::EditSequenceConflict, + ), + ( + initial.into_stored(), + ComposerRevision::INITIAL, + sequence(6), + ComposerPersistenceError::EditSequenceConflict, + ), + ( + maximum_edit.into_stored(), + ComposerRevision::INITIAL, + sequence(u64::MAX), + ComposerPersistenceError::EditSequenceConflict, + ), + ] { + let store = FaultStore { + inner: client.storage().unwrap(), + fault: AppendFault::None, + head_override: Some(head), + appends: AtomicUsize::new(0), + }; + let repo = ComposerRepository { + store: &store, + scope: &selected, + }; + assert_eq!( + repo.save(id, expected, edit, form("unsaved"), NOW + 1) + .await + .unwrap_err(), + error + ); + assert_eq!(store.appends.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn backward_local_clock_preserves_order_and_invalid_time_does_not_append() { + let client = radroots_sdk::ClientBuilder::memory_default() + .build() + .unwrap(); + let selected = scope(); + let repo = ComposerRepository { + store: client.storage().unwrap(), + scope: &selected, + }; + let id = ComposerId::generate().unwrap(); + repo.create(id, sequence(1), form("original"), NOW) + .await + .unwrap(); + repo.save( + id, + ComposerRevision::INITIAL, + sequence(9), + form("changed"), + NOW - 1, + ) + .await + .unwrap(); + let current = repo.load(id).await.unwrap(); + assert_eq!(current.stored().created_at_unix_ms(), NOW); + assert_eq!(current.stored().updated_at_unix_ms(), NOW); + assert_eq!(current.draft().revision().get(), 2); + for time in [0, i64::MAX as u64 + 1] { + assert_eq!( + repo.save( + id, + current.draft().revision(), + sequence(10), + form("unsaved"), + time + ) + .await + .unwrap_err(), + ComposerPersistenceError::Record(ComposerStorageError::InvalidTimestamp) + ); + assert_eq!(repo.load(id).await.unwrap().stored(), current.stored()); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/composer/storage.rs b/core/crates/tera_core/src/runtime/product_surface/composer/storage.rs @@ -80,17 +80,7 @@ impl ComposerStorageRecord { ) -> Result<Self, ComposerStorageError> { validate_time(created_at_unix_ms)?; let stored_scope = Self::scope_digest(&scope)?; - let wire = StoredComposerV1 { - schema_version: COMPOSER_SCHEMA_VERSION, - schema_sha256: COMPOSER_SCHEMA_SHA256.to_owned(), - scope: scope.clone(), - edit_sequence, - form, - }; - let payload = serde_json::to_vec(&wire).map_err(|_| ComposerStorageError::InvalidRecord)?; - if payload.len() > COMPOSER_FORM_MAX_BYTES { - return Err(ComposerStorageError::InvalidRecord); - } + let (payload, form) = encode(scope.clone(), edit_sequence, form)?; let stored = AuthoredDraft::initial( AuthoredDraftId::new(*id.as_bytes()) .map_err(|_| ComposerStorageError::InvalidRecord)?, @@ -105,13 +95,7 @@ impl ComposerStorageRecord { .map_err(|_| ComposerStorageError::InvalidRecord)?; Ok(Self { stored, - draft: ComposerDraft::new( - id, - ComposerRevision::INITIAL, - scope, - edit_sequence, - wire.form, - ), + draft: ComposerDraft::new(id, ComposerRevision::INITIAL, scope, edit_sequence, form), }) } @@ -173,6 +157,38 @@ impl ComposerStorageRecord { self.stored } + pub(super) fn successor( + &self, + edit_sequence: ComposerEditSequence, + form: ComposerPartialForm, + persisted_at_unix_ms: u64, + ) -> Result<Self, ComposerStorageError> { + validate_time(persisted_at_unix_ms)?; + let revision = self + .draft + .revision() + .next() + .map_err(|_| ComposerStorageError::InvalidRecord)?; + if edit_sequence <= self.draft.edit_sequence() { + return Err(ComposerStorageError::InvalidRecord); + } + let scope = self.draft.scope().clone(); + let (payload, form) = encode(scope.clone(), edit_sequence, form)?; + let stored = self + .stored + .successor( + payload, + AuthoredDraftStage::Draft, + None, + persisted_at_unix_ms.max(self.stored.updated_at_unix_ms()), + ) + .map_err(|_| ComposerStorageError::InvalidRecord)?; + Ok(Self { + stored, + draft: ComposerDraft::new(self.draft.id(), revision, scope, edit_sequence, form), + }) + } + /// Stable namespace, independent of process/session generation and relay configuration. pub fn scope_digest(scope: &ComposerScope) -> Result<AuthoredDraftScope, ComposerStorageError> { let context = scope.local_network().as_bytes(); @@ -188,6 +204,25 @@ impl ComposerStorageRecord { } } +fn encode( + scope: ComposerScope, + edit_sequence: ComposerEditSequence, + form: ComposerPartialForm, +) -> Result<(Vec<u8>, ComposerPartialForm), ComposerStorageError> { + let wire = StoredComposerV1 { + schema_version: COMPOSER_SCHEMA_VERSION, + schema_sha256: COMPOSER_SCHEMA_SHA256.to_owned(), + scope, + edit_sequence, + form, + }; + let payload = serde_json::to_vec(&wire).map_err(|_| ComposerStorageError::InvalidRecord)?; + if payload.len() > COMPOSER_FORM_MAX_BYTES { + return Err(ComposerStorageError::InvalidRecord); + } + Ok((payload, wire.form)) +} + fn validate_time(value: u64) -> Result<(), ComposerStorageError> { if value == 0 || value > i64::MAX as u64 { return Err(ComposerStorageError::InvalidTimestamp); diff --git a/release/provenance.json b/release/provenance.json @@ -2,7 +2,7 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "e2e025ee9a29182970f52f53593796afd154cdfb09aba197474577103dcb4ad6", - "ffi_provenance_sha256": "2cc3c61abb397437ace14ca9d9aec9da57eee00064152a8c6bd4faac7b7536ec", + "ffi_provenance_sha256": "ac4c1d2d93d0fee6cfb4ceb3c3e75e3ac6afaf4c382bf435c484d831c0d532ad", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "61e3d99a2c7f051a91d6a38b3b1f7974fb4c56da3a1c9758f5ad06fc66f1d856", @@ -22,7 +22,7 @@ "lib_revision": "363b9be18831be2fd6ef505d6154fb878ed8efd7", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "e0ba029e9f889064f46953f7033508d9ea2e48cf", + "tera_ffi_source_tree": "ea52e196d8cec300a827b65f3c28bcd0c08f38a9", "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 @@ -4243,6 +4243,14 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs", + "count": 1 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/composer/storage_tests.rs", "count": 1 }, @@ -4453,6 +4461,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs", + "count": 5 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/outbox.rs", "count": 43 }, @@ -4589,6 +4601,18 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/composer/repository.rs", + "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/composer/repository_sqlite_tests.rs", + "count": 2 + }, + { + "path": "core/crates/tera_core/src/runtime/product_surface/composer/repository_tests.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/composer/storage.rs", "count": 1 },