lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 9d0d027635b2f223f5a6585d23cb4976e6bc7894
parent 52019253cdbb3bdd62c1bbc9c24e7be377526b80
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Jul 2026 02:04:20 +0000

event-codec: seal Phase 1 publication artifact contract

- freeze the exact seven-leaf canonical envelope and persistence operations
- enforce signed-wire, artifact, media, and Blossom descriptor boundaries
- publish executable operation-specific vectors and generated contract authority
- cover strict reload, tamper, field-order, and boundary behavior

Diffstat:
MCHANGELOG.md | 17++++++++++-------
Mcontracts/conformance/vectors/publication/phase1_artifact.v1.json | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcontracts/operations.toml | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcontracts/releases/1.0.0-alpha.1.toml | 2+-
Mcrates/event_codec/README | 24++++++++++++++++++++----
Mcrates/event_codec/contracts/phase1_publication_artifact_v1.manifest.json | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcrates/event_codec/contracts/phase1_publication_artifact_v1.manifest.schema.json | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/event_codec/contracts/phase1_publication_artifact_v1.manifest.sha256 | 2+-
Mcrates/event_codec/src/wire/publication.rs | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/event_codec/tests/fixtures/phase1_publication_artifact.v1.json | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/event_codec/tests/publication_artifact.rs | 322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtools/xtask/src/contract/phase1_publication_artifact.rs | 355++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
12 files changed, 1328 insertions(+), 373 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -174,13 +174,16 @@ publish policy both pass for the same source revision. <!-- release-change: phase1-publication-artifact --> - The `serde_json` event-codec surface now exposes a sealed Phase 1 publication artifact constructed only from strict authored Profile, Update, PhotoUpdate, - Ask, date/time Event, and FoodAvailability models. Exact canonical JSON binds - the operation and event-contract profile, author, frozen unsigned draft, - expected NIP-01 id, and a full-URL media commitment inventory under a - domain-separated digest. Strict bounded reload rejects unknown fields, - alternate encodings, cross-profile promotion, stale identifiers, media - drift, and digest tampering without claiming restored byte verification, - upload completion, signature authenticity, or signer authority. + Ask, date/time Event, and FoodAvailability models. The exact version-1 + envelope places the expected NIP-01 id at top level after the frozen draft, + exposes only explicit canonical-byte encode/decode operations, and binds all + fields except the digest under an ASCII-domain, single-NUL SHA-256 preimage. + Construction and reload enforce the 2 MiB artifact, 256 KiB signed-event, + and 4,096-reference bounds plus complete case-preserving Blossom URL + commitments. Strict reload rejects unknown or duplicate fields, alternate + encodings, cross-profile promotion, stale identifiers, media drift, and + digest tampering without claiming restored byte verification, upload + completion, signature authenticity, or signer authority. - Bare-envelope replica ingestion is quarantined behind the explicit, non-default `legacy-ingest` feature. Default replica APIs expose emit and sync surfaces only; a future product ingest boundary must consume a store-produced diff --git a/contracts/conformance/vectors/publication/phase1_artifact.v1.json b/contracts/conformance/vectors/publication/phase1_artifact.v1.json @@ -4,7 +4,7 @@ "vectors": [ { "id": "profile_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_profile.valid", "input": { "fixture": "profile" }, "expected": { "semantic_variant": "profile", @@ -13,14 +13,15 @@ "event_kind": 0, "media_count": 2, "expected_event_id": "a7d081b9c142e3a68245bafe1921b0e0dd88f886406e7165a9729f6c57026289", - "artifact_digest": "10db30ccd1ec4986a43f9ac2fefe201f582e8965ac0c36f0fe467b066194fa21", + "artifact_digest": "e296ed63312d58b4e3ee160d4ca3457eb922920d3116329ca498f08e402215b0", "canonical_json_bytes": 1305, - "canonical_json_sha256": "9ddaee58d68cce2a400880c62a2c3f4b3b11765a2136b3c930f474ec28680b43" + "canonical_json_sha256": "131d8131bb821179e6fd64c5201d01bee384451d09c20d789860439ef05620e8", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"profile\",\"authored_operation_id\":\"profile.build_authored_draft\",\"event_contract_id\":\"radroots.profile.metadata.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"victoria-farm\\\",\\\"display_name\\\":\\\"Victoria Farm\\\",\\\"about\\\":\\\"Seasonal produce from the Saanich Peninsula\\\",\\\"picture\\\":\\\"https://media.example/512f6a371c77694502e7d08f0b1f1080c7103ca90925bfe2fa23106aac11003a.png\\\",\\\"banner\\\":\\\"https://media.example/fbf3819415a76ea0d3ba71817578bb89c4903aa958e38f76f86578dfa8a35145.webp\\\",\\\"nip05\\\":\\\"farm@example.com\\\",\\\"bot\\\":false}\"},\"expected_event_id\":\"a7d081b9c142e3a68245bafe1921b0e0dd88f886406e7165a9729f6c57026289\",\"media_references\":[{\"url\":\"https://media.example/512f6a371c77694502e7d08f0b1f1080c7103ca90925bfe2fa23106aac11003a.png\",\"sha256\":\"512f6a371c77694502e7d08f0b1f1080c7103ca90925bfe2fa23106aac11003a\",\"size\":15,\"media_type\":\"image/png\"},{\"url\":\"https://media.example/fbf3819415a76ea0d3ba71817578bb89c4903aa958e38f76f86578dfa8a35145.webp\",\"sha256\":\"fbf3819415a76ea0d3ba71817578bb89c4903aa958e38f76f86578dfa8a35145\",\"size\":14,\"media_type\":\"image/webp\"}],\"artifact_digest\":\"e296ed63312d58b4e3ee160d4ca3457eb922920d3116329ca498f08e402215b0\"}" } }, { "id": "update_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_update.valid", "input": { "fixture": "update" }, "expected": { "semantic_variant": "update", @@ -29,14 +30,15 @@ "event_kind": 1, "media_count": 0, "expected_event_id": "7f2d9dbbd6d2f3db1e83338ea2e669b2458e62ded159de15a3fa98dcf9f164e3", - "artifact_digest": "345676a88a68663b16ab22f44117589f7a429aae0fbd6bd5f168e5fc7b2df8ab", + "artifact_digest": "9ad318496bd4a710fbc7f3e0f6d5a01de808d352db91ca56a12e710904784cc5", "canonical_json_bytes": 525, - "canonical_json_sha256": "69d0b7aaec8e2fb3d85a677ef9ac1b6d3eb56d05b9ba7b98737687bfba074110" + "canonical_json_sha256": "21be0d18a7f5812f7fa14600f46e0948480d2525eb1dcab416bb4ef99ca731c6", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"update\",\"authored_operation_id\":\"social.update.build_authored_draft\",\"event_contract_id\":\"radroots.social.update.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":1,\"tags\":[],\"content\":\"Carrots harvested today\"},\"expected_event_id\":\"7f2d9dbbd6d2f3db1e83338ea2e669b2458e62ded159de15a3fa98dcf9f164e3\",\"media_references\":[],\"artifact_digest\":\"9ad318496bd4a710fbc7f3e0f6d5a01de808d352db91ca56a12e710904784cc5\"}" } }, { "id": "photo_update_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_photo_update.valid", "input": { "fixture": "photo_update" }, "expected": { "semantic_variant": "photo_update", @@ -45,14 +47,15 @@ "event_kind": 1, "media_count": 2, "expected_event_id": "e592229776cd2a0d0e25a701f4629c0a5dc6e08481a235623d50da6ee8e8f438", - "artifact_digest": "e9b83a57a78adaf2740ec0fd819c8754d96ae325d5a6f785be99e0267fa29251", + "artifact_digest": "9ed6b7a6fcec3b07bd0c9179fd5b6ecf74517390051e3c2bd1659be62a26c197", "canonical_json_bytes": 1415, - "canonical_json_sha256": "ebb29fa48adda8660f35218d879bcf1535d5584bead441ba10f996b54bcdbc76" + "canonical_json_sha256": "38caab5242ae70fe2e36b39658dba7e6f723b2bd4fa512c8f166387c17faf69c", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"photo_update\",\"authored_operation_id\":\"social.photo_update.build_authored_draft\",\"event_contract_id\":\"radroots.social.photo_update.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"imeta\",\"url https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"x 51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"m image/webp\",\"dim 1200x900\",\"size 13\",\"alt Fresh strawberries\",\"fallback https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"]],\"content\":\"Strawberries at the farm stand https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"},\"expected_event_id\":\"e592229776cd2a0d0e25a701f4629c0a5dc6e08481a235623d50da6ee8e8f438\",\"media_references\":[{\"url\":\"https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"},{\"url\":\"https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"}],\"artifact_digest\":\"9ed6b7a6fcec3b07bd0c9179fd5b6ecf74517390051e3c2bd1659be62a26c197\"}" } }, { "id": "ask_round_trip_with_fallback", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_ask.valid", "input": { "fixture": "ask" }, "expected": { "semantic_variant": "ask", @@ -61,14 +64,15 @@ "event_kind": 1, "media_count": 2, "expected_event_id": "bd45fbbb5bfb8a5aa32df81d240e5f3bd56b0f8cde3a20b3f7d455801373fe48", - "artifact_digest": "cfb3c4c74ec56edb5d4bbbed6c08f33cf9202a16c12e3fd41e54839ba8fe1762", + "artifact_digest": "41eddea199bba97adebef4ce433ebfa05d97978ce8464ed3d52762ab717c2a3c", "canonical_json_bytes": 1411, - "canonical_json_sha256": "b7f3eec4930f40642e69c90ab293d2fc8f370c95286794c33d55cda942a1f696" + "canonical_json_sha256": "4985ad0919758e3747f0548409854deb77b9734825ab597623e5c6b566e2d5c7", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"ask\",\"authored_operation_id\":\"social.ask.build_authored_draft\",\"event_contract_id\":\"radroots.social.ask.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"x 51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"m image/webp\",\"dim 1200x900\",\"size 13\",\"alt Fresh strawberries\",\"fallback https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"]],\"content\":\"When will strawberries be ready? https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"},\"expected_event_id\":\"bd45fbbb5bfb8a5aa32df81d240e5f3bd56b0f8cde3a20b3f7d455801373fe48\",\"media_references\":[{\"url\":\"https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"},{\"url\":\"https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"}],\"artifact_digest\":\"41eddea199bba97adebef4ce433ebfa05d97978ce8464ed3d52762ab717c2a3c\"}" } }, { "id": "event_date_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_calendar_date_event.valid", "input": { "fixture": "event_date" }, "expected": { "semantic_variant": "event_date", @@ -77,14 +81,15 @@ "event_kind": 31922, "media_count": 1, "expected_event_id": "bea82662b6acf2f1272c8335a24bb0957e35d22c2b29d3e30c094ad7398dc7c7", - "artifact_digest": "0a5b30799263dff58fd88e5b581eca13b0eb324dfe2698471459821af847d040", + "artifact_digest": "0ec5162ab80988999aac62329d4cfc9c7efce6a82bbda539a723347d73596eb3", "canonical_json_bytes": 1013, - "canonical_json_sha256": "1eba894c4f19b4c4f9dc62e47feb4ce0b1c232b24bc57ca80aa29c007916efd1" + "canonical_json_sha256": "46b015e34556762f3d7ca592cd3dc6d4d5652ff085c7d8a8dc458d5897cf0b22", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"event_date\",\"authored_operation_id\":\"social.calendar_date_event.build_authored_draft\",\"event_contract_id\":\"radroots.calendar.date_event.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":31922,\"tags\":[[\"d\",\"farmers-market-2026\"],[\"title\",\"Moss Street Farmers Market\"],[\"start\",\"2026-07-25\"],[\"end\",\"2026-07-26\"],[\"location\",\"Victoria, BC\"],[\"image\",\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\"]],\"content\":\"Saturday market in Victoria\"},\"expected_event_id\":\"bea82662b6acf2f1272c8335a24bb0957e35d22c2b29d3e30c094ad7398dc7c7\",\"media_references\":[{\"url\":\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\",\"sha256\":\"0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8\",\"size\":10,\"media_type\":\"image/jpeg\"}],\"artifact_digest\":\"0ec5162ab80988999aac62329d4cfc9c7efce6a82bbda539a723347d73596eb3\"}" } }, { "id": "event_time_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_calendar_time_event.valid", "input": { "fixture": "event_time" }, "expected": { "semantic_variant": "event_time", @@ -93,14 +98,15 @@ "event_kind": 31923, "media_count": 1, "expected_event_id": "2088ed11cd8c1495a82e6f5198ed2ee98e0cbc599f22d7f3380892155dc55894", - "artifact_digest": "a0cdbfdbc6a7067ee65ce6564c5660435074bf85c42baaa52694a03833dcada9", + "artifact_digest": "56bfb4bed87559a58922343905a73c0c96dd0a57f94ff0955caecf0589198638", "canonical_json_bytes": 1013, - "canonical_json_sha256": "512561b622c5c86e8d5e28045778f0eb9abf8f9eab89cb7ada000d42c6ffd61a" + "canonical_json_sha256": "58e3c7e684bc774c35b08b246075076b5286d51639eb0651ed5b6362d958639e", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"event_time\",\"authored_operation_id\":\"social.calendar_time_event.build_authored_draft\",\"event_contract_id\":\"radroots.calendar.time_event.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":31923,\"tags\":[[\"d\",\"farm-tour-2026\"],[\"title\",\"Saanich Farm Tour\"],[\"start\",\"1785003600\"],[\"end\",\"1785007200\"],[\"D\",\"20659\"],[\"start_tzid\",\"America/Vancouver\"],[\"image\",\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\"]],\"content\":\"A one-hour farm tour\"},\"expected_event_id\":\"2088ed11cd8c1495a82e6f5198ed2ee98e0cbc599f22d7f3380892155dc55894\",\"media_references\":[{\"url\":\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\",\"sha256\":\"0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8\",\"size\":10,\"media_type\":\"image/jpeg\"}],\"artifact_digest\":\"56bfb4bed87559a58922343905a73c0c96dd0a57f94ff0955caecf0589198638\"}" } }, { "id": "food_availability_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_food_availability.valid", "input": { "fixture": "food_availability" }, "expected": { "semantic_variant": "food_availability", @@ -109,152 +115,219 @@ "event_kind": 30402, "media_count": 1, "expected_event_id": "76b60f1b178c0dbdc14a87de36151ea6b04a866ce50e34a0ccae2f406f514e81", - "artifact_digest": "ff04d736df4b1dd01b17197ef0e9c88678133057130623377954332a9ce3e6b3", + "artifact_digest": "f0989cb993194af4a2f907ad8337e50378d2757c536a09afb4055a681043a7d8", "canonical_json_bytes": 1132, - "canonical_json_sha256": "b7d55bf0c75acdae53f670cd6a263c036fe619c73601eac5a03c6ef58d91695f" + "canonical_json_sha256": "525dc96e87b79b1ef3aa67c711cccd8b8e6c205665770ebcb8283035b7b496da", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"food_availability\",\"authored_operation_id\":\"food_availability.build_authored_draft\",\"event_contract_id\":\"radroots.food.availability.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":30402,\"tags\":[[\"d\",\"nantes-carrots\"],[\"title\",\"Nantes Carrots\"],[\"summary\",\"Fresh bunches\"],[\"published_at\",\"1784347140\"],[\"location\",\"Central Saanich, BC\"],[\"price\",\"3\",\"CAD\"],[\"radroots:price_unit\",\"lb\"],[\"radroots:quantity\",\"24\",\"lb\"],[\"status\",\"active\"],[\"image\",\"https://food.example/8a66441b557b29193781023160b0216c5518e9b84749f29ecf118a728f451a5e.png\",\"1200x800\"]],\"content\":\"Fresh Nantes carrots available this week.\"},\"expected_event_id\":\"76b60f1b178c0dbdc14a87de36151ea6b04a866ce50e34a0ccae2f406f514e81\",\"media_references\":[{\"url\":\"https://food.example/8a66441b557b29193781023160b0216c5518e9b84749f29ecf118a728f451a5e.png\",\"sha256\":\"8a66441b557b29193781023160b0216c5518e9b84749f29ecf118a728f451a5e\",\"size\":14,\"media_type\":\"image/png\"}],\"artifact_digest\":\"f0989cb993194af4a2f907ad8337e50378d2757c536a09afb4055a681043a7d8\"}" + } + }, + { + "id": "canonical_json_serialization", + "kind": "publication_artifact.to_canonical_json.valid", + "input": { "fixture": "profile" }, + "expected": { + "canonical_json_bytes": 1305, + "canonical_json_sha256": "131d8131bb821179e6fd64c5201d01bee384451d09c20d789860439ef05620e8" + } + }, + { + "id": "canonical_json_reload", + "kind": "publication_artifact.from_canonical_json.valid", + "input": { "fixture": "profile" }, + "expected": { + "semantic_variant": "profile", + "canonical_json_sha256": "131d8131bb821179e6fd64c5201d01bee384451d09c20d789860439ef05620e8" } }, { "id": "leading_whitespace_is_noncanonical", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "leading_whitespace" }, "expected": { "error": "publication_artifact_non_canonical_json" } }, { + "id": "artifact_exact_byte_limit_reaches_parser", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "artifact_exact_byte_limit" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { + "id": "artifact_one_byte_over_limit_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "artifact_one_over_byte_limit" }, + "expected": { "error": "publication_artifact_too_large" } + }, + { "id": "unknown_field_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "unknown_field" }, "expected": { "error": "publication_artifact_invalid_json" } }, { "id": "unknown_draft_field_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "unknown_draft_field" }, "expected": { "error": "publication_artifact_invalid_json" } }, { + "id": "nested_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "nested_expected_event_id" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { + "id": "missing_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "missing_expected_event_id" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { + "id": "malformed_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "malformed_expected_event_id" }, + "expected": { "error": "publication_expected_event_id_invalid" } + }, + { + "id": "uppercase_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "uppercase_expected_event_id" }, + "expected": { "error": "publication_expected_event_id_invalid" } + }, + { + "id": "duplicate_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "duplicate_expected_event_id" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { "id": "unknown_media_field_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "unknown_media_field" }, "expected": { "error": "publication_artifact_invalid_json" } }, { "id": "json_field_order_is_noncanonical", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "json_field_order" }, "expected": { "error": "publication_artifact_non_canonical_json" } }, { "id": "unknown_version_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "schema_version" }, "expected": { "error": "publication_artifact_version_unsupported" } }, { "id": "cross_variant_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "all_cross_variants" }, "expected": { "error": "publication_authored_operation_mismatch" } }, { "id": "operation_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "operation_id" }, "expected": { "error": "publication_authored_operation_mismatch" } }, { "id": "contract_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "contract_id" }, "expected": { "error": "publication_event_contract_mismatch" } }, { "id": "kind_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "kind" }, "expected": { "error": "publication_kind_mismatch" } }, { "id": "author_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "author" }, "expected": { "error": "publication_expected_author_invalid" } }, { "id": "created_at_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "created_at" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "draft_tags_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "draft_tags" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "draft_content_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "draft_content" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "noncanonical_nip05_is_rejected_after_id_rebuild", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "noncanonical_nip05" }, "expected": { "error": "publication_profile_invalid" } }, { "id": "empty_ask_content_is_rejected_after_id_rebuild", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "ask", "mutation": "empty_ask_content" }, "expected": { "error": "publication_post_profile_invalid" } }, { "id": "expected_event_id_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "expected_event_id" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "digest_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "digest" }, "expected": { "error": "publication_artifact_digest_mismatch" } }, { "id": "media_order_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_order" }, "expected": { "error": "publication_media_inventory_non_canonical" } }, { "id": "profile_media_commitment_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_size" }, "expected": { "error": "publication_artifact_digest_mismatch" } }, { "id": "media_url_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_url" }, "expected": { "error": "publication_media_inventory_mismatch" } }, { + "id": "noncanonical_media_url_casing_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "media_url_casing" }, + "expected": { "error": "publication_media_reference_invalid" } + }, + { "id": "media_hash_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_hash" }, "expected": { "error": "publication_media_reference_invalid" } }, { "id": "media_type_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_type" }, "expected": { "error": "publication_artifact_digest_mismatch" } }, { "id": "post_media_commitment_must_match_imeta", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "photo_update", "mutation": "media_size" }, "expected": { "error": "publication_media_inventory_mismatch" } } diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -262,6 +262,7 @@ public = [ [errors] classes = [ + "none", "encode_error", "parse_error", "validation_error", @@ -680,6 +681,7 @@ transport = "none" [operations.phase1_publication_artifact_build_profile.implementation] rust_modules = [ "crates/event/src/profile.rs", + "crates/event_codec/src/profile/authored.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -690,6 +692,7 @@ rust_types = [ [operations.phase1_publication_artifact_build_profile.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_profile.valid"] [operations.phase1_publication_artifact_build_update] domain = "publication" @@ -705,6 +708,7 @@ transport = "none" [operations.phase1_publication_artifact_build_update.implementation] rust_modules = [ "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -715,6 +719,7 @@ rust_types = [ [operations.phase1_publication_artifact_build_update.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_update.valid"] [operations.phase1_publication_artifact_build_photo_update] domain = "publication" @@ -730,6 +735,7 @@ transport = "none" [operations.phase1_publication_artifact_build_photo_update.implementation] rust_modules = [ "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -740,6 +746,7 @@ rust_types = [ [operations.phase1_publication_artifact_build_photo_update.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_photo_update.valid"] [operations.phase1_publication_artifact_build_ask] domain = "publication" @@ -755,6 +762,7 @@ transport = "none" [operations.phase1_publication_artifact_build_ask.implementation] rust_modules = [ "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -765,6 +773,7 @@ rust_types = [ [operations.phase1_publication_artifact_build_ask.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_ask.valid"] [operations.phase1_publication_artifact_build_calendar_date_event] domain = "publication" @@ -780,6 +789,7 @@ transport = "none" [operations.phase1_publication_artifact_build_calendar_date_event.implementation] rust_modules = [ "crates/event/src/calendar.rs", + "crates/event_codec/src/calendar/encode.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -790,6 +800,7 @@ rust_types = [ [operations.phase1_publication_artifact_build_calendar_date_event.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_calendar_date_event.valid"] [operations.phase1_publication_artifact_build_calendar_time_event] domain = "publication" @@ -805,6 +816,7 @@ transport = "none" [operations.phase1_publication_artifact_build_calendar_time_event.implementation] rust_modules = [ "crates/event/src/calendar.rs", + "crates/event_codec/src/calendar/encode.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -815,6 +827,7 @@ rust_types = [ [operations.phase1_publication_artifact_build_calendar_time_event.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_calendar_time_event.valid"] [operations.phase1_publication_artifact_build_food_availability] domain = "publication" @@ -830,6 +843,7 @@ transport = "none" [operations.phase1_publication_artifact_build_food_availability.implementation] rust_modules = [ "crates/event/src/food_availability.rs", + "crates/event_codec/src/food_availability/authored.rs", "crates/event_codec/src/wire/publication.rs", ] rust_types = [ @@ -840,10 +854,32 @@ rust_types = [ [operations.phase1_publication_artifact_build_food_availability.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.build_food_availability.valid"] + +[operations.phase1_publication_artifact_to_canonical_json] +domain = "publication" +id = "publication_artifact.to_canonical_json" +stability = "beta" +inputs = ["RadrootsPhase1PublicationArtifact"] +outputs = ["Bytes"] +error_class = "none" +deterministic = true +signing = "none" +transport = "none" + +[operations.phase1_publication_artifact_to_canonical_json.implementation] +rust_modules = ["crates/event_codec/src/wire/publication.rs"] +rust_types = [ + "radroots_event_codec::wire::publication::RadrootsPhase1PublicationArtifact", +] + +[operations.phase1_publication_artifact_to_canonical_json.conformance] +vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = ["publication_artifact.to_canonical_json.valid"] -[operations.phase1_publication_artifact_reload] +[operations.phase1_publication_artifact_from_canonical_json] domain = "publication" -id = "publication_artifact.reload" +id = "publication_artifact.from_canonical_json" stability = "beta" inputs = ["Bytes"] outputs = ["RadrootsPhase1PublicationArtifact"] @@ -852,15 +888,27 @@ deterministic = true signing = "none" transport = "none" -[operations.phase1_publication_artifact_reload.implementation] -rust_modules = ["crates/event_codec/src/wire/publication.rs"] +[operations.phase1_publication_artifact_from_canonical_json.implementation] +rust_modules = [ + "crates/event_codec/src/calendar/decode.rs", + "crates/event_codec/src/food_availability/inbound.rs", + "crates/event_codec/src/food_availability/inbound/registry_v7.rs", + "crates/event_codec/src/post/authored.rs", + "crates/event_codec/src/post/inbound.rs", + "crates/event_codec/src/post/inbound/registry_v7.rs", + "crates/event_codec/src/wire/publication.rs", +] rust_types = [ "radroots_event_codec::wire::publication::RadrootsPhase1PublicationArtifact", "radroots_event_codec::wire::publication::RadrootsPhase1PublicationArtifactError", ] -[operations.phase1_publication_artifact_reload.conformance] +[operations.phase1_publication_artifact_from_canonical_json.conformance] vector = "contracts/conformance/vectors/publication/phase1_artifact.v1.json" +case_kinds = [ + "publication_artifact.from_canonical_json.valid", + "publication_artifact.from_canonical_json.invalid", +] [operations.profile_build_authored_draft] domain = "profile" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -285,7 +285,7 @@ semver_impacts = [ "add_exported_constant", "add_conformance_vector", ] -summary = "Add an authored-only, canonical, tamper-evident persistence artifact for the closed Phase 1 Profile, root Post, typed Event, and FoodAvailability publication set." +summary = "Add the authored-only Phase 1 publication artifact with seven typed leaves, exact top-level NIP-01 id placement, explicit canonical-byte encode/decode operations, bounded signed-event and artifact wires, complete Blossom URL commitments, and a single-NUL domain-separated digest." [[changes]] id = "event-store-validity-visibility-split" diff --git a/crates/event_codec/README b/crates/event_codec/README @@ -2,10 +2,26 @@ The optional `serde_json` feature includes the sealed Phase 1 publication artifact. It accepts only the strict authored Profile, Update, PhotoUpdate, -Ask, date/time Event, and FoodAvailability models and emits bounded canonical -JSON suitable for persistence. Reloading validates the exact profile, NIP-01 -identifier, media inventory, and domain-separated digest, but does not restore -byte-verification, upload, signing, or authenticity capabilities. +Ask, date/time Event, and FoodAvailability models. The seven serialized leaves +are `profile`, `update`, `photo_update`, `ask`, `event_date`, `event_time`, and +`food_availability`; Event remains one semantic role with typed date and time +subvariants. + +Persistence crosses only the explicit `to_canonical_json` and +`from_canonical_json` operations. Schema version 1 orders the envelope as +`schema_version`, `semantic_variant`, `authored_operation_id`, +`event_contract_id`, `expected_author`, `draft`, `expected_event_id`, +`media_references`, and `artifact_digest`. Compact JSON is capped at 2,097,152 +bytes, the eventual signed-event wire at 262,144 bytes, and media commitments +at 4,096 complete canonical URLs. Primary byte-verified BUD-02 descriptor URLs +retain their required hash-path extension; post fallbacks may be extensionless. + +The artifact digest is SHA-256 over the ASCII domain +`radroots.phase1.publication-artifact.v1`, one literal NUL byte, and the exact +canonical envelope through `media_references`. Reloading rejects alternate +encodings, noncanonical identifiers or URL casing, profile drift, and digest +tampering, but does not restore byte verification, upload, signing, or +authenticity capabilities. This is the README for `radroots_event_codec`, which provides canonical event codecs and tag builders for the `radroots` core libraries. diff --git a/crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.json b/crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.json @@ -4,8 +4,8 @@ "authority_id": "phase1_publication_artifact_v1", "manifest_schema": { "path": "crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.schema.json", - "byte_length": 9568, - "sha256": "c8d25afc65de8c21a83decefa62036385cb826d58248cc070cd1948ffcab1ec4", + "byte_length": 11972, + "sha256": "1d72cee2754e7ac45105d79b1ecf7d44251991be7a18ba106166e962000e8320", "hash_algorithm": "sha256_bytes_v1" }, "predecessor": { @@ -13,7 +13,7 @@ "manifest": { "path": "crates/event_store/contracts/raw_source_rebuild_v1.manifest.json", "byte_length": 45449, - "sha256": "b8737a9c5836517114e7df6c2194c46e3c200093e12c4e6297165d2b9dae56a1", + "sha256": "cde4346fe1f3fce6ec97c7a6c17c4f7e96800456b1a0fdab2d9c86ad87c08b37", "hash_algorithm": "sha256_bytes_v1" } }, @@ -35,7 +35,8 @@ }, "artifact": { "schema_version": 1, - "semantic_variants": [ + "validator": "validate_phase1_publication_artifact", + "semantic_roles": [ "profile", "update", "photo_update", @@ -43,18 +44,57 @@ "event", "food_availability" ], + "serialized_semantic_variants": [ + "profile", + "update", + "photo_update", + "ask", + "event_date", + "event_time", + "food_availability" + ], "event_subvariants": [ "date", "time" ], "canonical_encoding": "serde_json_compact_struct_field_order_v1", - "artifact_max_bytes": 524288, + "artifact_max_bytes": 2097152, + "signed_event_wire_max_bytes": 262144, "media_reference_max_count": 4096, + "envelope_fields": [ + "schema_version", + "semantic_variant", + "authored_operation_id", + "event_contract_id", + "expected_author", + "draft", + "expected_event_id", + "media_references", + "artifact_digest" + ], + "draft_fields": [ + "created_at", + "kind", + "tags", + "content" + ], + "media_reference_fields": [ + "url", + "sha256", + "size", + "media_type" + ], + "media_reference_identity": "exact_case_preserving_approved_url_with_descriptor_commitment_v1", + "primary_media_url_requirement": "blossom_hash_path_extension_required_v1", + "post_fallback_url_requirement": "approved_blossom_url_extension_optional_v1", "digest_algorithm": "sha256_domain_nul_canonical_json_v1", - "digest_domain": "radroots.phase1.publication-artifact.v1\u0000", + "digest_domain": "radroots.phase1.publication-artifact.v1", + "digest_domain_terminator": "0x00", + "digest_preimage": "ascii_domain_then_single_nul_then_canonical_envelope_without_digest_v1", "constructors": [ { "semantic_variant": "profile", + "serialized_semantic_variant": "profile", "event_variant": null, "strict_input": "RadrootsAuthoredProfile", "constructor": "RadrootsPhase1PublicationArtifact::from_profile", @@ -65,6 +105,7 @@ }, { "semantic_variant": "update", + "serialized_semantic_variant": "update", "event_variant": null, "strict_input": "RadrootsAuthoredUpdate", "constructor": "RadrootsPhase1PublicationArtifact::from_update", @@ -75,6 +116,7 @@ }, { "semantic_variant": "photo_update", + "serialized_semantic_variant": "photo_update", "event_variant": null, "strict_input": "RadrootsAuthoredPhotoUpdate", "constructor": "RadrootsPhase1PublicationArtifact::from_photo_update", @@ -85,6 +127,7 @@ }, { "semantic_variant": "ask", + "serialized_semantic_variant": "ask", "event_variant": null, "strict_input": "RadrootsAuthoredAsk", "constructor": "RadrootsPhase1PublicationArtifact::from_ask", @@ -95,6 +138,7 @@ }, { "semantic_variant": "event", + "serialized_semantic_variant": "event_date", "event_variant": "date", "strict_input": "RadrootsAuthoredCalendarDateEvent", "constructor": "RadrootsPhase1PublicationArtifact::from_calendar_date_event", @@ -105,6 +149,7 @@ }, { "semantic_variant": "event", + "serialized_semantic_variant": "event_time", "event_variant": "time", "strict_input": "RadrootsAuthoredCalendarTimeEvent", "constructor": "RadrootsPhase1PublicationArtifact::from_calendar_time_event", @@ -115,6 +160,7 @@ }, { "semantic_variant": "food_availability", + "serialized_semantic_variant": "food_availability", "event_variant": null, "strict_input": "RadrootsFoodAvailabilityDetails", "constructor": "RadrootsPhase1PublicationArtifact::from_food_availability", @@ -124,23 +170,6 @@ "kind": 30402 } ], - "persisted_fields": [ - "schema_version", - "semantic_variant", - "authored_operation_id", - "event_contract_id", - "expected_author", - "draft.created_at", - "draft.kind", - "draft.tags", - "draft.content", - "draft.expected_event_id", - "media_references[].url", - "media_references[].sha256", - "media_references[].size", - "media_references[].media_type", - "artifact_digest" - ], "denied_inputs": [ "arbitrary_event_draft", "raw_json", @@ -218,7 +247,15 @@ "transport": "none" }, { - "id": "publication_artifact.reload", + "id": "publication_artifact.to_canonical_json", + "strict_input": "RadrootsPhase1PublicationArtifact", + "output": "Bytes", + "error_class": "none", + "signing": "none", + "transport": "none" + }, + { + "id": "publication_artifact.from_canonical_json", "strict_input": "Bytes", "output": "RadrootsPhase1PublicationArtifact", "error_class": "parse_error", @@ -245,8 +282,8 @@ { "role": "release_notes", "path": "CHANGELOG.md", - "byte_length": 29665, - "sha256": "9433606926a70efc33e89658d9a1cf807d1fe51c31d1135f5f3d4dd188546e5b", + "byte_length": 29887, + "sha256": "957fe99c958e3ee9fe1667dc481c715fbf409d9a8cf7bb6ee9f72bee73cfebf2", "hash_algorithm": "sha256_bytes_v1" }, { @@ -266,15 +303,15 @@ { "role": "operations_authority", "path": "contracts/operations.toml", - "byte_length": 73838, - "sha256": "45faa0dc3f71bc6b75c59e6746b25a2689e33cf09d6c6bbbaf8311defc9ef2d4", + "byte_length": 75859, + "sha256": "5bd81e76f4adaad23e1ff76576152802e6abd70607f65fcc39a737920da03aeb", "hash_algorithm": "sha256_bytes_v1" }, { "role": "release_authority", "path": "contracts/releases/1.0.0-alpha.1.toml", - "byte_length": 20216, - "sha256": "c7b5a9878c71720027f97357c11cfdd624acac9ccf7a6d6011c7a12d186708aa", + "byte_length": 20333, + "sha256": "8f00dacf80ca415c56ace3228f21c2ff9ec64c66bb5a3e585f9042e5dbde590a", "hash_algorithm": "sha256_bytes_v1" }, { @@ -910,8 +947,8 @@ { "role": "event_codec_documentation", "path": "crates/event_codec/README", - "byte_length": 20441, - "sha256": "b8a88edb2a82aaf06a33b7cdedb48ea9c5d66eee52e961bf5cbc1e709f0969f6", + "byte_length": 21360, + "sha256": "ed2bda152f594c3c9b751399870e0738c5debb12c63103e94862182c1bccf3a8", "hash_algorithm": "sha256_bytes_v1" }, { @@ -2142,15 +2179,15 @@ { "role": "public_production_source", "path": "crates/event_codec/src/wire/publication.rs", - "byte_length": 52207, - "sha256": "7edf56bf53680f14f0a684cf7ab34a85a4a4d367d858ab0dafdb7285a444d860", + "byte_length": 59609, + "sha256": "18046b34c831c56ceee0a21a65c48072a7b05de41f0087095ed6f0354335170e", "hash_algorithm": "sha256_bytes_v1" }, { "role": "publication_vector_executor", "path": "crates/event_codec/tests/publication_artifact.rs", - "byte_length": 24811, - "sha256": "1762d456f0c2958011fd66edf6e0bc8c19c7ebb5ace45a157fca3de1cfb606cd", + "byte_length": 34582, + "sha256": "7a31169eac4217a38cb3ef25eb9213f2f89e11fb17e76ceaf7449b34225e98af", "hash_algorithm": "sha256_bytes_v1" }, { @@ -2247,8 +2284,8 @@ { "role": "xtask_manifest_authority", "path": "tools/xtask/Cargo.toml", - "byte_length": 1097, - "sha256": "7e858f4f33913f986c565be2a31c41615ea0585c9e19572363ef5cae36cafdc9", + "byte_length": 1173, + "sha256": "b915e0289bf7390d3c4194aaed1e748cf5e591426e0443c310775ddc7d7f63a5", "hash_algorithm": "sha256_bytes_v1" }, { @@ -2268,15 +2305,15 @@ { "role": "publication_contract_governance", "path": "tools/xtask/src/contract/phase1_publication_artifact.rs", - "byte_length": 62736, - "sha256": "a52c8c6de697789176fbd1cc2e4b48795397f38b5bf1a43029520f5399f4d707", + "byte_length": 69965, + "sha256": "a636682f52e12920a93ef0709cac6ebf76a4ef68cba8e0d22390e8a7a81babe7", "hash_algorithm": "sha256_bytes_v1" }, { "role": "superseded_raw_rebuild_contract_governance", "path": "tools/xtask/src/contract/raw_source_rebuild.rs", - "byte_length": 294574, - "sha256": "e9b7b50fbe4e1d5890137dd634f7ddcece2fdd9aaf68904d4c4e3510d4ff5d05", + "byte_length": 297547, + "sha256": "e8cf84ba8f27432d0ba7aefa01fc61f9e37810d48f407fa2b9c7c969e5c76724", "hash_algorithm": "sha256_bytes_v1" }, { @@ -2290,8 +2327,8 @@ "result_vector": { "canonical_path": "contracts/conformance/vectors/publication/phase1_artifact.v1.json", "mirror_path": "crates/event_codec/tests/fixtures/phase1_publication_artifact.v1.json", - "byte_length": 11213, - "sha256": "dadf61674ae27672c22f924a9ff0636fce2d5eab6c37f8305c1c702e5a899ee8", + "byte_length": 23113, + "sha256": "ec18c687d5b0710a48624ddb620d89157e6b645dbea8bb91c62e3a111d20c622", "hash_algorithm": "sha256_bytes_v1", "executor_path": "crates/event_codec/tests/publication_artifact.rs", "executor_test": "publication_artifact_conformance_vector_executes_every_case", @@ -2302,12 +2339,21 @@ "ask_round_trip_with_fallback", "event_date_round_trip", "event_time_round_trip", - "food_availability_round_trip" + "food_availability_round_trip", + "canonical_json_serialization", + "canonical_json_reload" ], "invalid_case_ids": [ "leading_whitespace_is_noncanonical", + "artifact_exact_byte_limit_reaches_parser", + "artifact_one_byte_over_limit_is_rejected", "unknown_field_is_rejected", "unknown_draft_field_is_rejected", + "nested_expected_event_id_is_rejected", + "missing_expected_event_id_is_rejected", + "malformed_expected_event_id_is_rejected", + "uppercase_expected_event_id_is_rejected", + "duplicate_expected_event_id_is_rejected", "unknown_media_field_is_rejected", "json_field_order_is_noncanonical", "unknown_version_is_rejected", @@ -2326,6 +2372,7 @@ "media_order_is_rejected", "profile_media_commitment_tamper_is_rejected", "media_url_tamper_is_rejected", + "noncanonical_media_url_casing_is_rejected", "media_hash_tamper_is_rejected", "media_type_tamper_is_rejected", "post_media_commitment_must_match_imeta" diff --git a/crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.schema.json b/crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.schema.json @@ -33,6 +33,10 @@ "minLength": 1, "type": "string" }, + "serialized_semantic_variant": { + "minLength": 1, + "type": "string" + }, "strict_input": { "minLength": 1, "type": "string" @@ -40,6 +44,7 @@ }, "required": [ "semantic_variant", + "serialized_semantic_variant", "event_variant", "strict_input", "constructor", @@ -82,6 +87,7 @@ "properties": { "error_class": { "enum": [ + "none", "validation_error", "parse_error" ] @@ -91,7 +97,10 @@ "type": "string" }, "output": { - "const": "RadrootsPhase1PublicationArtifact" + "enum": [ + "RadrootsPhase1PublicationArtifact", + "Bytes" + ] }, "signing": { "const": "none" @@ -155,12 +164,10 @@ "additionalProperties": false, "properties": { "artifact_max_bytes": { - "minimum": 1, - "type": "integer" + "const": 2097152 }, "canonical_encoding": { - "minLength": 1, - "type": "string" + "const": "serde_json_compact_struct_field_order_v1" }, "constructors": { "items": { @@ -171,75 +178,140 @@ "type": "array" }, "denied_inputs": { - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" + "const": [ + "arbitrary_event_draft", + "raw_json", + "numeric_kind", + "signed_event", + "private_key", + "signer" + ] }, "digest_algorithm": { - "minLength": 1, - "type": "string" + "const": "sha256_domain_nul_canonical_json_v1" }, "digest_domain": { - "minLength": 1, - "type": "string" + "const": "radroots.phase1.publication-artifact.v1" + }, + "digest_domain_terminator": { + "const": "0x00" + }, + "digest_preimage": { + "const": "ascii_domain_then_single_nul_then_canonical_envelope_without_digest_v1" + }, + "draft_fields": { + "const": [ + "created_at", + "kind", + "tags", + "content" + ] + }, + "envelope_fields": { + "const": [ + "schema_version", + "semantic_variant", + "authored_operation_id", + "event_contract_id", + "expected_author", + "draft", + "expected_event_id", + "media_references", + "artifact_digest" + ] }, "event_subvariants": { - "items": { - "minLength": 1, - "type": "string" - }, - "maxItems": 2, - "minItems": 2, - "type": "array" + "const": [ + "date", + "time" + ] + }, + "media_reference_fields": { + "const": [ + "url", + "sha256", + "size", + "media_type" + ] + }, + "media_reference_identity": { + "const": "exact_case_preserving_approved_url_with_descriptor_commitment_v1" }, "media_reference_max_count": { - "minimum": 1, - "type": "integer" + "const": 4096 }, - "persisted_fields": { - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" + "post_fallback_url_requirement": { + "const": "approved_blossom_url_extension_optional_v1" + }, + "primary_media_url_requirement": { + "const": "blossom_hash_path_extension_required_v1" }, "reload_capability": { - "minLength": 1, - "type": "string" + "const": "persisted_artifact_only_no_prior_capability_restoration_v1" }, "schema_version": { "const": 1 }, - "semantic_variants": { - "items": { - "minLength": 1, - "type": "string" - }, - "maxItems": 6, - "minItems": 6, - "type": "array" + "semantic_roles": { + "const": [ + "profile", + "update", + "photo_update", + "ask", + "event", + "food_availability" + ] + }, + "serialized_semantic_variants": { + "const": [ + "profile", + "update", + "photo_update", + "ask", + "event_date", + "event_time", + "food_availability" + ] + }, + "signed_event_wire_max_bytes": { + "const": 262144 }, "threat_boundary": { - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" + "const": [ + "detects_accidental_corruption", + "detects_payload_only_modification", + "detects_digest_only_modification", + "does_not_authenticate_actor_rewriting_payload_and_digest", + "does_not_survive_validator_binary_or_host_compromise", + "does_not_restore_byte_verification_or_upload_completion", + "does_not_replace_nip01_id_and_signature_verification" + ] + }, + "validator": { + "const": "validate_phase1_publication_artifact" } }, "required": [ "schema_version", - "semantic_variants", + "validator", + "semantic_roles", + "serialized_semantic_variants", "event_subvariants", "canonical_encoding", "artifact_max_bytes", + "signed_event_wire_max_bytes", "media_reference_max_count", + "envelope_fields", + "draft_fields", + "media_reference_fields", + "media_reference_identity", + "primary_media_url_requirement", + "post_fallback_url_requirement", "digest_algorithm", "digest_domain", + "digest_domain_terminator", + "digest_preimage", "constructors", - "persisted_fields", "denied_inputs", "reload_capability", "threat_boundary" @@ -284,8 +356,8 @@ "items": { "$ref": "#/$defs/operation" }, - "maxItems": 8, - "minItems": 8, + "maxItems": 9, + "minItems": 9, "type": "array" }, "predecessor": { diff --git a/crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.sha256 b/crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.sha256 @@ -1 +1 @@ -d5182e42a61627c26535cdf7e337365d104e0a02b8202f2c973ef8a82e018d28 +fc0de2277097e49cf7afc6667d0c5862a4e83c2c9159c808ffd99baf3e2ba174 diff --git a/crates/event_codec/src/wire/publication.rs b/crates/event_codec/src/wire/publication.rs @@ -43,7 +43,8 @@ use radroots_event::{ RadrootsNip05Identifier, }, wire::{ - DEFAULT_CONTENT_MAX_BYTES, RadrootsNip01EventWireParts, compute_canonical_nip01_event_id, + DEFAULT_CONTENT_MAX_BYTES, DEFAULT_RAW_JSON_MAX_BYTES, RadrootsNip01EventWireParts, + compute_canonical_nip01_event_id, }, }; use serde::{Deserialize, Serialize}; @@ -57,10 +58,12 @@ use crate::{ }; pub const RADROOTS_PHASE1_PUBLICATION_ARTIFACT_SCHEMA_VERSION: u32 = 1; -pub const RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES: usize = 512 * 1024; +pub const RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES: usize = 2 * 1024 * 1024; pub const RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT: usize = 4096; +pub const RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES: usize = DEFAULT_RAW_JSON_MAX_BYTES; -const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"radroots.phase1.publication-artifact.v1\0"; +const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"radroots.phase1.publication-artifact.v1"; +const ARTIFACT_DIGEST_DOMAIN_TERMINATOR: &[u8] = b"\0"; const PROFILE_OPERATION_ID: &str = "profile.build_authored_draft"; const PROFILE_CONTRACT_ID: &str = "radroots.profile.metadata.v1"; const UPDATE_OPERATION_ID: &str = "social.update.build_authored_draft"; @@ -192,7 +195,6 @@ pub struct RadrootsPhase1PublicationDraft { kind: u32, tags: Vec<Vec<String>>, content: String, - expected_event_id: RadrootsEventId, } impl RadrootsPhase1PublicationDraft { @@ -211,10 +213,6 @@ impl RadrootsPhase1PublicationDraft { pub fn content(&self) -> &str { &self.content } - - pub fn expected_event_id(&self) -> &RadrootsEventId { - &self.expected_event_id - } } /// Persisted media commitment associated with one complete canonical URL. @@ -339,6 +337,7 @@ pub struct RadrootsPhase1PublicationArtifact { semantic_variant: RadrootsPhase1PublicationSemanticVariant, expected_author: RadrootsPublicKey, draft: RadrootsPhase1PublicationDraft, + expected_event_id: RadrootsEventId, media_references: Vec<RadrootsPhase1PublicationMediaReference>, artifact_digest: RadrootsPhase1PublicationArtifactDigest, canonical_json: Vec<u8>, @@ -498,6 +497,10 @@ impl RadrootsPhase1PublicationArtifact { &self.draft } + pub fn expected_event_id(&self) -> &RadrootsEventId { + &self.expected_event_id + } + pub fn media_references(&self) -> &[RadrootsPhase1PublicationMediaReference] { &self.media_references } @@ -506,10 +509,6 @@ impl RadrootsPhase1PublicationArtifact { self.artifact_digest } - pub fn canonical_json(&self) -> &[u8] { - &self.canonical_json - } - pub fn to_canonical_json(&self) -> Vec<u8> { self.canonical_json.clone() } @@ -548,16 +547,15 @@ impl RadrootsPhase1PublicationArtifact { }); } let expected_author = parse_expected_author(&wire.expected_author)?; - let expected_event_id = RadrootsEventId::parse(&wire.draft.expected_event_id) - .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidExpectedEventId)?; + let expected_event_id = parse_expected_event_id(&wire.expected_event_id)?; let draft = RadrootsPhase1PublicationDraft { created_at: wire.draft.created_at, kind: wire.draft.kind, tags: wire.draft.tags, content: wire.draft.content, - expected_event_id, }; - validate_draft_identifier(&expected_author, &draft)?; + validate_draft_identifier(&expected_author, &draft, &expected_event_id)?; + validate_signed_event_wire_size(&expected_author, &draft, &expected_event_id)?; if wire.media_references.len() > RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT { return Err( @@ -585,8 +583,13 @@ impl RadrootsPhase1PublicationArtifact { let artifact_digest = RadrootsPhase1PublicationArtifactDigest::parse(&wire.artifact_digest)?; - let computed = - compute_artifact_digest(variant, &expected_author, &draft, &media_references)?; + let computed = compute_artifact_digest( + variant, + &expected_author, + &draft, + &expected_event_id, + &media_references, + )?; if computed != artifact_digest { return Err(RadrootsPhase1PublicationArtifactError::DigestMismatch); } @@ -594,6 +597,7 @@ impl RadrootsPhase1PublicationArtifact { variant, &expected_author, &draft, + &expected_event_id, &media_references, artifact_digest, )?; @@ -604,6 +608,7 @@ impl RadrootsPhase1PublicationArtifact { semantic_variant: variant, expected_author, draft, + expected_event_id, media_references, artifact_digest, canonical_json, @@ -647,15 +652,21 @@ impl RadrootsPhase1PublicationArtifact { kind: parts.kind, tags: parts.tags, content: parts.content, - expected_event_id, }; + validate_signed_event_wire_size(&expected_author, &draft, &expected_event_id)?; validate_phase1_publication_profile(variant, &draft, &media_references)?; - let artifact_digest = - compute_artifact_digest(variant, &expected_author, &draft, &media_references)?; + let artifact_digest = compute_artifact_digest( + variant, + &expected_author, + &draft, + &expected_event_id, + &media_references, + )?; let canonical_json = serialize_artifact( variant, &expected_author, &draft, + &expected_event_id, &media_references, artifact_digest, )?; @@ -669,6 +680,7 @@ impl RadrootsPhase1PublicationArtifact { semantic_variant: variant, expected_author, draft, + expected_event_id, media_references, artifact_digest, canonical_json, @@ -681,8 +693,8 @@ impl RadrootsPhase1PublicationArtifact { pub fn validate_phase1_publication_artifact( artifact: &RadrootsPhase1PublicationArtifact, ) -> Result<(), RadrootsPhase1PublicationArtifactError> { - let reloaded = - RadrootsPhase1PublicationArtifact::from_canonical_json(artifact.canonical_json())?; + let canonical_json = artifact.to_canonical_json(); + let reloaded = RadrootsPhase1PublicationArtifact::from_canonical_json(&canonical_json)?; if &reloaded != artifact { return Err(RadrootsPhase1PublicationArtifactError::ArtifactStateMismatch); } @@ -693,6 +705,7 @@ pub fn validate_phase1_publication_artifact( #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsPhase1PublicationArtifactError { ArtifactTooLarge { max: usize, actual: usize }, + EventWireTooLarge { max: usize, actual: usize }, TooManyMediaReferences { max: usize, actual: usize }, InvalidJson, NonCanonicalJson, @@ -724,6 +737,7 @@ impl RadrootsPhase1PublicationArtifactError { pub const fn code(&self) -> &'static str { match self { Self::ArtifactTooLarge { .. } => "publication_artifact_too_large", + Self::EventWireTooLarge { .. } => "publication_event_wire_too_large", Self::TooManyMediaReferences { .. } => "publication_media_count_exceeded", Self::InvalidJson => "publication_artifact_invalid_json", Self::NonCanonicalJson => "publication_artifact_non_canonical_json", @@ -760,6 +774,10 @@ impl fmt::Display for RadrootsPhase1PublicationArtifactError { formatter, "publication artifact is {actual} bytes; maximum is {max}" ), + Self::EventWireTooLarge { max, actual } => write!( + formatter, + "publication event wire is {actual} bytes; maximum is {max}" + ), Self::TooManyMediaReferences { max, actual } => write!( formatter, "publication artifact has {actual} media references; maximum is {max}" @@ -801,9 +819,21 @@ fn parse_expected_author( Ok(author) } +fn parse_expected_event_id( + value: &str, +) -> Result<RadrootsEventId, RadrootsPhase1PublicationArtifactError> { + let event_id = RadrootsEventId::parse(value) + .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidExpectedEventId)?; + if event_id.as_str() != value { + return Err(RadrootsPhase1PublicationArtifactError::InvalidExpectedEventId); + } + Ok(event_id) +} + fn validate_draft_identifier( author: &RadrootsPublicKey, draft: &RadrootsPhase1PublicationDraft, + expected_event_id: &RadrootsEventId, ) -> Result<(), RadrootsPhase1PublicationArtifactError> { let computed = compute_canonical_nip01_event_id( author.as_str(), @@ -813,12 +843,57 @@ fn validate_draft_identifier( &draft.content, ) .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidDraft)?; - if computed != draft.expected_event_id { + if &computed != expected_event_id { return Err(RadrootsPhase1PublicationArtifactError::ExpectedEventIdMismatch); } Ok(()) } +#[derive(Serialize)] +struct SignedEventSizeWire<'a> { + id: &'a str, + pubkey: &'a str, + created_at: u64, + kind: u32, + tags: &'a [Vec<String>], + content: &'a str, + sig: &'a str, +} + +fn validate_signed_event_wire_size( + author: &RadrootsPublicKey, + draft: &RadrootsPhase1PublicationDraft, + expected_event_id: &RadrootsEventId, +) -> Result<(), RadrootsPhase1PublicationArtifactError> { + let actual = signed_event_wire_size(author, draft, expected_event_id)?; + if actual > RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES { + return Err(RadrootsPhase1PublicationArtifactError::EventWireTooLarge { + max: RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES, + actual, + }); + } + Ok(()) +} + +fn signed_event_wire_size( + author: &RadrootsPublicKey, + draft: &RadrootsPhase1PublicationDraft, + expected_event_id: &RadrootsEventId, +) -> Result<usize, RadrootsPhase1PublicationArtifactError> { + let signature = "0".repeat(128); + Ok(serde_json::to_vec(&SignedEventSizeWire { + id: expected_event_id.as_str(), + pubkey: author.as_str(), + created_at: draft.created_at, + kind: draft.kind, + tags: &draft.tags, + content: &draft.content, + sig: &signature, + }) + .map_err(|_| RadrootsPhase1PublicationArtifactError::Serialization)? + .len()) +} + fn post_media_references( images: &[radroots_event::post::RadrootsAuthoredPostImage], ) -> Vec<RadrootsPhase1PublicationMediaReference> { @@ -939,14 +1014,16 @@ fn validate_profile( if canonical != draft.content { return Err(RadrootsPhase1PublicationArtifactError::InvalidProfile); } - validate_media_urls( - content - .picture - .iter() - .chain(content.banner.iter()) - .map(String::as_str), - media, - ) + let urls = content + .picture + .iter() + .chain(content.banner.iter()) + .map(String::as_str) + .collect::<Vec<_>>(); + for url in &urls { + validate_primary_media_url(url, media)?; + } + validate_media_urls(urls.into_iter(), media) } fn validate_update( @@ -1023,6 +1100,7 @@ fn validate_post( urls.push(url); urls.extend(imeta.fallbacks().iter().map(String::as_str)); let reference = media_reference_for_url(media, url)?; + validate_primary_media_reference(reference)?; if reference.sha256.to_hex() != sha256 || reference.media_type.as_str() != media_type || reference.size != size @@ -1170,6 +1248,9 @@ fn validate_calendar_media( if common.legacy_name().is_some() { return Err(RadrootsPhase1PublicationArtifactError::InvalidCalendarProfile); } + if let Some(image) = common.image() { + validate_primary_media_url(image.as_str(), media)?; + } validate_media_urls(common.image().into_iter().map(|url| url.as_str()), media) } @@ -1224,6 +1305,7 @@ fn validate_food_availability( url.to_string(), dimensions.to_string(), ]); + validate_primary_media_url(url, media)?; urls.push(url); } if canonical != draft.tags || projection.content().as_str() != draft.content { @@ -1255,6 +1337,28 @@ fn media_reference_for_url<'a>( .ok_or(RadrootsPhase1PublicationArtifactError::MediaInventoryMismatch) } +fn validate_primary_media_url( + url: &str, + media: &[RadrootsPhase1PublicationMediaReference], +) -> Result<(), RadrootsPhase1PublicationArtifactError> { + validate_primary_media_reference(media_reference_for_url(media, url)?) +} + +fn validate_primary_media_reference( + reference: &RadrootsPhase1PublicationMediaReference, +) -> Result<(), RadrootsPhase1PublicationArtifactError> { + if reference + .url + .as_blob_url() + .hash_path() + .extension() + .is_none() + { + return Err(RadrootsPhase1PublicationArtifactError::InvalidMediaReference); + } + Ok(()) +} + fn validate_media_urls<'a>( urls: impl Iterator<Item = &'a str>, media: &[RadrootsPhase1PublicationMediaReference], @@ -1280,7 +1384,6 @@ struct DraftWire { kind: u32, tags: Vec<Vec<String>>, content: String, - expected_event_id: String, } #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1301,6 +1404,7 @@ struct ArtifactWire { event_contract_id: String, expected_author: String, draft: DraftWire, + expected_event_id: String, media_references: Vec<MediaReferenceWire>, artifact_digest: String, } @@ -1313,6 +1417,7 @@ struct ArtifactPayloadWire<'a> { event_contract_id: &'a str, expected_author: &'a str, draft: DraftWire, + expected_event_id: &'a str, media_references: Vec<MediaReferenceWire>, } @@ -1320,6 +1425,7 @@ fn artifact_payload_wire<'a>( variant: RadrootsPhase1PublicationSemanticVariant, author: &'a RadrootsPublicKey, draft: &RadrootsPhase1PublicationDraft, + expected_event_id: &'a RadrootsEventId, media: &[RadrootsPhase1PublicationMediaReference], ) -> ArtifactPayloadWire<'a> { ArtifactPayloadWire { @@ -1333,8 +1439,8 @@ fn artifact_payload_wire<'a>( kind: draft.kind, tags: draft.tags.clone(), content: draft.content.clone(), - expected_event_id: draft.expected_event_id.as_str().to_string(), }, + expected_event_id: expected_event_id.as_str(), media_references: media .iter() .map(RadrootsPhase1PublicationMediaReference::to_wire) @@ -1346,12 +1452,20 @@ fn compute_artifact_digest( variant: RadrootsPhase1PublicationSemanticVariant, author: &RadrootsPublicKey, draft: &RadrootsPhase1PublicationDraft, + expected_event_id: &RadrootsEventId, media: &[RadrootsPhase1PublicationMediaReference], ) -> Result<RadrootsPhase1PublicationArtifactDigest, RadrootsPhase1PublicationArtifactError> { - let payload = serde_json::to_vec(&artifact_payload_wire(variant, author, draft, media)) - .map_err(|_| RadrootsPhase1PublicationArtifactError::Serialization)?; + let payload = serde_json::to_vec(&artifact_payload_wire( + variant, + author, + draft, + expected_event_id, + media, + )) + .map_err(|_| RadrootsPhase1PublicationArtifactError::Serialization)?; let mut hasher = Sha256::new(); hasher.update(ARTIFACT_DIGEST_DOMAIN); + hasher.update(ARTIFACT_DIGEST_DOMAIN_TERMINATOR); hasher.update(payload); Ok(RadrootsPhase1PublicationArtifactDigest( hasher.finalize().into(), @@ -1362,10 +1476,11 @@ fn serialize_artifact( variant: RadrootsPhase1PublicationSemanticVariant, author: &RadrootsPublicKey, draft: &RadrootsPhase1PublicationDraft, + expected_event_id: &RadrootsEventId, media: &[RadrootsPhase1PublicationMediaReference], digest: RadrootsPhase1PublicationArtifactDigest, ) -> Result<Vec<u8>, RadrootsPhase1PublicationArtifactError> { - let payload = artifact_payload_wire(variant, author, draft, media); + let payload = artifact_payload_wire(variant, author, draft, expected_event_id, media); serde_json::to_vec(&ArtifactWire { schema_version: payload.schema_version, semantic_variant: payload.semantic_variant.to_string(), @@ -1373,8 +1488,109 @@ fn serialize_artifact( event_contract_id: payload.event_contract_id.to_string(), expected_author: payload.expected_author.to_string(), draft: payload.draft, + expected_event_id: payload.expected_event_id.to_string(), media_references: payload.media_references, artifact_digest: digest.to_hex(), }) .map_err(|_| RadrootsPhase1PublicationArtifactError::Serialization) } + +#[cfg(test)] +mod tests { + use super::*; + + const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn signed_event_wire_size_accepts_exact_limit_and_rejects_one_over() { + let author = RadrootsPublicKey::parse(AUTHOR).unwrap(); + let mut draft = RadrootsPhase1PublicationDraft { + created_at: 1_784_347_200, + kind: KIND_POST, + tags: Vec::new(), + content: String::new(), + }; + let empty_id = event_id(&author, &draft); + let base = signed_event_wire_size(&author, &draft, &empty_id).unwrap(); + let available = RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES - base; + draft.content = "\0".repeat(available / 6) + &"x".repeat(available % 6); + assert!(draft.content.len() <= DEFAULT_CONTENT_MAX_BYTES); + + let exact_id = event_id(&author, &draft); + assert_eq!( + signed_event_wire_size(&author, &draft, &exact_id).unwrap(), + RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES + ); + validate_signed_event_wire_size(&author, &draft, &exact_id).unwrap(); + + draft.content.push('x'); + let oversized_id = event_id(&author, &draft); + assert_eq!( + validate_signed_event_wire_size(&author, &draft, &oversized_id), + Err(RadrootsPhase1PublicationArtifactError::EventWireTooLarge { + max: RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES, + actual: RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES + 1, + }) + ); + } + + #[test] + fn publication_artifact_digest_has_exactly_one_nul_domain_terminator() { + let author = RadrootsPublicKey::parse(AUTHOR).unwrap(); + let draft = RadrootsPhase1PublicationDraft { + created_at: 1_784_347_200, + kind: KIND_POST, + tags: Vec::new(), + content: "Carrots harvested today".to_string(), + }; + let expected_event_id = event_id(&author, &draft); + let payload = serde_json::to_vec(&artifact_payload_wire( + RadrootsPhase1PublicationSemanticVariant::Update, + &author, + &draft, + &expected_event_id, + &[], + )) + .unwrap(); + let actual = compute_artifact_digest( + RadrootsPhase1PublicationSemanticVariant::Update, + &author, + &draft, + &expected_event_id, + &[], + ) + .unwrap(); + + let mut exact = Sha256::new(); + exact.update(b"radroots.phase1.publication-artifact.v1"); + exact.update([0]); + exact.update(&payload); + let exact: [u8; 32] = exact.finalize().into(); + assert_eq!(actual.as_bytes(), &exact); + + for prefix in [ + b"radroots.phase1.publication-artifact.v1".as_slice(), + b"radroots.phase1.publication-artifact.v1\0\0".as_slice(), + ] { + let mut alternate = Sha256::new(); + alternate.update(prefix); + alternate.update(&payload); + let alternate: [u8; 32] = alternate.finalize().into(); + assert_ne!(actual.as_bytes(), &alternate); + } + } + + fn event_id( + author: &RadrootsPublicKey, + draft: &RadrootsPhase1PublicationDraft, + ) -> RadrootsEventId { + compute_canonical_nip01_event_id( + author.as_str(), + draft.created_at, + draft.kind, + &draft.tags, + &draft.content, + ) + .unwrap() + } +} diff --git a/crates/event_codec/tests/fixtures/phase1_publication_artifact.v1.json b/crates/event_codec/tests/fixtures/phase1_publication_artifact.v1.json @@ -4,7 +4,7 @@ "vectors": [ { "id": "profile_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_profile.valid", "input": { "fixture": "profile" }, "expected": { "semantic_variant": "profile", @@ -13,14 +13,15 @@ "event_kind": 0, "media_count": 2, "expected_event_id": "a7d081b9c142e3a68245bafe1921b0e0dd88f886406e7165a9729f6c57026289", - "artifact_digest": "10db30ccd1ec4986a43f9ac2fefe201f582e8965ac0c36f0fe467b066194fa21", + "artifact_digest": "e296ed63312d58b4e3ee160d4ca3457eb922920d3116329ca498f08e402215b0", "canonical_json_bytes": 1305, - "canonical_json_sha256": "9ddaee58d68cce2a400880c62a2c3f4b3b11765a2136b3c930f474ec28680b43" + "canonical_json_sha256": "131d8131bb821179e6fd64c5201d01bee384451d09c20d789860439ef05620e8", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"profile\",\"authored_operation_id\":\"profile.build_authored_draft\",\"event_contract_id\":\"radroots.profile.metadata.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"victoria-farm\\\",\\\"display_name\\\":\\\"Victoria Farm\\\",\\\"about\\\":\\\"Seasonal produce from the Saanich Peninsula\\\",\\\"picture\\\":\\\"https://media.example/512f6a371c77694502e7d08f0b1f1080c7103ca90925bfe2fa23106aac11003a.png\\\",\\\"banner\\\":\\\"https://media.example/fbf3819415a76ea0d3ba71817578bb89c4903aa958e38f76f86578dfa8a35145.webp\\\",\\\"nip05\\\":\\\"farm@example.com\\\",\\\"bot\\\":false}\"},\"expected_event_id\":\"a7d081b9c142e3a68245bafe1921b0e0dd88f886406e7165a9729f6c57026289\",\"media_references\":[{\"url\":\"https://media.example/512f6a371c77694502e7d08f0b1f1080c7103ca90925bfe2fa23106aac11003a.png\",\"sha256\":\"512f6a371c77694502e7d08f0b1f1080c7103ca90925bfe2fa23106aac11003a\",\"size\":15,\"media_type\":\"image/png\"},{\"url\":\"https://media.example/fbf3819415a76ea0d3ba71817578bb89c4903aa958e38f76f86578dfa8a35145.webp\",\"sha256\":\"fbf3819415a76ea0d3ba71817578bb89c4903aa958e38f76f86578dfa8a35145\",\"size\":14,\"media_type\":\"image/webp\"}],\"artifact_digest\":\"e296ed63312d58b4e3ee160d4ca3457eb922920d3116329ca498f08e402215b0\"}" } }, { "id": "update_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_update.valid", "input": { "fixture": "update" }, "expected": { "semantic_variant": "update", @@ -29,14 +30,15 @@ "event_kind": 1, "media_count": 0, "expected_event_id": "7f2d9dbbd6d2f3db1e83338ea2e669b2458e62ded159de15a3fa98dcf9f164e3", - "artifact_digest": "345676a88a68663b16ab22f44117589f7a429aae0fbd6bd5f168e5fc7b2df8ab", + "artifact_digest": "9ad318496bd4a710fbc7f3e0f6d5a01de808d352db91ca56a12e710904784cc5", "canonical_json_bytes": 525, - "canonical_json_sha256": "69d0b7aaec8e2fb3d85a677ef9ac1b6d3eb56d05b9ba7b98737687bfba074110" + "canonical_json_sha256": "21be0d18a7f5812f7fa14600f46e0948480d2525eb1dcab416bb4ef99ca731c6", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"update\",\"authored_operation_id\":\"social.update.build_authored_draft\",\"event_contract_id\":\"radroots.social.update.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":1,\"tags\":[],\"content\":\"Carrots harvested today\"},\"expected_event_id\":\"7f2d9dbbd6d2f3db1e83338ea2e669b2458e62ded159de15a3fa98dcf9f164e3\",\"media_references\":[],\"artifact_digest\":\"9ad318496bd4a710fbc7f3e0f6d5a01de808d352db91ca56a12e710904784cc5\"}" } }, { "id": "photo_update_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_photo_update.valid", "input": { "fixture": "photo_update" }, "expected": { "semantic_variant": "photo_update", @@ -45,14 +47,15 @@ "event_kind": 1, "media_count": 2, "expected_event_id": "e592229776cd2a0d0e25a701f4629c0a5dc6e08481a235623d50da6ee8e8f438", - "artifact_digest": "e9b83a57a78adaf2740ec0fd819c8754d96ae325d5a6f785be99e0267fa29251", + "artifact_digest": "9ed6b7a6fcec3b07bd0c9179fd5b6ecf74517390051e3c2bd1659be62a26c197", "canonical_json_bytes": 1415, - "canonical_json_sha256": "ebb29fa48adda8660f35218d879bcf1535d5584bead441ba10f996b54bcdbc76" + "canonical_json_sha256": "38caab5242ae70fe2e36b39658dba7e6f723b2bd4fa512c8f166387c17faf69c", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"photo_update\",\"authored_operation_id\":\"social.photo_update.build_authored_draft\",\"event_contract_id\":\"radroots.social.photo_update.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"imeta\",\"url https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"x 51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"m image/webp\",\"dim 1200x900\",\"size 13\",\"alt Fresh strawberries\",\"fallback https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"]],\"content\":\"Strawberries at the farm stand https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"},\"expected_event_id\":\"e592229776cd2a0d0e25a701f4629c0a5dc6e08481a235623d50da6ee8e8f438\",\"media_references\":[{\"url\":\"https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"},{\"url\":\"https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"}],\"artifact_digest\":\"9ed6b7a6fcec3b07bd0c9179fd5b6ecf74517390051e3c2bd1659be62a26c197\"}" } }, { "id": "ask_round_trip_with_fallback", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_ask.valid", "input": { "fixture": "ask" }, "expected": { "semantic_variant": "ask", @@ -61,14 +64,15 @@ "event_kind": 1, "media_count": 2, "expected_event_id": "bd45fbbb5bfb8a5aa32df81d240e5f3bd56b0f8cde3a20b3f7d455801373fe48", - "artifact_digest": "cfb3c4c74ec56edb5d4bbbed6c08f33cf9202a16c12e3fd41e54839ba8fe1762", + "artifact_digest": "41eddea199bba97adebef4ce433ebfa05d97978ce8464ed3d52762ab717c2a3c", "canonical_json_bytes": 1411, - "canonical_json_sha256": "b7f3eec4930f40642e69c90ab293d2fc8f370c95286794c33d55cda942a1f696" + "canonical_json_sha256": "4985ad0919758e3747f0548409854deb77b9734825ab597623e5c6b566e2d5c7", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"ask\",\"authored_operation_id\":\"social.ask.build_authored_draft\",\"event_contract_id\":\"radroots.social.ask.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"x 51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"m image/webp\",\"dim 1200x900\",\"size 13\",\"alt Fresh strawberries\",\"fallback https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"]],\"content\":\"When will strawberries be ready? https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\"},\"expected_event_id\":\"bd45fbbb5bfb8a5aa32df81d240e5f3bd56b0f8cde3a20b3f7d455801373fe48\",\"media_references\":[{\"url\":\"https://backup.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"},{\"url\":\"https://media.example/51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0.webp\",\"sha256\":\"51bcf96cda2475475246e3a994aabfee7ff9d7694ba0db9bdc74408632827ad0\",\"size\":13,\"media_type\":\"image/webp\"}],\"artifact_digest\":\"41eddea199bba97adebef4ce433ebfa05d97978ce8464ed3d52762ab717c2a3c\"}" } }, { "id": "event_date_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_calendar_date_event.valid", "input": { "fixture": "event_date" }, "expected": { "semantic_variant": "event_date", @@ -77,14 +81,15 @@ "event_kind": 31922, "media_count": 1, "expected_event_id": "bea82662b6acf2f1272c8335a24bb0957e35d22c2b29d3e30c094ad7398dc7c7", - "artifact_digest": "0a5b30799263dff58fd88e5b581eca13b0eb324dfe2698471459821af847d040", + "artifact_digest": "0ec5162ab80988999aac62329d4cfc9c7efce6a82bbda539a723347d73596eb3", "canonical_json_bytes": 1013, - "canonical_json_sha256": "1eba894c4f19b4c4f9dc62e47feb4ce0b1c232b24bc57ca80aa29c007916efd1" + "canonical_json_sha256": "46b015e34556762f3d7ca592cd3dc6d4d5652ff085c7d8a8dc458d5897cf0b22", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"event_date\",\"authored_operation_id\":\"social.calendar_date_event.build_authored_draft\",\"event_contract_id\":\"radroots.calendar.date_event.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":31922,\"tags\":[[\"d\",\"farmers-market-2026\"],[\"title\",\"Moss Street Farmers Market\"],[\"start\",\"2026-07-25\"],[\"end\",\"2026-07-26\"],[\"location\",\"Victoria, BC\"],[\"image\",\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\"]],\"content\":\"Saturday market in Victoria\"},\"expected_event_id\":\"bea82662b6acf2f1272c8335a24bb0957e35d22c2b29d3e30c094ad7398dc7c7\",\"media_references\":[{\"url\":\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\",\"sha256\":\"0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8\",\"size\":10,\"media_type\":\"image/jpeg\"}],\"artifact_digest\":\"0ec5162ab80988999aac62329d4cfc9c7efce6a82bbda539a723347d73596eb3\"}" } }, { "id": "event_time_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_calendar_time_event.valid", "input": { "fixture": "event_time" }, "expected": { "semantic_variant": "event_time", @@ -93,14 +98,15 @@ "event_kind": 31923, "media_count": 1, "expected_event_id": "2088ed11cd8c1495a82e6f5198ed2ee98e0cbc599f22d7f3380892155dc55894", - "artifact_digest": "a0cdbfdbc6a7067ee65ce6564c5660435074bf85c42baaa52694a03833dcada9", + "artifact_digest": "56bfb4bed87559a58922343905a73c0c96dd0a57f94ff0955caecf0589198638", "canonical_json_bytes": 1013, - "canonical_json_sha256": "512561b622c5c86e8d5e28045778f0eb9abf8f9eab89cb7ada000d42c6ffd61a" + "canonical_json_sha256": "58e3c7e684bc774c35b08b246075076b5286d51639eb0651ed5b6362d958639e", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"event_time\",\"authored_operation_id\":\"social.calendar_time_event.build_authored_draft\",\"event_contract_id\":\"radroots.calendar.time_event.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":31923,\"tags\":[[\"d\",\"farm-tour-2026\"],[\"title\",\"Saanich Farm Tour\"],[\"start\",\"1785003600\"],[\"end\",\"1785007200\"],[\"D\",\"20659\"],[\"start_tzid\",\"America/Vancouver\"],[\"image\",\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\"]],\"content\":\"A one-hour farm tour\"},\"expected_event_id\":\"2088ed11cd8c1495a82e6f5198ed2ee98e0cbc599f22d7f3380892155dc55894\",\"media_references\":[{\"url\":\"https://events.example/0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8.jpeg\",\"sha256\":\"0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8\",\"size\":10,\"media_type\":\"image/jpeg\"}],\"artifact_digest\":\"56bfb4bed87559a58922343905a73c0c96dd0a57f94ff0955caecf0589198638\"}" } }, { "id": "food_availability_round_trip", - "kind": "publication_artifact.round_trip.valid", + "kind": "publication_artifact.build_food_availability.valid", "input": { "fixture": "food_availability" }, "expected": { "semantic_variant": "food_availability", @@ -109,152 +115,219 @@ "event_kind": 30402, "media_count": 1, "expected_event_id": "76b60f1b178c0dbdc14a87de36151ea6b04a866ce50e34a0ccae2f406f514e81", - "artifact_digest": "ff04d736df4b1dd01b17197ef0e9c88678133057130623377954332a9ce3e6b3", + "artifact_digest": "f0989cb993194af4a2f907ad8337e50378d2757c536a09afb4055a681043a7d8", "canonical_json_bytes": 1132, - "canonical_json_sha256": "b7d55bf0c75acdae53f670cd6a263c036fe619c73601eac5a03c6ef58d91695f" + "canonical_json_sha256": "525dc96e87b79b1ef3aa67c711cccd8b8e6c205665770ebcb8283035b7b496da", + "canonical_json": "{\"schema_version\":1,\"semantic_variant\":\"food_availability\",\"authored_operation_id\":\"food_availability.build_authored_draft\",\"event_contract_id\":\"radroots.food.availability.v1\",\"expected_author\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"draft\":{\"created_at\":1784347200,\"kind\":30402,\"tags\":[[\"d\",\"nantes-carrots\"],[\"title\",\"Nantes Carrots\"],[\"summary\",\"Fresh bunches\"],[\"published_at\",\"1784347140\"],[\"location\",\"Central Saanich, BC\"],[\"price\",\"3\",\"CAD\"],[\"radroots:price_unit\",\"lb\"],[\"radroots:quantity\",\"24\",\"lb\"],[\"status\",\"active\"],[\"image\",\"https://food.example/8a66441b557b29193781023160b0216c5518e9b84749f29ecf118a728f451a5e.png\",\"1200x800\"]],\"content\":\"Fresh Nantes carrots available this week.\"},\"expected_event_id\":\"76b60f1b178c0dbdc14a87de36151ea6b04a866ce50e34a0ccae2f406f514e81\",\"media_references\":[{\"url\":\"https://food.example/8a66441b557b29193781023160b0216c5518e9b84749f29ecf118a728f451a5e.png\",\"sha256\":\"8a66441b557b29193781023160b0216c5518e9b84749f29ecf118a728f451a5e\",\"size\":14,\"media_type\":\"image/png\"}],\"artifact_digest\":\"f0989cb993194af4a2f907ad8337e50378d2757c536a09afb4055a681043a7d8\"}" + } + }, + { + "id": "canonical_json_serialization", + "kind": "publication_artifact.to_canonical_json.valid", + "input": { "fixture": "profile" }, + "expected": { + "canonical_json_bytes": 1305, + "canonical_json_sha256": "131d8131bb821179e6fd64c5201d01bee384451d09c20d789860439ef05620e8" + } + }, + { + "id": "canonical_json_reload", + "kind": "publication_artifact.from_canonical_json.valid", + "input": { "fixture": "profile" }, + "expected": { + "semantic_variant": "profile", + "canonical_json_sha256": "131d8131bb821179e6fd64c5201d01bee384451d09c20d789860439ef05620e8" } }, { "id": "leading_whitespace_is_noncanonical", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "leading_whitespace" }, "expected": { "error": "publication_artifact_non_canonical_json" } }, { + "id": "artifact_exact_byte_limit_reaches_parser", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "artifact_exact_byte_limit" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { + "id": "artifact_one_byte_over_limit_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "artifact_one_over_byte_limit" }, + "expected": { "error": "publication_artifact_too_large" } + }, + { "id": "unknown_field_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "unknown_field" }, "expected": { "error": "publication_artifact_invalid_json" } }, { "id": "unknown_draft_field_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "unknown_draft_field" }, "expected": { "error": "publication_artifact_invalid_json" } }, { + "id": "nested_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "nested_expected_event_id" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { + "id": "missing_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "missing_expected_event_id" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { + "id": "malformed_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "malformed_expected_event_id" }, + "expected": { "error": "publication_expected_event_id_invalid" } + }, + { + "id": "uppercase_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "uppercase_expected_event_id" }, + "expected": { "error": "publication_expected_event_id_invalid" } + }, + { + "id": "duplicate_expected_event_id_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "duplicate_expected_event_id" }, + "expected": { "error": "publication_artifact_invalid_json" } + }, + { "id": "unknown_media_field_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "unknown_media_field" }, "expected": { "error": "publication_artifact_invalid_json" } }, { "id": "json_field_order_is_noncanonical", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "json_field_order" }, "expected": { "error": "publication_artifact_non_canonical_json" } }, { "id": "unknown_version_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "schema_version" }, "expected": { "error": "publication_artifact_version_unsupported" } }, { "id": "cross_variant_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "all_cross_variants" }, "expected": { "error": "publication_authored_operation_mismatch" } }, { "id": "operation_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "operation_id" }, "expected": { "error": "publication_authored_operation_mismatch" } }, { "id": "contract_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "contract_id" }, "expected": { "error": "publication_event_contract_mismatch" } }, { "id": "kind_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "kind" }, "expected": { "error": "publication_kind_mismatch" } }, { "id": "author_mismatch_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "author" }, "expected": { "error": "publication_expected_author_invalid" } }, { "id": "created_at_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "created_at" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "draft_tags_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "draft_tags" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "draft_content_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "draft_content" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "noncanonical_nip05_is_rejected_after_id_rebuild", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "noncanonical_nip05" }, "expected": { "error": "publication_profile_invalid" } }, { "id": "empty_ask_content_is_rejected_after_id_rebuild", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "ask", "mutation": "empty_ask_content" }, "expected": { "error": "publication_post_profile_invalid" } }, { "id": "expected_event_id_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "expected_event_id" }, "expected": { "error": "publication_expected_event_id_mismatch" } }, { "id": "digest_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "digest" }, "expected": { "error": "publication_artifact_digest_mismatch" } }, { "id": "media_order_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_order" }, "expected": { "error": "publication_media_inventory_non_canonical" } }, { "id": "profile_media_commitment_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_size" }, "expected": { "error": "publication_artifact_digest_mismatch" } }, { "id": "media_url_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_url" }, "expected": { "error": "publication_media_inventory_mismatch" } }, { + "id": "noncanonical_media_url_casing_is_rejected", + "kind": "publication_artifact.from_canonical_json.invalid", + "input": { "fixture": "profile", "mutation": "media_url_casing" }, + "expected": { "error": "publication_media_reference_invalid" } + }, + { "id": "media_hash_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_hash" }, "expected": { "error": "publication_media_reference_invalid" } }, { "id": "media_type_tamper_is_rejected", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "profile", "mutation": "media_type" }, "expected": { "error": "publication_artifact_digest_mismatch" } }, { "id": "post_media_commitment_must_match_imeta", - "kind": "publication_artifact.reload.invalid", + "kind": "publication_artifact.from_canonical_json.invalid", "input": { "fixture": "photo_update", "mutation": "media_size" }, "expected": { "error": "publication_media_inventory_mismatch" } } diff --git a/crates/event_codec/tests/publication_artifact.rs b/crates/event_codec/tests/publication_artifact.rs @@ -24,9 +24,10 @@ use radroots_event::{ wire::compute_canonical_nip01_event_id, }; use radroots_event_codec::wire::publication::{ - RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES, RadrootsPhase1PublicationArtifact, - RadrootsPhase1PublicationArtifactError, RadrootsPhase1PublicationEventVariant, - RadrootsPhase1PublicationSemanticVariant, validate_phase1_publication_artifact, + RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES, RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT, + RadrootsPhase1PublicationArtifact, RadrootsPhase1PublicationArtifactError, + RadrootsPhase1PublicationEventVariant, RadrootsPhase1PublicationSemanticVariant, + validate_phase1_publication_artifact, }; use serde::Deserialize; use serde_json::Value; @@ -69,6 +70,7 @@ struct VectorExpected { artifact_digest: Option<String>, canonical_json_bytes: Option<usize>, canonical_json_sha256: Option<String>, + canonical_json: Option<String>, error: Option<String>, } @@ -77,13 +79,13 @@ fn publication_artifact_conformance_vector_executes_every_case() { let suite: VectorSuite = serde_json::from_str(PUBLICATION_ARTIFACT_VECTOR).unwrap(); assert_eq!(suite.suite, "phase1_publication_artifact"); assert_eq!(suite.contract_version, "1.0.0"); - assert_eq!(suite.vectors.len(), 31); + assert_eq!(suite.vectors.len(), 41); let artifacts = all_artifacts(); for case in suite.vectors { let artifact = artifact_fixture(&artifacts, &case.input.fixture); match case.kind.as_str() { - "publication_artifact.round_trip.valid" => { + kind if kind.starts_with("publication_artifact.build_") && kind.ends_with(".valid") => { assert_eq!(case.input.mutation, None, "{}", case.id); assert_eq!( artifact.semantic_variant().as_str(), @@ -116,7 +118,7 @@ fn publication_artifact_conformance_vector_executes_every_case() { case.id ); assert_eq!( - artifact.draft().expected_event_id().as_str(), + artifact.expected_event_id().as_str(), case.expected.expected_event_id.as_deref().unwrap(), "{}", case.id @@ -128,20 +130,26 @@ fn publication_artifact_conformance_vector_executes_every_case() { case.id ); assert_eq!( - artifact.canonical_json().len(), + artifact.to_canonical_json().len(), case.expected.canonical_json_bytes.unwrap(), "{}", case.id ); assert_eq!( - RadrootsBlossomSha256::digest(artifact.canonical_json()).to_hex(), + RadrootsBlossomSha256::digest(&artifact.to_canonical_json()).to_hex(), case.expected.canonical_json_sha256.unwrap(), "{}", case.id ); assert_eq!( + artifact.to_canonical_json(), + case.expected.canonical_json.unwrap().as_bytes(), + "{}", + case.id + ); + assert_eq!( RadrootsPhase1PublicationArtifact::from_canonical_json( - artifact.canonical_json() + &artifact.to_canonical_json() ) .unwrap(), *artifact, @@ -149,7 +157,42 @@ fn publication_artifact_conformance_vector_executes_every_case() { case.id ); } - "publication_artifact.reload.invalid" => { + "publication_artifact.to_canonical_json.valid" => { + assert_eq!(case.input.mutation, None, "{}", case.id); + let bytes = artifact.to_canonical_json(); + assert_eq!( + bytes.len(), + case.expected.canonical_json_bytes.unwrap(), + "{}", + case.id + ); + assert_eq!( + RadrootsBlossomSha256::digest(&bytes).to_hex(), + case.expected.canonical_json_sha256.unwrap(), + "{}", + case.id + ); + } + "publication_artifact.from_canonical_json.valid" => { + assert_eq!(case.input.mutation, None, "{}", case.id); + let bytes = artifact.to_canonical_json(); + let reloaded = + RadrootsPhase1PublicationArtifact::from_canonical_json(&bytes).unwrap(); + assert_eq!( + reloaded.semantic_variant().as_str(), + case.expected.semantic_variant.as_deref().unwrap(), + "{}", + case.id + ); + assert_eq!( + RadrootsBlossomSha256::digest(&reloaded.to_canonical_json()).to_hex(), + case.expected.canonical_json_sha256.unwrap(), + "{}", + case.id + ); + assert_eq!(reloaded, *artifact, "{}", case.id); + } + "publication_artifact.from_canonical_json.invalid" => { if case.input.mutation.as_deref() == Some("all_cross_variants") { assert_every_cross_variant_is_rejected( &artifacts, @@ -159,7 +202,7 @@ fn publication_artifact_conformance_vector_executes_every_case() { continue; } let bytes = mutate_artifact( - artifact.canonical_json(), + &artifact.to_canonical_json(), case.input.mutation.as_deref().unwrap(), ); assert_eq!( @@ -244,12 +287,12 @@ fn publication_artifact_round_trips_every_closed_variant() { assert_eq!(artifact.expected_author().as_str(), AUTHOR); assert_eq!(artifact.draft().kind(), kind); assert_eq!(artifact.media_references().len(), media_count); - assert!(artifact.canonical_json().len() <= RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES); + let canonical_json = artifact.to_canonical_json(); + assert!(canonical_json.len() <= RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES); let reloaded = - RadrootsPhase1PublicationArtifact::from_canonical_json(artifact.canonical_json()) - .unwrap(); + RadrootsPhase1PublicationArtifact::from_canonical_json(&canonical_json).unwrap(); assert_eq!(&reloaded, artifact); - assert_eq!(reloaded.to_canonical_json(), artifact.canonical_json()); + assert_eq!(reloaded.to_canonical_json(), canonical_json); validate_phase1_publication_artifact(artifact).unwrap(); } } @@ -285,7 +328,8 @@ fn publication_artifact_accepts_text_only_ask() { RadrootsPhase1PublicationSemanticVariant::Ask ); assert_eq!( - RadrootsPhase1PublicationArtifact::from_canonical_json(artifact.canonical_json()).unwrap(), + RadrootsPhase1PublicationArtifact::from_canonical_json(&artifact.to_canonical_json()) + .unwrap(), artifact ); } @@ -293,10 +337,10 @@ fn publication_artifact_accepts_text_only_ask() { #[test] fn publication_artifact_reload_rejects_cross_variant_and_every_envelope_tamper() { let artifact = &all_artifacts()[0]; - let canonical = artifact.canonical_json(); + let canonical = artifact.to_canonical_json(); let mut leading_space = vec![b' ']; - leading_space.extend_from_slice(canonical); + leading_space.extend_from_slice(&canonical); assert_error( &leading_space, RadrootsPhase1PublicationArtifactError::NonCanonicalJson, @@ -337,26 +381,26 @@ fn publication_artifact_reload_rejects_cross_variant_and_every_envelope_tamper() RadrootsPhase1PublicationArtifactError::DigestMismatch, ), ] { - let mut value: Value = serde_json::from_slice(canonical).unwrap(); + let mut value: Value = serde_json::from_slice(&canonical).unwrap(); value[field] = replacement; assert_error(&serde_json::to_vec(&value).unwrap(), expected); } - let mut value: Value = serde_json::from_slice(canonical).unwrap(); + let mut value: Value = serde_json::from_slice(&canonical).unwrap(); value["draft"]["content"] = Value::from("changed"); assert_error( &serde_json::to_vec(&value).unwrap(), RadrootsPhase1PublicationArtifactError::ExpectedEventIdMismatch, ); - let mut value: Value = serde_json::from_slice(canonical).unwrap(); - value["draft"]["expected_event_id"] = Value::from("00".repeat(32)); + let mut value: Value = serde_json::from_slice(&canonical).unwrap(); + value["expected_event_id"] = Value::from("00".repeat(32)); assert_error( &serde_json::to_vec(&value).unwrap(), RadrootsPhase1PublicationArtifactError::ExpectedEventIdMismatch, ); - let mut value: Value = serde_json::from_slice(canonical).unwrap(); + let mut value: Value = serde_json::from_slice(&canonical).unwrap(); value["unknown"] = Value::Bool(true); assert_error( &serde_json::to_vec(&value).unwrap(), @@ -365,16 +409,60 @@ fn publication_artifact_reload_rejects_cross_variant_and_every_envelope_tamper() } #[test] +fn publication_artifact_envelope_uses_the_exact_contract_field_order() { + let canonical = String::from_utf8(all_artifacts()[0].to_canonical_json()).unwrap(); + let fields = [ + "\"schema_version\"", + "\"semantic_variant\"", + "\"authored_operation_id\"", + "\"event_contract_id\"", + "\"expected_author\"", + "\"draft\"", + "\"expected_event_id\"", + "\"media_references\"", + "\"artifact_digest\"", + ]; + let positions = fields.map(|field| { + canonical + .find(field) + .unwrap_or_else(|| panic!("missing {field}")) + }); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); + + let draft_start = canonical.find("\"draft\":{").unwrap(); + let draft_end = canonical[draft_start..] + .find("},\"expected_event_id\"") + .map(|offset| draft_start + offset) + .unwrap(); + let draft = &canonical[draft_start..draft_end]; + let draft_positions = ["\"created_at\"", "\"kind\"", "\"tags\"", "\"content\""].map(|field| { + draft + .find(field) + .unwrap_or_else(|| panic!("missing {field}")) + }); + assert!(draft_positions.windows(2).all(|pair| pair[0] < pair[1])); + let media = &canonical[positions[7]..positions[8]]; + let media_positions = ["\"url\"", "\"sha256\"", "\"size\"", "\"media_type\""].map(|field| { + media + .find(field) + .unwrap_or_else(|| panic!("missing {field}")) + }); + assert!(media_positions.windows(2).all(|pair| pair[0] < pair[1])); + let value: Value = serde_json::from_str(&canonical).unwrap(); + assert!(value["expected_event_id"].is_string()); +} + +#[test] fn publication_artifact_reload_rejects_media_order_and_commitment_tamper() { let artifact = &all_artifacts()[0]; - let mut value: Value = serde_json::from_slice(artifact.canonical_json()).unwrap(); + let mut value: Value = serde_json::from_slice(&artifact.to_canonical_json()).unwrap(); value["media_references"].as_array_mut().unwrap().reverse(); assert_error( &serde_json::to_vec(&value).unwrap(), RadrootsPhase1PublicationArtifactError::NonCanonicalMediaInventory, ); - let mut value: Value = serde_json::from_slice(artifact.canonical_json()).unwrap(); + let mut value: Value = serde_json::from_slice(&artifact.to_canonical_json()).unwrap(); value["media_references"][0]["size"] = Value::from(999); assert_error( &serde_json::to_vec(&value).unwrap(), @@ -382,7 +470,7 @@ fn publication_artifact_reload_rejects_media_order_and_commitment_tamper() { ); let photo = &all_artifacts()[2]; - let mut value: Value = serde_json::from_slice(photo.canonical_json()).unwrap(); + let mut value: Value = serde_json::from_slice(&photo.to_canonical_json()).unwrap(); value["media_references"][0]["size"] = Value::from(999); assert_error( &serde_json::to_vec(&value).unwrap(), @@ -392,6 +480,12 @@ fn publication_artifact_reload_rejects_media_order_and_commitment_tamper() { #[test] fn publication_artifact_decode_is_bounded_before_json_parsing() { + let exact = vec![b' '; RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES]; + assert_eq!( + RadrootsPhase1PublicationArtifact::from_canonical_json(&exact).unwrap_err(), + RadrootsPhase1PublicationArtifactError::InvalidJson + ); + let bytes = vec![b' '; RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES + 1]; assert_eq!( RadrootsPhase1PublicationArtifact::from_canonical_json(&bytes).unwrap_err(), @@ -402,6 +496,106 @@ fn publication_artifact_decode_is_bounded_before_json_parsing() { ); } +#[test] +fn publication_artifact_media_count_accepts_exact_limit_and_rejects_one_over() { + let artifact = &all_artifacts()[1]; + let mut value: Value = serde_json::from_slice(&artifact.to_canonical_json()).unwrap(); + let sha256 = "11".repeat(32); + let references = (0..RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT) + .map(|index| { + serde_json::json!({ + "url": format!("https://media-{index:04}.example/{sha256}.png"), + "sha256": sha256.clone(), + "size": 1, + "media_type": "image/png" + }) + }) + .collect::<Vec<_>>(); + value["media_references"] = Value::Array(references.clone()); + assert_error( + &serde_json::to_vec(&value).unwrap(), + RadrootsPhase1PublicationArtifactError::InvalidPostProfile, + ); + + let mut one_over = references; + one_over.push(serde_json::json!({ + "url": format!("https://media-4096.example/{sha256}.png"), + "sha256": sha256, + "size": 1, + "media_type": "image/png" + })); + value["media_references"] = Value::Array(one_over); + assert_error( + &serde_json::to_vec(&value).unwrap(), + RadrootsPhase1PublicationArtifactError::TooManyMediaReferences { + max: RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT, + actual: RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT + 1, + }, + ); +} + +#[test] +fn publication_artifact_reload_requires_extensions_for_primary_media() { + let artifacts = all_artifacts(); + for (index, host) in [ + (0, "media.example"), + (2, "media.example"), + (4, "events.example"), + (6, "food.example"), + ] { + let mut value: Value = + serde_json::from_slice(&artifacts[index].to_canonical_json()).unwrap(); + let reference = value["media_references"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|reference| reference["url"].as_str().unwrap().contains(host)) + .unwrap(); + let original = reference["url"].as_str().unwrap().to_string(); + let extension_start = original.rfind('.').unwrap(); + let extensionless = original[..extension_start].to_string(); + reference["url"] = Value::from(extensionless.clone()); + replace_json_string(&mut value["draft"], &original, &extensionless); + rebuild_expected_event_id(&mut value); + assert_error( + &serde_json::to_vec(&value).unwrap(), + RadrootsPhase1PublicationArtifactError::InvalidMediaReference, + ); + } +} + +#[test] +fn publication_artifact_reload_accepts_extensionless_post_fallback() { + let image = authored_image( + b"extensionless-fallback", + "media.example", + "webp", + "image/webp", + ); + let hash = image.descriptor().sha256(); + let fallback = RadrootsBlossomBlobUrl::parse(&format!("https://backup.example/{hash}")) + .unwrap() + .approve() + .unwrap(); + let post_image = RadrootsAuthoredPostImage::new( + image, + RadrootsPostImageDimensions::new(1200, 900).unwrap(), + "Fresh strawberries", + ) + .unwrap() + .try_with_fallback(fallback) + .unwrap(); + let url = post_image.url().to_string(); + let ask = + RadrootsAuthoredAsk::new(format!("Available this week? {url}"), vec![post_image]).unwrap(); + let artifact = RadrootsPhase1PublicationArtifact::from_ask(&ask, CREATED_AT, AUTHOR).unwrap(); + assert_eq!( + RadrootsPhase1PublicationArtifact::from_canonical_json(&artifact.to_canonical_json()) + .unwrap(), + artifact + ); +} + fn assert_error(bytes: &[u8], expected: RadrootsPhase1PublicationArtifactError) { assert_eq!( RadrootsPhase1PublicationArtifact::from_canonical_json(bytes).unwrap_err(), @@ -429,7 +623,7 @@ fn assert_every_cross_variant_is_rejected( if target == artifact.semantic_variant().as_str() { continue; } - let mut value: Value = serde_json::from_slice(artifact.canonical_json()).unwrap(); + let mut value: Value = serde_json::from_slice(&artifact.to_canonical_json()).unwrap(); value["semantic_variant"] = Value::from(target); let error = RadrootsPhase1PublicationArtifact::from_canonical_json( &serde_json::to_vec(&value).unwrap(), @@ -460,15 +654,44 @@ fn artifact_fixture<'a>( } fn mutate_artifact(canonical: &[u8], mutation: &str) -> Vec<u8> { - if mutation == "leading_whitespace" { - let mut bytes = vec![b' ']; - bytes.extend_from_slice(canonical); - return bytes; + match mutation { + "leading_whitespace" => { + let mut bytes = vec![b' ']; + bytes.extend_from_slice(canonical); + return bytes; + } + "artifact_exact_byte_limit" => { + return vec![b' '; RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES]; + } + "artifact_one_over_byte_limit" => { + return vec![b' '; RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES + 1]; + } + "duplicate_expected_event_id" => { + return duplicate_expected_event_id(canonical); + } + _ => {} } let mut value: Value = serde_json::from_slice(canonical).unwrap(); match mutation { "unknown_field" => value["unknown"] = Value::Bool(true), "unknown_draft_field" => value["draft"]["unknown"] = Value::Bool(true), + "nested_expected_event_id" => { + value["draft"]["expected_event_id"] = value["expected_event_id"].clone(); + } + "missing_expected_event_id" => { + value.as_object_mut().unwrap().remove("expected_event_id"); + } + "malformed_expected_event_id" => { + value["expected_event_id"] = Value::from("not-an-event-id"); + } + "uppercase_expected_event_id" => { + value["expected_event_id"] = Value::from( + value["expected_event_id"] + .as_str() + .unwrap() + .to_ascii_uppercase(), + ); + } "unknown_media_field" => value["media_references"][0]["unknown"] = Value::Bool(true), "json_field_order" => {} "schema_version" => value["schema_version"] = Value::from(2), @@ -497,7 +720,7 @@ fn mutate_artifact(canonical: &[u8], mutation: &str) -> Vec<u8> { rebuild_expected_event_id(&mut value); } "expected_event_id" => { - value["draft"]["expected_event_id"] = Value::from("00".repeat(32)); + value["expected_event_id"] = Value::from("00".repeat(32)); } "digest" => value["artifact_digest"] = Value::from("00".repeat(32)), "media_order" => value["media_references"].as_array_mut().unwrap().reverse(), @@ -507,6 +730,11 @@ fn mutate_artifact(canonical: &[u8], mutation: &str) -> Vec<u8> { value["media_references"][0]["url"] = Value::from(url.replacen("https://media.example", "https://alternate.example", 1)); } + "media_url_casing" => { + let url = value["media_references"][0]["url"].as_str().unwrap(); + value["media_references"][0]["url"] = + Value::from(url.replacen("https://", "HTTPS://", 1)); + } "media_hash" => value["media_references"][0]["sha256"] = Value::from("00".repeat(32)), "media_type" => value["media_references"][0]["media_type"] = Value::from("image/jpeg"), other => panic!("unknown publication mutation {other}"), @@ -514,19 +742,47 @@ fn mutate_artifact(canonical: &[u8], mutation: &str) -> Vec<u8> { serde_json::to_vec(&value).unwrap() } +fn duplicate_expected_event_id(canonical: &[u8]) -> Vec<u8> { + let value: Value = serde_json::from_slice(canonical).unwrap(); + let event_id = value["expected_event_id"].as_str().unwrap(); + let field = format!("\"expected_event_id\":\"{event_id}\""); + let duplicate = format!("{field},{field}"); + let canonical = core::str::from_utf8(canonical).unwrap(); + let mutated = canonical.replacen(&field, &duplicate, 1); + assert_ne!(mutated, canonical); + mutated.into_bytes() +} + fn rebuild_expected_event_id(value: &mut Value) { let author = value["expected_author"].as_str().unwrap(); let created_at = value["draft"]["created_at"].as_u64().unwrap(); let kind = value["draft"]["kind"].as_u64().unwrap() as u32; let tags: Vec<Vec<String>> = serde_json::from_value(value["draft"]["tags"].clone()).unwrap(); let content = value["draft"]["content"].as_str().unwrap(); - value["draft"]["expected_event_id"] = Value::from( + value["expected_event_id"] = Value::from( compute_canonical_nip01_event_id(author, created_at, kind, &tags, content) .unwrap() .to_string(), ); } +fn replace_json_string(value: &mut Value, from: &str, to: &str) { + match value { + Value::String(string) => *string = string.replace(from, to), + Value::Array(values) => { + for value in values { + replace_json_string(value, from, to); + } + } + Value::Object(values) => { + for value in values.values_mut() { + replace_json_string(value, from, to); + } + } + _ => {} + } +} + fn all_artifacts() -> Vec<RadrootsPhase1PublicationArtifact> { let picture = authored_image(b"profile-picture", "media.example", "png", "image/png"); let banner = authored_image(b"profile-banner", "media.example", "webp", "image/webp"); diff --git a/tools/xtask/src/contract/phase1_publication_artifact.rs b/tools/xtask/src/contract/phase1_publication_artifact.rs @@ -5,6 +5,7 @@ use radroots_event_codec::wire::publication::{ RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES, RADROOTS_PHASE1_PUBLICATION_ARTIFACT_SCHEMA_VERSION, RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT, + RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -130,20 +131,58 @@ const PUBLIC_TYPES: &[&str] = &[ "RadrootsPhase1PublicationArtifactError", ]; -const VALID_VECTOR_IDS: &[&str] = &[ - "profile_round_trip", - "update_round_trip", - "photo_update_round_trip", - "ask_round_trip_with_fallback", - "event_date_round_trip", - "event_time_round_trip", - "food_availability_round_trip", +const VALID_VECTOR_CASES: &[(&str, &str)] = &[ + ( + "profile_round_trip", + "publication_artifact.build_profile.valid", + ), + ( + "update_round_trip", + "publication_artifact.build_update.valid", + ), + ( + "photo_update_round_trip", + "publication_artifact.build_photo_update.valid", + ), + ( + "ask_round_trip_with_fallback", + "publication_artifact.build_ask.valid", + ), + ( + "event_date_round_trip", + "publication_artifact.build_calendar_date_event.valid", + ), + ( + "event_time_round_trip", + "publication_artifact.build_calendar_time_event.valid", + ), + ( + "food_availability_round_trip", + "publication_artifact.build_food_availability.valid", + ), + ( + "canonical_json_serialization", + "publication_artifact.to_canonical_json.valid", + ), + ( + "canonical_json_reload", + "publication_artifact.from_canonical_json.valid", + ), ]; +const INVALID_VECTOR_KIND: &str = "publication_artifact.from_canonical_json.invalid"; + const REQUIRED_INVALID_VECTOR_IDS: &[&str] = &[ "leading_whitespace_is_noncanonical", + "artifact_exact_byte_limit_reaches_parser", + "artifact_one_byte_over_limit_is_rejected", "unknown_field_is_rejected", "unknown_draft_field_is_rejected", + "nested_expected_event_id_is_rejected", + "missing_expected_event_id_is_rejected", + "malformed_expected_event_id_is_rejected", + "uppercase_expected_event_id_is_rejected", + "duplicate_expected_event_id_is_rejected", "unknown_media_field_is_rejected", "json_field_order_is_noncanonical", "unknown_version_is_rejected", @@ -162,6 +201,7 @@ const REQUIRED_INVALID_VECTOR_IDS: &[&str] = &[ "media_order_is_rejected", "profile_media_commitment_tamper_is_rejected", "media_url_tamper_is_rejected", + "noncanonical_media_url_casing_is_rejected", "media_hash_tamper_is_rejected", "media_type_tamper_is_rejected", "post_media_commitment_must_match_imeta", @@ -171,7 +211,7 @@ const RAW_IMMUTABLE_ARTIFACTS: &[ImmutableArtifactSpec] = &[ ImmutableArtifactSpec::new( RAW_MANIFEST_RELATIVE, 45_449, - "b8737a9c5836517114e7df6c2194c46e3c200093e12c4e6297165d2b9dae56a1", + "cde4346fe1f3fce6ec97c7a6c17c4f7e96800456b1a0fdab2d9c86ad87c08b37", ), ImmutableArtifactSpec::new( RAW_MANIFEST_SCHEMA_RELATIVE, @@ -181,12 +221,12 @@ const RAW_IMMUTABLE_ARTIFACTS: &[ImmutableArtifactSpec] = &[ ImmutableArtifactSpec::new( RAW_MANIFEST_SHA256_RELATIVE, 65, - "737ee2e4ecd400e1c647e80422c432cd2955d7c7cc04fdf3f9993551480e7957", + "ac399b4cc9ea589d441c310e0edbef6459d7f4b9c5761fa3055df69c676d8fa9", ), ImmutableArtifactSpec::new( RAW_GENERATED_DESCRIPTOR_RELATIVE, 50_735, - "20ad0d83304bb4ea3aeb0b37fc068891f1f2e5a0c3abc93d1a9932770330307c", + "b092c04d7892a441a723ed61958d084f67412da763c887531dbfb79b66973f98", ), ImmutableArtifactSpec::new( RAW_VECTOR_RELATIVE, @@ -241,6 +281,7 @@ const GOVERNED_COMPILER_TABLES: &[(&str, &str, &str)] = &[ const CONSTRUCTORS: &[ConstructorSpec] = &[ ConstructorSpec { semantic_variant: "profile", + serialized_semantic_variant: "profile", event_variant: None, strict_input: "RadrootsAuthoredProfile", constructor: "from_profile", @@ -251,6 +292,7 @@ const CONSTRUCTORS: &[ConstructorSpec] = &[ }, ConstructorSpec { semantic_variant: "update", + serialized_semantic_variant: "update", event_variant: None, strict_input: "RadrootsAuthoredUpdate", constructor: "from_update", @@ -261,6 +303,7 @@ const CONSTRUCTORS: &[ConstructorSpec] = &[ }, ConstructorSpec { semantic_variant: "photo_update", + serialized_semantic_variant: "photo_update", event_variant: None, strict_input: "RadrootsAuthoredPhotoUpdate", constructor: "from_photo_update", @@ -271,6 +314,7 @@ const CONSTRUCTORS: &[ConstructorSpec] = &[ }, ConstructorSpec { semantic_variant: "ask", + serialized_semantic_variant: "ask", event_variant: None, strict_input: "RadrootsAuthoredAsk", constructor: "from_ask", @@ -281,6 +325,7 @@ const CONSTRUCTORS: &[ConstructorSpec] = &[ }, ConstructorSpec { semantic_variant: "event", + serialized_semantic_variant: "event_date", event_variant: Some("date"), strict_input: "RadrootsAuthoredCalendarDateEvent", constructor: "from_calendar_date_event", @@ -291,6 +336,7 @@ const CONSTRUCTORS: &[ConstructorSpec] = &[ }, ConstructorSpec { semantic_variant: "event", + serialized_semantic_variant: "event_time", event_variant: Some("time"), strict_input: "RadrootsAuthoredCalendarTimeEvent", constructor: "from_calendar_time_event", @@ -301,6 +347,7 @@ const CONSTRUCTORS: &[ConstructorSpec] = &[ }, ConstructorSpec { semantic_variant: "food_availability", + serialized_semantic_variant: "food_availability", event_variant: None, strict_input: "RadrootsFoodAvailabilityDetails", constructor: "from_food_availability", @@ -331,6 +378,7 @@ impl ImmutableArtifactSpec { #[derive(Clone, Copy)] struct ConstructorSpec { semantic_variant: &'static str, + serialized_semantic_variant: &'static str, event_variant: Option<&'static str>, strict_input: &'static str, constructor: &'static str, @@ -396,15 +444,25 @@ struct RegistryDescriptor { #[serde(deny_unknown_fields)] struct ArtifactDescriptor { schema_version: u32, - semantic_variants: Vec<String>, + validator: String, + semantic_roles: Vec<String>, + serialized_semantic_variants: Vec<String>, event_subvariants: Vec<String>, canonical_encoding: String, artifact_max_bytes: u64, + signed_event_wire_max_bytes: u64, media_reference_max_count: u64, + envelope_fields: Vec<String>, + draft_fields: Vec<String>, + media_reference_fields: Vec<String>, + media_reference_identity: String, + primary_media_url_requirement: String, + post_fallback_url_requirement: String, digest_algorithm: String, digest_domain: String, + digest_domain_terminator: String, + digest_preimage: String, constructors: Vec<ConstructorDescriptor>, - persisted_fields: Vec<String>, denied_inputs: Vec<String>, reload_capability: String, threat_boundary: Vec<String>, @@ -414,6 +472,7 @@ struct ArtifactDescriptor { #[serde(deny_unknown_fields)] struct ConstructorDescriptor { semantic_variant: String, + serialized_semantic_variant: String, event_variant: Option<String>, strict_input: String, constructor: String, @@ -623,7 +682,8 @@ fn describe_manifest( fn expected_artifact_descriptor() -> ArtifactDescriptor { ArtifactDescriptor { schema_version: RADROOTS_PHASE1_PUBLICATION_ARTIFACT_SCHEMA_VERSION, - semantic_variants: [ + validator: "validate_phase1_publication_artifact".to_owned(), + semantic_roles: [ "profile", "update", "photo_update", @@ -634,16 +694,59 @@ fn expected_artifact_descriptor() -> ArtifactDescriptor { .into_iter() .map(str::to_owned) .collect(), + serialized_semantic_variants: [ + "profile", + "update", + "photo_update", + "ask", + "event_date", + "event_time", + "food_availability", + ] + .into_iter() + .map(str::to_owned) + .collect(), event_subvariants: ["date", "time"].into_iter().map(str::to_owned).collect(), canonical_encoding: "serde_json_compact_struct_field_order_v1".to_owned(), artifact_max_bytes: RADROOTS_PHASE1_PUBLICATION_ARTIFACT_MAX_BYTES as u64, + signed_event_wire_max_bytes: RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES as u64, media_reference_max_count: RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT as u64, + envelope_fields: [ + "schema_version", + "semantic_variant", + "authored_operation_id", + "event_contract_id", + "expected_author", + "draft", + "expected_event_id", + "media_references", + "artifact_digest", + ] + .into_iter() + .map(str::to_owned) + .collect(), + draft_fields: ["created_at", "kind", "tags", "content"] + .into_iter() + .map(str::to_owned) + .collect(), + media_reference_fields: ["url", "sha256", "size", "media_type"] + .into_iter() + .map(str::to_owned) + .collect(), + media_reference_identity: + "exact_case_preserving_approved_url_with_descriptor_commitment_v1".to_owned(), + primary_media_url_requirement: "blossom_hash_path_extension_required_v1".to_owned(), + post_fallback_url_requirement: "approved_blossom_url_extension_optional_v1".to_owned(), digest_algorithm: "sha256_domain_nul_canonical_json_v1".to_owned(), - digest_domain: "radroots.phase1.publication-artifact.v1\0".to_owned(), + digest_domain: "radroots.phase1.publication-artifact.v1".to_owned(), + digest_domain_terminator: "0x00".to_owned(), + digest_preimage: "ascii_domain_then_single_nul_then_canonical_envelope_without_digest_v1" + .to_owned(), constructors: CONSTRUCTORS .iter() .map(|spec| ConstructorDescriptor { semantic_variant: spec.semantic_variant.to_owned(), + serialized_semantic_variant: spec.serialized_semantic_variant.to_owned(), event_variant: spec.event_variant.map(str::to_owned), strict_input: spec.strict_input.to_owned(), constructor: format!("RadrootsPhase1PublicationArtifact::{}", spec.constructor), @@ -653,26 +756,6 @@ fn expected_artifact_descriptor() -> ArtifactDescriptor { kind: spec.kind, }) .collect(), - persisted_fields: [ - "schema_version", - "semantic_variant", - "authored_operation_id", - "event_contract_id", - "expected_author", - "draft.created_at", - "draft.kind", - "draft.tags", - "draft.content", - "draft.expected_event_id", - "media_references[].url", - "media_references[].sha256", - "media_references[].size", - "media_references[].media_type", - "artifact_digest", - ] - .into_iter() - .map(str::to_owned) - .collect(), denied_inputs: [ "arbitrary_event_draft", "raw_json", @@ -711,14 +794,24 @@ fn expected_operation_descriptors() -> Vec<OperationDescriptor> { signing: "none".to_owned(), transport: "none".to_owned(), }) - .chain([OperationDescriptor { - id: "publication_artifact.reload".to_owned(), - strict_input: "Bytes".to_owned(), - output: "RadrootsPhase1PublicationArtifact".to_owned(), - error_class: "parse_error".to_owned(), - signing: "none".to_owned(), - transport: "none".to_owned(), - }]) + .chain([ + OperationDescriptor { + id: "publication_artifact.to_canonical_json".to_owned(), + strict_input: "RadrootsPhase1PublicationArtifact".to_owned(), + output: "Bytes".to_owned(), + error_class: "none".to_owned(), + signing: "none".to_owned(), + transport: "none".to_owned(), + }, + OperationDescriptor { + id: "publication_artifact.from_canonical_json".to_owned(), + strict_input: "Bytes".to_owned(), + output: "RadrootsPhase1PublicationArtifact".to_owned(), + error_class: "parse_error".to_owned(), + signing: "none".to_owned(), + transport: "none".to_owned(), + }, + ]) .collect() } @@ -894,6 +987,20 @@ fn validate_package_shape( fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { let manifest = parse_toml(workspace_root, OPERATIONS_RELATIVE)?; + let error_classes = toml_string_array( + OPERATIONS_RELATIVE, + manifest + .get("errors") + .and_then(|value| value.get("classes")), + )?; + if error_classes + .iter() + .filter(|value| value.as_str() == "none") + .count() + != 1 + { + return Err("error classes must contain none exactly once".to_owned()); + } let domains = toml_string_array( OPERATIONS_RELATIVE, manifest @@ -930,34 +1037,44 @@ fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { .get("operations") .and_then(toml::Value::as_table) .ok_or_else(|| "operations.toml must declare [operations]".to_owned())?; - if operations.contains_key("phase1_publication_artifact_build") { - return Err("ambiguous aggregate publication build operation is forbidden".to_owned()); + for forbidden in [ + "phase1_publication_artifact_build", + "phase1_publication_artifact_reload", + ] { + if operations.contains_key(forbidden) { + return Err(format!( + "obsolete publication operation {forbidden} is forbidden" + )); + } } - for (index, expected) in expected_operation_descriptors().iter().enumerate() { - let key = if index < CONSTRUCTORS.len() { - format!( - "phase1_publication_artifact_build_{}", - expected - .id - .strip_prefix("publication_artifact.build_") - .expect("constructor operation prefix") - ) - } else { - "phase1_publication_artifact_reload".to_owned() - }; + for expected in expected_operation_descriptors() { + let (key, expected_inputs) = + if let Some(suffix) = expected.id.strip_prefix("publication_artifact.build_") { + ( + format!("phase1_publication_artifact_build_{suffix}"), + vec![ + expected.strict_input.clone(), + "u64".to_owned(), + "String".to_owned(), + ], + ) + } else { + match expected.id.as_str() { + "publication_artifact.to_canonical_json" => ( + "phase1_publication_artifact_to_canonical_json".to_owned(), + vec![expected.strict_input.clone()], + ), + "publication_artifact.from_canonical_json" => ( + "phase1_publication_artifact_from_canonical_json".to_owned(), + vec![expected.strict_input.clone()], + ), + other => return Err(format!("unknown publication operation {other}")), + } + }; let operation = operations .get(&key) .and_then(toml::Value::as_table) .ok_or_else(|| format!("operations.toml is missing {key}"))?; - let expected_inputs = if index < CONSTRUCTORS.len() { - vec![ - expected.strict_input.clone(), - "u64".to_owned(), - "String".to_owned(), - ] - } else { - vec!["Bytes".to_owned()] - }; require_toml_string(operation, "domain", "publication", &key)?; require_toml_string(operation, "id", &expected.id, &key)?; require_toml_string(operation, "stability", "beta", &key)?; @@ -969,8 +1086,7 @@ fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { .and_then(toml::Value::as_bool) != Some(true) || toml_string_array(&key, operation.get("inputs"))? != expected_inputs - || toml_string_array(&key, operation.get("outputs"))? - != ["RadrootsPhase1PublicationArtifact"] + || toml_string_array(&key, operation.get("outputs"))? != [expected.output.clone()] { return Err(format!("{key} signature or determinism drifted")); } @@ -992,6 +1108,17 @@ fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { .and_then(toml::Value::as_table) .ok_or_else(|| format!("{key} conformance is missing"))?; require_toml_string(conformance, "vector", VECTOR_RELATIVE, &key)?; + let expected_case_kinds = if expected.id == "publication_artifact.from_canonical_json" { + vec![ + "publication_artifact.from_canonical_json.valid".to_owned(), + "publication_artifact.from_canonical_json.invalid".to_owned(), + ] + } else { + vec![format!("{}.valid", expected.id)] + }; + if toml_string_array(&key, conformance.get("case_kinds"))? != expected_case_kinds { + return Err(format!("{key} conformance case kinds drifted")); + } } Ok(()) } @@ -1053,15 +1180,27 @@ fn validate_result_vector(workspace_root: &Path) -> Result<ValidatedResultVector if !case.input.is_object() || !case.expected.is_object() { return Err(format!("vector case {} must use object data", case.id)); } - match case.kind.as_str() { - "publication_artifact.round_trip.valid" => valid.push(case.id), - "publication_artifact.reload.invalid" => invalid.push(case.id), - other => return Err(format!("vector case uses unknown kind {other}")), + if case.kind == INVALID_VECTOR_KIND { + invalid.push(case.id); + } else if VALID_VECTOR_CASES + .iter() + .any(|(id, kind)| *id == case.id && *kind == case.kind) + { + valid.push((case.id, case.kind)); + } else { + return Err(format!( + "vector case {} uses unknown or mismatched kind {}", + case.id, case.kind + )); } } - if valid != VALID_VECTOR_IDS { + let expected_valid = VALID_VECTOR_CASES + .iter() + .map(|(id, kind)| ((*id).to_owned(), (*kind).to_owned())) + .collect::<Vec<_>>(); + if valid != expected_valid { return Err(format!( - "{VECTOR_RELATIVE} valid inventory drifted: expected {VALID_VECTOR_IDS:?}, found {valid:?}" + "{VECTOR_RELATIVE} valid inventory drifted: expected {expected_valid:?}, found {valid:?}" )); } for required in REQUIRED_INVALID_VECTOR_IDS { @@ -1077,7 +1216,7 @@ fn validate_result_vector(workspace_root: &Path) -> Result<ValidatedResultVector } } Ok(ValidatedResultVector { - valid_case_ids: valid, + valid_case_ids: valid.into_iter().map(|(id, _)| id).collect(), invalid_case_ids: invalid, bytes, }) @@ -1454,54 +1593,64 @@ fn manifest_schema() -> Value { "additionalProperties": false, "required": [ "schema_version", - "semantic_variants", + "validator", + "semantic_roles", + "serialized_semantic_variants", "event_subvariants", "canonical_encoding", "artifact_max_bytes", + "signed_event_wire_max_bytes", "media_reference_max_count", + "envelope_fields", + "draft_fields", + "media_reference_fields", + "media_reference_identity", + "primary_media_url_requirement", + "post_fallback_url_requirement", "digest_algorithm", "digest_domain", + "digest_domain_terminator", + "digest_preimage", "constructors", - "persisted_fields", "denied_inputs", "reload_capability", "threat_boundary" ], "properties": { "schema_version": {"const": 1}, - "semantic_variants": { - "type": "array", - "minItems": 6, - "maxItems": 6, - "items": {"type": "string", "minLength": 1} - }, - "event_subvariants": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": {"type": "string", "minLength": 1} - }, - "canonical_encoding": {"type": "string", "minLength": 1}, - "artifact_max_bytes": {"type": "integer", "minimum": 1}, - "media_reference_max_count": {"type": "integer", "minimum": 1}, - "digest_algorithm": {"type": "string", "minLength": 1}, - "digest_domain": {"type": "string", "minLength": 1}, + "validator": {"const": "validate_phase1_publication_artifact"}, + "semantic_roles": {"const": ["profile", "update", "photo_update", "ask", "event", "food_availability"]}, + "serialized_semantic_variants": {"const": ["profile", "update", "photo_update", "ask", "event_date", "event_time", "food_availability"]}, + "event_subvariants": {"const": ["date", "time"]}, + "canonical_encoding": {"const": "serde_json_compact_struct_field_order_v1"}, + "artifact_max_bytes": {"const": 2097152}, + "signed_event_wire_max_bytes": {"const": 262144}, + "media_reference_max_count": {"const": 4096}, + "envelope_fields": {"const": ["schema_version", "semantic_variant", "authored_operation_id", "event_contract_id", "expected_author", "draft", "expected_event_id", "media_references", "artifact_digest"]}, + "draft_fields": {"const": ["created_at", "kind", "tags", "content"]}, + "media_reference_fields": {"const": ["url", "sha256", "size", "media_type"]}, + "media_reference_identity": {"const": "exact_case_preserving_approved_url_with_descriptor_commitment_v1"}, + "primary_media_url_requirement": {"const": "blossom_hash_path_extension_required_v1"}, + "post_fallback_url_requirement": {"const": "approved_blossom_url_extension_optional_v1"}, + "digest_algorithm": {"const": "sha256_domain_nul_canonical_json_v1"}, + "digest_domain": {"const": "radroots.phase1.publication-artifact.v1"}, + "digest_domain_terminator": {"const": "0x00"}, + "digest_preimage": {"const": "ascii_domain_then_single_nul_then_canonical_envelope_without_digest_v1"}, "constructors": { "type": "array", "minItems": 7, "maxItems": 7, "items": {"$ref": "#/$defs/constructor"} }, - "persisted_fields": {"type": "array", "minItems": 1, "items": {"type": "string"}}, - "denied_inputs": {"type": "array", "minItems": 1, "items": {"type": "string"}}, - "reload_capability": {"type": "string", "minLength": 1}, - "threat_boundary": {"type": "array", "minItems": 1, "items": {"type": "string"}} + "denied_inputs": {"const": ["arbitrary_event_draft", "raw_json", "numeric_kind", "signed_event", "private_key", "signer"]}, + "reload_capability": {"const": "persisted_artifact_only_no_prior_capability_restoration_v1"}, + "threat_boundary": {"const": ["detects_accidental_corruption", "detects_payload_only_modification", "detects_digest_only_modification", "does_not_authenticate_actor_rewriting_payload_and_digest", "does_not_survive_validator_binary_or_host_compromise", "does_not_restore_byte_verification_or_upload_completion", "does_not_replace_nip01_id_and_signature_verification"]} } }, "operations": { "type": "array", - "minItems": 8, - "maxItems": 8, + "minItems": 9, + "maxItems": 9, "items": {"$ref": "#/$defs/operation"} }, "predecessor_source_supersessions": { @@ -1582,6 +1731,7 @@ fn manifest_schema() -> Value { "additionalProperties": false, "required": [ "semantic_variant", + "serialized_semantic_variant", "event_variant", "strict_input", "constructor", @@ -1592,6 +1742,7 @@ fn manifest_schema() -> Value { ], "properties": { "semantic_variant": {"type": "string", "minLength": 1}, + "serialized_semantic_variant": {"type": "string", "minLength": 1}, "event_variant": {"type": ["string", "null"]}, "strict_input": {"type": "string", "minLength": 1}, "constructor": {"type": "string", "minLength": 1}, @@ -1608,8 +1759,8 @@ fn manifest_schema() -> Value { "properties": { "id": {"type": "string", "minLength": 1}, "strict_input": {"type": "string", "minLength": 1}, - "output": {"const": "RadrootsPhase1PublicationArtifact"}, - "error_class": {"enum": ["validation_error", "parse_error"]}, + "output": {"enum": ["RadrootsPhase1PublicationArtifact", "Bytes"]}, + "error_class": {"enum": ["none", "validation_error", "parse_error"]}, "signing": {"const": "none"}, "transport": {"const": "none"} }