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:
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"}
}