commit 676d2335114857d1092ba64d11083e6a9bed6ee3 parent c5aaaff421b879b7cbfabed086dd2e0c96e83afa Author: triesap <tyson@radroots.org> Date: Wed, 9 Sep 2026 17:43:51 +0000 today: enforce scoped deterministic keyset paging - Bind pages and filtered projections to the current query and authenticated account. - Version bounded cursors and snapshots while preserving readable local projection data. - Verify exact repeated keysets, scope isolation, deletion and retained author overlays. - Rebuild native artifacts with unchanged binding APIs and exact naming contracts. Diffstat:
13 files changed, 738 insertions(+), 121 deletions(-)
diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71240640, + "bytes": 71269296, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "7d2dcf5b097e28fd1d91029e6685a899a474ace53224d259ae0d07f32f1285ce" + "sha256": "ad0337396952da878b2e107c1be573b4e0c48d0fdd866e88120c4fb4c701c151" }, { "bytes": 70167, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71295120, + "bytes": 71322736, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "ec4f869934ae02c7eacfe83c2597054843d1d6cee0d26d50121306a0452557c6" + "sha256": "8858bdbba41ed0cb54d52751aa0ba520416c6d01f042b91124e8702e1133fb6f" }, { "bytes": 41698, @@ -77,34 +77,34 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 19860560, + "bytes": 19898496, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "8197b184f891653e082dca33004bba475d21ffd0b977f6ca38cdd363e914801f" + "sha256": "f0af279892c33ee52459d743929f029089033ded48acb8d64f193fe37da464db" }, { - "bytes": 71240640, + "bytes": 71269296, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "7d2dcf5b097e28fd1d91029e6685a899a474ace53224d259ae0d07f32f1285ce" + "sha256": "ad0337396952da878b2e107c1be573b4e0c48d0fdd866e88120c4fb4c701c151" }, { - "bytes": 71295120, + "bytes": 71322736, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "ec4f869934ae02c7eacfe83c2597054843d1d6cee0d26d50121306a0452557c6" + "sha256": "8858bdbba41ed0cb54d52751aa0ba520416c6d01f042b91124e8702e1133fb6f" }, { - "bytes": 62025, + "bytes": 62605, "path": "source/aarch64-apple-darwin.json", - "sha256": "e21de0fdf37daa17745596396421c849045cdbd30c43e491726d23a3e03c089d" + "sha256": "51a771c0fa3f18124fe4380374155ed66e01f37becf03c911c1e6fa34754aafd" }, { - "bytes": 61869, + "bytes": 62449, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "87018a29e7f5f3616d71750c9b04399a882e66656a8ca4b13299d86dab2fa84b" + "sha256": "6705cc1ddf4b64331c113f81c8f3bef337fac04213267d543056f3432687acfa" }, { - "bytes": 61865, + "bytes": 62445, "path": "source/aarch64-apple-ios.json", - "sha256": "4c973612c9ed7bc4c59dee90327e663b8cff89220f3839dd941ebf3d01433f36" + "sha256": "be92fa3025d4ee18789b661022bd02227f71e92b70fce0f70918b6b16cf55cb1" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "ec5432e7e1b5f9b17a23cee343947880ee292026" + "tree": "15bab536eccf803b40d32dbb228e64282272709f" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71240640, + "bytes": 71269296, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "7d2dcf5b097e28fd1d91029e6685a899a474ace53224d259ae0d07f32f1285ce" + "sha256": "ad0337396952da878b2e107c1be573b4e0c48d0fdd866e88120c4fb4c701c151" }, { "bytes": 70167, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71295120, + "bytes": 71322736, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "ec4f869934ae02c7eacfe83c2597054843d1d6cee0d26d50121306a0452557c6" + "sha256": "8858bdbba41ed0cb54d52751aa0ba520416c6d01f042b91124e8702e1133fb6f" }, { "bytes": 492997, @@ -174,19 +174,19 @@ "sha256": "ab87eb6e3d4512acc3986120c38988f8e7559ac1781de76d9808e0bae338d1de" }, { - "bytes": 62025, + "bytes": 62605, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "e21de0fdf37daa17745596396421c849045cdbd30c43e491726d23a3e03c089d" + "sha256": "51a771c0fa3f18124fe4380374155ed66e01f37becf03c911c1e6fa34754aafd" }, { - "bytes": 61869, + "bytes": 62449, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "87018a29e7f5f3616d71750c9b04399a882e66656a8ca4b13299d86dab2fa84b" + "sha256": "6705cc1ddf4b64331c113f81c8f3bef337fac04213267d543056f3432687acfa" }, { - "bytes": 61865, + "bytes": 62445, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "4c973612c9ed7bc4c59dee90327e663b8cff89220f3839dd941ebf3d01433f36" + "sha256": "be92fa3025d4ee18789b661022bd02227f71e92b70fce0f70918b6b16cf55cb1" } ], "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 = "ec5432e7e1b5f9b17a23cee343947880ee292026" -manifest_sha256 = "29b6ada7a8b8e83f9d992d6eb9971638adb6c3044c37b86830f59ad046dd1924" +source_tree = "15bab536eccf803b40d32dbb228e64282272709f" +manifest_sha256 = "629c3620deddf0b3b80b01b1ae13d508567ae09872efd5fb39975bc02b7eb682" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -655,16 +655,16 @@ "sha256": "e2bba7483e5c22413d621fdc7e18e79648b2afaab67078ffaba18f9737e2f120" }, "core/crates/tera_core/src/runtime/product_surface/cursor.rs": { - "bytes": 16624, - "git_blob": "64c80d13fe0c0437af16debd48bd218c398a7760", + "bytes": 18266, + "git_blob": "f60a13850e3c4bbb1e9a21ed2159702de8ea050e", "mode": "100644", - "sha256": "dbe14164757208306fb03e27063dc90cf948904b8582c29e87a60e085425598d" + "sha256": "5f844521e3442e954f6f9235e6193397ee096c25421fe2835d7956fe99b971f8" }, "core/crates/tera_core/src/runtime/product_surface/cursor_boundary_tests.rs": { - "bytes": 4749, - "git_blob": "43c6d53da203b400da8996e8cbdd4e643fa013b6", + "bytes": 5038, + "git_blob": "d7ca7ec056db87f362813e3bda56068240300db6", "mode": "100644", - "sha256": "27add9cd6152a8c21ad66e0eab03632445a14039c43c3bf7848571b5da869606" + "sha256": "5b82fce0bbe3cb64980de253112518111af3a586eb26d5f98a2d01f54bc02d0d" }, "core/crates/tera_core/src/runtime/product_surface/identity.rs": { "bytes": 6310, @@ -709,10 +709,10 @@ "sha256": "9b3eb39e9bbf9a22d92c14be5433ee97356fcfecb4ff77c66f49ecef243889ac" }, "core/crates/tera_core/src/runtime/product_surface/ranking.rs": { - "bytes": 8139, - "git_blob": "8e44791b3a527f54b00d0df49fcec6952a678dc4", + "bytes": 8707, + "git_blob": "a216d127d747e0b1e423b48532995c3307674734", "mode": "100644", - "sha256": "4e1dbc7f9e76ee4df62bedb915e0d92bd713c4408a4a5e1c9508b84e6e39cdc2" + "sha256": "84cab243db775c801c87d0e9bb69a225af73d4283feb48d0ec3310ff46794642" }, "core/crates/tera_core/src/runtime/product_surface/settings.rs": { "bytes": 57107, @@ -721,10 +721,22 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 138718, - "git_blob": "92d2a82d70f7f16d7db9076b20158b90560f1c64", + "bytes": 140369, + "git_blob": "5b3c7fdf795eb3fa203a53a695cf7468af724c7e", "mode": "100644", - "sha256": "800d7c8e8adb3ca6e7206955a1f84afea76f59ef5c43ee083d7634ac101d6c0a" + "sha256": "349d6f786d8b7759c2c8e7231d36fc042d9bb937d064d9e7ed4b2a4242fc2708" + }, + "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs": { + "bytes": 1703, + "git_blob": "cc1fb36406b9ad236a5653bbd59d8256dd91bd19", + "mode": "100644", + "sha256": "e9ea1a0ee6c52031895184cd0a2b3c526318fd521ab2bd6b1b08ef53f3c9ff66" + }, + "core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs": { + "bytes": 13698, + "git_blob": "e1640d8367278dc45dd85fcc598f991ae7cb16ca", + "mode": "100644", + "sha256": "ee40a9f39d46c0780dff2d64d0d88c7a48a9ce81d68e636063c2d02ec5501357" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -1339,13 +1351,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 113263, - "git_blob": "c173fdb56956eb47bc9b7b1ae650775031428682", + "bytes": 113398, + "git_blob": "aca340e901baa1d1e4d761cf64c831b2293cbe68", "mode": "100644", - "sha256": "a316e2fc020f7ba47229e084e5b890c1dd239fbc68735c9117297f629e978389" + "sha256": "bc47ac6840e208fbdecede86067111c559679554c5d9787c56499059097f4de5" } }, "policy": "staged_inputs", - "tree": "ec5432e7e1b5f9b17a23cee343947880ee292026" + "tree": "15bab536eccf803b40d32dbb228e64282272709f" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -651,16 +651,16 @@ "sha256": "e2bba7483e5c22413d621fdc7e18e79648b2afaab67078ffaba18f9737e2f120" }, "core/crates/tera_core/src/runtime/product_surface/cursor.rs": { - "bytes": 16624, - "git_blob": "64c80d13fe0c0437af16debd48bd218c398a7760", + "bytes": 18266, + "git_blob": "f60a13850e3c4bbb1e9a21ed2159702de8ea050e", "mode": "100644", - "sha256": "dbe14164757208306fb03e27063dc90cf948904b8582c29e87a60e085425598d" + "sha256": "5f844521e3442e954f6f9235e6193397ee096c25421fe2835d7956fe99b971f8" }, "core/crates/tera_core/src/runtime/product_surface/cursor_boundary_tests.rs": { - "bytes": 4749, - "git_blob": "43c6d53da203b400da8996e8cbdd4e643fa013b6", + "bytes": 5038, + "git_blob": "d7ca7ec056db87f362813e3bda56068240300db6", "mode": "100644", - "sha256": "27add9cd6152a8c21ad66e0eab03632445a14039c43c3bf7848571b5da869606" + "sha256": "5b82fce0bbe3cb64980de253112518111af3a586eb26d5f98a2d01f54bc02d0d" }, "core/crates/tera_core/src/runtime/product_surface/identity.rs": { "bytes": 6310, @@ -705,10 +705,10 @@ "sha256": "9b3eb39e9bbf9a22d92c14be5433ee97356fcfecb4ff77c66f49ecef243889ac" }, "core/crates/tera_core/src/runtime/product_surface/ranking.rs": { - "bytes": 8139, - "git_blob": "8e44791b3a527f54b00d0df49fcec6952a678dc4", + "bytes": 8707, + "git_blob": "a216d127d747e0b1e423b48532995c3307674734", "mode": "100644", - "sha256": "4e1dbc7f9e76ee4df62bedb915e0d92bd713c4408a4a5e1c9508b84e6e39cdc2" + "sha256": "84cab243db775c801c87d0e9bb69a225af73d4283feb48d0ec3310ff46794642" }, "core/crates/tera_core/src/runtime/product_surface/settings.rs": { "bytes": 57107, @@ -717,10 +717,22 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 138718, - "git_blob": "92d2a82d70f7f16d7db9076b20158b90560f1c64", + "bytes": 140369, + "git_blob": "5b3c7fdf795eb3fa203a53a695cf7468af724c7e", "mode": "100644", - "sha256": "800d7c8e8adb3ca6e7206955a1f84afea76f59ef5c43ee083d7634ac101d6c0a" + "sha256": "349d6f786d8b7759c2c8e7231d36fc042d9bb937d064d9e7ed4b2a4242fc2708" + }, + "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs": { + "bytes": 1703, + "git_blob": "cc1fb36406b9ad236a5653bbd59d8256dd91bd19", + "mode": "100644", + "sha256": "e9ea1a0ee6c52031895184cd0a2b3c526318fd521ab2bd6b1b08ef53f3c9ff66" + }, + "core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs": { + "bytes": 13698, + "git_blob": "e1640d8367278dc45dd85fcc598f991ae7cb16ca", + "mode": "100644", + "sha256": "ee40a9f39d46c0780dff2d64d0d88c7a48a9ce81d68e636063c2d02ec5501357" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -1335,13 +1347,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 113263, - "git_blob": "c173fdb56956eb47bc9b7b1ae650775031428682", + "bytes": 113398, + "git_blob": "aca340e901baa1d1e4d761cf64c831b2293cbe68", "mode": "100644", - "sha256": "a316e2fc020f7ba47229e084e5b890c1dd239fbc68735c9117297f629e978389" + "sha256": "bc47ac6840e208fbdecede86067111c559679554c5d9787c56499059097f4de5" } }, "policy": "staged_inputs", - "tree": "ec5432e7e1b5f9b17a23cee343947880ee292026" + "tree": "15bab536eccf803b40d32dbb228e64282272709f" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -651,16 +651,16 @@ "sha256": "e2bba7483e5c22413d621fdc7e18e79648b2afaab67078ffaba18f9737e2f120" }, "core/crates/tera_core/src/runtime/product_surface/cursor.rs": { - "bytes": 16624, - "git_blob": "64c80d13fe0c0437af16debd48bd218c398a7760", + "bytes": 18266, + "git_blob": "f60a13850e3c4bbb1e9a21ed2159702de8ea050e", "mode": "100644", - "sha256": "dbe14164757208306fb03e27063dc90cf948904b8582c29e87a60e085425598d" + "sha256": "5f844521e3442e954f6f9235e6193397ee096c25421fe2835d7956fe99b971f8" }, "core/crates/tera_core/src/runtime/product_surface/cursor_boundary_tests.rs": { - "bytes": 4749, - "git_blob": "43c6d53da203b400da8996e8cbdd4e643fa013b6", + "bytes": 5038, + "git_blob": "d7ca7ec056db87f362813e3bda56068240300db6", "mode": "100644", - "sha256": "27add9cd6152a8c21ad66e0eab03632445a14039c43c3bf7848571b5da869606" + "sha256": "5b82fce0bbe3cb64980de253112518111af3a586eb26d5f98a2d01f54bc02d0d" }, "core/crates/tera_core/src/runtime/product_surface/identity.rs": { "bytes": 6310, @@ -705,10 +705,10 @@ "sha256": "9b3eb39e9bbf9a22d92c14be5433ee97356fcfecb4ff77c66f49ecef243889ac" }, "core/crates/tera_core/src/runtime/product_surface/ranking.rs": { - "bytes": 8139, - "git_blob": "8e44791b3a527f54b00d0df49fcec6952a678dc4", + "bytes": 8707, + "git_blob": "a216d127d747e0b1e423b48532995c3307674734", "mode": "100644", - "sha256": "4e1dbc7f9e76ee4df62bedb915e0d92bd713c4408a4a5e1c9508b84e6e39cdc2" + "sha256": "84cab243db775c801c87d0e9bb69a225af73d4283feb48d0ec3310ff46794642" }, "core/crates/tera_core/src/runtime/product_surface/settings.rs": { "bytes": 57107, @@ -717,10 +717,22 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 138718, - "git_blob": "92d2a82d70f7f16d7db9076b20158b90560f1c64", + "bytes": 140369, + "git_blob": "5b3c7fdf795eb3fa203a53a695cf7468af724c7e", "mode": "100644", - "sha256": "800d7c8e8adb3ca6e7206955a1f84afea76f59ef5c43ee083d7634ac101d6c0a" + "sha256": "349d6f786d8b7759c2c8e7231d36fc042d9bb937d064d9e7ed4b2a4242fc2708" + }, + "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs": { + "bytes": 1703, + "git_blob": "cc1fb36406b9ad236a5653bbd59d8256dd91bd19", + "mode": "100644", + "sha256": "e9ea1a0ee6c52031895184cd0a2b3c526318fd521ab2bd6b1b08ef53f3c9ff66" + }, + "core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs": { + "bytes": 13698, + "git_blob": "e1640d8367278dc45dd85fcc598f991ae7cb16ca", + "mode": "100644", + "sha256": "ee40a9f39d46c0780dff2d64d0d88c7a48a9ce81d68e636063c2d02ec5501357" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -1335,13 +1347,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 113263, - "git_blob": "c173fdb56956eb47bc9b7b1ae650775031428682", + "bytes": 113398, + "git_blob": "aca340e901baa1d1e4d761cf64c831b2293cbe68", "mode": "100644", - "sha256": "a316e2fc020f7ba47229e084e5b890c1dd239fbc68735c9117297f629e978389" + "sha256": "bc47ac6840e208fbdecede86067111c559679554c5d9787c56499059097f4de5" } }, "policy": "staged_inputs", - "tree": "ec5432e7e1b5f9b17a23cee343947880ee292026" + "tree": "15bab536eccf803b40d32dbb228e64282272709f" } } diff --git a/core/crates/tera_core/src/runtime/product_surface/cursor.rs b/core/crates/tera_core/src/runtime/product_surface/cursor.rs @@ -5,13 +5,14 @@ use super::local_network_id::LOCAL_NETWORK_ID_MAX_BYTES; use super::{CardId, ContextRank, LocalNetworkId, TODAY_RANK_SCHEMA_VERSION, TodayRank}; use crate::runtime::product_surface::ranking::TODAY_RANK_ALGORITHM_VERSION; -const CURSOR_PREFIX: &str = "rrtc1:"; -const CURSOR_DOMAIN: &[u8] = b"radroots.today-cursor.v1\0"; -const CURSOR_SCHEMA_VERSION: u16 = 1; -const FIXED_PAYLOAD_BYTES: usize = 2 + 2 + 2 + 2 + 8 + 8 + 32 + 8 + 1 + 1 + 8 + 32; +const CURSOR_PREFIX: &str = "rrtc2:"; +const CURSOR_DOMAIN: &[u8] = b"radroots.today-cursor.v2\0"; +const CURSOR_SCHEMA_VERSION: u16 = 2; +const FIXED_PAYLOAD_BYTES: usize = 2 + 2 + 2 + 2 + 8 + 8 + 32 + 8 + 1 + 1 + 8 + 32 + 32; const DIGEST_BYTES: usize = 32; const MAX_CURSOR_BYTES: usize = CURSOR_PREFIX.len() + 2 * (FIXED_PAYLOAD_BYTES + LOCAL_NETWORK_ID_MAX_BYTES + DIGEST_BYTES); +const LEGACY_MAX_CURSOR_BYTES: usize = MAX_CURSOR_BYTES - 2 * 32; #[derive(Clone, Debug, Eq, PartialEq)] pub struct CursorScope { @@ -20,6 +21,7 @@ pub struct CursorScope { pub as_of: u64, pub store_generation: [u8; 32], pub projection_generation: u64, + pub query_scope: [u8; 32], } impl CursorScope { @@ -29,6 +31,7 @@ impl CursorScope { as_of: u64, store_generation: [u8; 32], projection_generation: u64, + query_scope: [u8; 32], ) -> Result<Self, CursorError> { let context_id = LocalNetworkId::new(context_id).map_err(|_| CursorError::InvalidContext)?; @@ -38,6 +41,7 @@ impl CursorScope { as_of, store_generation, projection_generation, + query_scope, }) } } @@ -97,6 +101,7 @@ impl TodayCursor { payload.push(position.rank.time_relevance_rank); payload.extend_from_slice(&position.rank.effective_at.to_be_bytes()); payload.extend_from_slice(position.rank.card_id.as_bytes()); + payload.extend_from_slice(&scope.query_scope); let digest = cursor_digest(&payload); payload.extend_from_slice(&digest); Ok(Self(format!("{CURSOR_PREFIX}{}", hex::encode(payload)))) @@ -106,6 +111,7 @@ impl TodayCursor { let (scope, position) = decode_unbound(value)?; if scope.context_id != expected.context_id || scope.context_generation != expected.context_generation + || scope.query_scope != expected.query_scope { return Err(CursorError::ContextMismatch); } @@ -131,10 +137,17 @@ impl TodayCursor { } fn decode_unbound(value: &str) -> Result<(CursorScope, TodayCursorPosition), CursorError> { - // The v1 token is bounded before any content scan, hex allocation or hash. + // The v2 token is bounded before any content scan, hex allocation or hash. if value.len() > MAX_CURSOR_BYTES { return Err(CursorError::Malformed); } + if value.starts_with("rrtc1:") { + return Err(if value.len() > LEGACY_MAX_CURSOR_BYTES { + CursorError::Malformed + } else { + CursorError::Version + }); + } let encoded = value .strip_prefix(CURSOR_PREFIX) .ok_or(CursorError::Malformed)?; @@ -184,6 +197,7 @@ fn decode_payload(payload: &[u8]) -> Result<(CursorScope, TodayCursorPosition), let effective_at = decoder.u64()?; let card_id = CardId::parse(&hex::encode(decoder.array_32()?)).map_err(|_| CursorError::Malformed)?; + let query_scope = decoder.array_32()?; if !decoder.is_finished() { return Err(CursorError::Malformed); } @@ -194,6 +208,7 @@ fn decode_payload(payload: &[u8]) -> Result<(CursorScope, TodayCursorPosition), as_of, store_generation, projection_generation, + query_scope, }, TodayCursorPosition { rank: TodayRank { @@ -267,7 +282,7 @@ mod tests { use super::*; fn scope() -> CursorScope { - CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope") + CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 9, [6; 32]).expect("scope") } fn position() -> TodayCursorPosition { @@ -299,7 +314,7 @@ mod tests { let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); assert_eq!( cursor.as_str(), - "rrtc1:00010001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaedf305be41633dfc2f7d621e067c3d33a71c3548c6a1fcf68a6707a1d8664b11" + "rrtc2:00020001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa06060606060606060606060606060606060606060606060606060606060606062c4a887d72e34c722620c5a685cde8c288bc9b5bad5fd2faa4791ebfa1021b4c" ); assert_eq!( TodayCursor::decode(cursor.as_str(), &scope()).expect("decode"), @@ -309,6 +324,23 @@ mod tests { } #[test] + fn old_unbound_cursor_is_typed_unsupported_and_query_scope_is_checked() { + let old = "rrtc1:00010001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaedf305be41633dfc2f7d621e067c3d33a71c3548c6a1fcf68a6707a1d8664b11"; + assert_eq!(TodayCursor::scope(old), Err(CursorError::Version)); + assert_eq!( + TodayCursor::decode(old, &scope()), + Err(CursorError::Version) + ); + let cursor = TodayCursor::encode(&scope(), position()).unwrap(); + let mut changed = scope(); + changed.query_scope[0] ^= 1; + assert_eq!( + TodayCursor::decode(cursor.as_str(), &changed), + Err(CursorError::ContextMismatch) + ); + } + + #[test] fn cursor_rejects_tamper_context_snapshot_and_stale_generations() { let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); let mut tampered = cursor.as_str().as_bytes().to_vec(); @@ -318,30 +350,34 @@ mod tests { Err(CursorError::Integrity) ); let other_context = - CursorScope::new("other".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope"); + CursorScope::new("other".into(), 4, 2_000_000_000, [7; 32], 9, [6; 32]).expect("scope"); assert_eq!( TodayCursor::decode(cursor.as_str(), &other_context), Err(CursorError::ContextMismatch) ); let other_context_generation = - CursorScope::new("nearby".into(), 5, 2_000_000_000, [7; 32], 9).expect("scope"); + CursorScope::new("nearby".into(), 5, 2_000_000_000, [7; 32], 9, [6; 32]) + .expect("scope"); assert_eq!( TodayCursor::decode(cursor.as_str(), &other_context_generation), Err(CursorError::ContextMismatch) ); let other_snapshot = - CursorScope::new("nearby".into(), 4, 2_000_000_001, [7; 32], 9).expect("scope"); + CursorScope::new("nearby".into(), 4, 2_000_000_001, [7; 32], 9, [6; 32]) + .expect("scope"); assert_eq!( TodayCursor::decode(cursor.as_str(), &other_snapshot), Err(CursorError::SnapshotMismatch) ); - let stale = CursorScope::new("nearby".into(), 4, 2_000_000_000, [8; 32], 9).expect("scope"); + let stale = CursorScope::new("nearby".into(), 4, 2_000_000_000, [8; 32], 9, [6; 32]) + .expect("scope"); assert_eq!( TodayCursor::decode(cursor.as_str(), &stale), Err(CursorError::Stale) ); let stale_projection = - CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 10).expect("scope"); + CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 10, [6; 32]) + .expect("scope"); assert_eq!( TodayCursor::decode(cursor.as_str(), &stale_projection), Err(CursorError::Stale) @@ -354,7 +390,7 @@ mod tests { TodayCursor::decode("nope", &scope()), Err(CursorError::Malformed) ); - for malformed in ["rrtc1:0", "rrtc1:GG", "rrtc1:00"] { + for malformed in ["rrtc2:0", "rrtc2:GG", "rrtc2:00"] { assert_eq!( TodayCursor::decode(malformed, &scope()), Err(CursorError::Malformed) @@ -370,14 +406,14 @@ mod tests { ), Err(CursorError::Malformed) ); - assert!(CursorScope::new("".into(), 0, 0, [0; 32], 0).is_err()); - assert!(CursorScope::new("x".repeat(257), 0, 0, [0; 32], 0).is_err()); - assert!(CursorScope::new(" nearby ".into(), 0, 0, [0; 32], 0).is_err()); - assert!(CursorScope::new("near\u{7f}by".into(), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new("".into(), 0, 0, [0; 32], 0, [6; 32]).is_err()); + assert!(CursorScope::new("x".repeat(257), 0, 0, [0; 32], 0, [6; 32]).is_err()); + assert!(CursorScope::new(" nearby ".into(), 0, 0, [0; 32], 0, [6; 32]).is_err()); + assert!(CursorScope::new("near\u{7f}by".into(), 0, 0, [0; 32], 0, [6; 32]).is_err()); let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); for version_offset in [1, 3, 5] { let mut unsupported = payload(&cursor); - unsupported[version_offset] = 2; + unsupported[version_offset] = 3; assert_eq!( TodayCursor::decode(&signed_payload(unsupported), &scope()), Err(CursorError::Version) @@ -414,7 +450,7 @@ mod tests { Err(CursorError::Malformed) ); let mut truncated_field = vec![0; FIXED_PAYLOAD_BYTES]; - truncated_field[1] = 1; + truncated_field[1] = 2; truncated_field[3] = 1; truncated_field[5] = 1; truncated_field[6] = 1; diff --git a/core/crates/tera_core/src/runtime/product_surface/cursor_boundary_tests.rs b/core/crates/tera_core/src/runtime/product_surface/cursor_boundary_tests.rs @@ -1,7 +1,15 @@ use super::*; fn scope(context: &str) -> CursorScope { - CursorScope::new(context.into(), u64::MAX, u64::MAX, [0xff; 32], u64::MAX).expect("valid scope") + CursorScope::new( + context.into(), + u64::MAX, + u64::MAX, + [0xff; 32], + u64::MAX, + [0xff; 32], + ) + .expect("valid scope") } fn position() -> TodayCursorPosition { @@ -29,8 +37,9 @@ fn rehashed(mut payload: Vec<u8>) -> String { #[test] fn maximum_cursor_round_trips_ascii_and_multibyte_contexts() { - assert_eq!(FIXED_PAYLOAD_BYTES, 106); - assert_eq!(MAX_CURSOR_BYTES, 794); + assert_eq!(FIXED_PAYLOAD_BYTES, 138); + assert_eq!(MAX_CURSOR_BYTES, 858); + assert_eq!(LEGACY_MAX_CURSOR_BYTES, 794); for context in ["x".repeat(256), "é".repeat(128)] { let scope = scope(&context); let cursor = TodayCursor::encode(&scope, position()).expect("cursor"); @@ -43,6 +52,10 @@ fn maximum_cursor_round_trips_ascii_and_multibyte_contexts() { #[test] fn oversized_hex_is_rejected_before_integrity_decoding() { + assert_error( + &format!("rrtc1:{}", "0".repeat(LEGACY_MAX_CURSOR_BYTES + 1 - 6)), + CursorError::Malformed, + ); let valid = TodayCursor::encode(&scope(&"x".repeat(256)), position()).expect("cursor"); for suffix in ["0", "00"] { assert_error( @@ -62,7 +75,7 @@ fn oversized_hex_is_rejected_before_integrity_decoding() { #[test] fn both_cursor_entry_points_reject_malformed_shapes_and_checksums() { for malformed in [ - "", "rrtc1:", "rrtc1:0", "rrtc1:00", "rrtc1:GG", "rrtc1:é", "rrtc2:00", + "", "rrtc2:", "rrtc2:0", "rrtc2:00", "rrtc2:GG", "rrtc2:é", "rrtc3:00", ] { assert_error(malformed, CursorError::Malformed); } @@ -84,7 +97,7 @@ fn integrity_valid_payloads_reject_versions_lengths_and_trailing_bytes() { payload.truncate(payload.len() - DIGEST_BYTES); for offset in [1, 3, 5] { let mut invalid = payload.clone(); - invalid[offset] = 2; + invalid[offset] = 3; assert_error(&rehashed(invalid), CursorError::Version); } for length in [257_u16, u16::MAX] { @@ -106,7 +119,7 @@ fn scope_construction_and_deserialization_cannot_admit_invalid_contexts() { "x\u{7f}".into(), ] { assert_eq!( - CursorScope::new(context.clone(), 0, 0, [0; 32], 0), + CursorScope::new(context.clone(), 0, 0, [0; 32], 0, [0; 32]), Err(CursorError::InvalidContext) ); let wire = serde_json::to_string(&context).expect("wire"); @@ -120,6 +133,7 @@ fn scope_construction_and_deserialization_cannot_admit_invalid_contexts() { as_of: 0, store_generation: [0; 32], projection_generation: 0, + query_scope: [0; 32], }; let cursor = TodayCursor::encode(&scope, position()).expect("cursor"); assert_eq!(TodayCursor::decode(cursor.as_str(), &scope), Ok(position())); diff --git a/core/crates/tera_core/src/runtime/product_surface/ranking.rs b/core/crates/tera_core/src/runtime/product_surface/ranking.rs @@ -90,6 +90,8 @@ impl Ord for TodayRank { .then_with(|| other.time_relevance_rank.cmp(&self.time_relevance_rank)) .then_with(|| other.effective_at.cmp(&self.effective_at)) .then_with(|| self.card_id.cmp(&other.card_id)) + .then_with(|| self.schema_version.cmp(&other.schema_version)) + .then_with(|| self.algorithm_version.cmp(&other.algorithm_version)) } } @@ -196,6 +198,20 @@ mod tests { fn tuple_sorts_in_locked_feed_order_and_has_a_fixed_digest() { let exact = TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) .expect("rank"); + for mixed in [ + TodayRank { + schema_version: 2, + ..exact + }, + TodayRank { + algorithm_version: 2, + ..exact + }, + ] { + assert_ne!(exact, mixed); + assert_ne!(exact.cmp(&mixed), Ordering::Equal); + assert_eq!(exact.cmp(&mixed), mixed.cmp(&exact).reverse()); + } assert_eq!( exact.digest_hex(), "c7792876c8177f6f5420cc0f9aa84fb3c478f0bc6555c94ea5a7288502d6e4db" diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs @@ -56,7 +56,7 @@ use crate::runtime::TeraRuntime; const TODAY_PROJECTION_ID: &str = "radroots.today.v1"; const TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION: u16 = 1; -const TODAY_SNAPSHOT_SCHEMA_VERSION: u16 = 1; +const TODAY_SNAPSHOT_SCHEMA_VERSION: u16 = 2; const TODAY_PAGE_LIMIT_MAX: u16 = 100; const TODAY_SEARCH_LIMIT_MAX: u16 = 100; #[cfg(feature = "mobile-social")] @@ -68,7 +68,14 @@ const TODAY_SYNC_KINDS: [u32; 7] = [0, 1, 5, 1111, 30_402, 31_922, 31_923]; const PROJECTION_GENERATION_DOMAIN: &[u8] = b"radroots.today-projection.v1\0"; const PROJECTION_CONTENT_DOMAIN: &[u8] = b"radroots.today-content-generation.v1\0"; const PROJECTION_DOCUMENT_KEY_DOMAIN: &[u8] = b"radroots.today-document-key.v1\0"; -const SNAPSHOT_ID_DOMAIN: &[u8] = b"radroots.today-snapshot-id.v1\0"; +const SNAPSHOT_ID_DOMAIN: &[u8] = b"radroots.today-snapshot-id.v2\0"; + +#[path = "today_paging_scope.rs"] +mod paging_scope; + +#[cfg(test)] +#[path = "today_scope_tests.rs"] +mod scope_tests; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "PascalCase")] @@ -204,6 +211,10 @@ struct TodayProjectionState { overlays: BTreeMap<String, LocalAuthorOverlay>, #[serde(default)] media_cache: Phase1MediaCacheIndex, + // Missing metadata remains readable with the original content hash. The + // next scoped read rebuilds it from source while retaining overlays/cache. + #[serde(default, skip_serializing_if = "Option::is_none")] + query_scope: Option<[u8; 32]>, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -215,6 +226,7 @@ struct FrozenTodaySnapshot { as_of: u64, store_generation: [u8; 32], projection_generation: u64, + query_scope: [u8; 32], items: Vec<TodayCard>, } @@ -339,11 +351,13 @@ impl TeraRuntime { let projection_id = projection_id()?; let key = projection_document_key(context); let prior = load_state(storage, context, generation).await?; + let query_scope = paging_scope::query_scope(context, self.store_public_key)?; if update == TodayProjectionUpdate::Incremental - && prior - .as_ref() - .is_some_and(|state| state.source_events == event_status.raw_events()) + && prior.as_ref().is_some_and(|state| { + state.source_events == event_status.raw_events() + && state.query_scope == Some(query_scope) + }) { let state = prior.expect("checked present"); return Ok(refresh_receipt(update, &state, false)); @@ -363,6 +377,7 @@ impl TeraRuntime { visible, overlays, )?; + state.query_scope = Some(query_scope); state.media_cache = media_cache; apply_local_media_evidence(&mut state, &local_media); state.content_generation = content_generation(&state)?; @@ -431,10 +446,14 @@ impl TeraRuntime { let event_status = EventStore::status(storage).await?; let algorithm_generation = projection_generation()?; let projection_id = projection_id()?; + let query_scope = paging_scope::query_scope(context, self.store_public_key)?; let (scope, snapshot, after) = if let Some(cursor) = request.cursor.as_deref() { let scope = TodayCursor::scope(cursor)?; - if scope.context_id != context.id || scope.context_generation != context.generation { + if scope.context_id != context.id + || scope.context_generation != context.generation + || scope.query_scope != query_scope + { return Err(CursorError::ContextMismatch.into()); } if request.as_of.is_some_and(|as_of| as_of != scope.as_of) { @@ -458,8 +477,8 @@ impl TeraRuntime { .filter(|value| *value != 0) .ok_or(TodayError::InvalidRequest)?; let state = match load_state(storage, context, algorithm_generation).await? { - Some(state) => state, - None => { + Some(state) if state.query_scope == Some(query_scope) => state, + _ => { // A first local read must not need a prior relay refresh. // Materialize only already admitted local events; errors // remain errors rather than becoming an empty feed. @@ -470,7 +489,9 @@ impl TeraRuntime { .ok_or(TodayError::ProjectionMissing)? } }; - if state.store_generation != *event_status.generation().as_bytes() { + if state.store_generation != *event_status.generation().as_bytes() + || state.query_scope != Some(query_scope) + { return Err(CursorError::Stale.into()); } let scope = CursorScope::new( @@ -479,8 +500,9 @@ impl TeraRuntime { as_of, state.store_generation, state.content_generation, + query_scope, )?; - let snapshot = frozen_snapshot(&state, context, as_of)?; + let snapshot = frozen_snapshot(&state, context, as_of, query_scope)?; persist_snapshot(storage, algorithm_generation, &scope, &snapshot).await?; (scope, snapshot, None) }; @@ -1398,6 +1420,7 @@ fn project_state( thread, overlays, media_cache: Phase1MediaCacheIndex::default(), + query_scope: None, }) } @@ -1591,6 +1614,7 @@ fn frozen_snapshot( state: &TodayProjectionState, context: &LocalNetwork, as_of: u64, + query_scope: [u8; 32], ) -> Result<FrozenTodaySnapshot, TodayError> { Ok(FrozenTodaySnapshot { schema_version: TODAY_SNAPSHOT_SCHEMA_VERSION, @@ -1599,6 +1623,7 @@ fn frozen_snapshot( as_of, store_generation: state.store_generation, projection_generation: state.content_generation, + query_scope, items: ranked_cards(state, context, as_of)?, }) } @@ -1610,6 +1635,7 @@ fn page_from_snapshot( limit: u16, ) -> Result<TodayPage, TodayError> { validate_snapshot(&snapshot, &scope)?; + paging_scope::validate_order(&snapshot.items)?; let start = if let Some(after) = after { snapshot .items @@ -1651,6 +1677,7 @@ fn validate_snapshot( || snapshot.as_of != scope.as_of || snapshot.store_generation != scope.store_generation || snapshot.projection_generation != scope.projection_generation + || snapshot.query_scope != scope.query_scope { return Err(TodayError::CorruptProjection); } @@ -1831,6 +1858,15 @@ fn sanitize_snapshot_media(snapshot: &mut FrozenTodaySnapshot, cache: &Phase1Med } fn decode_snapshot(value: &[u8]) -> Result<FrozenTodaySnapshot, TodayError> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Header { + schema_version: u16, + } + let header: Header = decode(value)?; + if header.schema_version == 1 { + return Err(CursorError::Stale.into()); + } let snapshot: FrozenTodaySnapshot = match serde_json::from_slice(value) { Ok(snapshot) => snapshot, Err(_) => { @@ -2000,6 +2036,7 @@ fn snapshot_id(scope: &CursorScope) -> [u8; 32] { digest.update(scope.as_of.to_be_bytes()); digest.update(scope.store_generation); digest.update(scope.projection_generation.to_be_bytes()); + digest.update(scope.query_scope); digest.finalize().into() } @@ -2145,7 +2182,7 @@ mod tests { } } - fn context(locality: Option<&str>, generation: u64) -> LocalNetwork { + pub(super) fn context(locality: Option<&str>, generation: u64) -> LocalNetwork { LocalNetwork::new( "victoria".into(), "Victoria".into(), @@ -2157,11 +2194,16 @@ mod tests { .expect("context") } - fn keys() -> Keys { + pub(super) fn keys() -> Keys { Keys::parse(SECRET).expect("keys") } - fn signed(kind: u32, tags: Vec<Vec<&str>>, content: &str, created_at: u64) -> SignedEvent { + pub(super) fn signed( + kind: u32, + tags: Vec<Vec<&str>>, + content: &str, + created_at: u64, + ) -> SignedEvent { signed_owned( kind, tags.into_iter() @@ -2257,7 +2299,7 @@ mod tests { .expect("codec admission") } - async fn ingest( + pub(super) async fn ingest( runtime: &TeraRuntime, context: &LocalNetwork, event: SignedEvent, @@ -2597,6 +2639,7 @@ mod tests { cursor = page.next_cursor; } ids.sort(); + assert_eq!(ids.len(), 3, "each frozen item appears exactly once"); ids.dedup(); assert_eq!(ids.len(), 3, "frozen snapshot has no loss or duplicates"); @@ -3575,6 +3618,7 @@ mod tests { 2_000_000_200, state.store_generation, state.content_generation, + paging_scope::query_scope(&context, runtime.store_public_key).unwrap(), ) .expect("scope"); @@ -3637,7 +3681,8 @@ mod tests { Err(TodayError::SnapshotMissing) )); - let snapshot = frozen_snapshot(&state, &context, scope.as_of).expect("snapshot"); + let snapshot = + frozen_snapshot(&state, &context, scope.as_of, scope.query_scope).expect("snapshot"); assert!(validate_snapshot(&snapshot, &scope).is_ok()); for invalid in [ { diff --git a/core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs b/core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs @@ -0,0 +1,43 @@ +use std::collections::BTreeSet; + +use radroots_identity::PublicKey; +use sha2::{Digest, Sha256}; + +use super::{LocalNetwork, TodayCard, TodayError}; +use crate::runtime::product_surface::ranking::{ + TODAY_RANK_ALGORITHM_VERSION, TODAY_RANK_SCHEMA_VERSION, +}; + +/// Binds the host's selected query to the runtime's authenticated store owner. +/// The digest detects scope changes; it is not a secret or an authorization token. +pub(super) fn query_scope( + context: &LocalNetwork, + owner: Option<PublicKey>, +) -> Result<[u8; 32], TodayError> { + let mut digest = Sha256::new(); + digest.update(b"tera.today-query-scope.v1\0"); + // Serialize an unambiguous tuple of the full context and runtime identity. + // No field supplied by the cursor participates in this independent binding. + digest.update(super::encode(&(context, owner.map(|key| key.to_hex())))?); + Ok(digest.finalize().into()) +} + +/// Reject corrupt persisted order before a keyset boundary can skip or repeat data. +pub(super) fn validate_order(items: &[TodayCard]) -> Result<(), TodayError> { + let mut identities = BTreeSet::new(); + let mut previous = None; + for item in items { + let rank = item.card.rank.ok_or(TodayError::CorruptProjection)?; + if rank.schema_version != TODAY_RANK_SCHEMA_VERSION + || rank.algorithm_version != TODAY_RANK_ALGORITHM_VERSION + || rank.time_relevance_rank > 4 + || rank.card_id != item.card.card_id + || !identities.insert(item.card.card_id) + || previous.is_some_and(|previous| previous >= rank) + { + return Err(TodayError::CorruptProjection); + } + previous = Some(rank); + } + Ok(()) +} diff --git a/core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs b/core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs @@ -0,0 +1,423 @@ +use super::tests::{context, ingest, keys, signed}; +use super::*; + +const NOW: u64 = 2_000_000_000; + +#[tokio::test] +async fn arrivals_and_authorized_deletion_produce_complete_new_keysets() { + let runtime = TeraRuntime::test_memory().unwrap(); + let selected = context(None, 1); + let removed = signed(1, vec![], "remove-me", NOW); + let removed_id = removed.id().to_hex(); + ingest(&runtime, &selected, removed, NOW).await; + for body in ["alpha", "bravo"] { + ingest(&runtime, &selected, signed(1, vec![], body, NOW), NOW).await; + } + let first = runtime + .phase1_today_page(&selected, TodayPageRequest::first(1, NOW + 10)) + .await + .unwrap(); + ingest( + &runtime, + &selected, + signed(1, vec![], "late-equal-time", NOW), + NOW + 1, + ) + .await; + ingest( + &runtime, + &selected, + signed(5, vec![vec!["e", &removed_id]], "", NOW + 2), + NOW + 2, + ) + .await; + let fresh = runtime + .phase1_today_page(&selected, TodayPageRequest::first(1, NOW + 10)) + .await + .unwrap(); + assert_ne!(fresh.next_cursor, first.next_cursor); + let mut items = fresh.items; + let mut cursor = fresh.next_cursor; + while let Some(value) = cursor { + let request = TodayPageRequest::after(1, value); + let page = runtime + .phase1_today_page(&selected, request.clone()) + .await + .unwrap(); + assert_eq!( + runtime.phase1_today_page(&selected, request).await.unwrap(), + page + ); + items.extend(page.items); + assert!(items.len() <= 3); + cursor = page.next_cursor; + } + assert_eq!(items.len(), 3); + assert!( + items + .iter() + .all(|item| item.card.source_event_id != removed_id) + ); + let contents = items + .iter() + .map(|item| item.card.content.as_str()) + .collect::<std::collections::BTreeSet<_>>(); + assert_eq!( + contents, + ["alpha", "bravo", "late-equal-time"].into_iter().collect() + ); +} + +#[tokio::test] +async fn same_identity_and_generation_do_not_authorize_another_query() { + let runtime = TeraRuntime::test_memory().expect("runtime"); + let first = context(Some("first"), 1); + let second = context(Some("second"), 1); + for locality in ["first", "second"] { + for body in ["alpha", "bravo"] { + ingest( + &runtime, + &first, + signed(1, vec![vec!["g", locality]], body, NOW), + NOW, + ) + .await; + } + } + let page = runtime + .phase1_today_page(&first, TodayPageRequest::first(1, NOW)) + .await + .unwrap(); + let cursor = page.next_cursor.expect("second matching item"); + let result = runtime + .phase1_today_page(&second, TodayPageRequest::after(1, cursor.clone())) + .await; + assert!( + matches!( + result, + Err(TodayError::Cursor(CursorError::ContextMismatch)) + ), + "{result:?}" + ); + let original = runtime + .phase1_today_page(&first, TodayPageRequest::after(1, cursor.clone())) + .await + .unwrap(); + let other = runtime + .phase1_today_page(&second, TodayPageRequest::first(1, NOW)) + .await + .unwrap(); + assert_eq!(other.items.len(), 1); + assert!( + other.next_cursor.is_some(), + "both second-locality records must be projected" + ); + let other_rest = runtime + .phase1_today_page( + &second, + TodayPageRequest::after(1, other.next_cursor.clone().unwrap()), + ) + .await + .unwrap(); + assert_eq!(other_rest.items.len(), 1); + assert!(other_rest.next_cursor.is_none()); + let second_ids = [ + other.items[0].card.source_event_id.clone(), + other_rest.items[0].card.source_event_id.clone(), + ] + .into_iter() + .collect::<std::collections::BTreeSet<_>>(); + let expected_ids = ["alpha", "bravo"] + .map(|body| { + signed(1, vec![vec!["g", "second"]], body, NOW) + .id() + .to_hex() + }) + .into_iter() + .collect(); + assert_eq!(second_ids, expected_ids); + assert_ne!(other.next_cursor.as_ref(), Some(&cursor)); + assert_eq!( + runtime + .phase1_today_page(&first, TodayPageRequest::after(1, cursor.clone())) + .await + .unwrap(), + original + ); + for changed in [ + LocalNetwork { + label: "Changed".into(), + ..first.clone() + }, + LocalNetwork { + relay_urls: vec!["wss://another.example".into()], + ..first.clone() + }, + LocalNetwork { + followed_authors: vec![keys().public_key().to_string()], + ..first.clone() + }, + ] { + assert!(matches!( + runtime + .phase1_today_page(&changed, TodayPageRequest::after(1, cursor.clone())) + .await, + Err(TodayError::Cursor(CursorError::ContextMismatch)) + )); + } +} + +#[tokio::test] +async fn tied_keysets_are_exact_repeatable_and_independent_of_ingest_order() { + let selected = context(Some("first"), 1); + let mut expected = None; + for reverse in [false, true] { + let runtime = TeraRuntime::test_memory().unwrap(); + let mut events = (0..9) + .map(|index| { + signed( + 1, + if index < 6 { + vec![vec!["g", "first"]] + } else { + vec![] + }, + &format!("post-{index}"), + NOW, + ) + }) + .collect::<Vec<_>>(); + if reverse { + events.reverse(); + } + for event in events { + ingest(&runtime, &selected, event, NOW).await; + } + let all = runtime + .phase1_today_page(&selected, TodayPageRequest::first(100, NOW)) + .await + .unwrap(); + let ids = all + .items + .iter() + .map(|item| item.card.card_id) + .collect::<Vec<_>>(); + assert_eq!(ids.len(), 9); + assert_eq!( + ids.iter().collect::<std::collections::BTreeSet<_>>().len(), + 9 + ); + assert!( + all.items + .windows(2) + .all(|pair| pair[0].card.rank < pair[1].card.rank) + ); + if let Some(expected) = &expected { + assert_eq!(&ids, expected); + } else { + expected = Some(ids.clone()); + } + for limit in [1, 2, 4, 8, 9, 100] { + let mut request = TodayPageRequest::first(limit, NOW); + let mut observed = Vec::new(); + loop { + let page = runtime + .phase1_today_page(&selected, request.clone()) + .await + .unwrap(); + assert_eq!( + runtime + .phase1_today_page(&selected, request.clone()) + .await + .unwrap(), + page + ); + observed.extend(page.items.iter().map(|item| item.card.card_id)); + assert!(observed.len() <= ids.len()); + let Some(cursor) = page.next_cursor else { + break; + }; + request = TodayPageRequest::after(limit, cursor); + } + assert_eq!(observed, ids); + } + runtime.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn authenticated_accounts_and_retired_stores_cannot_share_cursor_authority() { + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + let root = tempfile::tempdir().unwrap(); + let selected = context(None, 1); + let first_owner = keys().public_key().to_string(); + let second_owner = nostr::Keys::parse(&format!("{:064x}", 2)) + .unwrap() + .public_key() + .to_string(); + let mut first_cursor: Option<String> = None; + for (index, owner, generation) in [ + (0, &first_owner, "31"), + (1, &second_owner, "31"), + (2, &first_owner, "32"), + ] { + let store = MobileUserStoreConfig::from_encoded( + root.path().join(index.to_string()), + owner, + &generation.repeat(32), + NOW * 1000, + ProtectedDataAvailability::Available, + ) + .unwrap(); + std::fs::create_dir_all(store.owner_directory()).unwrap(); + let runtime = RuntimeBuilder::new(store).build().await.unwrap(); + for body in ["alpha", "bravo"] { + ingest(&runtime, &selected, signed(1, vec![], body, NOW), NOW).await; + } + let page = runtime + .phase1_today_page(&selected, TodayPageRequest::first(1, NOW)) + .await + .unwrap(); + let own_cursor = page.next_cursor.unwrap(); + assert_eq!( + runtime + .phase1_today_page(&selected, TodayPageRequest::after(1, own_cursor.clone())) + .await + .unwrap() + .items + .len(), + 1 + ); + if let Some(cursor) = &first_cursor { + let result = runtime + .phase1_today_page(&selected, TodayPageRequest::after(1, cursor.clone())) + .await; + assert!(matches!( + (index, result), + (1, Err(TodayError::Cursor(CursorError::ContextMismatch))) + | (2, Err(TodayError::Cursor(CursorError::Stale))) + )); + } else { + first_cursor = Some(own_cursor); + } + runtime.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn corrupt_snapshot_order_and_legacy_unbound_cache_fail_closed() { + let runtime = TeraRuntime::test_memory().unwrap(); + let selected = context(None, 1); + for body in ["alpha", "bravo", "charlie"] { + ingest(&runtime, &selected, signed(1, vec![], body, NOW), NOW).await; + } + let state = load_state( + runtime.client.storage().unwrap(), + &selected, + projection_generation().unwrap(), + ) + .await + .unwrap() + .unwrap(); + let snapshot = frozen_snapshot( + &state, + &selected, + NOW, + paging_scope::query_scope(&selected, None).unwrap(), + ) + .unwrap(); + assert!(paging_scope::validate_order(&snapshot.items).is_ok()); + for index in 0..6 { + let mut invalid = snapshot.items.clone(); + match index { + 0 => invalid.swap(0, 1), + 1 => invalid.insert(1, invalid[0].clone()), + 2 => invalid[0].card.rank = None, + 3 => invalid[0].card.rank.as_mut().unwrap().schema_version += 1, + 4 => invalid[0].card.rank.as_mut().unwrap().algorithm_version += 1, + _ => invalid[0].card.rank.as_mut().unwrap().card_id = invalid[1].card.card_id, + } + assert!(matches!( + paging_scope::validate_order(&invalid), + Err(TodayError::CorruptProjection) + )); + } + let mut legacy = serde_json::to_value(&snapshot).unwrap(); + legacy["schemaVersion"] = 1.into(); + legacy.as_object_mut().unwrap().remove("queryScope"); + let bytes = serde_json::to_vec(&legacy).unwrap(); + assert!(matches!( + decode_snapshot(&bytes), + Err(TodayError::Cursor(CursorError::Stale)) + )); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(), + legacy + ); + let page = runtime + .phase1_today_page(&selected, TodayPageRequest::first(100, NOW)) + .await + .unwrap(); + assert_eq!(page.items, snapshot.items); + + // Genuine pre-binding projection bytes still verify with their old hash. + // Scope repair retains local author overlays instead of discarding the cache. + let mut unbound = state.clone(); + unbound.query_scope = None; + let overlay = LocalAuthorOverlay { + operation_id: "retained-operation".into(), + state: "delivered".into(), + }; + let overlaid_id = unbound.cards[0].card.card_id; + unbound + .overlays + .insert(overlaid_id.to_hex(), overlay.clone()); + unbound.content_generation = content_generation(&unbound).unwrap(); + let old_bytes = encode(&unbound).unwrap(); + assert!( + !std::str::from_utf8(&old_bytes) + .unwrap() + .contains("queryScope") + ); + assert_eq!(decode_state(&old_bytes).unwrap(), unbound); + store_state( + runtime.client.storage().unwrap(), + &selected, + projection_generation().unwrap(), + &unbound, + ) + .await + .unwrap(); + let repaired = runtime + .phase1_today_page(&selected, TodayPageRequest::first(100, NOW)) + .await + .unwrap(); + assert_eq!(repaired.items.len(), snapshot.items.len()); + assert_eq!( + repaired + .items + .iter() + .find(|item| item.card.card_id == overlaid_id) + .unwrap() + .local_overlay + .as_ref(), + Some(&overlay) + ); + let rebound = load_state( + runtime.client.storage().unwrap(), + &selected, + projection_generation().unwrap(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + rebound.query_scope, + Some(paging_scope::query_scope(&selected, None).unwrap()) + ); + assert_eq!(rebound.overlays, unbound.overlays); + assert_eq!(rebound.media_cache, unbound.media_cache); +} diff --git a/release/provenance.json b/release/provenance.json @@ -2,7 +2,7 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "ab87eb6e3d4512acc3986120c38988f8e7559ac1781de76d9808e0bae338d1de", - "ffi_provenance_sha256": "29b6ada7a8b8e83f9d992d6eb9971638adb6c3044c37b86830f59ad046dd1924", + "ffi_provenance_sha256": "629c3620deddf0b3b80b01b1ae13d508567ae09872efd5fb39975bc02b7eb682", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410", @@ -22,7 +22,7 @@ "lib_revision": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "ec5432e7e1b5f9b17a23cee343947880ee292026", + "tera_ffi_source_tree": "15bab536eccf803b40d32dbb228e64282272709f", "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 @@ -3641,7 +3641,7 @@ ] }, { - "identifier": "radroots.today-cursor.v1", + "identifier": "radroots.today-cursor.v2", "category": "persisted_data", "occurrences": [ { @@ -3681,7 +3681,7 @@ ] }, { - "identifier": "radroots.today-snapshot-id.v1", + "identifier": "radroots.today-snapshot-id.v2", "category": "persisted_data", "occurrences": [ { @@ -4099,6 +4099,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/store.rs", "count": 1 }