commit f614df95bcc7ed795d63f892d64870531384cb62
parent 2c6069bdca02455e402fcf7c364386c2b9fd9f1a
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 06:28:22 +0000
event: add focused food availability contract
- add strict FoodAvailability domain, authored wire, admission, and revision boundaries
- partition NIP-99 listings and register the typed admission-only profile
- add deterministic signed conformance vectors and drift enforcement
- document breaking registry version 4 and verify package isolation
Diffstat:
28 files changed, 10150 insertions(+), 81 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -48,7 +48,7 @@ publish policy both pass for the same source revision.
access; externally supplied unsigned events, NIP-46 signing, and signed-event
relay remain explicit low-level Nostr interoperability with no typed product
authoring claim.
-- Frozen drafts now carry event-contract registry version `3`, revalidate all
+- Frozen drafts now carry event-contract registry version `4`, revalidate all
persisted fields and the recomputed event id during deserialization and
signing, and enforce explicit `GenericDraft`, `TypedOnly`, and `ReadOnly`
authoring policies.
@@ -76,9 +76,17 @@ publish policy both pass for the same source revision.
for bounded identifiers and text, canonical decimal price and quantity,
uppercase currency, the closed ten-unit food vocabulary, active or sold
status, timestamps, dimensions, and at most 64 unique byte-verified Blossom
- images. These details are not yet a signable authored draft and add no codec,
- admission, registry, replica, publication, upload, raster-decoding, or
- network-availability claim.
+ images. Its typed codec emits exact kind-`30402` wire parts, while verified
+ tolerant admission normalizes compatible inbound values, preserves bounded
+ ordered image diagnostics, and excludes generic or operational listings.
+ Strict revision comparison revalidates both signed events against authored
+ wire semantics before enforcing a stable coordinate and `published_at` plus
+ NIP-01 replacement ordering. This adds no replica, signing, publication,
+ upload, raster-decoding, or network-availability claim.
+- Event-contract identification now selects Operational Listing only for its
+ raw marker partition. Focused FoodAvailability is admission-only, while
+ marker-free generic and mixed-marker NIP-99 events cannot be mislabeled as
+ operational contracts.
- Verified Profile admission binds a signed exact kind-`0` envelope to the
tolerant metadata projection, accepts standard tagless events, and exposes
deterministic equal-time lowest-id replacement vectors.
@@ -134,8 +142,8 @@ publish policy both pass for the same source revision.
compatibility version instead of package SemVer. Existing backups stamped by
`0.1.0-alpha.2` remain restorable because this release does not change their
stored schema.
-- Persisted frozen drafts with event-contract registry versions `1` or `2` are
- rejected and must be reconstructed against registry version `3`; strict
+- Persisted frozen drafts with event-contract registry versions `1`, `2`, or
+ `3` are rejected and must be reconstructed against registry version `4`; strict
Profile plus typed and read-only post contracts can no longer be
reconstructed as generic drafts.
- This breaking capsule revision must not be pinned or published through
diff --git a/contracts/conformance/vectors/food_availability/profile.v1.json b/contracts/conformance/vectors/food_availability/profile.v1.json
@@ -0,0 +1,3222 @@
+{
+ "contract_version": "1.0.0",
+ "suite": "food_availability_profile",
+ "vectors": [
+ {
+ "id": "food_authored_unit_g_001",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Microgreens available from a Victoria-area farm."
+ },
+ "identifier": "microgreens",
+ "title": "Microgreens",
+ "summary": "Local microgreens",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "120",
+ "currency": "CAD",
+ "unit": "g"
+ },
+ "quantity": {
+ "amount": "250",
+ "unit": "g"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Microgreens available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "microgreens"
+ ],
+ [
+ "title",
+ "Microgreens"
+ ],
+ [
+ "summary",
+ "Local microgreens"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "120",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "g"
+ ],
+ [
+ "radroots:quantity",
+ "250",
+ "g"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_kg_002",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Yukon Gold Potatoes available from a Victoria-area farm."
+ },
+ "identifier": "yukon-gold-potatoes",
+ "title": "Yukon Gold Potatoes",
+ "summary": "Local yukon gold potatoes",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "5.5",
+ "currency": "CAD",
+ "unit": "kg"
+ },
+ "quantity": {
+ "amount": "18",
+ "unit": "kg"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Yukon Gold Potatoes available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "yukon-gold-potatoes"
+ ],
+ [
+ "title",
+ "Yukon Gold Potatoes"
+ ],
+ [
+ "summary",
+ "Local yukon gold potatoes"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "5.5",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "kg"
+ ],
+ [
+ "radroots:quantity",
+ "18",
+ "kg"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_lb_003",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Nantes Carrots available from a Victoria-area farm."
+ },
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Local nantes carrots",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3.5",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": {
+ "amount": "24",
+ "unit": "lb"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Nantes Carrots available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Local nantes carrots"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3.5",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_oz_004",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Fresh Basil available from a Victoria-area farm."
+ },
+ "identifier": "fresh-basil",
+ "title": "Fresh Basil",
+ "summary": "Local fresh basil",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "2.25",
+ "currency": "CAD",
+ "unit": "oz"
+ },
+ "quantity": {
+ "amount": "16",
+ "unit": "oz"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Fresh Basil available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "fresh-basil"
+ ],
+ [
+ "title",
+ "Fresh Basil"
+ ],
+ [
+ "summary",
+ "Local fresh basil"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "2.25",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "oz"
+ ],
+ [
+ "radroots:quantity",
+ "16",
+ "oz"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_each_005",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Delicata Squash available from a Victoria-area farm."
+ },
+ "identifier": "delicata-squash",
+ "title": "Delicata Squash",
+ "summary": "Local delicata squash",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "4",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": {
+ "amount": "12",
+ "unit": "each"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Delicata Squash available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "delicata-squash"
+ ],
+ [
+ "title",
+ "Delicata Squash"
+ ],
+ [
+ "summary",
+ "Local delicata squash"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "4",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "each"
+ ],
+ [
+ "radroots:quantity",
+ "12",
+ "each"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_dozen_006",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Pasture Eggs available from a Victoria-area farm."
+ },
+ "identifier": "pasture-eggs",
+ "title": "Pasture Eggs",
+ "summary": "Local pasture eggs",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "8",
+ "currency": "CAD",
+ "unit": "dozen"
+ },
+ "quantity": {
+ "amount": "6",
+ "unit": "dozen"
+ },
+ "status": "sold",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Pasture Eggs available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "pasture-eggs"
+ ],
+ [
+ "title",
+ "Pasture Eggs"
+ ],
+ [
+ "summary",
+ "Local pasture eggs"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "8",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "dozen"
+ ],
+ [
+ "radroots:quantity",
+ "6",
+ "dozen"
+ ],
+ [
+ "status",
+ "sold"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_bunch_007",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Lacinato Kale available from a Victoria-area farm."
+ },
+ "identifier": "lacinato-kale",
+ "title": "Lacinato Kale",
+ "summary": "Local lacinato kale",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "4.5",
+ "currency": "CAD",
+ "unit": "bunch"
+ },
+ "quantity": {
+ "amount": "10",
+ "unit": "bunch"
+ },
+ "status": "active",
+ "images": [
+ {
+ "bytes_utf8": "radroots-food-image-one",
+ "url": "https://blossom.victoria.example/85ccc876a2fe359a2f8dd491bbc6c1f5674af8f0e94d11b556a069f8176e303d.webp",
+ "media_type": "image/webp",
+ "uploaded_at": 1700000000,
+ "dimensions": "1200x800"
+ },
+ {
+ "bytes_utf8": "radroots-food-image-two",
+ "url": "https://blossom.victoria.example/e3d093fc5e999bd2eb6eb476d24fd421accb134efa8dc750bd87d22a7e49fb22.webp",
+ "media_type": "image/webp",
+ "uploaded_at": 1700000000,
+ "dimensions": "800x800"
+ }
+ ]
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Lacinato Kale available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "lacinato-kale"
+ ],
+ [
+ "title",
+ "Lacinato Kale"
+ ],
+ [
+ "summary",
+ "Local lacinato kale"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "4.5",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "bunch"
+ ],
+ [
+ "radroots:quantity",
+ "10",
+ "bunch"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "image",
+ "https://blossom.victoria.example/85ccc876a2fe359a2f8dd491bbc6c1f5674af8f0e94d11b556a069f8176e303d.webp",
+ "1200x800"
+ ],
+ [
+ "image",
+ "https://blossom.victoria.example/e3d093fc5e999bd2eb6eb476d24fd421accb134efa8dc750bd87d22a7e49fb22.webp",
+ "800x800"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_punnet_008",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "June Strawberries available from a Victoria-area farm."
+ },
+ "identifier": "june-strawberries",
+ "title": "June Strawberries",
+ "summary": "Local june strawberries",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "7",
+ "currency": "CAD",
+ "unit": "punnet"
+ },
+ "quantity": {
+ "amount": "20",
+ "unit": "punnet"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "June Strawberries available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "june-strawberries"
+ ],
+ [
+ "title",
+ "June Strawberries"
+ ],
+ [
+ "summary",
+ "Local june strawberries"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "7",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "punnet"
+ ],
+ [
+ "radroots:quantity",
+ "20",
+ "punnet"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_bag_009",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Gravenstein Apples available from a Victoria-area farm."
+ },
+ "identifier": "gravenstein-apples",
+ "title": "Gravenstein Apples",
+ "summary": "Local gravenstein apples",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "9",
+ "currency": "CAD",
+ "unit": "bag"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Gravenstein Apples available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "gravenstein-apples"
+ ],
+ [
+ "title",
+ "Gravenstein Apples"
+ ],
+ [
+ "summary",
+ "Local gravenstein apples"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "9",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "bag"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_basket_010",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Cherry Tomatoes available from a Victoria-area farm."
+ },
+ "identifier": "cherry-tomatoes",
+ "title": "Cherry Tomatoes",
+ "summary": "Local cherry tomatoes",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "12",
+ "currency": "CAD",
+ "unit": "basket"
+ },
+ "quantity": {
+ "amount": "8",
+ "unit": "basket"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Cherry Tomatoes available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "cherry-tomatoes"
+ ],
+ [
+ "title",
+ "Cherry Tomatoes"
+ ],
+ [
+ "summary",
+ "Local cherry tomatoes"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "12",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "basket"
+ ],
+ [
+ "radroots:quantity",
+ "8",
+ "basket"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_wire_budget_ascii_max_011",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "repeat": "x",
+ "count": 131072
+ },
+ "identifier": "wire-budget-pass",
+ "title": "Wire Budget Pass",
+ "summary": "Maximum ASCII content",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "1",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content_length": 131072,
+ "tags": [
+ [
+ "d",
+ "wire-budget-pass"
+ ],
+ [
+ "title",
+ "Wire Budget Pass"
+ ],
+ [
+ "summary",
+ "Maximum ASCII content"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "1",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "each"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_wire_budget_escaped_overflow_012",
+ "kind": "food_availability.build_authored_draft.invalid",
+ "input": {
+ "details": {
+ "content": {
+ "repeat": "\u0000",
+ "count": 65536
+ },
+ "identifier": "wire-budget-overflow",
+ "title": "Wire Budget Overflow",
+ "summary": "Escaped JSON expansion",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "1",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "error": {
+ "code": "food_event_wire_too_large",
+ "message": "FoodAvailability canonical signed event would be 393796 bytes; max is 262144",
+ "max": 262144,
+ "actual": 393796
+ }
+ }
+ },
+ {
+ "id": "food_authored_future_published_at_013",
+ "kind": "food_availability.build_authored_draft.invalid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Future publication"
+ },
+ "identifier": "future-publication",
+ "title": "Future Publication",
+ "summary": "Invalid publication time",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "1",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1699999999
+ },
+ "expected": {
+ "error": {
+ "code": "food_published_at_future",
+ "message": "FoodAvailability published_at 1700000000 exceeds created_at 1699999999",
+ "published_at": 1700000000,
+ "created_at": 1699999999
+ }
+ }
+ },
+ {
+ "id": "food_admission_normalizes_decimal_currency_014",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "20adf6026a55207a21111ac0617c212a2abb8fd15e39f3042991d85da287c74f",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "0003.5000",
+ "cad"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "0010.00",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "94b1b19946890a42cff0b6c08c55f855291c18a7bf89ebc2f0e92af5fa486da207d1c80edd1c166367e63c573a3cfade38f10514cc4cd3b64d67f1ee42540077"
+ }
+ },
+ "expected": {
+ "outcome": "focused",
+ "event_id": "20adf6026a55207a21111ac0617c212a2abb8fd15e39f3042991d85da287c74f",
+ "projection": {
+ "content": "Carrots available this week.",
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Fresh bunches",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3.5",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": {
+ "amount": "10",
+ "unit": "lb"
+ },
+ "status": "active",
+ "images": [],
+ "diagnostics": []
+ }
+ }
+ },
+ {
+ "id": "food_admission_optional_standard_tags_015",
+ "kind": "food_availability.verify_and_admit_event.valid",
+ "input": {
+ "event": {
+ "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "t",
+ "vegetables"
+ ],
+ [
+ "g",
+ "c28hr"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a"
+ }
+ },
+ "expected": {
+ "outcome": "admitted",
+ "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7",
+ "projection": {
+ "content": "Carrots available this week.",
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Fresh bunches",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": [],
+ "diagnostics": []
+ }
+ }
+ },
+ {
+ "id": "food_admission_excludes_operational_before_validation_016",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "radroots:bin"
+ ],
+ [
+ "delivery"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1fc38e6183061bb64f2337941b96a6cc75067b85aa46b4780bd395f62961b54569872c51090bec9f97185add686e3e6e11a1aa0c593d63266c433a614f2d90af"
+ }
+ },
+ "expected": {
+ "outcome": "excluded",
+ "event_id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40",
+ "partition": "operational_listing"
+ }
+ },
+ {
+ "id": "food_admission_excludes_generic_nip99_017",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "generic-offer"
+ ],
+ [
+ "title",
+ "Generic Offer"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "e533df401a4c6ddfd7dcfd2b3525e9fb8938748dcdc9492aa617ec50d966554511853704929b88b7fe791f3a36659f341b20245182574dd5e5353fa80f57d441"
+ }
+ },
+ "expected": {
+ "outcome": "excluded",
+ "event_id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d",
+ "partition": "generic_nip99"
+ }
+ },
+ {
+ "id": "food_admission_rejects_ambiguous_markers_018",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "1e40dd34180c85871cc1a7369290876e004d56890ebca9a619f4c1f61e46d866",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "radroots:price_unit"
+ ],
+ [
+ "radroots:price"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1da60ee3d831adae2aeb61df60c2e14f7168b696cf50a9362f326119d5c4d5a03dd8d6da1c9b10a29c3dd4ffbbfbe76404d758ee149bb56b13e2fe14eece385f"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_profile_ambiguous",
+ "message": "classified listing mixes focused and operational markers"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_wrong_kind_019",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "2cdc1807110058f1732d68e5de00b0f1cb3dd9e067aa36af835a3823f522a6d2",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 1,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "957d64ffbe2f7dd94c785aa2111e6b0e6e3d6220bd2a756ff75aeb44dc1663d08a23345ad4a1729c0d6aaf1cf35f8f22021262f9131a15364e303d774eb2b2f6"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "invalid_kind",
+ "message": "FoodAvailability event kind must be 30402, got 1"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_core_tag_shape_020",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "c9902f6f65bbd890fc5b89119aeae481ae66359ed1c5f8b763a72cb11e89a75c",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "radroots:price_unit"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "0d2fe687930c908410b667b025dbf2630790a06b957473aabfeecd93467b8ec4e9ec52dee83807c68c06bfab12efe89e4b039c751bd07d6618a4b2e7fc339f83"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_tag_invalid",
+ "message": "FoodAvailability core tag shape is invalid"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_prohibited_delivery_021",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "da2bee62ff0c998eaebe8e881c1e1388b2e5f8922d8973fce908fc21d24f7868",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "delivery",
+ "tomorrow"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "73534923e2a0cb6f37dfeaf1947c32da2cea110f48d642366b18eaed501c9b4227eb11c86bc83433fc0f03054d89260e860d3fb055c3c3a8ef70238f77dac2d5"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "prohibited_capability",
+ "message": "FoodAvailability tag `delivery` is a prohibited capability"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_price_frequency_022",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "d376ad9ac9446dcca0285ab284d72662a8fba02a71f5f7501ba40299fdbc5041",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD",
+ "week"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "d5dd7a9864e78c374b7213b0460fdb142c4fd54dba4b7795781bc348095438852b3535ff64d8eb692baf1c572ee5c254e759b93293d27e0885e7ef0bb53af35b"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_frequency_forbidden",
+ "message": "FoodAvailability price frequency is forbidden"
+ }
+ }
+ },
+ {
+ "id": "food_admission_requires_price_unit_023",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "6317ed3da993b973aed902dd6c1e3b12f2bc70523a91de034f735e10b89b7255",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:quantity",
+ "10",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "a318aaae549bb91eaf7a1c5c042972c7e70fa26282a705df8d0414557aa2a41bca87ad88d7f1fef7c2edba05ce9562db46be90028aa5f40d66b82af0fd837c4e"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_unit_missing",
+ "message": "FoodAvailability price unit is missing"
+ }
+ }
+ },
+ {
+ "id": "food_admission_bounds_raw_decimal_digits_024",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "dd884aeb7c7cd3f8e7b29c6992fdc44e6f9d8ebf0cd964708acd1bc334b1d238",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "00000000000000000000000000003",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "eae8efa71b884818173ba4a5c41615be10e111cb221117feccd43fc69281517480188644e08921d608c03d7fbbcdc1875796de8d2013a85d23cf537c0fa28cda"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_invalid",
+ "message": "FoodAvailability price must be a canonical unsigned decimal with at most 28 digits"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_malformed_price_unit_025",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "9ae38292772927c1e92e65505e280f8e93e997d90bb4528fffff2f1fa350e10d",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "e276e8dfd4cc4e5b11b9981fd20ca165a9537180b2919e20cdbd43652d0806c4e3f17cc4966e4b35aa86eb82a3bc9fcb07ecb44a49cee27e24aec64d0bcaa457"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_unit_invalid",
+ "message": "FoodAvailability price unit is not governed"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_quantity_unit_mismatch_026",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "7c861cdfd2275445c756bd8d136d340df3e1133a7c480d9e2c91df06cef58d44",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "10",
+ "kg"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "7f7524cc5170a6076fed95a6f53e6d597c94c6a3373f56a062740f915258f93bc2ff1116776479736804023ca8059d0e13b790f60a98429cca318262256bbec1"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "quantity_invalid",
+ "message": "FoodAvailability quantity must be canonical and use the price unit"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_status_027",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "paused"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "5728cd88796dc4beed1d293db77a9e5a9297bb9e3d7d56b9ee108f3c6d3ad2fd5468b359d1fba09a4eeca7539d678d2e373c8bd151969e19feaceb9aebaca917"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_status_invalid",
+ "message": "FoodAvailability status must be active or sold"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_future_published_at_028",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "6f21223ebac44a2888abe98e4aefcc7368f1150fa8eac379b964f33657ec2f0b",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000061"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "71256286f982d6daa7e2253f4b5f33c7f65aaa02a36182401629f0bc771cf613ad17544be1d820cfba1484c83251c17f87769fa44ee35b12ceee4327cec455db"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_published_at_future",
+ "message": "FoodAvailability published_at 1700000061 exceeds created_at 1700000060"
+ }
+ }
+ },
+ {
+ "id": "food_admission_preserves_ordered_image_diagnostics_029",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"
+ ],
+ [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "0x600",
+ "extra"
+ ],
+ [
+ "image",
+ "not-a-url",
+ "800x600"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1d5eb699944bc1eba93c85071ad1d94878f1c767015391e200b45f1a2066558675a382ede737fb15307a944598687843618c067accf6608f53daad377683c2c9"
+ }
+ },
+ "expected": {
+ "outcome": "focused",
+ "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe",
+ "projection": {
+ "content": "Carrots available this week.",
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Fresh bunches",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": [
+ {
+ "raw_tag": [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"
+ ],
+ "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "dimensions": null,
+ "diagnostics": [
+ "food_image_shape_invalid",
+ "food_image_dimensions_missing"
+ ],
+ "qualifies": false
+ },
+ {
+ "raw_tag": [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "0x600",
+ "extra"
+ ],
+ "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "dimensions": null,
+ "diagnostics": [
+ "food_image_shape_invalid",
+ "food_image_dimensions_invalid",
+ "food_image_duplicate_url",
+ "food_image_duplicate_digest"
+ ],
+ "qualifies": false
+ },
+ {
+ "raw_tag": [
+ "image",
+ "not-a-url",
+ "800x600"
+ ],
+ "url": null,
+ "dimensions": {
+ "width": 800,
+ "height": 600
+ },
+ "diagnostics": [
+ "food_image_url_invalid"
+ ],
+ "qualifies": false
+ }
+ ],
+ "diagnostics": [
+ "food_image_shape_invalid",
+ "food_image_dimensions_missing",
+ "food_image_shape_invalid",
+ "food_image_dimensions_invalid",
+ "food_image_duplicate_url",
+ "food_image_duplicate_digest",
+ "food_image_url_invalid"
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_admission_bounds_image_projection_030",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "ac3b37c0368816b8d707e0e2e194c39291ea47fa7bb22dd998fa526b03609280",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000000.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000001.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000002.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000003.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000004.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000005.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000006.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000007.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000008.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000009.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000010.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000011.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000012.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000013.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000014.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000015.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000016.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000017.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000018.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000019.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000020.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000021.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000022.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000023.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000024.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000025.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000026.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000027.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000028.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000029.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000030.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000031.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000032.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000033.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000034.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000035.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000036.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000037.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000038.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000039.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000040.webp",
+ "800x600"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "28775630afde6c73e412d47a1d36227fae11057595186655ceab5a6d96a60f25289a0f5b27c680025152f121b322c35e0bb248f9392567af946dc881f08eb6b5"
+ }
+ },
+ "expected": {
+ "outcome": "focused",
+ "event_id": "ac3b37c0368816b8d707e0e2e194c39291ea47fa7bb22dd998fa526b03609280",
+ "image_count": 64,
+ "diagnostics": [
+ "food_image_count_exceeded"
+ ],
+ "first_raw_tag": [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000000.webp",
+ "800x600"
+ ],
+ "last_raw_tag": [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003f.webp",
+ "800x600"
+ ]
+ }
+ },
+ {
+ "id": "food_admission_rejects_invalid_signature_031",
+ "kind": "food_availability.verify_and_admit_event.invalid",
+ "input": {
+ "event": {
+ "id": "f600337abfe4a2482225a369852c9899784c71d9221635b5ac34bca52a06d2d1",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "94b1b19946890a42cff0b6c08c55f855291c18a7bf89ebc2f0e92af5fa486da207d1c80edd1c166367e63c573a3cfade38f10514cc4cd3b64d67f1ee42540077"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "signature_invalid",
+ "message": "invalid NIP-01 event signature"
+ }
+ }
+ },
+ {
+ "id": "food_revision_accepts_later_created_at_032",
+ "kind": "food_availability.validate_revision.valid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "78304e7b25d60f037f3c65556ecc31075e724394f7ee7c7f94081a4e083432a9",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Updated carrots",
+ "sig": "deea0e868afa659aad33572c0c45aecc6d59756e05c442f268d5c7d85ffe319136b3696cbd894546587ea0e0c7090b185a585e236ecbd9050e66c8fc0422106a"
+ }
+ },
+ "expected": {
+ "result": "accepted",
+ "current_event_id": "78304e7b25d60f037f3c65556ecc31075e724394f7ee7c7f94081a4e083432a9"
+ }
+ },
+ {
+ "id": "food_revision_rejects_invalid_previous_033",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "7d097fd38c0b5a74f7e415178415d8a119607e2338b600fe136829f1dcec8a43",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "46780ae06e1968be9e1031e84a25539a1b120dc70468fda0401078b627ab7fb264f5bbe19aaebe8f32b696aa829564dee58b740cf2f22595b3c1e5fa530dbd38"
+ },
+ "current": {
+ "id": "46e2c8f4db3621330939318af101b8fc55b5b65ca7bc9e3ea4d498b4627c634c",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "65be312cd60c3c6ec5722bd947375640b9eb32f9dee2692ef25853e2c7ec64b4620aab142d0ad2b8ad6138d8948c962aac6c373ace60a6c99ebad1627b6a1cde"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_previous_invalid",
+ "message": "previous FoodAvailability revision is invalid: FoodAvailability core tag shape is invalid"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_invalid_current_034",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "b694c47d68fe69f79794420a5b3409866f0e9208bab1ee2738299477fac0bdf7",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "03.00",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "416dca196142e25a38b63600ec258d85221d1220192ea80e2c61c0f828837e4887690130e3460fb53bbbe2ad0f6fe80c143212ed7c1f4e898c0249b06c0236f1"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_current_invalid",
+ "message": "current FoodAvailability revision is invalid: FoodAvailability price must be a canonical unsigned decimal with at most 28 digits"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_identifier_coordinate_change_035",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "16b893630a9b737ff4d05bbbc627ec68ad60c76498dbe75a3ebb1857042d5e08",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "golden-beets"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "efd2572f098034f6d653fd6b8bce4486db2b6e366f4ea7832917328ddbacb286a47ad5b45c74b15323e2e271441371543f413e49fd03debe5332cc4f769b4565"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_coordinate_changed",
+ "message": "FoodAvailability revision coordinate changed"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_author_coordinate_change_036",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "a72f1b05cc4398de9eb9f21389ceadc2d49035667304125855099b86b009e6e1",
+ "pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "fa3b264c55e767d3a1720439eef1c000659143004fbb22fa43ea15ad7b45c79ad4d59dfdbbbab368447ca345c426382f3c92b8377d6362d65e6f5b8b3d2a33e3"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_coordinate_changed",
+ "message": "FoodAvailability revision coordinate changed"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_published_at_change_037",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "fd0d610781437790e0ec242e3b3d0dd136b75c02c01533b98affd3566e09f54c",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000001"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "4e4076d3a0a6b770df87208e54e93962eaa031c57d5168c425f0fb27526aa841a0878873035e75aa9ef98b0dfd654c718afeb02bebe28e590c9b870eb5a8c063"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_published_at_changed",
+ "message": "FoodAvailability revision published_at changed"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_older_created_at_038",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "4617873052bca26d7b39cdbc26a8134f759de807ba92d2052f96ecf07ab8d6ab",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000059,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Older candidate",
+ "sig": "eaa553976301cb71ea22446651909e20fc09bb736b72489ca434e2fd0184be2070fb259c0dd3b93bb8eda195a677eb977c2bc42da3c0b37062b23e908191de4c"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_not_newer",
+ "message": "FoodAvailability revision does not win NIP-01 ordering"
+ }
+ }
+ },
+ {
+ "id": "food_revision_equal_time_a_current_039",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "6e3a51de252e123bee2b94da38d2796406e518abffe52c8295cde473b75e16d4",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time B",
+ "sig": "9f99d5fa61b0d9661590602ce8260bf9e9a2f022de1eb30bf22ccf29c253c6b66d08dab79048bc97a39dd062757bc2219bf1f9fc4aef1ec2c426282d73d8d6ff"
+ },
+ "current": {
+ "id": "8ed82f79ffc5e3ca5c6feba18822d0d8ed4da0fe37baf5352b492c45bc28c3d3",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time A",
+ "sig": "0ca47b54a0f4e534f49a5373d56a803ea9abaed24fe412fde6be71995ebdaa930395533f934101782fa199404976b17caa00303a6a93463d138c4b76a39becb9"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_not_newer",
+ "message": "FoodAvailability revision does not win NIP-01 ordering"
+ }
+ }
+ },
+ {
+ "id": "food_revision_equal_time_b_current_040",
+ "kind": "food_availability.validate_revision.valid",
+ "input": {
+ "previous": {
+ "id": "8ed82f79ffc5e3ca5c6feba18822d0d8ed4da0fe37baf5352b492c45bc28c3d3",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time A",
+ "sig": "0ca47b54a0f4e534f49a5373d56a803ea9abaed24fe412fde6be71995ebdaa930395533f934101782fa199404976b17caa00303a6a93463d138c4b76a39becb9"
+ },
+ "current": {
+ "id": "6e3a51de252e123bee2b94da38d2796406e518abffe52c8295cde473b75e16d4",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time B",
+ "sig": "9f99d5fa61b0d9661590602ce8260bf9e9a2f022de1eb30bf22ccf29c253c6b66d08dab79048bc97a39dd062757bc2219bf1f9fc4aef1ec2c426282d73d8d6ff"
+ }
+ },
+ "expected": {
+ "result": "accepted",
+ "current_event_id": "6e3a51de252e123bee2b94da38d2796406e518abffe52c8295cde473b75e16d4"
+ }
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json
@@ -6,11 +6,11 @@
"id": "knowledge_manifest_fields_valid_001",
"kind": "knowledge.contract_manifest_json.valid",
"input": {
- "registry": "radroots_event_contract_registry_v3"
+ "registry": "radroots_event_contract_registry_v4"
},
"expected": {
"schema_version": 2,
- "registry_version": 3,
+ "registry_version": 4,
"required_fields": [
"schema_version",
"registry_version",
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -31,7 +31,7 @@ an envelope whose id and signature it has independently verified and whose kind
the corresponding parser accepted. Outbound authored models produce unsigned
wire parts and require runtime signing and transport.
-## Classified and operational listing boundary rule
+## Classified-listing profile boundary rule
Kind `30402` is the standard NIP-99 classified-listing kind and
`RadrootsClassifiedListingAddress` is the exact coordinate authority for that
@@ -51,14 +51,36 @@ as `FocusedFoodAvailability`, `OperationalListing`, `GenericNip99`, and
are case-sensitive. This central partition does not inspect kind, values, or
arity and does not validate either profile.
-The current FoodAvailability boundary exposes strict domain and authored-media
-input primitives only. Its validated details contain no farm, bin, route,
-pickup, delivery, order, or checkout fields, but they are not yet a signable
-authored draft. No FoodAvailability codec, registry contract, inbound
-admission, operation, replica projection, publication path, or client behavior
-is established by this checkpoint. A validated image proves local Blossom
-descriptor-to-byte agreement only; it does not prove upload completion, raster
-decoding, reachability, or network availability.
+The focused `radroots.food.availability.v1` profile has four public operation
+boundaries. Strict authored details plus `created_at` produce deterministic
+unsigned kind-`30402` wire parts. Projection accepts only a
+`RadrootsSignatureVerifiedEvent`, returns focused data or an explicit
+Operational Listing or generic NIP-99 exclusion, rejects mixed markers as
+ambiguous, and applies focused validation only after partitioning. Combined
+admission performs NIP-01 id and signature verification before projection and
+keeps either result bound to its verified envelope.
+
+Strict authoring emits the exact ordered `d`, `title`, `summary`,
+`published_at`, `location`, `price`, `radroots:price_unit`, optional
+`radroots:quantity`, `status`, and repeated `image` tags. Inbound focused
+projection normalizes accepted decimal and currency spellings, ignores
+unowned optional NIP-99 tags, and preserves a bounded ordered image projection
+with stable diagnostics instead of treating malformed image metadata as a
+valid authored image. Focused capability tags for checkout, fulfillment,
+payments, provenance, and operational workflows are rejected; the profile
+does not gain those semantics by carrying an unknown tag.
+
+Revision validation re-applies strict authored wire semantics to both verified
+events. It requires stable kind, author, `d` coordinate, and `published_at`,
+then applies NIP-01 replacement order: later `created_at` wins, and the lower
+event id wins at equal time. Side-specific errors identify an invalid previous
+or current candidate before revision comparison.
+
+No operation in this boundary signs, publishes, retrieves, or replicates an
+event. A validated authored image proves local Blossom descriptor-to-byte
+agreement only; successful BUD-02 upload completion and any raster,
+retrievability, or availability checks remain runtime responsibilities before
+signing.
## Kind-1 post boundary rule
@@ -109,6 +131,7 @@ implementation.
| document | 30361 | RadrootsDocument | events.document.publish, events.document.list, events.document.get | requires `d` and pubkey tags; optional address tag |
| resource_area | 30370 | RadrootsResourceArea | events.resource_area.publish, events.resource_area.list, events.resource_area.get | addressable; GCS location and `g` tag required |
| resource_cap | 30371 | RadrootsResourceHarvestCap | events.resource_cap.publish, events.resource_cap.list, events.resource_cap.get | addressable; required address, pubkey, key, start, and end tags |
+| food_availability | 30402 | RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent | food_availability.build_authored_draft, food_availability.project_verified_event, food_availability.verify_and_admit_event, food_availability.validate_revision | focused `radroots.food.availability.v1` profile; strict deterministic authoring, verified projection/admission, explicit non-focused exclusion, and stable-coordinate revision validation; no signing, transport, replica, client behavior, or publication operation |
| operational_listing | 30402 | RadrootsOperationalListing | events.operational_listing.publish, events.operational_listing.list, events.operational_listing.get | NIP-99 classified-listing kind with the richer Radroots operational profile; canonical Markdown content and tags; farm author required |
| dvm_request | 5000-5999 | RadrootsJobRequest | events.dvm_request.publish, events.dvm_request.list, events.dvm_request.get | generic DVM request surface |
| dvm_result | 6000-6999 | RadrootsJobResult | events.dvm_result.publish, events.dvm_result.list, events.dvm_result.get | generic DVM result surface |
diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md
@@ -30,8 +30,9 @@ authoring and admission profile are separate contract layers.
The repository implements strict authored and verified-projected kind `1` post profiles, kind `1111`
`RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, operational listing
records through `RadrootsOperationalListing`, the raw kind-`30402` profile partition and validated
-FoodAvailability domain primitives, articles, generic public file metadata, calendar date events,
-calendar time events, reposts, generic reposts, calendar collections, RSVP events, and reports.
+FoodAvailability authored, verified-admission, and revision contract, articles, generic public file
+metadata, calendar date events, calendar time events, reposts, generic reposts, calendar
+collections, RSVP events, and reports.
The closeout contract requires:
@@ -73,9 +74,9 @@ The production-v1 public social substrate includes:
- `RadrootsReport` for NIP-56 kind `1984`
- operational-listing profile validation through `RadrootsOperationalListing` at NIP-99
classified-listing kind `30402`
-- validated FoodAvailability details and a raw marker-name partition for focused, operational,
- generic NIP-99, and ambiguous kind-`30402` inputs; the focused codec and admission boundary are
- not part of this checkpoint
+- strict FoodAvailability details, deterministic unsigned authoring, verified tolerant projection,
+ NIP-01 admission, and strict revision validation for focused kind-`30402` inputs, with explicit
+ exclusion of Operational Listing and marker-free generic NIP-99 candidates
- relay-list kind `10002` validation through `RadrootsList`
## Contract Decisions
@@ -144,11 +145,11 @@ contract. Strict Profile, Update, PhotoUpdate, and Ask contracts are
`TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft
contracts remain `GenericDraft`. `RadrootsEventDraft::new` therefore rejects
the strict Profile contract and all four governed kind-1 post contracts with
-`contract_not_draft_authorable`. Serialized drafts record registry version `3`
+`contract_not_draft_authorable`. Serialized drafts record registry version `4`
and are accepted only after deserialization revalidates the registry version,
contract, kind, shape, policy, recomputed event id, and known fields. The
-frozen-draft signing boundary repeats that validation, so stale version-`1`
-and version-`2` drafts must be rebuilt. Typed root posts enter Nostr signing and client
+frozen-draft signing boundary repeats that validation, so stale version-`1`, version-`2`, and
+version-`3` drafts must be rebuilt. Typed root posts enter Nostr signing and client
publication only through an opaque post builder that exposes timestamp
selection and signing, but no raw tag/content mutation or public conversion to
the upstream builder. The opaque generic builder rejects kind `0` and unmarked
@@ -224,12 +225,42 @@ completion, decoded raster dimensions, content safety, retrieval, reachability,
availability.
The details model deliberately has no farm, bin, route, pickup, delivery, order, checkout, or other
-commerce-workflow field. It retains `published_at` as input for later replacement checks, but this
-checkpoint does not compare revisions or prove its stability. The model is not serializable and is
-not a signable authored draft; it emits no tags or wire event. This checkpoint adds no
-FoodAvailability codec, registry identity, verified inbound admission, revision validator, replica
-projection, signing, publication, or client behavior; those boundaries require separate executable
-contract work.
+commerce-workflow field. `authored_food_availability_to_wire_parts` accepts these details plus
+`created_at`, requires `published_at <= created_at`, and emits unsigned kind-`30402` wire parts. Its
+closed tag sequence is exactly `d`, `title`, `summary`, `published_at`, `location`, `price`,
+`radroots:price_unit`, optional `radroots:quantity`, `status`, then zero or more `image` tags. Price
+is exactly amount plus currency, quantity is exactly amount plus unit, and image is exactly URL plus
+dimensions. The decoded tag budgets and the 262144-byte compact signed-event budget are both
+enforced. The operation neither chooses `created_at` nor signs or transports the result.
+
+Inbound projection accepts only a `RadrootsSignatureVerifiedEvent`. Raw marker partitioning occurs
+before focused tag validation: Operational Listing and marker-free generic NIP-99 candidates are
+explicit exclusions, while mixed markers are an error. A focused candidate requires the complete
+core profile and rejects tags that purport to add buyer, checkout, delivery, exception, group,
+invite, order, payment, pickup, proof, provenance, receipt, route, `route_stop`, or task capability.
+Other optional NIP-99 tags remain outside the focused projection and are ignored. Accepted inbound
+decimal values are normalized without exceeding the 28-digit wire bound, and three-letter currency
+is normalized to uppercase.
+
+Inbound image observations are not authored media typestates. The projection retains at most the
+first 64 tags in wire order and records stable ordered diagnostics for count overflow, malformed
+shape, invalid HTTP(S) URL, missing or invalid dimensions, duplicate URL, and duplicate Blossom
+digest. Image diagnostics do not invalidate an otherwise complete focused core. They confer no
+Blossom approval, byte agreement, upload, decoding, retrieval, safety, or availability claim.
+
+`verify_and_admit_food_availability_event` performs NIP-01 id and Schnorr verification before that
+projection and returns either `RadrootsAdmittedFoodAvailabilityEvent` or an explicit verified
+non-focused exclusion. The admitted type binds the projection to the verified envelope and exposes
+the admission-only `radroots.food.availability.v1` registry contract. The contract is `TypedOnly`:
+strict authored details are its only product-authoring input, and unsigned kind or tag matching does
+not confer focused admission.
+
+Revision validation accepts two independently signature-verified events and re-applies the exact
+authored wire profile to each side; tolerant normalized or diagnostic-bearing projections cannot
+enter this comparison. Kind, author, `d`, and `published_at` must remain stable. The candidate must
+have a later `created_at`, or the lower event id when both timestamps are equal. Invalid previous
+and current inputs have side-specific errors. The profile contract does not implement signing,
+relay publication, replica selection, media upload, or client behavior.
### Calendar Trust Layers
@@ -367,8 +398,8 @@ or an availability guarantee.
Product routing uses surface-specific kind classifiers rather than a broad public-social set. Home,
Events, Market, Map, and Profile public-content candidates are explicit. A kind-`30402` value alone
does not select FoodAvailability: raw marker partitioning first distinguishes focused,
-operational, generic NIP-99, and ambiguous candidates. Full FoodAvailability admission remains a
-later boundary. Report kind `1984` is a moderation/admin candidate, not
+operational, generic NIP-99, and ambiguous candidates, and only signature-verified focused admission
+returns the FoodAvailability contract. Report kind `1984` is a moderation/admin candidate, not
normal feed content. Relay and HTTP auth kinds are transient and excluded from durable social and
farm-ops candidate sets. Private farm operations candidates include the farm workspace manifest,
farm CRDT change envelope, farm file metadata, and the supported NIP-29 group event subset.
@@ -399,9 +430,11 @@ this contract boundary.
## Conformance Boundary
Every new social codec and every upgraded existing social codec must have deterministic valid and
-invalid conformance vectors before closeout. Upgraded vectors must include the strict comment,
-reaction, operational-listing, farm, list, and list-set behavior whose public contract changes during the
-refactor.
+invalid conformance vectors before closeout. The FoodAvailability profile vector assigns exact
+valid and invalid case kinds to strict authoring, verified projection, combined verification and
+admission, and revision validation. Other upgraded vectors must include the strict comment,
+reaction, operational-listing, farm, list, and list-set behavior whose public contract changes
+during the refactor.
Social vectors are repo-owned and synthetic. They must not depend on application relay state, local
databases, external services, root fixture catalogs, or ambient machine state.
diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.json b/contracts/knowledge/knowledge_event_contract_manifest.v2.json
@@ -1,6 +1,6 @@
{
"schema_version": 2,
- "registry_version": 3,
+ "registry_version": 4,
"radroots_event_version": "1.0.0-alpha.1",
"radroots_event_codec_version": "1.0.0-alpha.1",
"contract_count": 11,
diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 b/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256
@@ -1 +1 @@
-9a8f8495aa9bbc040a707e2b77c415aa71949164cdf3647c91060d3baf36fe6f
+ce21d5e585215956386e0243d7ddebe568293b9635c68c2b42c07bd8984a7c4b
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -7,6 +7,7 @@ source = "rust"
domains = [
"blossom",
"event",
+ "food_availability",
"profile",
"farm",
"operational_listing",
@@ -55,8 +56,34 @@ public = [
"RadrootsEventRef",
"RadrootsEventPtr",
"RadrootsClassifiedListingAddress",
+ "RadrootsClassifiedListingPartition",
"RadrootsAuthoredImage",
"RadrootsAuthoredImageError",
+ "RadrootsFoodAvailabilityError",
+ "RadrootsFoodContent",
+ "RadrootsFoodIdentifier",
+ "RadrootsFoodText",
+ "RadrootsFoodPublishedAt",
+ "RadrootsFoodCurrency",
+ "RadrootsFoodUnit",
+ "RadrootsFoodPrice",
+ "RadrootsFoodQuantity",
+ "RadrootsFoodAvailabilityStatus",
+ "RadrootsFoodImageDimensions",
+ "RadrootsFoodAvailabilityImage",
+ "RadrootsFoodAvailabilityDetailsParts",
+ "RadrootsFoodAvailabilityDetails",
+ "RadrootsFoodAvailabilityEncodeError",
+ "RadrootsFoodAvailabilityImageDiagnostic",
+ "RadrootsInboundFoodAvailabilityImage",
+ "RadrootsInboundFoodAvailabilityProjection",
+ "RadrootsFoodAvailabilityProjectionOutcome",
+ "RadrootsFoodAvailabilityProjectionError",
+ "RadrootsAdmittedFoodAvailabilityEvent",
+ "RadrootsExcludedClassifiedListingCandidate",
+ "RadrootsFoodAvailabilityAdmissionOutcome",
+ "RadrootsFoodAvailabilityAdmissionError",
+ "RadrootsFoodAvailabilityRevisionError",
"RadrootsNip05Identifier",
"RadrootsNip05IdentifierError",
"RadrootsAuthoredProfile",
@@ -412,6 +439,138 @@ rust_types = [
[operations.event_select_head.conformance]
vector = "contracts/conformance/vectors/profile/verified_event.v1.json"
+[operations.food_availability_build_authored_draft]
+domain = "food_availability"
+id = "food_availability.build_authored_draft"
+stability = "beta"
+inputs = ["RadrootsFoodAvailabilityDetails", "u64"]
+outputs = ["RadrootsNip01EventWireParts"]
+error_class = "encode_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.food_availability_build_authored_draft.implementation]
+rust_modules = [
+ "crates/event/src/food_availability.rs",
+ "crates/event_codec/src/food_availability/authored.rs",
+]
+rust_types = [
+ "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityDetails",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityDetailsParts",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityError",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityImage",
+ "radroots_event::media::RadrootsAuthoredImage",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::food_availability::authored::RadrootsFoodAvailabilityEncodeError",
+]
+
+[operations.food_availability_build_authored_draft.conformance]
+vector = "contracts/conformance/vectors/food_availability/profile.v1.json"
+case_kinds = [
+ "food_availability.build_authored_draft.valid",
+ "food_availability.build_authored_draft.invalid",
+]
+
+[operations.food_availability_project_verified_event]
+domain = "food_availability"
+id = "food_availability.project_verified_event"
+stability = "beta"
+inputs = ["RadrootsSignatureVerifiedEvent"]
+outputs = ["RadrootsFoodAvailabilityProjectionOutcome"]
+error_class = "parse_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.food_availability_project_verified_event.implementation]
+rust_modules = [
+ "crates/event/src/classified_listing.rs",
+ "crates/event/src/food_availability.rs",
+ "crates/event_codec/src/food_availability/inbound.rs",
+]
+rust_types = [
+ "radroots_event::classified_listing::RadrootsClassifiedListingPartition",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityError",
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityImageDiagnostic",
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityProjectionError",
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityProjectionOutcome",
+ "radroots_event_codec::food_availability::inbound::RadrootsInboundFoodAvailabilityImage",
+ "radroots_event_codec::food_availability::inbound::RadrootsInboundFoodAvailabilityProjection",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+]
+
+[operations.food_availability_project_verified_event.conformance]
+vector = "contracts/conformance/vectors/food_availability/profile.v1.json"
+case_kinds = [
+ "food_availability.project_verified_event.valid",
+ "food_availability.project_verified_event.invalid",
+]
+
+[operations.food_availability_verify_and_admit_event]
+domain = "food_availability"
+id = "food_availability.verify_and_admit_event"
+stability = "beta"
+inputs = ["RadrootsEventEnvelope"]
+outputs = ["RadrootsFoodAvailabilityAdmissionOutcome"]
+error_class = "admission_error"
+deterministic = true
+signing = "nip01"
+transport = "none"
+
+[operations.food_availability_verify_and_admit_event.implementation]
+rust_modules = [
+ "crates/event_codec/src/food_availability/admission.rs",
+ "crates/event_codec/src/food_availability/inbound.rs",
+ "crates/event_codec/src/verification.rs",
+]
+rust_types = [
+ "radroots_event::RadrootsEventEnvelope",
+ "radroots_event_codec::food_availability::admission::RadrootsAdmittedFoodAvailabilityEvent",
+ "radroots_event_codec::food_availability::admission::RadrootsExcludedClassifiedListingCandidate",
+ "radroots_event_codec::food_availability::admission::RadrootsFoodAvailabilityAdmissionError",
+ "radroots_event_codec::food_availability::admission::RadrootsFoodAvailabilityAdmissionOutcome",
+ "radroots_event_codec::food_availability::inbound::RadrootsInboundFoodAvailabilityProjection",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+]
+
+[operations.food_availability_verify_and_admit_event.conformance]
+vector = "contracts/conformance/vectors/food_availability/profile.v1.json"
+case_kinds = [
+ "food_availability.verify_and_admit_event.valid",
+ "food_availability.verify_and_admit_event.invalid",
+]
+
+[operations.food_availability_validate_revision]
+domain = "food_availability"
+id = "food_availability.validate_revision"
+stability = "beta"
+inputs = ["RadrootsSignatureVerifiedEvent"]
+outputs = ["Unit"]
+error_class = "validation_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.food_availability_validate_revision.implementation]
+rust_modules = [
+ "crates/event_codec/src/food_availability/inbound.rs",
+ "crates/event_codec/src/food_availability/revision.rs",
+]
+rust_types = [
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityProjectionError",
+ "radroots_event_codec::food_availability::revision::RadrootsFoodAvailabilityRevisionError",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+]
+
+[operations.food_availability_validate_revision.conformance]
+vector = "contracts/conformance/vectors/food_availability/profile.v1.json"
+case_kinds = [
+ "food_availability.validate_revision.valid",
+ "food_availability.validate_revision.invalid",
+]
+
[operations.profile_build_authored_draft]
domain = "profile"
id = "profile.build_authored_draft"
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -155,3 +155,16 @@ semver_impacts = [
"add_enum_variant",
]
summary = "Add raw kind-30402 profile partitioning and validated FoodAvailability domain and media-input primitives without claiming a codec, authored draft, admission, signing, or publication boundary."
+
+[[changes]]
+id = "food-availability-codec-contract"
+classification = "breaking"
+semver_impacts = [
+ "add_exported_type",
+ "add_exported_constant",
+ "add_enum_variant",
+ "add_conformance_vector",
+ "change_exported_constant_value",
+ "change_exported_algorithm_behavior",
+]
+summary = "Add typed FoodAvailability encoding, signature-gated tolerant admission, strict signed-revision comparison, and partition-aware kind-30402 contract identification while advancing the event registry to version 4."
diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs
@@ -443,14 +443,14 @@ mod tests {
}
#[test]
- fn stale_v1_and_v2_draft_validation_failures_do_not_invoke_custom_signer() {
+ fn stale_registry_draft_validation_failures_do_not_invoke_custom_signer() {
let pubkey = hex_64('a');
let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = CountingSigner::new(pubkey.as_str());
- assert_eq!(RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 3);
- for actual in [1, 2] {
+ assert_eq!(RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 4);
+ for actual in [1, 2, 3] {
let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| {
Err(RadrootsDraftError::ContractRegistryVersionMismatch {
expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
@@ -463,7 +463,7 @@ mod tests {
error,
RadrootsAuthorityError::DraftValidation(
RadrootsDraftError::ContractRegistryVersionMismatch {
- expected: 3,
+ expected: 4,
actual,
}
)
diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs
@@ -407,24 +407,29 @@ mod tests {
}
#[test]
- fn stale_v1_and_v2_draft_validation_errors_preserve_typed_sources() {
+ fn stale_registry_draft_validation_errors_preserve_typed_sources() {
let cases = [
(
1,
- "draft validation failed: event contract registry version mismatch: expected 3, got 1",
- "event contract registry version mismatch: expected 3, got 1",
+ "draft validation failed: event contract registry version mismatch: expected 4, got 1",
+ "event contract registry version mismatch: expected 4, got 1",
),
(
2,
- "draft validation failed: event contract registry version mismatch: expected 3, got 2",
- "event contract registry version mismatch: expected 3, got 2",
+ "draft validation failed: event contract registry version mismatch: expected 4, got 2",
+ "event contract registry version mismatch: expected 4, got 2",
+ ),
+ (
+ 3,
+ "draft validation failed: event contract registry version mismatch: expected 4, got 3",
+ "event contract registry version mismatch: expected 4, got 3",
),
];
for (actual, expected_authority, expected_source) in cases {
let authority_error = RadrootsAuthorityError::DraftValidation(
radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch {
- expected: 3,
+ expected: 4,
actual,
},
);
diff --git a/crates/event/README b/crates/event/README
@@ -55,17 +55,20 @@ Image dimensions use two nonzero canonical `u32` decimal values in
`WIDTHxHEIGHT` form. Details accept at most 64 images, require unique image URLs
and Blossom digests, and accept only `RadrootsAuthoredImage` values that already
prove local descriptor-to-byte agreement. Details retain nonzero `published_at`
-for later replacement checks and can validate that it is not later than a
-supplied `created_at`; this checkpoint does not compare revisions or prove
-timestamp stability across them.
-
-These checked details are domain input, not a signable authored draft or wire
-event. This checkpoint supplies no FoodAvailability tag codec, event-contract
-registry entry, relay admission, signing, publication, upload receipt, raster
-decoding, network retrieval, or availability proof. Successful BUD-02 upload
-and any required media decoding or retrievability checks remain separate
-runtime responsibilities before a later publication boundary may sign an
-event.
+and can validate that it is not later than a supplied `created_at`.
+
+These checked details are the exclusive typed input to the focused
+`radroots.food.availability.v1` authoring contract. The event-contract registry
+classifies that kind-`30402` profile as `TypedOnly` and `AdmissionOnly`, so a
+generic unsigned classified listing cannot claim the focused contract. The
+details themselves remain domain values rather than signed events;
+`radroots_event_codec` owns deterministic wire construction, verified inbound
+projection and admission, and strict revision comparison.
+
+An authored image proves local descriptor-to-byte agreement only. Successful
+BUD-02 upload completion and any required raster, retrieval, or availability
+checks remain runtime responsibilities before signing. Neither this domain
+module nor the registry signs, publishes, replicates, or retrieves an event.
The calendar module keeps three different states explicit for NIP-52 kinds
`31922`, `31923`, `31924`, and `31925`: the complete structural event envelope,
diff --git a/crates/event/src/classified_listing.rs b/crates/event/src/classified_listing.rs
@@ -1,3 +1,8 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
+
use crate::{RadrootsEventTag, RadrootsEventTags};
pub const TAG_RADROOTS_PRICE_UNIT: &str = "radroots:price_unit";
@@ -33,11 +38,26 @@ pub fn classify_classified_listing_tags(
pub fn classify_classified_listing_tag_slice(
tags: &[RadrootsEventTag],
) -> RadrootsClassifiedListingPartition {
+ classify_classified_listing_tag_names(
+ tags.iter()
+ .map(|tag| tag.as_slice().first().map(String::as_str)),
+ )
+}
+
+pub(crate) fn classify_classified_listing_raw_tags(
+ tags: &[Vec<String>],
+) -> RadrootsClassifiedListingPartition {
+ classify_classified_listing_tag_names(tags.iter().map(|tag| tag.first().map(String::as_str)))
+}
+
+fn classify_classified_listing_tag_names<'a>(
+ names: impl IntoIterator<Item = Option<&'a str>>,
+) -> RadrootsClassifiedListingPartition {
let mut has_focused_marker = false;
let mut has_operational_marker = false;
- for tag in tags {
- let Some(name) = tag.as_slice().first().map(|value| value.as_str()) else {
+ for name in names {
+ let Some(name) = name else {
continue;
};
diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs
@@ -10,6 +10,10 @@ use crate::{
RadrootsCalendarUri, RadrootsIanaTimeZoneId, canonical_calendar_geohash_is_valid,
canonical_calendar_tag_text_is_valid, covered_utc_days,
},
+ classified_listing::{
+ RadrootsClassifiedListingPartition, TAG_RADROOTS_PRICE_UNIT, TAG_RADROOTS_QUANTITY,
+ classify_classified_listing_raw_tags,
+ },
ids::{
RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey,
relay_url_is_valid,
@@ -18,7 +22,7 @@ use crate::{
};
use radroots_blossom::RadrootsBlossomBlobUrl;
-pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 3;
+pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsEventClass {
@@ -202,6 +206,8 @@ pub enum RadrootsEventDiscriminator {
KindOnly,
/// Exact profile selection is owned by a verified admission algorithm.
AdmissionOnly,
+ /// Exact NIP-99 profile selection uses the central raw marker partition.
+ ClassifiedListingPartition(RadrootsClassifiedListingPartition),
DTagExact(&'static str),
DTagPrefix(&'static str),
DTagSuffix(&'static str),
@@ -731,6 +737,62 @@ const TAG_IMAGE: RadrootsTagContract = tag(
RadrootsTagValueType::Url,
false,
);
+const TAG_FOOD_TITLE: RadrootsTagContract = tag(
+ "title",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Title,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_FOOD_SUMMARY: RadrootsTagContract = tag(
+ "summary",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Summary,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_FOOD_PUBLISHED_AT: RadrootsTagContract = tag(
+ "published_at",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::PublishedAt,
+ RadrootsTagValueType::UnixTimestamp,
+ false,
+);
+const TAG_FOOD_LOCATION: RadrootsTagContract = tag(
+ "location",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Location,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_FOOD_PRICE: RadrootsTagContract = tag(
+ "price",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Price,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_FOOD_PRICE_UNIT: RadrootsTagContract = tag(
+ TAG_RADROOTS_PRICE_UNIT,
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Price,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_FOOD_QUANTITY: RadrootsTagContract = tag(
+ TAG_RADROOTS_QUANTITY,
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Price,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_FOOD_STATUS: RadrootsTagContract = tag(
+ "status",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Status,
+ RadrootsTagValueType::Text,
+ false,
+);
const TAG_OPERATIONAL_LISTING_FARM: RadrootsTagContract = tag(
"a",
RadrootsTagCardinality::RequiredOne,
@@ -1033,6 +1095,18 @@ const CALENDAR_RSVP_TAGS: &[RadrootsTagContract] = &[
TAG_CALENDAR_RSVP_AUTHOR,
];
const FARM_TAGS: &[RadrootsTagContract] = &[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_IMAGE];
+const FOOD_AVAILABILITY_TAGS: &[RadrootsTagContract] = &[
+ TAG_D,
+ TAG_FOOD_TITLE,
+ TAG_FOOD_SUMMARY,
+ TAG_FOOD_PUBLISHED_AT,
+ TAG_FOOD_LOCATION,
+ TAG_FOOD_PRICE,
+ TAG_FOOD_PRICE_UNIT,
+ TAG_FOOD_QUANTITY,
+ TAG_FOOD_STATUS,
+ TAG_IMAGE,
+];
const OPERATIONAL_LISTING_TAGS: &[RadrootsTagContract] = &[
TAG_D,
TAG_P_REQUIRED,
@@ -1114,6 +1188,7 @@ const OPERATIONAL_LISTING_REDUCERS: &[RadrootsReducer] = &[
RadrootsReducer::MarketProjection,
RadrootsReducer::OperationalListingInventoryAccounting,
];
+const FOOD_AVAILABILITY_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::MarketProjection];
const TRADE_MUTATION_REDUCERS: &[RadrootsReducer] = &[
RadrootsReducer::TradeProjection,
RadrootsReducer::OperationalListingInventoryAccounting,
@@ -1880,7 +1955,10 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[
"Classified Listing",
RadrootsEventClass::Addressable,
RadrootsNostrStandard::Nip99,
- ["radroots.operational_listing.published.v1"]
+ [
+ "radroots.operational_listing.published.v1",
+ "radroots.food.availability.v1"
+ ]
),
kind_contract!(
KIND_KNOWLEDGE_SOURCE,
@@ -3091,10 +3169,26 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[
RadrootsEventPrivacy::Public,
RadrootsActorRole::Seller,
RadrootsContentSchema::Markdown,
- RadrootsEventDiscriminator::KindOnly,
+ RadrootsEventDiscriminator::ClassifiedListingPartition(
+ RadrootsClassifiedListingPartition::OperationalListing,
+ ),
OPERATIONAL_LISTING_TAGS,
OPERATIONAL_LISTING_REDUCERS
),
+ event_contract_with_authoring_policy!(
+ "radroots.food.availability.v1",
+ KIND_CLASSIFIED_LISTING,
+ "Food Availability",
+ "RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection",
+ RadrootsEventClass::Addressable,
+ RadrootsEventPrivacy::Public,
+ RadrootsActorRole::Seller,
+ RadrootsContentSchema::Markdown,
+ RadrootsEventAuthoringPolicy::TypedOnly,
+ RadrootsEventDiscriminator::AdmissionOnly,
+ FOOD_AVAILABILITY_TAGS,
+ FOOD_AVAILABILITY_REDUCERS
+ ),
experimental_event_contract!(
"radroots.knowledge.source.v1",
KIND_KNOWLEDGE_SOURCE,
@@ -3564,6 +3658,7 @@ pub fn validate_event_contract_parts(
contract_id: contract.id,
});
}
+ validate_classified_listing_partition_parts(tags, contract)?;
validate_content_shape_parts(content, contract)?;
validate_contract_tags_parts(tags, contract)?;
validate_discriminator_parts(content, contract)?;
@@ -3572,6 +3667,23 @@ pub fn validate_event_contract_parts(
Ok(())
}
+fn validate_classified_listing_partition_parts(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ let RadrootsEventDiscriminator::ClassifiedListingPartition(expected) = contract.discriminator
+ else {
+ return Ok(());
+ };
+ if classify_classified_listing_raw_tags(tags) == expected {
+ Ok(())
+ } else {
+ Err(RadrootsContractValidationError::ContractMatch {
+ error: RadrootsContractMatchError::UnsupportedShape(contract.kind),
+ })
+ }
+}
+
fn identify_from_contracts<'a, I>(
contracts: I,
kind: u32,
@@ -3617,7 +3729,7 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> {
Some(RadrootsContractFamily::Knowledge)
} else if id.starts_with("radroots.list.") || id.starts_with("radroots.list_set.") {
Some(RadrootsContractFamily::List)
- } else if id.starts_with("radroots.operational_listing.") {
+ } else if id.starts_with("radroots.operational_listing.") || id.starts_with("radroots.food.") {
Some(RadrootsContractFamily::Market)
} else if id.starts_with("radroots.message.") {
Some(RadrootsContractFamily::Message)
@@ -4526,6 +4638,9 @@ fn discriminator_matches(
match discriminator {
RadrootsEventDiscriminator::KindOnly => true,
RadrootsEventDiscriminator::AdmissionOnly => false,
+ RadrootsEventDiscriminator::ClassifiedListingPartition(expected) => {
+ classify_classified_listing_raw_tags(tags) == *expected
+ }
RadrootsEventDiscriminator::DTagExact(expected) => tag_value(tags, "d") == Some(*expected),
RadrootsEventDiscriminator::DTagPrefix(prefix) => tag_value(tags, "d")
.map(|value| value.starts_with(prefix))
@@ -5100,7 +5215,7 @@ mod tests {
}
#[test]
- fn classified_listing_kind_and_operational_profile_are_distinct() {
+ fn classified_listing_kind_profiles_are_partitioned_and_explicit() {
let kind = kind_contract(KIND_CLASSIFIED_LISTING).expect("classified listing kind");
assert_eq!(kind.canonical_constant, "KIND_CLASSIFIED_LISTING");
assert_eq!(kind.name, "Classified Listing");
@@ -5108,15 +5223,66 @@ mod tests {
assert_eq!(kind.standard, RadrootsNostrStandard::Nip99);
assert_eq!(
kind.accepted_event_contracts,
- &["radroots.operational_listing.published.v1"]
+ &[
+ "radroots.operational_listing.published.v1",
+ "radroots.food.availability.v1",
+ ]
);
- let profile = event_contract("radroots.operational_listing.published.v1")
+ let operational = event_contract("radroots.operational_listing.published.v1")
.expect("operational listing profile");
- assert_eq!(profile.name, "Operational Listing");
- assert_eq!(profile.payload_type, "RadrootsOperationalListing");
- assert_eq!(profile.content_schema, RadrootsContentSchema::Markdown);
- assert_eq!(profile.discriminator, RadrootsEventDiscriminator::KindOnly);
+ assert_eq!(operational.name, "Operational Listing");
+ assert_eq!(operational.payload_type, "RadrootsOperationalListing");
+ assert_eq!(operational.content_schema, RadrootsContentSchema::Markdown);
+ assert_eq!(
+ operational.discriminator,
+ RadrootsEventDiscriminator::ClassifiedListingPartition(
+ RadrootsClassifiedListingPartition::OperationalListing,
+ )
+ );
+
+ let food = event_contract("radroots.food.availability.v1")
+ .expect("focused food availability profile");
+ assert_eq!(food.name, "Food Availability");
+ assert_eq!(
+ food.payload_type,
+ "RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection"
+ );
+ assert_eq!(food.class, RadrootsEventClass::Addressable);
+ assert_eq!(food.privacy, RadrootsEventPrivacy::Public);
+ assert_eq!(food.author_role, RadrootsActorRole::Seller);
+ assert_eq!(food.content_schema, RadrootsContentSchema::Markdown);
+ assert_eq!(
+ food.authoring_policy,
+ RadrootsEventAuthoringPolicy::TypedOnly
+ );
+ assert_eq!(
+ food.discriminator,
+ RadrootsEventDiscriminator::AdmissionOnly
+ );
+ assert_eq!(food.reducers, &[RadrootsReducer::MarketProjection]);
+ assert_eq!(
+ event_contract_family(food),
+ Some(RadrootsContractFamily::Market)
+ );
+ assert_eq!(
+ food.tags
+ .iter()
+ .map(|tag| (tag.name, tag.cardinality))
+ .collect::<Vec<_>>(),
+ vec![
+ ("d", RadrootsTagCardinality::RequiredOne),
+ ("title", RadrootsTagCardinality::RequiredOne),
+ ("summary", RadrootsTagCardinality::RequiredOne),
+ ("published_at", RadrootsTagCardinality::RequiredOne),
+ ("location", RadrootsTagCardinality::RequiredOne),
+ ("price", RadrootsTagCardinality::RequiredOne),
+ ("radroots:price_unit", RadrootsTagCardinality::RequiredOne),
+ ("radroots:quantity", RadrootsTagCardinality::OptionalOne),
+ ("status", RadrootsTagCardinality::RequiredOne),
+ ("image", RadrootsTagCardinality::OptionalMany),
+ ]
+ );
assert!(event_contract("radroots.listing.published.v1").is_none());
let seller = "a".repeat(64);
@@ -5140,6 +5306,76 @@ mod tests {
),
Ok(())
);
+ assert_eq!(
+ identify_event_contract(KIND_CLASSIFIED_LISTING, &tags, "# Carrots")
+ .expect("operational discriminator")
+ .id,
+ operational.id
+ );
+
+ let malformed_operational = vec![owned_tag(&["radroots:bin"])];
+ assert_eq!(
+ identify_event_contract(KIND_CLASSIFIED_LISTING, &malformed_operational, "Carrots",)
+ .expect("raw operational marker partitions before shape validation")
+ .id,
+ operational.id
+ );
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CLASSIFIED_LISTING,
+ &malformed_operational,
+ "Carrots",
+ operational.id,
+ ),
+ Err(RadrootsContractValidationError::MissingTag {
+ contract_id: "radroots.operational_listing.published.v1",
+ name: "d",
+ })
+ );
+
+ let focused = vec![owned_tag(&["radroots:price_unit", "lb"])];
+ assert_eq!(
+ identify_event_contract(KIND_CLASSIFIED_LISTING, &focused, "Carrots"),
+ Err(RadrootsContractMatchError::UnsupportedShape(
+ KIND_CLASSIFIED_LISTING
+ ))
+ );
+ assert_eq!(
+ validate_event_contract_parts(KIND_CLASSIFIED_LISTING, &focused, "Carrots", food.id,),
+ Err(RadrootsContractValidationError::AdmissionRequired {
+ contract_id: "radroots.food.availability.v1",
+ })
+ );
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CLASSIFIED_LISTING,
+ &focused,
+ "Carrots",
+ operational.id,
+ ),
+ Err(RadrootsContractValidationError::ContractMatch {
+ error: RadrootsContractMatchError::UnsupportedShape(KIND_CLASSIFIED_LISTING),
+ })
+ );
+
+ let generic = vec![owned_tag(&["d", "carrots"])];
+ assert_eq!(
+ identify_event_contract(KIND_CLASSIFIED_LISTING, &generic, "Carrots"),
+ Err(RadrootsContractMatchError::UnsupportedShape(
+ KIND_CLASSIFIED_LISTING
+ ))
+ );
+
+ let ambiguous = vec![
+ owned_tag(&["radroots:price_unit"]),
+ owned_tag(&["radroots:primary_bin"]),
+ ];
+ assert_eq!(
+ identify_event_contract(KIND_CLASSIFIED_LISTING, &ambiguous, "Carrots"),
+ Err(RadrootsContractMatchError::UnsupportedShape(
+ KIND_CLASSIFIED_LISTING
+ ))
+ );
}
#[test]
@@ -5233,6 +5469,10 @@ mod tests {
Some(RadrootsContractFamily::Market),
),
(
+ "radroots.food.test.v1",
+ Some(RadrootsContractFamily::Market),
+ ),
+ (
"radroots.message.test.v1",
Some(RadrootsContractFamily::Message),
),
diff --git a/crates/event/src/food_availability.rs b/crates/event/src/food_availability.rs
@@ -4,7 +4,9 @@ use alloc::{string::String, vec::Vec};
use std::{string::String, vec::Vec};
use core::{fmt, str::FromStr};
+use radroots_blossom::{RadrootsBlossomHashPath, RadrootsBlossomSha256};
use unicode_general_category::{GeneralCategory, get_general_category};
+use url_nostd::{Host, Url};
use crate::media::RadrootsAuthoredImage;
@@ -13,6 +15,7 @@ pub const RADROOTS_FOOD_IDENTIFIER_MAX_BYTES: usize = 512;
pub const RADROOTS_FOOD_TEXT_MAX_BYTES: usize = 4 * 1024;
pub const RADROOTS_FOOD_DECIMAL_MAX_DIGITS: usize = 28;
pub const RADROOTS_FOOD_IMAGE_MAX_COUNT: usize = 64;
+pub const RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID: &str = "radroots.food.availability.v1";
/// Errors raised while constructing strict FoodAvailability details.
#[non_exhaustive]
@@ -748,6 +751,109 @@ fn validate_images(
Ok(())
}
+/// Returns whether an inbound FoodAvailability image is a structural HTTP(S) URL.
+///
+/// This is deliberately broader than strict authored Blossom policy. Success
+/// makes no byte-verification, upload, reachability, or media-safety claim.
+pub fn food_media_http_url_is_valid(value: &str) -> bool {
+ if !value.contains("://")
+ || value.chars().any(|character| {
+ character.is_whitespace()
+ || matches!(
+ get_general_category(character),
+ GeneralCategory::Control | GeneralCategory::Format
+ )
+ })
+ {
+ return false;
+ }
+
+ let Ok(url) = Url::parse(value) else {
+ return false;
+ };
+ if !matches!(url.scheme(), "http" | "https")
+ || !url.username().is_empty()
+ || url.password().is_some()
+ {
+ return false;
+ }
+
+ let Some((raw_host, raw_path)) = raw_food_media_host_and_path(value) else {
+ return false;
+ };
+ if raw_path.is_empty() || !raw_path.starts_with('/') {
+ return false;
+ }
+
+ match url.host() {
+ Some(Host::Domain(_)) => raw_food_dns_host_is_valid(raw_host),
+ Some(Host::Ipv4(_)) => raw_host.is_ascii() && !raw_host.is_empty(),
+ Some(Host::Ipv6(_)) => raw_host.is_ascii() && !raw_host.is_empty(),
+ None => false,
+ }
+}
+
+/// Extracts a structural Blossom hash from an otherwise valid inbound URL.
+///
+/// A `None` result does not invalidate a standard inbound NIP-58 image URL; it
+/// only means duplicate-digest diagnostics cannot be derived from its path.
+pub fn food_media_blossom_digest(value: &str) -> Option<RadrootsBlossomSha256> {
+ if !food_media_http_url_is_valid(value) {
+ return None;
+ }
+ let url = Url::parse(value).ok()?;
+ RadrootsBlossomHashPath::parse(url.path())
+ .ok()
+ .map(|path| path.hash())
+}
+
+fn raw_food_media_host_and_path(value: &str) -> Option<(&str, &str)> {
+ let (_, remainder) = value.split_once("://")?;
+ let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
+ let authority = &remainder[..authority_end];
+ if authority.is_empty() || authority.contains('@') {
+ return None;
+ }
+ let path_and_suffix = &remainder[authority_end..];
+ let path_end = path_and_suffix
+ .find(['?', '#'])
+ .unwrap_or(path_and_suffix.len());
+ let raw_path = &path_and_suffix[..path_end];
+
+ let raw_host = if let Some(bracketed) = authority.strip_prefix('[') {
+ let (host, suffix) = bracketed.split_once(']')?;
+ if !suffix.is_empty() && !suffix.starts_with(':') {
+ return None;
+ }
+ host
+ } else if let Some((host, _port)) = authority.rsplit_once(':') {
+ if host.contains(':') {
+ return None;
+ }
+ host
+ } else {
+ authority
+ };
+
+ Some((raw_host, raw_path))
+}
+
+fn raw_food_dns_host_is_valid(host: &str) -> bool {
+ !host.is_empty()
+ && host.is_ascii()
+ && host.len() <= 253
+ && host.split('.').all(|label| {
+ let bytes = label.as_bytes();
+ !bytes.is_empty()
+ && bytes.len() <= 63
+ && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
+ && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
+ && bytes
+ .iter()
+ .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
+ })
+}
+
fn validate_canonical_decimal(value: &str) -> bool {
let mut digits = 0usize;
let mut seen_dot = false;
@@ -914,6 +1020,38 @@ mod tests {
}
#[test]
+ fn inbound_food_media_urls_are_structural_without_claiming_blossom() {
+ let hash = RadrootsBlossomSha256::digest(b"carrots").to_string();
+ for valid in [
+ format!("https://media.example/{hash}.webp"),
+ format!("http://media.example:0/{hash}?download=1"),
+ "https://media.example/not-a-blossom-path.jpg".to_string(),
+ format!("https://[::1]/{hash}"),
+ ] {
+ assert!(food_media_http_url_is_valid(&valid), "{valid}");
+ }
+ for invalid in [
+ format!("ftp://media.example/{hash}"),
+ format!("https://user@media.example/{hash}"),
+ "https://media.example".to_string(),
+ format!("https://média.example/{hash}"),
+ format!("https://media.example/\u{200b}{hash}"),
+ ] {
+ assert!(!food_media_http_url_is_valid(&invalid), "{invalid}");
+ }
+
+ let blossom = format!("https://media.example/{hash}.webp?download=1");
+ assert_eq!(
+ food_media_blossom_digest(&blossom),
+ Some(RadrootsBlossomSha256::from_hex(&hash).unwrap())
+ );
+ assert_eq!(
+ food_media_blossom_digest("https://media.example/not-a-hash.jpg"),
+ None
+ );
+ }
+
+ #[test]
fn identifier_enforces_bytes_whitespace_and_unicode_categories() {
let exact = "a".repeat(RADROOTS_FOOD_IDENTIFIER_MAX_BYTES);
assert_eq!(
diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml
@@ -14,7 +14,7 @@ readme = "README"
[features]
default = ["std"]
-std = ["radroots_core/std", "radroots_event/std"]
+std = ["radroots_blossom/std", "radroots_core/std", "radroots_event/std"]
serde = ["dep:serde", "radroots_core/serde", "radroots_event/serde"]
serde_json = ["serde", "dep:serde_json"]
nostr = ["dep:nostr", "std"]
@@ -23,6 +23,7 @@ knowledge-nip54 = ["knowledge", "radroots_event/knowledge-nip54"]
contract-manifest = ["knowledge", "dep:hex", "dep:sha2"]
[dependencies]
+radroots_blossom = { workspace = true, default-features = false }
radroots_core = { workspace = true, default-features = false }
radroots_event = { workspace = true, default-features = false }
serde = { workspace = true, default-features = false, features = [
diff --git a/crates/event_codec/README b/crates/event_codec/README
@@ -41,6 +41,41 @@ remain ordered; malformed media becomes diagnostic Update unless a valid Ask
marker takes precedence. Structural inbound URLs remain unverified and no
network retrieval occurs.
+The FoodAvailability codec owns four boundaries for the focused
+`radroots.food.availability.v1` kind-`30402` profile. Strict details plus
+`created_at` produce bounded unsigned wire parts whose tag order is exactly
+`d`, `title`, `summary`, `published_at`, `location`, `price`,
+`radroots:price_unit`, optional `radroots:quantity`, `status`, and repeated
+`image`. Content and decoded tags remain bounded, and compact signed-event size
+is checked after exact JSON escaping. Authored images already prove local
+Blossom descriptor-to-byte agreement, but not upload completion or network
+availability.
+
+Verified inbound projection partitions raw tag names before validation.
+Focused-only candidates are projected, Operational Listing and marker-free
+generic NIP-99 candidates are explicit exclusions, and mixed-marker candidates
+fail as ambiguous. Focused core fields are required; decimal and currency
+spellings are normalized within their wire bounds; optional unowned NIP-99
+tags are ignored; and tags purporting to add commerce, fulfillment,
+provenance, or operational capabilities fail closed. Image observations remain
+ordered structural HTTP(S) data. At most 64 are retained, with stable ordered
+diagnostics for overflow, shape, URL, dimensions, duplicate URL, and duplicate
+Blossom digest. An image diagnostic does not invalidate an otherwise complete
+focused core and never upgrades observed media to an authored typestate.
+
+`verify_and_admit_food_availability_event` first verifies the NIP-01 id and
+Schnorr signature, then binds either the focused projection or an explicit
+non-focused exclusion to that verified envelope. Revision validation accepts
+two independently verified events, requires both to satisfy the exact authored
+wire profile, preserves kind, author, `d`, and `published_at`, and applies the
+NIP-01 newer-event rule: later `created_at`, then lower event id at equal time.
+Successful combined verification and admission uses the optional `nostr`
+feature; without it, signature verification returns the stable unavailable
+error rather than admitting an event. The packaged conformance runner enables
+`serde_json,nostr` for signed JSON event cases.
+These codecs do not sign, publish, replicate, upload, retrieve, or provide
+client behavior.
+
The NIP-52 calendar codecs expose a deliberate three-stage boundary for kinds
`31922`, `31923`, `31924`, and `31925`:
diff --git a/crates/event_codec/src/food_availability/admission.rs b/crates/event_codec/src/food_availability/admission.rs
@@ -0,0 +1,174 @@
+#[cfg(not(feature = "std"))]
+use alloc::boxed::Box;
+
+use core::fmt;
+
+use radroots_event::{
+ RadrootsEventEnvelope, classified_listing::RadrootsClassifiedListingPartition,
+ contract::RadrootsEventContract, food_availability::RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID,
+};
+
+use crate::{
+ food_availability::inbound::{
+ RadrootsFoodAvailabilityProjectionError, RadrootsFoodAvailabilityProjectionOutcome,
+ RadrootsInboundFoodAvailabilityProjection, project_verified_food_availability_event,
+ },
+ verification::{
+ RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event,
+ },
+};
+
+/// A verified focused FoodAvailability event bound to its tolerant projection.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAdmittedFoodAvailabilityEvent {
+ verified_event: RadrootsSignatureVerifiedEvent,
+ projection: RadrootsInboundFoodAvailabilityProjection,
+}
+
+impl RadrootsAdmittedFoodAvailabilityEvent {
+ pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent {
+ &self.verified_event
+ }
+
+ pub fn event(&self) -> &RadrootsEventEnvelope {
+ self.verified_event.event()
+ }
+
+ pub fn projection(&self) -> &RadrootsInboundFoodAvailabilityProjection {
+ &self.projection
+ }
+
+ pub fn contract(&self) -> &'static RadrootsEventContract {
+ radroots_event::contract::event_contract(RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID)
+ .expect("FoodAvailability contract ID is registry-owned")
+ }
+
+ pub fn into_parts(
+ self,
+ ) -> (
+ RadrootsSignatureVerifiedEvent,
+ RadrootsInboundFoodAvailabilityProjection,
+ ) {
+ (self.verified_event, self.projection)
+ }
+}
+
+/// A verified kind-30402 event excluded from the focused Food profile.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsExcludedClassifiedListingCandidate {
+ verified_event: RadrootsSignatureVerifiedEvent,
+ partition: RadrootsClassifiedListingPartition,
+}
+
+impl RadrootsExcludedClassifiedListingCandidate {
+ pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent {
+ &self.verified_event
+ }
+
+ pub fn event(&self) -> &RadrootsEventEnvelope {
+ self.verified_event.event()
+ }
+
+ pub const fn partition(&self) -> RadrootsClassifiedListingPartition {
+ self.partition
+ }
+
+ pub fn into_parts(
+ self,
+ ) -> (
+ RadrootsSignatureVerifiedEvent,
+ RadrootsClassifiedListingPartition,
+ ) {
+ (self.verified_event, self.partition)
+ }
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityAdmissionOutcome {
+ Admitted(Box<RadrootsAdmittedFoodAvailabilityEvent>),
+ Excluded(RadrootsExcludedClassifiedListingCandidate),
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityAdmissionError {
+ Nip01Verification(RadrootsNip01VerificationError),
+ Projection(RadrootsFoodAvailabilityProjectionError),
+}
+
+impl RadrootsFoodAvailabilityAdmissionError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::Nip01Verification(error) => error.code(),
+ Self::Projection(error) => error.code(),
+ }
+ }
+}
+
+impl fmt::Display for RadrootsFoodAvailabilityAdmissionError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Nip01Verification(error) => write!(formatter, "{error}"),
+ Self::Projection(error) => write!(formatter, "{error}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsFoodAvailabilityAdmissionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Nip01Verification(error) => Some(error),
+ Self::Projection(error) => Some(error),
+ }
+ }
+}
+
+impl From<RadrootsNip01VerificationError> for RadrootsFoodAvailabilityAdmissionError {
+ fn from(value: RadrootsNip01VerificationError) -> Self {
+ Self::Nip01Verification(value)
+ }
+}
+
+impl From<RadrootsFoodAvailabilityProjectionError> for RadrootsFoodAvailabilityAdmissionError {
+ fn from(value: RadrootsFoodAvailabilityProjectionError) -> Self {
+ Self::Projection(value)
+ }
+}
+
+/// Admits or explicitly excludes an already verified classified listing.
+pub fn admit_verified_food_availability_event(
+ verified_event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsFoodAvailabilityAdmissionOutcome, RadrootsFoodAvailabilityAdmissionError> {
+ match project_verified_food_availability_event(&verified_event)? {
+ RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) => {
+ Ok(RadrootsFoodAvailabilityAdmissionOutcome::Admitted(
+ Box::new(RadrootsAdmittedFoodAvailabilityEvent {
+ verified_event,
+ projection: *projection,
+ }),
+ ))
+ }
+ RadrootsFoodAvailabilityProjectionOutcome::Excluded(partition) => {
+ debug_assert!(matches!(
+ partition,
+ RadrootsClassifiedListingPartition::OperationalListing
+ | RadrootsClassifiedListingPartition::GenericNip99
+ ));
+ Ok(RadrootsFoodAvailabilityAdmissionOutcome::Excluded(
+ RadrootsExcludedClassifiedListingCandidate {
+ verified_event,
+ partition,
+ },
+ ))
+ }
+ }
+}
+
+/// Verifies NIP-01 id and signature before focused Food admission.
+pub fn verify_and_admit_food_availability_event(
+ event: RadrootsEventEnvelope,
+) -> Result<RadrootsFoodAvailabilityAdmissionOutcome, RadrootsFoodAvailabilityAdmissionError> {
+ admit_verified_food_availability_event(verify_nip01_event(event)?)
+}
diff --git a/crates/event_codec/src/food_availability/authored.rs b/crates/event_codec/src/food_availability/authored.rs
@@ -0,0 +1,282 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, string::ToString, vec, vec::Vec};
+
+use core::fmt;
+use radroots_event::{
+ RadrootsEventEnvelopeError, RadrootsEventTags,
+ classified_listing::{TAG_RADROOTS_PRICE_UNIT, TAG_RADROOTS_QUANTITY},
+ food_availability::{RadrootsFoodAvailabilityDetails, RadrootsFoodAvailabilityError},
+ kinds::KIND_CLASSIFIED_LISTING,
+ tags::{
+ TAG_D, TAG_IMAGE, TAG_LOCATION, TAG_PRICE, TAG_PUBLISHED_AT, TAG_STATUS, TAG_SUMMARY,
+ TAG_TITLE,
+ },
+ wire::{DEFAULT_RAW_JSON_MAX_BYTES, RadrootsNip01EventWireParts},
+};
+
+const FOOD_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len()
+ + 64
+ + "\",\"pubkey\":\"".len()
+ + 64
+ + "\",\"created_at\":".len()
+ + ",\"kind\":30402,\"tags\":".len()
+ + ",\"content\":".len()
+ + ",\"sig\":\"".len()
+ + 128
+ + "\"}".len();
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityEncodeError {
+ Domain(RadrootsFoodAvailabilityError),
+ Wire(RadrootsEventEnvelopeError),
+ EventWireTooLarge { max: usize, actual: usize },
+}
+
+impl RadrootsFoodAvailabilityEncodeError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::Domain(error) => error.code(),
+ Self::Wire(RadrootsEventEnvelopeError::TagElementTooLarge { .. }) => {
+ "food_tag_element_too_large"
+ }
+ Self::Wire(RadrootsEventEnvelopeError::TagsTooLarge { .. }) => {
+ "food_tag_bytes_exceeded"
+ }
+ Self::Wire(_) => "food_wire_invalid",
+ Self::EventWireTooLarge { .. } => "food_event_wire_too_large",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsFoodAvailabilityEncodeError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Domain(error) => write!(formatter, "{error}"),
+ Self::Wire(error) => write!(formatter, "invalid FoodAvailability wire parts: {error}"),
+ Self::EventWireTooLarge { max, actual } => write!(
+ formatter,
+ "FoodAvailability canonical signed event would be {actual} bytes; max is {max}"
+ ),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsFoodAvailabilityEncodeError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Domain(error) => Some(error),
+ Self::Wire(error) => Some(error),
+ Self::EventWireTooLarge { .. } => None,
+ }
+ }
+}
+
+impl From<RadrootsFoodAvailabilityError> for RadrootsFoodAvailabilityEncodeError {
+ fn from(value: RadrootsFoodAvailabilityError) -> Self {
+ Self::Domain(value)
+ }
+}
+
+impl From<RadrootsEventEnvelopeError> for RadrootsFoodAvailabilityEncodeError {
+ fn from(value: RadrootsEventEnvelopeError) -> Self {
+ Self::Wire(value)
+ }
+}
+
+/// Builds exact canonical NIP-99 tags for strict FoodAvailability details.
+pub fn authored_food_availability_build_tags(
+ details: &RadrootsFoodAvailabilityDetails,
+ created_at: u64,
+) -> Result<Vec<Vec<String>>, RadrootsFoodAvailabilityEncodeError> {
+ details.validate_created_at(created_at)?;
+ let mut tags =
+ Vec::with_capacity(8 + usize::from(details.quantity().is_some()) + details.images().len());
+ tags.push(vec![TAG_D.into(), details.identifier().as_str().into()]);
+ tags.push(vec![TAG_TITLE.into(), details.title().as_str().into()]);
+ tags.push(vec![TAG_SUMMARY.into(), details.summary().as_str().into()]);
+ tags.push(vec![
+ TAG_PUBLISHED_AT.into(),
+ details.published_at().to_string(),
+ ]);
+ tags.push(vec![
+ TAG_LOCATION.into(),
+ details.location().as_str().into(),
+ ]);
+ tags.push(vec![
+ TAG_PRICE.into(),
+ details.price().amount().into(),
+ details.price().currency().as_str().into(),
+ ]);
+ tags.push(vec![
+ TAG_RADROOTS_PRICE_UNIT.into(),
+ details.price().unit().as_str().into(),
+ ]);
+ if let Some(quantity) = details.quantity() {
+ tags.push(vec![
+ TAG_RADROOTS_QUANTITY.into(),
+ quantity.amount().into(),
+ quantity.unit().as_str().into(),
+ ]);
+ }
+ tags.push(vec![TAG_STATUS.into(), details.status().as_str().into()]);
+ tags.extend(details.images().iter().map(|image| {
+ vec![
+ TAG_IMAGE.into(),
+ image.url().into(),
+ image.dimensions().to_string(),
+ ]
+ }));
+
+ RadrootsEventTags::new(tags.clone())?;
+ Ok(tags)
+}
+
+/// Builds deterministic unsigned kind-30402 wire parts for strict FoodAvailability details.
+///
+/// Every image is already byte-verified by the input typestate. A publication
+/// runtime must still prove BUD-02 upload completion before signing.
+pub fn authored_food_availability_to_wire_parts(
+ details: &RadrootsFoodAvailabilityDetails,
+ created_at: u64,
+) -> Result<RadrootsNip01EventWireParts, RadrootsFoodAvailabilityEncodeError> {
+ let tags = authored_food_availability_build_tags(details, created_at)?;
+ validate_compact_signed_wire_size(&tags, details.content().as_str(), created_at)?;
+ Ok(RadrootsNip01EventWireParts {
+ kind: KIND_CLASSIFIED_LISTING,
+ content: details.content().as_str().into(),
+ tags,
+ })
+}
+
+fn validate_compact_signed_wire_size(
+ tags: &[Vec<String>],
+ content: &str,
+ created_at: u64,
+) -> Result<(), RadrootsFoodAvailabilityEncodeError> {
+ let actual = canonical_food_signed_event_size(tags, content, created_at);
+ if actual > DEFAULT_RAW_JSON_MAX_BYTES {
+ return Err(RadrootsFoodAvailabilityEncodeError::EventWireTooLarge {
+ max: DEFAULT_RAW_JSON_MAX_BYTES,
+ actual,
+ });
+ }
+ Ok(())
+}
+
+pub(crate) fn canonical_food_signed_event_size(
+ tags: &[Vec<String>],
+ content: &str,
+ created_at: u64,
+) -> usize {
+ let mut tags_bytes = 2usize;
+ for (tag_index, tag) in tags.iter().enumerate() {
+ if tag_index > 0 {
+ tags_bytes = tags_bytes.saturating_add(1);
+ }
+ tags_bytes = tags_bytes.saturating_add(2);
+ for (element_index, element) in tag.iter().enumerate() {
+ if element_index > 0 {
+ tags_bytes = tags_bytes.saturating_add(1);
+ }
+ tags_bytes = tags_bytes.saturating_add(canonical_json_string_bytes(element));
+ }
+ }
+ FOOD_SIGNED_EVENT_FIXED_BYTES
+ .saturating_add(decimal_u64_bytes(created_at))
+ .saturating_add(tags_bytes)
+ .saturating_add(canonical_json_string_bytes(content))
+}
+
+fn decimal_u64_bytes(mut value: u64) -> usize {
+ let mut bytes = 1usize;
+ while value >= 10 {
+ value /= 10;
+ bytes += 1;
+ }
+ bytes
+}
+
+fn canonical_json_string_bytes(value: &str) -> usize {
+ value.chars().fold(2usize, |total, character| {
+ total.saturating_add(match character {
+ '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2,
+ '\u{0000}'..='\u{001f}' => 6,
+ _ => character.len_utf8(),
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_event::food_availability::{
+ RadrootsFoodAvailabilityDetailsParts, RadrootsFoodAvailabilityStatus, RadrootsFoodContent,
+ RadrootsFoodCurrency, RadrootsFoodIdentifier, RadrootsFoodPrice, RadrootsFoodPublishedAt,
+ RadrootsFoodQuantity, RadrootsFoodText, RadrootsFoodUnit,
+ };
+
+ #[test]
+ fn emits_the_exact_canonical_food_tag_order() {
+ let details = details("Carrots available this week.");
+ let expected: Vec<Vec<String>> = vec![
+ vec!["d".into(), "nantes-carrots".into()],
+ vec!["title".into(), "Nantes Carrots".into()],
+ vec!["summary".into(), "Fresh bunches".into()],
+ vec!["published_at".into(), "100".into()],
+ vec!["location".into(), "Central Saanich, BC".into()],
+ vec!["price".into(), "3".into(), "CAD".into()],
+ vec!["radroots:price_unit".into(), "lb".into()],
+ vec!["radroots:quantity".into(), "24".into(), "lb".into()],
+ vec!["status".into(), "active".into()],
+ ];
+
+ assert_eq!(
+ authored_food_availability_build_tags(&details, 200).unwrap(),
+ expected
+ );
+ let parts = authored_food_availability_to_wire_parts(&details, 200).unwrap();
+ assert_eq!(parts.kind, KIND_CLASSIFIED_LISTING);
+ assert_eq!(parts.content, "Carrots available this week.");
+ }
+
+ #[test]
+ fn rejects_future_publication_and_compact_wire_expansion() {
+ assert_eq!(
+ authored_food_availability_to_wire_parts(&details("Carrots"), 99)
+ .unwrap_err()
+ .code(),
+ "food_published_at_future"
+ );
+
+ let escaped_content = core::iter::repeat_n('\0', 64 * 1024).collect::<String>();
+ assert_eq!(
+ authored_food_availability_to_wire_parts(&details(&escaped_content), 200)
+ .unwrap_err()
+ .code(),
+ "food_event_wire_too_large"
+ );
+ }
+
+ fn details(content: &str) -> RadrootsFoodAvailabilityDetails {
+ RadrootsFoodAvailabilityDetails::new(RadrootsFoodAvailabilityDetailsParts {
+ content: RadrootsFoodContent::new(content).unwrap(),
+ identifier: RadrootsFoodIdentifier::parse("nantes-carrots").unwrap(),
+ title: RadrootsFoodText::new("Nantes Carrots").unwrap(),
+ summary: RadrootsFoodText::new("Fresh bunches").unwrap(),
+ published_at: RadrootsFoodPublishedAt::new(100).unwrap(),
+ location: RadrootsFoodText::new("Central Saanich, BC").unwrap(),
+ price: RadrootsFoodPrice::new(
+ "3",
+ RadrootsFoodCurrency::parse("CAD").unwrap(),
+ RadrootsFoodUnit::Pound,
+ )
+ .unwrap(),
+ quantity: Some(RadrootsFoodQuantity::new("24", RadrootsFoodUnit::Pound).unwrap()),
+ status: RadrootsFoodAvailabilityStatus::Active,
+ images: Vec::new(),
+ })
+ .unwrap()
+ }
+}
diff --git a/crates/event_codec/src/food_availability/inbound.rs b/crates/event_codec/src/food_availability/inbound.rs
@@ -0,0 +1,873 @@
+#[cfg(not(feature = "std"))]
+use alloc::{boxed::Box, string::String, string::ToString, vec::Vec};
+
+use core::fmt;
+use radroots_blossom::{RadrootsBlossomBlobUrl, RadrootsBlossomSha256};
+use radroots_event::{
+ classified_listing::{RadrootsClassifiedListingPartition, classify_classified_listing_tags},
+ food_availability::{
+ RADROOTS_FOOD_DECIMAL_MAX_DIGITS, RADROOTS_FOOD_IMAGE_MAX_COUNT,
+ RadrootsFoodAvailabilityError, RadrootsFoodAvailabilityStatus, RadrootsFoodContent,
+ RadrootsFoodCurrency, RadrootsFoodIdentifier, RadrootsFoodImageDimensions,
+ RadrootsFoodPrice, RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText,
+ RadrootsFoodUnit, food_media_blossom_digest, food_media_http_url_is_valid,
+ },
+ kinds::KIND_CLASSIFIED_LISTING,
+ wire::DEFAULT_RAW_JSON_MAX_BYTES,
+};
+
+use crate::{
+ food_availability::authored::canonical_food_signed_event_size,
+ verification::RadrootsSignatureVerifiedEvent,
+};
+
+const PROHIBITED_FOOD_CAPABILITY_TAGS: &[&str] = &[
+ "buyer",
+ "checkout",
+ "delivery",
+ "exception",
+ "group",
+ "invite",
+ "order",
+ "payment",
+ "pickup",
+ "proof",
+ "provenance",
+ "receipt",
+ "route",
+ "route_stop",
+ "task",
+];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityImageDiagnostic {
+ ShapeInvalid,
+ UrlInvalid,
+ DimensionsMissing,
+ DimensionsInvalid,
+ DuplicateUrl,
+ DuplicateDigest,
+ CountExceeded,
+}
+
+impl RadrootsFoodAvailabilityImageDiagnostic {
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::ShapeInvalid => "food_image_shape_invalid",
+ Self::UrlInvalid => "food_image_url_invalid",
+ Self::DimensionsMissing => "food_image_dimensions_missing",
+ Self::DimensionsInvalid => "food_image_dimensions_invalid",
+ Self::DuplicateUrl => "food_image_duplicate_url",
+ Self::DuplicateDigest => "food_image_duplicate_digest",
+ Self::CountExceeded => "food_image_count_exceeded",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsFoodAvailabilityImageDiagnostic {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.code())
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsInboundFoodAvailabilityImage {
+ raw_tag: Vec<String>,
+ url: Option<String>,
+ dimensions: Option<RadrootsFoodImageDimensions>,
+ diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>,
+}
+
+impl RadrootsInboundFoodAvailabilityImage {
+ pub fn raw_tag(&self) -> &[String] {
+ &self.raw_tag
+ }
+
+ pub fn url(&self) -> Option<&str> {
+ self.url.as_deref()
+ }
+
+ pub const fn dimensions(&self) -> Option<RadrootsFoodImageDimensions> {
+ self.dimensions
+ }
+
+ pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] {
+ &self.diagnostics
+ }
+
+ pub fn qualifies(&self) -> bool {
+ self.diagnostics.is_empty()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsInboundFoodAvailabilityProjection {
+ content: RadrootsFoodContent,
+ identifier: RadrootsFoodIdentifier,
+ title: RadrootsFoodText,
+ summary: RadrootsFoodText,
+ published_at: RadrootsFoodPublishedAt,
+ location: RadrootsFoodText,
+ price: RadrootsFoodPrice,
+ quantity: Option<RadrootsFoodQuantity>,
+ status: RadrootsFoodAvailabilityStatus,
+ images: Vec<RadrootsInboundFoodAvailabilityImage>,
+ diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>,
+}
+
+impl RadrootsInboundFoodAvailabilityProjection {
+ pub fn content(&self) -> &RadrootsFoodContent {
+ &self.content
+ }
+
+ pub fn identifier(&self) -> &RadrootsFoodIdentifier {
+ &self.identifier
+ }
+
+ pub fn title(&self) -> &RadrootsFoodText {
+ &self.title
+ }
+
+ pub fn summary(&self) -> &RadrootsFoodText {
+ &self.summary
+ }
+
+ pub const fn published_at(&self) -> RadrootsFoodPublishedAt {
+ self.published_at
+ }
+
+ pub fn location(&self) -> &RadrootsFoodText {
+ &self.location
+ }
+
+ pub fn price(&self) -> &RadrootsFoodPrice {
+ &self.price
+ }
+
+ pub fn quantity(&self) -> Option<&RadrootsFoodQuantity> {
+ self.quantity.as_ref()
+ }
+
+ pub const fn status(&self) -> RadrootsFoodAvailabilityStatus {
+ self.status
+ }
+
+ pub fn images(&self) -> &[RadrootsInboundFoodAvailabilityImage] {
+ &self.images
+ }
+
+ pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] {
+ &self.diagnostics
+ }
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityProjectionOutcome {
+ Focused(Box<RadrootsInboundFoodAvailabilityProjection>),
+ Excluded(RadrootsClassifiedListingPartition),
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityProjectionError {
+ InvalidKind { expected: u32, actual: u32 },
+ ProfileAmbiguous,
+ ProhibitedCapability { tag: String },
+ TagInvalid,
+ PriceFrequencyForbidden,
+ PriceUnitMissing,
+ EventWireTooLarge { max: usize, actual: usize },
+ Domain(RadrootsFoodAvailabilityError),
+}
+
+impl RadrootsFoodAvailabilityProjectionError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::InvalidKind { .. } => "invalid_kind",
+ Self::ProfileAmbiguous => "food_profile_ambiguous",
+ Self::ProhibitedCapability { .. } => "prohibited_capability",
+ Self::TagInvalid => "food_tag_invalid",
+ Self::PriceFrequencyForbidden => "price_frequency_forbidden",
+ Self::PriceUnitMissing => "price_unit_missing",
+ Self::EventWireTooLarge { .. } => "food_event_wire_too_large",
+ Self::Domain(error) => error.code(),
+ }
+ }
+}
+
+impl fmt::Display for RadrootsFoodAvailabilityProjectionError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidKind { expected, actual } => write!(
+ formatter,
+ "FoodAvailability event kind must be {expected}, got {actual}"
+ ),
+ Self::ProfileAmbiguous => {
+ formatter.write_str("classified listing mixes focused and operational markers")
+ }
+ Self::ProhibitedCapability { tag } => {
+ write!(
+ formatter,
+ "FoodAvailability tag `{tag}` is a prohibited capability"
+ )
+ }
+ Self::TagInvalid => formatter.write_str("FoodAvailability core tag shape is invalid"),
+ Self::PriceFrequencyForbidden => {
+ formatter.write_str("FoodAvailability price frequency is forbidden")
+ }
+ Self::PriceUnitMissing => formatter.write_str("FoodAvailability price unit is missing"),
+ Self::EventWireTooLarge { max, actual } => write!(
+ formatter,
+ "FoodAvailability canonical signed event is {actual} bytes; max is {max}"
+ ),
+ Self::Domain(error) => write!(formatter, "{error}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsFoodAvailabilityProjectionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Domain(error) => Some(error),
+ _ => None,
+ }
+ }
+}
+
+impl From<RadrootsFoodAvailabilityError> for RadrootsFoodAvailabilityProjectionError {
+ fn from(value: RadrootsFoodAvailabilityError) -> Self {
+ Self::Domain(value)
+ }
+}
+
+/// Projects a signature-and-id verified kind-30402 event at the focused Food boundary.
+///
+/// Raw marker partitioning always precedes tag-shape and prohibited-capability
+/// validation. Operational and generic NIP-99 inputs are valid exclusions.
+pub fn project_verified_food_availability_event(
+ verified_event: &RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> {
+ let event = verified_event.event();
+ project_inbound_food_availability_parts(
+ event.kind_u32(),
+ event.created_at_u64(),
+ event.tags(),
+ event.content(),
+ )
+}
+
+pub(crate) fn project_inbound_food_availability_parts(
+ kind: u32,
+ created_at: u64,
+ tags: &radroots_event::RadrootsEventTags,
+ content: &str,
+) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> {
+ if kind != KIND_CLASSIFIED_LISTING {
+ return Err(RadrootsFoodAvailabilityProjectionError::InvalidKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: kind,
+ });
+ }
+
+ match classify_classified_listing_tags(tags) {
+ RadrootsClassifiedListingPartition::Ambiguous => {
+ return Err(RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous);
+ }
+ partition @ (RadrootsClassifiedListingPartition::OperationalListing
+ | RadrootsClassifiedListingPartition::GenericNip99) => {
+ return Ok(RadrootsFoodAvailabilityProjectionOutcome::Excluded(
+ partition,
+ ));
+ }
+ RadrootsClassifiedListingPartition::FocusedFoodAvailability => {}
+ }
+
+ let tags = tags.to_vec();
+ if let Some(tag) = tags
+ .iter()
+ .filter_map(|tag| tag.first())
+ .find(|name| PROHIBITED_FOOD_CAPABILITY_TAGS.contains(&name.as_str()))
+ {
+ return Err(
+ RadrootsFoodAvailabilityProjectionError::ProhibitedCapability { tag: tag.clone() },
+ );
+ }
+
+ let identifier_value = singleton_value(&tags, "d")?;
+ let title_value = singleton_value(&tags, "title")?;
+ let summary_value = singleton_value(&tags, "summary")?;
+ let published_at_value = singleton_value(&tags, "published_at")?;
+ let location_value = singleton_value(&tags, "location")?;
+ let status_value = singleton_value(&tags, "status")?;
+
+ let content = RadrootsFoodContent::new(content.to_string())?;
+ let identifier = RadrootsFoodIdentifier::parse(identifier_value)?;
+ let title = RadrootsFoodText::new(title_value.to_string())?;
+ let summary = RadrootsFoodText::new(summary_value.to_string())?;
+ let published_at = RadrootsFoodPublishedAt::parse(published_at_value)?;
+ published_at.validate_created_at(created_at)?;
+ let location = RadrootsFoodText::new(location_value.to_string())?;
+
+ let price_tags = matching_tags(&tags, "price");
+ let price_tag = match price_tags.as_slice() {
+ [tag] => *tag,
+ _ => return Err(RadrootsFoodAvailabilityError::PriceInvalid.into()),
+ };
+ if price_tag.len() > 3 {
+ return Err(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden);
+ }
+ if price_tag.len() != 3 {
+ return Err(RadrootsFoodAvailabilityError::PriceInvalid.into());
+ }
+ let amount = normalize_decimal(&price_tag[1], RadrootsFoodAvailabilityError::PriceInvalid)?;
+ let currency = normalize_currency(&price_tag[2])?;
+
+ let price_unit_tags = matching_tags(&tags, "radroots:price_unit");
+ if price_unit_tags.is_empty() {
+ return Err(RadrootsFoodAvailabilityProjectionError::PriceUnitMissing);
+ }
+ let unit_value = match price_unit_tags.as_slice() {
+ [tag] if tag.len() == 2 => tag[1].as_str(),
+ _ => return Err(RadrootsFoodAvailabilityError::PriceUnitInvalid.into()),
+ };
+ let unit = RadrootsFoodUnit::parse(unit_value)?;
+ let price = RadrootsFoodPrice::new(amount, currency, unit)?;
+
+ let quantity_tags = matching_tags(&tags, "radroots:quantity");
+ let quantity = if quantity_tags.is_empty() {
+ None
+ } else {
+ let tag = match quantity_tags.as_slice() {
+ [tag] if tag.len() == 3 => *tag,
+ _ => {
+ return Err(RadrootsFoodAvailabilityError::QuantityInvalid.into());
+ }
+ };
+ let amount = normalize_decimal(&tag[1], RadrootsFoodAvailabilityError::QuantityInvalid)?;
+ let quantity_unit = RadrootsFoodUnit::parse(&tag[2])
+ .map_err(|_| RadrootsFoodAvailabilityError::QuantityInvalid)?;
+ if quantity_unit != unit {
+ return Err(RadrootsFoodAvailabilityError::QuantityInvalid.into());
+ }
+ Some(RadrootsFoodQuantity::new(amount, quantity_unit)?)
+ };
+
+ let status = RadrootsFoodAvailabilityStatus::parse(status_value)?;
+ let image_tags = matching_tags(&tags, "image");
+ let (images, diagnostics) = project_images(&image_tags);
+
+ Ok(RadrootsFoodAvailabilityProjectionOutcome::Focused(
+ Box::new(RadrootsInboundFoodAvailabilityProjection {
+ content,
+ identifier,
+ title,
+ summary,
+ published_at,
+ location,
+ price,
+ quantity,
+ status,
+ images,
+ diagnostics,
+ }),
+ ))
+}
+
+fn matching_tags<'a>(tags: &'a [Vec<String>], name: &str) -> Vec<&'a Vec<String>> {
+ tags.iter()
+ .filter(|tag| tag.first().is_some_and(|key| key == name))
+ .collect()
+}
+
+fn singleton_value<'a>(
+ tags: &'a [Vec<String>],
+ name: &str,
+) -> Result<&'a str, RadrootsFoodAvailabilityProjectionError> {
+ exact_single_value(matching_tags(tags, name))
+}
+
+fn exact_single_value(
+ matches: Vec<&Vec<String>>,
+) -> Result<&str, RadrootsFoodAvailabilityProjectionError> {
+ let tag = exact_single_tag_from_matches(matches)?;
+ if tag.len() != 2 {
+ return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid);
+ }
+ Ok(tag[1].as_str())
+}
+
+fn exact_single_tag_from_matches(
+ matches: Vec<&Vec<String>>,
+) -> Result<&Vec<String>, RadrootsFoodAvailabilityProjectionError> {
+ match matches.as_slice() {
+ [tag] => Ok(*tag),
+ _ => Err(RadrootsFoodAvailabilityProjectionError::TagInvalid),
+ }
+}
+
+fn normalize_decimal(
+ value: &str,
+ error: RadrootsFoodAvailabilityError,
+) -> Result<String, RadrootsFoodAvailabilityProjectionError> {
+ let mut digits = 0usize;
+ let mut dot = None;
+ for (index, byte) in value.bytes().enumerate() {
+ match byte {
+ b'0'..=b'9' => digits += 1,
+ b'.' if dot.is_none() => dot = Some(index),
+ _ => return Err(error.into()),
+ }
+ }
+ if digits == 0
+ || digits > RADROOTS_FOOD_DECIMAL_MAX_DIGITS
+ || dot.is_some_and(|index| index == 0 || index + 1 == value.len())
+ {
+ return Err(error.into());
+ }
+
+ let (integer, fraction) = dot.map_or((value, None), |index| {
+ (&value[..index], Some(&value[index + 1..]))
+ });
+ let integer = integer.trim_start_matches('0');
+ let integer = if integer.is_empty() { "0" } else { integer };
+ let fraction = fraction
+ .map(|value| value.trim_end_matches('0'))
+ .filter(|value| !value.is_empty());
+ let mut normalized = String::with_capacity(value.len());
+ normalized.push_str(integer);
+ if let Some(fraction) = fraction {
+ normalized.push('.');
+ normalized.push_str(fraction);
+ }
+ Ok(normalized)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct RadrootsStrictFoodAvailabilityProjection {
+ identifier: RadrootsFoodIdentifier,
+ published_at: RadrootsFoodPublishedAt,
+}
+
+impl RadrootsStrictFoodAvailabilityProjection {
+ pub(crate) fn identifier(&self) -> &RadrootsFoodIdentifier {
+ &self.identifier
+ }
+
+ pub(crate) const fn published_at(&self) -> RadrootsFoodPublishedAt {
+ self.published_at
+ }
+}
+
+/// Validates a verified event against the exact authored Food wire profile.
+///
+/// This does not construct signable parts or elevate inbound media to verified
+/// media typestate. It exists solely for comparing already signed revisions.
+pub(crate) fn project_strict_verified_food_availability_event(
+ verified_event: &RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsStrictFoodAvailabilityProjection, RadrootsFoodAvailabilityProjectionError> {
+ let event = verified_event.event();
+ if event.kind_u32() != KIND_CLASSIFIED_LISTING {
+ return Err(RadrootsFoodAvailabilityProjectionError::InvalidKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: event.kind_u32(),
+ });
+ }
+ if classify_classified_listing_tags(event.tags())
+ != RadrootsClassifiedListingPartition::FocusedFoodAvailability
+ {
+ return Err(RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous);
+ }
+
+ let tags = event.tags_as_vec();
+ let actual_wire_bytes =
+ canonical_food_signed_event_size(&tags, event.content(), event.created_at_u64());
+ if actual_wire_bytes > DEFAULT_RAW_JSON_MAX_BYTES {
+ return Err(RadrootsFoodAvailabilityProjectionError::EventWireTooLarge {
+ max: DEFAULT_RAW_JSON_MAX_BYTES,
+ actual: actual_wire_bytes,
+ });
+ }
+ let observed_names = tags
+ .iter()
+ .map(|tag| tag.first().map(String::as_str))
+ .collect::<Vec<_>>();
+ let has_quantity = observed_names.contains(&Some("radroots:quantity"));
+ let image_count = observed_names
+ .iter()
+ .filter(|name| **name == Some("image"))
+ .count();
+ let mut expected_names = Vec::with_capacity(8 + usize::from(has_quantity) + image_count);
+ expected_names.extend([
+ "d",
+ "title",
+ "summary",
+ "published_at",
+ "location",
+ "price",
+ "radroots:price_unit",
+ ]);
+ if has_quantity {
+ expected_names.push("radroots:quantity");
+ }
+ expected_names.push("status");
+ expected_names.extend(core::iter::repeat_n("image", image_count));
+ if observed_names
+ .iter()
+ .copied()
+ .ne(expected_names.iter().copied().map(Some))
+ {
+ return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid);
+ }
+
+ let price_tags = matching_tags(&tags, "price");
+ let price = match price_tags.as_slice() {
+ [tag] if tag.len() == 3 => *tag,
+ [tag] if tag.len() > 3 => {
+ return Err(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden);
+ }
+ _ => return Err(RadrootsFoodAvailabilityError::PriceInvalid.into()),
+ };
+ let normalized_price =
+ normalize_decimal(&price[1], RadrootsFoodAvailabilityError::PriceInvalid)?;
+ if normalized_price != price[1] {
+ return Err(RadrootsFoodAvailabilityError::PriceInvalid.into());
+ }
+ if normalize_currency(&price[2])?.as_str() != price[2] {
+ return Err(RadrootsFoodAvailabilityError::PriceCurrencyInvalid.into());
+ }
+
+ if let Some(quantity) = matching_tags(&tags, "radroots:quantity").first() {
+ if quantity.len() != 3 {
+ return Err(RadrootsFoodAvailabilityError::QuantityInvalid.into());
+ }
+ let normalized =
+ normalize_decimal(&quantity[1], RadrootsFoodAvailabilityError::QuantityInvalid)?;
+ if normalized != quantity[1] {
+ return Err(RadrootsFoodAvailabilityError::QuantityInvalid.into());
+ }
+ }
+
+ if image_count > RADROOTS_FOOD_IMAGE_MAX_COUNT {
+ return Err(RadrootsFoodAvailabilityError::ImageCountExceeded {
+ max: RADROOTS_FOOD_IMAGE_MAX_COUNT,
+ actual: image_count,
+ }
+ .into());
+ }
+ let mut seen_urls = Vec::<String>::new();
+ let mut seen_digests = Vec::<RadrootsBlossomSha256>::new();
+ for tag in matching_tags(&tags, "image") {
+ if tag.len() != 3 {
+ return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid);
+ }
+ let url = RadrootsBlossomBlobUrl::parse(&tag[1])
+ .and_then(RadrootsBlossomBlobUrl::approve)
+ .map_err(|_| RadrootsFoodAvailabilityProjectionError::TagInvalid)?;
+ RadrootsFoodImageDimensions::parse(&tag[2])?;
+ if seen_urls.iter().any(|seen| seen == &tag[1]) {
+ return Err(RadrootsFoodAvailabilityError::ImageDuplicateUrl.into());
+ }
+ let digest = url.as_blob_url().hash_path().hash();
+ if seen_digests.contains(&digest) {
+ return Err(RadrootsFoodAvailabilityError::ImageDuplicateDigest.into());
+ }
+ seen_urls.push(tag[1].clone());
+ seen_digests.push(digest);
+ }
+
+ match project_inbound_food_availability_parts(
+ event.kind_u32(),
+ event.created_at_u64(),
+ event.tags(),
+ event.content(),
+ )? {
+ RadrootsFoodAvailabilityProjectionOutcome::Focused(projection)
+ if projection.diagnostics().is_empty() =>
+ {
+ Ok(RadrootsStrictFoodAvailabilityProjection {
+ identifier: projection.identifier().clone(),
+ published_at: projection.published_at(),
+ })
+ }
+ _ => Err(RadrootsFoodAvailabilityProjectionError::TagInvalid),
+ }
+}
+
+fn normalize_currency(
+ value: &str,
+) -> Result<RadrootsFoodCurrency, RadrootsFoodAvailabilityProjectionError> {
+ if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) {
+ return Err(RadrootsFoodAvailabilityError::PriceCurrencyInvalid.into());
+ }
+ Ok(RadrootsFoodCurrency::parse(value.to_ascii_uppercase())?)
+}
+
+fn project_images(
+ image_tags: &[&Vec<String>],
+) -> (
+ Vec<RadrootsInboundFoodAvailabilityImage>,
+ Vec<RadrootsFoodAvailabilityImageDiagnostic>,
+) {
+ let mut diagnostics = Vec::new();
+ if image_tags.len() > RADROOTS_FOOD_IMAGE_MAX_COUNT {
+ diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::CountExceeded);
+ }
+ let bounded = &image_tags[..image_tags.len().min(RADROOTS_FOOD_IMAGE_MAX_COUNT)];
+ let mut images = Vec::with_capacity(bounded.len());
+ let mut seen_urls = Vec::<String>::new();
+ let mut seen_digests = Vec::<RadrootsBlossomSha256>::new();
+
+ for tag in bounded {
+ let raw_url = tag.get(1).cloned();
+ let raw_dimensions = tag.get(2).cloned();
+ let mut image_diagnostics = Vec::new();
+ if tag.len() != 3 {
+ image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid);
+ }
+
+ let url = match raw_url.as_deref() {
+ Some(value) if food_media_http_url_is_valid(value) => Some(value.to_string()),
+ Some(_) | None => {
+ image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::UrlInvalid);
+ None
+ }
+ };
+ let dimensions = match raw_dimensions.as_deref() {
+ None => {
+ image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing);
+ None
+ }
+ Some(value) => match RadrootsFoodImageDimensions::parse(value) {
+ Ok(dimensions) => Some(dimensions),
+ Err(_) => {
+ image_diagnostics
+ .push(RadrootsFoodAvailabilityImageDiagnostic::DimensionsInvalid);
+ None
+ }
+ },
+ };
+
+ if let Some(raw_url) = raw_url {
+ if seen_urls.iter().any(|seen| seen == &raw_url) {
+ image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::DuplicateUrl);
+ }
+ seen_urls.push(raw_url.clone());
+ if let Some(digest) = food_media_blossom_digest(&raw_url) {
+ if seen_digests.contains(&digest) {
+ image_diagnostics
+ .push(RadrootsFoodAvailabilityImageDiagnostic::DuplicateDigest);
+ }
+ seen_digests.push(digest);
+ }
+ }
+
+ diagnostics.extend(image_diagnostics.iter().copied());
+ images.push(RadrootsInboundFoodAvailabilityImage {
+ raw_tag: (*tag).clone(),
+ url,
+ dimensions,
+ diagnostics: image_diagnostics,
+ });
+ }
+
+ (images, diagnostics)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_event::RadrootsEventTags;
+
+ const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
+
+ #[test]
+ fn normalizes_tolerant_decimal_and_currency_values() {
+ let tags = focused_tags(vec![
+ tag(&["price", "0003.5000", "cad"]),
+ tag(&["radroots:price_unit", "lb"]),
+ tag(&["radroots:quantity", "0010.00", "lb"]),
+ ]);
+
+ let projection = focused(project(&tags).unwrap());
+ assert_eq!(projection.price().amount(), "3.5");
+ assert_eq!(projection.price().currency().as_str(), "CAD");
+ assert_eq!(projection.quantity().unwrap().amount(), "10");
+ }
+
+ #[test]
+ fn partitions_raw_markers_before_shape_or_capability_validation() {
+ let operational = tags(vec![tag(&["radroots:bin"]), tag(&["delivery"])]);
+ assert_eq!(
+ project(&operational).unwrap(),
+ RadrootsFoodAvailabilityProjectionOutcome::Excluded(
+ RadrootsClassifiedListingPartition::OperationalListing
+ )
+ );
+
+ let mixed = tags(vec![
+ tag(&["radroots:price_unit"]),
+ tag(&["radroots:price"]),
+ ]);
+ assert_eq!(
+ project(&mixed).unwrap_err().code(),
+ "food_profile_ambiguous"
+ );
+
+ let malformed_focused = tags(vec![tag(&["radroots:price_unit"])]);
+ assert_eq!(
+ project(&malformed_focused).unwrap_err().code(),
+ "food_tag_invalid"
+ );
+ assert_eq!(
+ project_inbound_food_availability_parts(
+ KIND_CLASSIFIED_LISTING,
+ 200,
+ &malformed_focused,
+ "",
+ )
+ .unwrap_err()
+ .code(),
+ "food_tag_invalid"
+ );
+ }
+
+ #[test]
+ fn focused_marker_errors_and_prohibited_capabilities_are_stable() {
+ let mut malformed_unit = base_tags();
+ malformed_unit.push(tag(&["price", "3", "CAD"]));
+ malformed_unit.push(tag(&["radroots:price_unit"]));
+ malformed_unit.push(tag(&["status", "active"]));
+ assert_eq!(
+ project(&tags(malformed_unit)).unwrap_err().code(),
+ "price_unit_invalid"
+ );
+
+ let mut prohibited = base_tags();
+ prohibited.push(tag(&["radroots:price_unit", "lb"]));
+ prohibited.push(tag(&["status", "active"]));
+ prohibited.push(tag(&["delivery", "tomorrow"]));
+ assert_eq!(
+ project(&tags(prohibited)).unwrap_err().code(),
+ "prohibited_capability"
+ );
+ }
+
+ #[test]
+ fn retains_ordered_bounded_image_diagnostics() {
+ let url = format!("https://media.example/{HASH}.webp");
+ let mut extra = focused_tags(Vec::new()).to_vec();
+ extra.extend([
+ tag(&["image", &url]),
+ tag(&["image", &url, "0x600", "extra"]),
+ ]);
+ let projection = focused(project(&tags(extra)).unwrap());
+ assert_eq!(
+ projection.diagnostics(),
+ &[
+ RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid,
+ RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing,
+ RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid,
+ RadrootsFoodAvailabilityImageDiagnostic::DimensionsInvalid,
+ RadrootsFoodAvailabilityImageDiagnostic::DuplicateUrl,
+ RadrootsFoodAvailabilityImageDiagnostic::DuplicateDigest,
+ ]
+ );
+
+ let mut bounded = focused_tags(Vec::new()).to_vec();
+ bounded.extend((0..=RADROOTS_FOOD_IMAGE_MAX_COUNT).map(|index| {
+ tag(&[
+ "image",
+ &format!("https://media.example/{index:064x}.webp"),
+ "800x600",
+ ])
+ }));
+ let projection = focused(project(&tags(bounded)).unwrap());
+ assert_eq!(projection.images().len(), RADROOTS_FOOD_IMAGE_MAX_COUNT);
+ assert_eq!(
+ projection.diagnostics().first(),
+ Some(&RadrootsFoodAvailabilityImageDiagnostic::CountExceeded)
+ );
+ }
+
+ #[test]
+ fn accepts_optional_standard_tags_but_bounds_raw_decimal_digits() {
+ let mut optional = focused_tags(Vec::new()).to_vec();
+ optional.extend([tag(&["t", "vegetables"]), tag(&["g", "c28"])]);
+ assert!(matches!(
+ project(&tags(optional)).unwrap(),
+ RadrootsFoodAvailabilityProjectionOutcome::Focused(_)
+ ));
+
+ let mut oversized = base_tags();
+ oversized.push(tag(&["price", "00000000000000000000000000003", "CAD"]));
+ oversized.push(tag(&["radroots:price_unit", "lb"]));
+ oversized.push(tag(&["status", "active"]));
+ assert_eq!(
+ project(&tags(oversized)).unwrap_err().code(),
+ "price_invalid"
+ );
+ }
+
+ fn project(
+ tags: &RadrootsEventTags,
+ ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError>
+ {
+ project_inbound_food_availability_parts(
+ KIND_CLASSIFIED_LISTING,
+ 200,
+ tags,
+ "Carrots available this week.",
+ )
+ }
+
+ fn focused(
+ outcome: RadrootsFoodAvailabilityProjectionOutcome,
+ ) -> RadrootsInboundFoodAvailabilityProjection {
+ match outcome {
+ RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) => *projection,
+ RadrootsFoodAvailabilityProjectionOutcome::Excluded(partition) => {
+ panic!("unexpected exclusion: {partition:?}")
+ }
+ }
+ }
+
+ fn focused_tags(mut additional: Vec<Vec<String>>) -> RadrootsEventTags {
+ let mut values = base_tags();
+ if !additional
+ .iter()
+ .any(|value| value.first().is_some_and(|name| name == "price"))
+ {
+ values.push(tag(&["price", "3", "CAD"]));
+ }
+ values.append(&mut additional);
+ if !values.iter().any(|value| {
+ value
+ .first()
+ .is_some_and(|name| name == "radroots:price_unit")
+ }) {
+ values.push(tag(&["radroots:price_unit", "lb"]));
+ }
+ values.push(tag(&["status", "active"]));
+ tags(values)
+ }
+
+ fn base_tags() -> Vec<Vec<String>> {
+ vec![
+ tag(&["d", "nantes-carrots"]),
+ tag(&["title", "Nantes Carrots"]),
+ tag(&["summary", "Fresh bunches"]),
+ tag(&["published_at", "100"]),
+ tag(&["location", "Central Saanich, BC"]),
+ ]
+ }
+
+ fn tags(values: Vec<Vec<String>>) -> RadrootsEventTags {
+ RadrootsEventTags::new(values).unwrap()
+ }
+
+ fn tag(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+}
diff --git a/crates/event_codec/src/food_availability/mod.rs b/crates/event_codec/src/food_availability/mod.rs
@@ -0,0 +1,4 @@
+pub mod admission;
+pub mod authored;
+pub mod inbound;
+pub mod revision;
diff --git a/crates/event_codec/src/food_availability/revision.rs b/crates/event_codec/src/food_availability/revision.rs
@@ -0,0 +1,101 @@
+use core::fmt;
+
+use crate::{
+ food_availability::inbound::{
+ RadrootsFoodAvailabilityProjectionError, project_strict_verified_food_availability_event,
+ },
+ verification::RadrootsSignatureVerifiedEvent,
+};
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsFoodAvailabilityRevisionError {
+ PreviousInvalid(RadrootsFoodAvailabilityProjectionError),
+ CurrentInvalid(RadrootsFoodAvailabilityProjectionError),
+ CoordinateChanged,
+ PublishedAtChanged,
+ NotNewer,
+}
+
+impl RadrootsFoodAvailabilityRevisionError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::PreviousInvalid(_) => "food_revision_previous_invalid",
+ Self::CurrentInvalid(_) => "food_revision_current_invalid",
+ Self::CoordinateChanged => "food_coordinate_changed",
+ Self::PublishedAtChanged => "food_published_at_changed",
+ Self::NotNewer => "food_revision_not_newer",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsFoodAvailabilityRevisionError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::PreviousInvalid(error) => {
+ write!(
+ formatter,
+ "previous FoodAvailability revision is invalid: {error}"
+ )
+ }
+ Self::CurrentInvalid(error) => {
+ write!(
+ formatter,
+ "current FoodAvailability revision is invalid: {error}"
+ )
+ }
+ Self::CoordinateChanged => {
+ formatter.write_str("FoodAvailability revision coordinate changed")
+ }
+ Self::PublishedAtChanged => {
+ formatter.write_str("FoodAvailability revision published_at changed")
+ }
+ Self::NotNewer => {
+ formatter.write_str("FoodAvailability revision does not win NIP-01 ordering")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsFoodAvailabilityRevisionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::PreviousInvalid(error) | Self::CurrentInvalid(error) => Some(error),
+ _ => None,
+ }
+ }
+}
+
+/// Validates a replacement using strict authored wire semantics on both sides.
+///
+/// Both inputs must already have passed NIP-01 id and signature verification.
+pub fn validate_food_availability_revision(
+ previous: &RadrootsSignatureVerifiedEvent,
+ current: &RadrootsSignatureVerifiedEvent,
+) -> Result<(), RadrootsFoodAvailabilityRevisionError> {
+ let previous_projection = project_strict_verified_food_availability_event(previous)
+ .map_err(RadrootsFoodAvailabilityRevisionError::PreviousInvalid)?;
+ let current_projection = project_strict_verified_food_availability_event(current)
+ .map_err(RadrootsFoodAvailabilityRevisionError::CurrentInvalid)?;
+ let previous_event = previous.event();
+ let current_event = current.event();
+
+ if previous_event.kind_u32() != current_event.kind_u32()
+ || previous_event.author_str() != current_event.author_str()
+ || previous_projection.identifier() != current_projection.identifier()
+ {
+ return Err(RadrootsFoodAvailabilityRevisionError::CoordinateChanged);
+ }
+ if previous_projection.published_at() != current_projection.published_at() {
+ return Err(RadrootsFoodAvailabilityRevisionError::PublishedAtChanged);
+ }
+
+ let current_wins = current_event.created_at_u64() > previous_event.created_at_u64()
+ || (current_event.created_at_u64() == previous_event.created_at_u64()
+ && current_event.id_str() < previous_event.id_str());
+ if !current_wins {
+ return Err(RadrootsFoodAvailabilityRevisionError::NotNewer);
+ }
+ Ok(())
+}
diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs
@@ -34,6 +34,7 @@ pub mod farm_file;
pub mod farm_workspace;
pub mod file_metadata;
pub mod follow;
+pub mod food_availability;
pub mod geochat;
pub mod gift_wrap;
pub mod group;
diff --git a/crates/event_codec/src/manifest.rs b/crates/event_codec/src/manifest.rs
@@ -74,6 +74,9 @@ pub struct RadrootsKnowledgeContractManifestEntry {
pub enum RadrootsKnowledgeManifestDiscriminator {
KindOnly,
AdmissionOnly,
+ ClassifiedListingPartition {
+ value: String,
+ },
DTagExact {
value: String,
},
@@ -219,6 +222,11 @@ fn discriminator_manifest(
RadrootsEventDiscriminator::AdmissionOnly => {
RadrootsKnowledgeManifestDiscriminator::AdmissionOnly
}
+ RadrootsEventDiscriminator::ClassifiedListingPartition(value) => {
+ RadrootsKnowledgeManifestDiscriminator::ClassifiedListingPartition {
+ value: classified_listing_partition_label(*value).to_string(),
+ }
+ }
RadrootsEventDiscriminator::DTagExact(value) => {
RadrootsKnowledgeManifestDiscriminator::DTagExact {
value: (*value).to_string(),
@@ -259,6 +267,19 @@ fn discriminator_manifest(
}
}
+fn classified_listing_partition_label(
+ value: radroots_event::classified_listing::RadrootsClassifiedListingPartition,
+) -> &'static str {
+ use radroots_event::classified_listing::RadrootsClassifiedListingPartition;
+
+ match value {
+ RadrootsClassifiedListingPartition::FocusedFoodAvailability => "focused_food_availability",
+ RadrootsClassifiedListingPartition::OperationalListing => "operational_listing",
+ RadrootsClassifiedListingPartition::GenericNip99 => "generic_nip99",
+ RadrootsClassifiedListingPartition::Ambiguous => "ambiguous",
+ }
+}
+
fn tag_contract_manifest(contract: &RadrootsTagContract) -> RadrootsKnowledgeManifestTagContract {
RadrootsKnowledgeManifestTagContract {
name: contract.name.to_string(),
@@ -451,8 +472,11 @@ fn reducer_label(value: RadrootsReducer) -> &'static str {
#[cfg(test)]
mod tests {
- use super::{reducer_label, standard_label};
- use radroots_event::contract::{RadrootsNostrStandard, RadrootsReducer};
+ use super::{discriminator_manifest, reducer_label, standard_label};
+ use radroots_event::{
+ classified_listing::RadrootsClassifiedListingPartition,
+ contract::{RadrootsEventDiscriminator, RadrootsNostrStandard, RadrootsReducer},
+ };
#[test]
fn classified_listing_standard_label_is_nip99() {
@@ -470,4 +494,34 @@ mod tests {
"operational_listing_inventory_accounting"
);
}
+
+ #[test]
+ fn classified_listing_partition_discriminators_render_exactly() {
+ for (partition, expected) in [
+ (
+ RadrootsClassifiedListingPartition::FocusedFoodAvailability,
+ "focused_food_availability",
+ ),
+ (
+ RadrootsClassifiedListingPartition::OperationalListing,
+ "operational_listing",
+ ),
+ (
+ RadrootsClassifiedListingPartition::GenericNip99,
+ "generic_nip99",
+ ),
+ (RadrootsClassifiedListingPartition::Ambiguous, "ambiguous"),
+ ] {
+ let manifest = discriminator_manifest(
+ &RadrootsEventDiscriminator::ClassifiedListingPartition(partition),
+ );
+ assert_eq!(
+ serde_json::to_value(manifest).expect("serialized discriminator"),
+ serde_json::json!({
+ "type": "classified_listing_partition",
+ "value": expected,
+ })
+ );
+ }
+ }
}
diff --git a/crates/event_codec/tests/fixtures/food_availability_profile.v1.json b/crates/event_codec/tests/fixtures/food_availability_profile.v1.json
@@ -0,0 +1,3222 @@
+{
+ "contract_version": "1.0.0",
+ "suite": "food_availability_profile",
+ "vectors": [
+ {
+ "id": "food_authored_unit_g_001",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Microgreens available from a Victoria-area farm."
+ },
+ "identifier": "microgreens",
+ "title": "Microgreens",
+ "summary": "Local microgreens",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "120",
+ "currency": "CAD",
+ "unit": "g"
+ },
+ "quantity": {
+ "amount": "250",
+ "unit": "g"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Microgreens available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "microgreens"
+ ],
+ [
+ "title",
+ "Microgreens"
+ ],
+ [
+ "summary",
+ "Local microgreens"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "120",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "g"
+ ],
+ [
+ "radroots:quantity",
+ "250",
+ "g"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_kg_002",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Yukon Gold Potatoes available from a Victoria-area farm."
+ },
+ "identifier": "yukon-gold-potatoes",
+ "title": "Yukon Gold Potatoes",
+ "summary": "Local yukon gold potatoes",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "5.5",
+ "currency": "CAD",
+ "unit": "kg"
+ },
+ "quantity": {
+ "amount": "18",
+ "unit": "kg"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Yukon Gold Potatoes available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "yukon-gold-potatoes"
+ ],
+ [
+ "title",
+ "Yukon Gold Potatoes"
+ ],
+ [
+ "summary",
+ "Local yukon gold potatoes"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "5.5",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "kg"
+ ],
+ [
+ "radroots:quantity",
+ "18",
+ "kg"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_lb_003",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Nantes Carrots available from a Victoria-area farm."
+ },
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Local nantes carrots",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3.5",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": {
+ "amount": "24",
+ "unit": "lb"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Nantes Carrots available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Local nantes carrots"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3.5",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_oz_004",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Fresh Basil available from a Victoria-area farm."
+ },
+ "identifier": "fresh-basil",
+ "title": "Fresh Basil",
+ "summary": "Local fresh basil",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "2.25",
+ "currency": "CAD",
+ "unit": "oz"
+ },
+ "quantity": {
+ "amount": "16",
+ "unit": "oz"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Fresh Basil available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "fresh-basil"
+ ],
+ [
+ "title",
+ "Fresh Basil"
+ ],
+ [
+ "summary",
+ "Local fresh basil"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "2.25",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "oz"
+ ],
+ [
+ "radroots:quantity",
+ "16",
+ "oz"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_each_005",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Delicata Squash available from a Victoria-area farm."
+ },
+ "identifier": "delicata-squash",
+ "title": "Delicata Squash",
+ "summary": "Local delicata squash",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "4",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": {
+ "amount": "12",
+ "unit": "each"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Delicata Squash available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "delicata-squash"
+ ],
+ [
+ "title",
+ "Delicata Squash"
+ ],
+ [
+ "summary",
+ "Local delicata squash"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "4",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "each"
+ ],
+ [
+ "radroots:quantity",
+ "12",
+ "each"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_dozen_006",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Pasture Eggs available from a Victoria-area farm."
+ },
+ "identifier": "pasture-eggs",
+ "title": "Pasture Eggs",
+ "summary": "Local pasture eggs",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "8",
+ "currency": "CAD",
+ "unit": "dozen"
+ },
+ "quantity": {
+ "amount": "6",
+ "unit": "dozen"
+ },
+ "status": "sold",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Pasture Eggs available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "pasture-eggs"
+ ],
+ [
+ "title",
+ "Pasture Eggs"
+ ],
+ [
+ "summary",
+ "Local pasture eggs"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "8",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "dozen"
+ ],
+ [
+ "radroots:quantity",
+ "6",
+ "dozen"
+ ],
+ [
+ "status",
+ "sold"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_bunch_007",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Lacinato Kale available from a Victoria-area farm."
+ },
+ "identifier": "lacinato-kale",
+ "title": "Lacinato Kale",
+ "summary": "Local lacinato kale",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "4.5",
+ "currency": "CAD",
+ "unit": "bunch"
+ },
+ "quantity": {
+ "amount": "10",
+ "unit": "bunch"
+ },
+ "status": "active",
+ "images": [
+ {
+ "bytes_utf8": "radroots-food-image-one",
+ "url": "https://blossom.victoria.example/85ccc876a2fe359a2f8dd491bbc6c1f5674af8f0e94d11b556a069f8176e303d.webp",
+ "media_type": "image/webp",
+ "uploaded_at": 1700000000,
+ "dimensions": "1200x800"
+ },
+ {
+ "bytes_utf8": "radroots-food-image-two",
+ "url": "https://blossom.victoria.example/e3d093fc5e999bd2eb6eb476d24fd421accb134efa8dc750bd87d22a7e49fb22.webp",
+ "media_type": "image/webp",
+ "uploaded_at": 1700000000,
+ "dimensions": "800x800"
+ }
+ ]
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Lacinato Kale available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "lacinato-kale"
+ ],
+ [
+ "title",
+ "Lacinato Kale"
+ ],
+ [
+ "summary",
+ "Local lacinato kale"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "4.5",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "bunch"
+ ],
+ [
+ "radroots:quantity",
+ "10",
+ "bunch"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "image",
+ "https://blossom.victoria.example/85ccc876a2fe359a2f8dd491bbc6c1f5674af8f0e94d11b556a069f8176e303d.webp",
+ "1200x800"
+ ],
+ [
+ "image",
+ "https://blossom.victoria.example/e3d093fc5e999bd2eb6eb476d24fd421accb134efa8dc750bd87d22a7e49fb22.webp",
+ "800x800"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_punnet_008",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "June Strawberries available from a Victoria-area farm."
+ },
+ "identifier": "june-strawberries",
+ "title": "June Strawberries",
+ "summary": "Local june strawberries",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "7",
+ "currency": "CAD",
+ "unit": "punnet"
+ },
+ "quantity": {
+ "amount": "20",
+ "unit": "punnet"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "June Strawberries available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "june-strawberries"
+ ],
+ [
+ "title",
+ "June Strawberries"
+ ],
+ [
+ "summary",
+ "Local june strawberries"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "7",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "punnet"
+ ],
+ [
+ "radroots:quantity",
+ "20",
+ "punnet"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_bag_009",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Gravenstein Apples available from a Victoria-area farm."
+ },
+ "identifier": "gravenstein-apples",
+ "title": "Gravenstein Apples",
+ "summary": "Local gravenstein apples",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "9",
+ "currency": "CAD",
+ "unit": "bag"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Gravenstein Apples available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "gravenstein-apples"
+ ],
+ [
+ "title",
+ "Gravenstein Apples"
+ ],
+ [
+ "summary",
+ "Local gravenstein apples"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "9",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "bag"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_unit_basket_010",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Cherry Tomatoes available from a Victoria-area farm."
+ },
+ "identifier": "cherry-tomatoes",
+ "title": "Cherry Tomatoes",
+ "summary": "Local cherry tomatoes",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "12",
+ "currency": "CAD",
+ "unit": "basket"
+ },
+ "quantity": {
+ "amount": "8",
+ "unit": "basket"
+ },
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content": "Cherry Tomatoes available from a Victoria-area farm.",
+ "tags": [
+ [
+ "d",
+ "cherry-tomatoes"
+ ],
+ [
+ "title",
+ "Cherry Tomatoes"
+ ],
+ [
+ "summary",
+ "Local cherry tomatoes"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "12",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "basket"
+ ],
+ [
+ "radroots:quantity",
+ "8",
+ "basket"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_wire_budget_ascii_max_011",
+ "kind": "food_availability.build_authored_draft.valid",
+ "input": {
+ "details": {
+ "content": {
+ "repeat": "x",
+ "count": 131072
+ },
+ "identifier": "wire-budget-pass",
+ "title": "Wire Budget Pass",
+ "summary": "Maximum ASCII content",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "1",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 30402,
+ "content_length": 131072,
+ "tags": [
+ [
+ "d",
+ "wire-budget-pass"
+ ],
+ [
+ "title",
+ "Wire Budget Pass"
+ ],
+ [
+ "summary",
+ "Maximum ASCII content"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "1",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "each"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_authored_wire_budget_escaped_overflow_012",
+ "kind": "food_availability.build_authored_draft.invalid",
+ "input": {
+ "details": {
+ "content": {
+ "repeat": "\u0000",
+ "count": 65536
+ },
+ "identifier": "wire-budget-overflow",
+ "title": "Wire Budget Overflow",
+ "summary": "Escaped JSON expansion",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "1",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1700000060
+ },
+ "expected": {
+ "error": {
+ "code": "food_event_wire_too_large",
+ "message": "FoodAvailability canonical signed event would be 393796 bytes; max is 262144",
+ "max": 262144,
+ "actual": 393796
+ }
+ }
+ },
+ {
+ "id": "food_authored_future_published_at_013",
+ "kind": "food_availability.build_authored_draft.invalid",
+ "input": {
+ "details": {
+ "content": {
+ "value": "Future publication"
+ },
+ "identifier": "future-publication",
+ "title": "Future Publication",
+ "summary": "Invalid publication time",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "1",
+ "currency": "CAD",
+ "unit": "each"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": []
+ },
+ "created_at": 1699999999
+ },
+ "expected": {
+ "error": {
+ "code": "food_published_at_future",
+ "message": "FoodAvailability published_at 1700000000 exceeds created_at 1699999999",
+ "published_at": 1700000000,
+ "created_at": 1699999999
+ }
+ }
+ },
+ {
+ "id": "food_admission_normalizes_decimal_currency_014",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "20adf6026a55207a21111ac0617c212a2abb8fd15e39f3042991d85da287c74f",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "0003.5000",
+ "cad"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "0010.00",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "94b1b19946890a42cff0b6c08c55f855291c18a7bf89ebc2f0e92af5fa486da207d1c80edd1c166367e63c573a3cfade38f10514cc4cd3b64d67f1ee42540077"
+ }
+ },
+ "expected": {
+ "outcome": "focused",
+ "event_id": "20adf6026a55207a21111ac0617c212a2abb8fd15e39f3042991d85da287c74f",
+ "projection": {
+ "content": "Carrots available this week.",
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Fresh bunches",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3.5",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": {
+ "amount": "10",
+ "unit": "lb"
+ },
+ "status": "active",
+ "images": [],
+ "diagnostics": []
+ }
+ }
+ },
+ {
+ "id": "food_admission_optional_standard_tags_015",
+ "kind": "food_availability.verify_and_admit_event.valid",
+ "input": {
+ "event": {
+ "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "t",
+ "vegetables"
+ ],
+ [
+ "g",
+ "c28hr"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a"
+ }
+ },
+ "expected": {
+ "outcome": "admitted",
+ "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7",
+ "projection": {
+ "content": "Carrots available this week.",
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Fresh bunches",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": [],
+ "diagnostics": []
+ }
+ }
+ },
+ {
+ "id": "food_admission_excludes_operational_before_validation_016",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "radroots:bin"
+ ],
+ [
+ "delivery"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1fc38e6183061bb64f2337941b96a6cc75067b85aa46b4780bd395f62961b54569872c51090bec9f97185add686e3e6e11a1aa0c593d63266c433a614f2d90af"
+ }
+ },
+ "expected": {
+ "outcome": "excluded",
+ "event_id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40",
+ "partition": "operational_listing"
+ }
+ },
+ {
+ "id": "food_admission_excludes_generic_nip99_017",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "generic-offer"
+ ],
+ [
+ "title",
+ "Generic Offer"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "e533df401a4c6ddfd7dcfd2b3525e9fb8938748dcdc9492aa617ec50d966554511853704929b88b7fe791f3a36659f341b20245182574dd5e5353fa80f57d441"
+ }
+ },
+ "expected": {
+ "outcome": "excluded",
+ "event_id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d",
+ "partition": "generic_nip99"
+ }
+ },
+ {
+ "id": "food_admission_rejects_ambiguous_markers_018",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "1e40dd34180c85871cc1a7369290876e004d56890ebca9a619f4c1f61e46d866",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "radroots:price_unit"
+ ],
+ [
+ "radroots:price"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1da60ee3d831adae2aeb61df60c2e14f7168b696cf50a9362f326119d5c4d5a03dd8d6da1c9b10a29c3dd4ffbbfbe76404d758ee149bb56b13e2fe14eece385f"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_profile_ambiguous",
+ "message": "classified listing mixes focused and operational markers"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_wrong_kind_019",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "2cdc1807110058f1732d68e5de00b0f1cb3dd9e067aa36af835a3823f522a6d2",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 1,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "957d64ffbe2f7dd94c785aa2111e6b0e6e3d6220bd2a756ff75aeb44dc1663d08a23345ad4a1729c0d6aaf1cf35f8f22021262f9131a15364e303d774eb2b2f6"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "invalid_kind",
+ "message": "FoodAvailability event kind must be 30402, got 1"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_core_tag_shape_020",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "c9902f6f65bbd890fc5b89119aeae481ae66359ed1c5f8b763a72cb11e89a75c",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "radroots:price_unit"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "0d2fe687930c908410b667b025dbf2630790a06b957473aabfeecd93467b8ec4e9ec52dee83807c68c06bfab12efe89e4b039c751bd07d6618a4b2e7fc339f83"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_tag_invalid",
+ "message": "FoodAvailability core tag shape is invalid"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_prohibited_delivery_021",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "da2bee62ff0c998eaebe8e881c1e1388b2e5f8922d8973fce908fc21d24f7868",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "delivery",
+ "tomorrow"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "73534923e2a0cb6f37dfeaf1947c32da2cea110f48d642366b18eaed501c9b4227eb11c86bc83433fc0f03054d89260e860d3fb055c3c3a8ef70238f77dac2d5"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "prohibited_capability",
+ "message": "FoodAvailability tag `delivery` is a prohibited capability"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_price_frequency_022",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "d376ad9ac9446dcca0285ab284d72662a8fba02a71f5f7501ba40299fdbc5041",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD",
+ "week"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "d5dd7a9864e78c374b7213b0460fdb142c4fd54dba4b7795781bc348095438852b3535ff64d8eb692baf1c572ee5c254e759b93293d27e0885e7ef0bb53af35b"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_frequency_forbidden",
+ "message": "FoodAvailability price frequency is forbidden"
+ }
+ }
+ },
+ {
+ "id": "food_admission_requires_price_unit_023",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "6317ed3da993b973aed902dd6c1e3b12f2bc70523a91de034f735e10b89b7255",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:quantity",
+ "10",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "a318aaae549bb91eaf7a1c5c042972c7e70fa26282a705df8d0414557aa2a41bca87ad88d7f1fef7c2edba05ce9562db46be90028aa5f40d66b82af0fd837c4e"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_unit_missing",
+ "message": "FoodAvailability price unit is missing"
+ }
+ }
+ },
+ {
+ "id": "food_admission_bounds_raw_decimal_digits_024",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "dd884aeb7c7cd3f8e7b29c6992fdc44e6f9d8ebf0cd964708acd1bc334b1d238",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "00000000000000000000000000003",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "eae8efa71b884818173ba4a5c41615be10e111cb221117feccd43fc69281517480188644e08921d608c03d7fbbcdc1875796de8d2013a85d23cf537c0fa28cda"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_invalid",
+ "message": "FoodAvailability price must be a canonical unsigned decimal with at most 28 digits"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_malformed_price_unit_025",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "9ae38292772927c1e92e65505e280f8e93e997d90bb4528fffff2f1fa350e10d",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "e276e8dfd4cc4e5b11b9981fd20ca165a9537180b2919e20cdbd43652d0806c4e3f17cc4966e4b35aa86eb82a3bc9fcb07ecb44a49cee27e24aec64d0bcaa457"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "price_unit_invalid",
+ "message": "FoodAvailability price unit is not governed"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_quantity_unit_mismatch_026",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "7c861cdfd2275445c756bd8d136d340df3e1133a7c480d9e2c91df06cef58d44",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "10",
+ "kg"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "7f7524cc5170a6076fed95a6f53e6d597c94c6a3373f56a062740f915258f93bc2ff1116776479736804023ca8059d0e13b790f60a98429cca318262256bbec1"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "quantity_invalid",
+ "message": "FoodAvailability quantity must be canonical and use the price unit"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_status_027",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "paused"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "5728cd88796dc4beed1d293db77a9e5a9297bb9e3d7d56b9ee108f3c6d3ad2fd5468b359d1fba09a4eeca7539d678d2e373c8bd151969e19feaceb9aebaca917"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_status_invalid",
+ "message": "FoodAvailability status must be active or sold"
+ }
+ }
+ },
+ {
+ "id": "food_admission_rejects_future_published_at_028",
+ "kind": "food_availability.project_verified_event.invalid",
+ "input": {
+ "event": {
+ "id": "6f21223ebac44a2888abe98e4aefcc7368f1150fa8eac379b964f33657ec2f0b",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000061"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "71256286f982d6daa7e2253f4b5f33c7f65aaa02a36182401629f0bc771cf613ad17544be1d820cfba1484c83251c17f87769fa44ee35b12ceee4327cec455db"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_published_at_future",
+ "message": "FoodAvailability published_at 1700000061 exceeds created_at 1700000060"
+ }
+ }
+ },
+ {
+ "id": "food_admission_preserves_ordered_image_diagnostics_029",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"
+ ],
+ [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "0x600",
+ "extra"
+ ],
+ [
+ "image",
+ "not-a-url",
+ "800x600"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1d5eb699944bc1eba93c85071ad1d94878f1c767015391e200b45f1a2066558675a382ede737fb15307a944598687843618c067accf6608f53daad377683c2c9"
+ }
+ },
+ "expected": {
+ "outcome": "focused",
+ "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe",
+ "projection": {
+ "content": "Carrots available this week.",
+ "identifier": "nantes-carrots",
+ "title": "Nantes Carrots",
+ "summary": "Fresh bunches",
+ "published_at": 1700000000,
+ "location": "Central Saanich, BC",
+ "price": {
+ "amount": "3",
+ "currency": "CAD",
+ "unit": "lb"
+ },
+ "quantity": null,
+ "status": "active",
+ "images": [
+ {
+ "raw_tag": [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"
+ ],
+ "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "dimensions": null,
+ "diagnostics": [
+ "food_image_shape_invalid",
+ "food_image_dimensions_missing"
+ ],
+ "qualifies": false
+ },
+ {
+ "raw_tag": [
+ "image",
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "0x600",
+ "extra"
+ ],
+ "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp",
+ "dimensions": null,
+ "diagnostics": [
+ "food_image_shape_invalid",
+ "food_image_dimensions_invalid",
+ "food_image_duplicate_url",
+ "food_image_duplicate_digest"
+ ],
+ "qualifies": false
+ },
+ {
+ "raw_tag": [
+ "image",
+ "not-a-url",
+ "800x600"
+ ],
+ "url": null,
+ "dimensions": {
+ "width": 800,
+ "height": 600
+ },
+ "diagnostics": [
+ "food_image_url_invalid"
+ ],
+ "qualifies": false
+ }
+ ],
+ "diagnostics": [
+ "food_image_shape_invalid",
+ "food_image_dimensions_missing",
+ "food_image_shape_invalid",
+ "food_image_dimensions_invalid",
+ "food_image_duplicate_url",
+ "food_image_duplicate_digest",
+ "food_image_url_invalid"
+ ]
+ }
+ }
+ },
+ {
+ "id": "food_admission_bounds_image_projection_030",
+ "kind": "food_availability.project_verified_event.valid",
+ "input": {
+ "event": {
+ "id": "ac3b37c0368816b8d707e0e2e194c39291ea47fa7bb22dd998fa526b03609280",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000000.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000001.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000002.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000003.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000004.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000005.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000006.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000007.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000008.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000009.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000000f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000010.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000011.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000012.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000013.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000014.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000015.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000016.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000017.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000018.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000019.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000001f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000020.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000021.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000022.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000023.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000024.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000025.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000026.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000027.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000028.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000029.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000002f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000030.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000031.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000032.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000033.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000034.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000035.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000036.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000037.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000038.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000039.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003a.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003b.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003c.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003d.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003e.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003f.webp",
+ "800x600"
+ ],
+ [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000040.webp",
+ "800x600"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "28775630afde6c73e412d47a1d36227fae11057595186655ceab5a6d96a60f25289a0f5b27c680025152f121b322c35e0bb248f9392567af946dc881f08eb6b5"
+ }
+ },
+ "expected": {
+ "outcome": "focused",
+ "event_id": "ac3b37c0368816b8d707e0e2e194c39291ea47fa7bb22dd998fa526b03609280",
+ "image_count": 64,
+ "diagnostics": [
+ "food_image_count_exceeded"
+ ],
+ "first_raw_tag": [
+ "image",
+ "https://media.example/0000000000000000000000000000000000000000000000000000000000000000.webp",
+ "800x600"
+ ],
+ "last_raw_tag": [
+ "image",
+ "https://media.example/000000000000000000000000000000000000000000000000000000000000003f.webp",
+ "800x600"
+ ]
+ }
+ },
+ {
+ "id": "food_admission_rejects_invalid_signature_031",
+ "kind": "food_availability.verify_and_admit_event.invalid",
+ "input": {
+ "event": {
+ "id": "f600337abfe4a2482225a369852c9899784c71d9221635b5ac34bca52a06d2d1",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "94b1b19946890a42cff0b6c08c55f855291c18a7bf89ebc2f0e92af5fa486da207d1c80edd1c166367e63c573a3cfade38f10514cc4cd3b64d67f1ee42540077"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "signature_invalid",
+ "message": "invalid NIP-01 event signature"
+ }
+ }
+ },
+ {
+ "id": "food_revision_accepts_later_created_at_032",
+ "kind": "food_availability.validate_revision.valid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "78304e7b25d60f037f3c65556ecc31075e724394f7ee7c7f94081a4e083432a9",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Updated carrots",
+ "sig": "deea0e868afa659aad33572c0c45aecc6d59756e05c442f268d5c7d85ffe319136b3696cbd894546587ea0e0c7090b185a585e236ecbd9050e66c8fc0422106a"
+ }
+ },
+ "expected": {
+ "result": "accepted",
+ "current_event_id": "78304e7b25d60f037f3c65556ecc31075e724394f7ee7c7f94081a4e083432a9"
+ }
+ },
+ {
+ "id": "food_revision_rejects_invalid_previous_033",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "7d097fd38c0b5a74f7e415178415d8a119607e2338b600fe136829f1dcec8a43",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "46780ae06e1968be9e1031e84a25539a1b120dc70468fda0401078b627ab7fb264f5bbe19aaebe8f32b696aa829564dee58b740cf2f22595b3c1e5fa530dbd38"
+ },
+ "current": {
+ "id": "46e2c8f4db3621330939318af101b8fc55b5b65ca7bc9e3ea4d498b4627c634c",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "65be312cd60c3c6ec5722bd947375640b9eb32f9dee2692ef25853e2c7ec64b4620aab142d0ad2b8ad6138d8948c962aac6c373ace60a6c99ebad1627b6a1cde"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_previous_invalid",
+ "message": "previous FoodAvailability revision is invalid: FoodAvailability core tag shape is invalid"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_invalid_current_034",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "b694c47d68fe69f79794420a5b3409866f0e9208bab1ee2738299477fac0bdf7",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "03.00",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "416dca196142e25a38b63600ec258d85221d1220192ea80e2c61c0f828837e4887690130e3460fb53bbbe2ad0f6fe80c143212ed7c1f4e898c0249b06c0236f1"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_current_invalid",
+ "message": "current FoodAvailability revision is invalid: FoodAvailability price must be a canonical unsigned decimal with at most 28 digits"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_identifier_coordinate_change_035",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "16b893630a9b737ff4d05bbbc627ec68ad60c76498dbe75a3ebb1857042d5e08",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "golden-beets"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "efd2572f098034f6d653fd6b8bce4486db2b6e366f4ea7832917328ddbacb286a47ad5b45c74b15323e2e271441371543f413e49fd03debe5332cc4f769b4565"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_coordinate_changed",
+ "message": "FoodAvailability revision coordinate changed"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_author_coordinate_change_036",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "a72f1b05cc4398de9eb9f21389ceadc2d49035667304125855099b86b009e6e1",
+ "pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "fa3b264c55e767d3a1720439eef1c000659143004fbb22fa43ea15ad7b45c79ad4d59dfdbbbab368447ca345c426382f3c92b8377d6362d65e6f5b8b3d2a33e3"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_coordinate_changed",
+ "message": "FoodAvailability revision coordinate changed"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_published_at_change_037",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "fd0d610781437790e0ec242e3b3d0dd136b75c02c01533b98affd3566e09f54c",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000061,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000001"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "4e4076d3a0a6b770df87208e54e93962eaa031c57d5168c425f0fb27526aa841a0878873035e75aa9ef98b0dfd654c718afeb02bebe28e590c9b870eb5a8c063"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_published_at_changed",
+ "message": "FoodAvailability revision published_at changed"
+ }
+ }
+ },
+ {
+ "id": "food_revision_rejects_older_created_at_038",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "5ce3afebae58643462c935730292791d178a3332686cc4d4e65b3196160209ae",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "11dba7311bf10b63f7913463f6537ff1607d6242787a6c091ece6a4969b74b75d5b286005970fb7fae5de9187438d9b7436ffa2f3057d18ae9b2748843964567"
+ },
+ "current": {
+ "id": "4617873052bca26d7b39cdbc26a8134f759de807ba92d2052f96ecf07ab8d6ab",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000059,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Older candidate",
+ "sig": "eaa553976301cb71ea22446651909e20fc09bb736b72489ca434e2fd0184be2070fb259c0dd3b93bb8eda195a677eb977c2bc42da3c0b37062b23e908191de4c"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_not_newer",
+ "message": "FoodAvailability revision does not win NIP-01 ordering"
+ }
+ }
+ },
+ {
+ "id": "food_revision_equal_time_a_current_039",
+ "kind": "food_availability.validate_revision.invalid",
+ "input": {
+ "previous": {
+ "id": "6e3a51de252e123bee2b94da38d2796406e518abffe52c8295cde473b75e16d4",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time B",
+ "sig": "9f99d5fa61b0d9661590602ce8260bf9e9a2f022de1eb30bf22ccf29c253c6b66d08dab79048bc97a39dd062757bc2219bf1f9fc4aef1ec2c426282d73d8d6ff"
+ },
+ "current": {
+ "id": "8ed82f79ffc5e3ca5c6feba18822d0d8ed4da0fe37baf5352b492c45bc28c3d3",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time A",
+ "sig": "0ca47b54a0f4e534f49a5373d56a803ea9abaed24fe412fde6be71995ebdaa930395533f934101782fa199404976b17caa00303a6a93463d138c4b76a39becb9"
+ }
+ },
+ "expected": {
+ "error": {
+ "code": "food_revision_not_newer",
+ "message": "FoodAvailability revision does not win NIP-01 ordering"
+ }
+ }
+ },
+ {
+ "id": "food_revision_equal_time_b_current_040",
+ "kind": "food_availability.validate_revision.valid",
+ "input": {
+ "previous": {
+ "id": "8ed82f79ffc5e3ca5c6feba18822d0d8ed4da0fe37baf5352b492c45bc28c3d3",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time A",
+ "sig": "0ca47b54a0f4e534f49a5373d56a803ea9abaed24fe412fde6be71995ebdaa930395533f934101782fa199404976b17caa00303a6a93463d138c4b76a39becb9"
+ },
+ "current": {
+ "id": "6e3a51de252e123bee2b94da38d2796406e518abffe52c8295cde473b75e16d4",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Equal-time B",
+ "sig": "9f99d5fa61b0d9661590602ce8260bf9e9a2f022de1eb30bf22ccf29c253c6b66d08dab79048bc97a39dd062757bc2219bf1f9fc4aef1ec2c426282d73d8d6ff"
+ }
+ },
+ "expected": {
+ "result": "accepted",
+ "current_event_id": "6e3a51de252e123bee2b94da38d2796406e518abffe52c8295cde473b75e16d4"
+ }
+ }
+ ]
+}
diff --git a/crates/event_codec/tests/food_availability_conformance.rs b/crates/event_codec/tests/food_availability_conformance.rs
@@ -0,0 +1,756 @@
+#![cfg(all(feature = "serde_json", feature = "nostr"))]
+
+use std::{borrow::Cow, collections::BTreeSet, fs, path::Path};
+
+use nostr::secp256k1::Message;
+use nostr::{Event as NostrEvent, JsonUtil, Keys, SECP256K1};
+use radroots_blossom::{
+ RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType,
+ RadrootsBlossomSha256,
+};
+use radroots_event::food_availability::{
+ RadrootsFoodAvailabilityDetails, RadrootsFoodAvailabilityDetailsParts,
+ RadrootsFoodAvailabilityError, RadrootsFoodAvailabilityImage, RadrootsFoodAvailabilityStatus,
+ RadrootsFoodContent, RadrootsFoodCurrency, RadrootsFoodIdentifier, RadrootsFoodImageDimensions,
+ RadrootsFoodPrice, RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText,
+ RadrootsFoodUnit,
+};
+use radroots_event::media::RadrootsAuthoredImage;
+use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id};
+use radroots_event::{
+ RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
+ classified_listing::RadrootsClassifiedListingPartition,
+};
+use radroots_event_codec::food_availability::admission::{
+ RadrootsFoodAvailabilityAdmissionError, RadrootsFoodAvailabilityAdmissionOutcome,
+ verify_and_admit_food_availability_event,
+};
+use radroots_event_codec::food_availability::authored::{
+ RadrootsFoodAvailabilityEncodeError, authored_food_availability_build_tags,
+ authored_food_availability_to_wire_parts,
+};
+use radroots_event_codec::food_availability::inbound::{
+ RadrootsFoodAvailabilityImageDiagnostic, RadrootsFoodAvailabilityProjectionError,
+ RadrootsFoodAvailabilityProjectionOutcome, RadrootsInboundFoodAvailabilityProjection,
+ project_verified_food_availability_event,
+};
+use radroots_event_codec::food_availability::revision::{
+ RadrootsFoodAvailabilityRevisionError, validate_food_availability_revision,
+};
+use radroots_event_codec::verification::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
+use serde::Deserialize;
+use serde_json::{Value, json};
+
+const SECRET_KEY_ONE: &str = "0000000000000000000000000000000000000000000000000000000000000001";
+const SECRET_KEY_TWO: &str = "0000000000000000000000000000000000000000000000000000000000000002";
+const PACKAGED_VECTORS: &str = include_str!("fixtures/food_availability_profile.v1.json");
+const WORKSPACE_VECTOR_PATH: &str =
+ "../../contracts/conformance/vectors/food_availability/profile.v1.json";
+const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
+
+const VECTOR_IDS: [&str; 40] = [
+ "food_authored_unit_g_001",
+ "food_authored_unit_kg_002",
+ "food_authored_unit_lb_003",
+ "food_authored_unit_oz_004",
+ "food_authored_unit_each_005",
+ "food_authored_unit_dozen_006",
+ "food_authored_unit_bunch_007",
+ "food_authored_unit_punnet_008",
+ "food_authored_unit_bag_009",
+ "food_authored_unit_basket_010",
+ "food_authored_wire_budget_ascii_max_011",
+ "food_authored_wire_budget_escaped_overflow_012",
+ "food_authored_future_published_at_013",
+ "food_admission_normalizes_decimal_currency_014",
+ "food_admission_optional_standard_tags_015",
+ "food_admission_excludes_operational_before_validation_016",
+ "food_admission_excludes_generic_nip99_017",
+ "food_admission_rejects_ambiguous_markers_018",
+ "food_admission_rejects_wrong_kind_019",
+ "food_admission_rejects_core_tag_shape_020",
+ "food_admission_rejects_prohibited_delivery_021",
+ "food_admission_rejects_price_frequency_022",
+ "food_admission_requires_price_unit_023",
+ "food_admission_bounds_raw_decimal_digits_024",
+ "food_admission_rejects_malformed_price_unit_025",
+ "food_admission_rejects_quantity_unit_mismatch_026",
+ "food_admission_rejects_status_027",
+ "food_admission_rejects_future_published_at_028",
+ "food_admission_preserves_ordered_image_diagnostics_029",
+ "food_admission_bounds_image_projection_030",
+ "food_admission_rejects_invalid_signature_031",
+ "food_revision_accepts_later_created_at_032",
+ "food_revision_rejects_invalid_previous_033",
+ "food_revision_rejects_invalid_current_034",
+ "food_revision_rejects_identifier_coordinate_change_035",
+ "food_revision_rejects_author_coordinate_change_036",
+ "food_revision_rejects_published_at_change_037",
+ "food_revision_rejects_older_created_at_038",
+ "food_revision_equal_time_a_current_039",
+ "food_revision_equal_time_b_current_040",
+];
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Suite {
+ suite: String,
+ contract_version: String,
+ vectors: Vec<Vector>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Vector {
+ id: String,
+ kind: String,
+ input: Value,
+ expected: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AuthoredInput {
+ details: DetailsInput,
+ created_at: u64,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct DetailsInput {
+ content: TextRecipe,
+ identifier: String,
+ title: String,
+ summary: String,
+ published_at: u64,
+ location: String,
+ price: PriceInput,
+ quantity: Option<QuantityInput>,
+ status: String,
+ images: Vec<ImageInput>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum TextRecipe {
+ Exact(ExactText),
+ Repeat(RepeatText),
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ExactText {
+ value: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RepeatText {
+ repeat: String,
+ count: usize,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PriceInput {
+ amount: String,
+ currency: String,
+ unit: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct QuantityInput {
+ amount: String,
+ unit: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ImageInput {
+ bytes_utf8: String,
+ url: String,
+ media_type: String,
+ uploaded_at: u64,
+ dimensions: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct EventInput {
+ event: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RevisionInput {
+ previous: Value,
+ current: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RawEvent {
+ id: String,
+ pubkey: String,
+ created_at: u64,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: String,
+ sig: String,
+}
+
+#[test]
+fn checked_in_food_availability_profile_vectors_execute() {
+ let suite = parse_suite();
+ for vector in &suite.vectors {
+ execute(vector);
+ }
+}
+
+#[test]
+fn strict_revision_rejects_a_direct_envelope_above_the_authored_wire_bound() {
+ let suite = parse_suite();
+ let vector = suite
+ .vectors
+ .iter()
+ .find(|vector| vector.id == "food_revision_accepts_later_created_at_032")
+ .expect("revision fixture");
+ let input = revision_input(vector);
+ let previous = verified_event(&input.previous, vector.id.as_str());
+ let mut current = raw_event(&input.current, vector.id.as_str());
+ current.content = core::iter::repeat_n('\0', 64 * 1024).collect();
+ let current = directly_signed_verified_event(current, vector.id.as_str());
+
+ let error = validate_food_availability_revision(&previous, ¤t)
+ .expect_err("oversized strict revision must fail");
+ match error {
+ RadrootsFoodAvailabilityRevisionError::CurrentInvalid(error) => {
+ assert_eq!(error.code(), "food_event_wire_too_large");
+ }
+ error => panic!("unexpected strict revision error: {error:?}"),
+ }
+}
+
+fn parse_suite() -> Suite {
+ let vectors = conformance_vectors();
+ let suite: Suite = serde_json::from_str(&vectors)
+ .unwrap_or_else(|error| panic!("FoodAvailability vectors must parse: {error}"));
+ assert_eq!(suite.suite, "food_availability_profile");
+ assert_eq!(suite.contract_version, "1.0.0");
+ assert_eq!(suite.vectors.len(), VECTOR_IDS.len());
+
+ let actual = suite
+ .vectors
+ .iter()
+ .map(|vector| vector.id.as_str())
+ .collect::<BTreeSet<_>>();
+ assert_eq!(actual.len(), suite.vectors.len(), "duplicate vector ids");
+ let expected = VECTOR_IDS.iter().copied().collect::<BTreeSet<_>>();
+ assert_eq!(actual, expected, "FoodAvailability vector inventory drift");
+ suite
+}
+
+fn conformance_vectors() -> Cow<'static, str> {
+ let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
+ match fs::read_to_string(&workspace_path) {
+ Ok(canonical) => {
+ assert_eq!(
+ canonical,
+ PACKAGED_VECTORS,
+ "packaged FoodAvailability vectors must match {}",
+ workspace_path.display()
+ );
+ Cow::Owned(canonical)
+ }
+ Err(error)
+ if error.kind() == std::io::ErrorKind::NotFound
+ && !Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join(WORKSPACE_CONTRACT_MARKER_PATH)
+ .is_file() =>
+ {
+ Cow::Borrowed(PACKAGED_VECTORS)
+ }
+ Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
+ }
+}
+
+fn execute(vector: &Vector) {
+ match vector.kind.as_str() {
+ "food_availability.build_authored_draft.valid" => authored_valid(
+ vector,
+ vector.expected["wire_parts"]
+ .get("content_length")
+ .is_some(),
+ ),
+ "food_availability.build_authored_draft.invalid" => authored_invalid(vector),
+ "food_availability.project_verified_event.valid" => projection_valid(vector),
+ "food_availability.project_verified_event.invalid" => projection_invalid(vector),
+ "food_availability.verify_and_admit_event.valid" => verify_and_admit_valid(vector),
+ "food_availability.verify_and_admit_event.invalid" => verify_and_admit_invalid(vector),
+ "food_availability.validate_revision.valid" => revision_valid(vector),
+ "food_availability.validate_revision.invalid" => revision_invalid(vector),
+ kind => panic!("{} uses unsupported vector kind {kind}", vector.id),
+ }
+}
+
+fn authored_valid(vector: &Vector, summarized_content: bool) {
+ let input = authored_input(vector);
+ let (details, content) = strict_details(&input.details, &vector.id);
+ let tags = authored_food_availability_build_tags(&details, input.created_at)
+ .unwrap_or_else(|error| panic!("{} tags failed: {error}", vector.id));
+ let wire = authored_food_availability_to_wire_parts(&details, input.created_at)
+ .unwrap_or_else(|error| panic!("{} wire failed: {error}", vector.id));
+ assert_eq!(wire.tags, tags, "{}", vector.id);
+ assert_eq!(wire.content, content, "{}", vector.id);
+
+ let actual = if summarized_content {
+ json!({
+ "wire_parts": {
+ "kind": wire.kind,
+ "content_length": wire.content.len(),
+ "tags": wire.tags,
+ }
+ })
+ } else {
+ json!({
+ "wire_parts": {
+ "kind": wire.kind,
+ "content": wire.content,
+ "tags": wire.tags,
+ }
+ })
+ };
+ assert_eq!(actual, vector.expected, "{}", vector.id);
+}
+
+fn authored_invalid(vector: &Vector) {
+ let input = authored_input(vector);
+ let (details, _) = strict_details(&input.details, &vector.id);
+ let error = authored_food_availability_to_wire_parts(&details, input.created_at)
+ .expect_err("invalid authored vector must fail");
+ assert_eq!(
+ authored_error_value(&error),
+ vector.expected["error"],
+ "{}",
+ vector.id
+ );
+}
+
+fn authored_input(vector: &Vector) -> AuthoredInput {
+ serde_json::from_value(vector.input.clone())
+ .unwrap_or_else(|error| panic!("{} authored input is invalid: {error}", vector.id))
+}
+
+fn strict_details(
+ input: &DetailsInput,
+ vector_id: &str,
+) -> (RadrootsFoodAvailabilityDetails, String) {
+ let content = input.content.materialize(vector_id);
+ let price_unit = RadrootsFoodUnit::parse(&input.price.unit)
+ .unwrap_or_else(|error| panic!("{vector_id} price unit failed: {error}"));
+ let price = RadrootsFoodPrice::new(
+ input.price.amount.clone(),
+ RadrootsFoodCurrency::parse(&input.price.currency)
+ .unwrap_or_else(|error| panic!("{vector_id} currency failed: {error}")),
+ price_unit,
+ )
+ .unwrap_or_else(|error| panic!("{vector_id} price failed: {error}"));
+ let quantity = input.quantity.as_ref().map(|quantity| {
+ let unit = RadrootsFoodUnit::parse(&quantity.unit)
+ .unwrap_or_else(|error| panic!("{vector_id} quantity unit failed: {error}"));
+ RadrootsFoodQuantity::new(quantity.amount.clone(), unit)
+ .unwrap_or_else(|error| panic!("{vector_id} quantity failed: {error}"))
+ });
+ let images = input
+ .images
+ .iter()
+ .map(|image| strict_image(image, vector_id))
+ .collect();
+ let details = RadrootsFoodAvailabilityDetails::new(RadrootsFoodAvailabilityDetailsParts {
+ content: RadrootsFoodContent::new(content.clone())
+ .unwrap_or_else(|error| panic!("{vector_id} content failed: {error}")),
+ identifier: RadrootsFoodIdentifier::parse(&input.identifier)
+ .unwrap_or_else(|error| panic!("{vector_id} identifier failed: {error}")),
+ title: RadrootsFoodText::new(input.title.clone())
+ .unwrap_or_else(|error| panic!("{vector_id} title failed: {error}")),
+ summary: RadrootsFoodText::new(input.summary.clone())
+ .unwrap_or_else(|error| panic!("{vector_id} summary failed: {error}")),
+ published_at: RadrootsFoodPublishedAt::new(input.published_at)
+ .unwrap_or_else(|error| panic!("{vector_id} published_at failed: {error}")),
+ location: RadrootsFoodText::new(input.location.clone())
+ .unwrap_or_else(|error| panic!("{vector_id} location failed: {error}")),
+ price,
+ quantity,
+ status: RadrootsFoodAvailabilityStatus::parse(&input.status)
+ .unwrap_or_else(|error| panic!("{vector_id} status failed: {error}")),
+ images,
+ })
+ .unwrap_or_else(|error| panic!("{vector_id} details failed: {error}"));
+ (details, content)
+}
+
+fn strict_image(input: &ImageInput, vector_id: &str) -> RadrootsFoodAvailabilityImage {
+ let bytes = input.bytes_utf8.as_bytes();
+ let hash = RadrootsBlossomSha256::digest(bytes);
+ let media_type = RadrootsBlossomMediaType::parse(&input.media_type)
+ .unwrap_or_else(|error| panic!("{vector_id} image media type failed: {error}"));
+ let verified = RadrootsBlossomBlobDescriptor::new(
+ RadrootsBlossomBlobUrl::parse(&input.url)
+ .unwrap_or_else(|error| panic!("{vector_id} image URL failed: {error}")),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ input.uploaded_at,
+ )
+ .unwrap_or_else(|error| panic!("{vector_id} image descriptor failed: {error}"))
+ .approve_reference()
+ .unwrap_or_else(|error| panic!("{vector_id} image reference failed: {error}"))
+ .verify_bytes(bytes, &media_type)
+ .unwrap_or_else(|error| panic!("{vector_id} image bytes failed: {error}"));
+ let image = RadrootsAuthoredImage::try_from_verified_descriptor(verified)
+ .unwrap_or_else(|error| panic!("{vector_id} authored image failed: {error}"));
+ let dimensions = RadrootsFoodImageDimensions::parse(&input.dimensions)
+ .unwrap_or_else(|error| panic!("{vector_id} image dimensions failed: {error}"));
+ RadrootsFoodAvailabilityImage::new(image, dimensions)
+}
+
+impl TextRecipe {
+ fn materialize(&self, vector_id: &str) -> String {
+ match self {
+ Self::Exact(value) => value.value.clone(),
+ Self::Repeat(value) => {
+ assert_eq!(
+ value.repeat.chars().count(),
+ 1,
+ "{vector_id} repeat value must be one character"
+ );
+ value.repeat.repeat(value.count)
+ }
+ }
+ }
+}
+
+fn authored_error_value(error: &RadrootsFoodAvailabilityEncodeError) -> Value {
+ match error {
+ RadrootsFoodAvailabilityEncodeError::Domain(
+ RadrootsFoodAvailabilityError::PublishedAtFuture {
+ published_at,
+ created_at,
+ },
+ ) => json!({
+ "code": error.code(),
+ "message": error.to_string(),
+ "published_at": published_at,
+ "created_at": created_at,
+ }),
+ RadrootsFoodAvailabilityEncodeError::EventWireTooLarge { max, actual } => json!({
+ "code": error.code(),
+ "message": error.to_string(),
+ "max": max,
+ "actual": actual,
+ }),
+ _ => json!({
+ "code": error.code(),
+ "message": error.to_string(),
+ }),
+ }
+}
+
+fn projection_valid(vector: &Vector) {
+ let input = event_input(vector);
+ let verified = verified_event(&input.event, &vector.id);
+ let event_id = verified.event().id_str().to_string();
+ let outcome = project_verified_food_availability_event(&verified)
+ .unwrap_or_else(|error| panic!("{} projection failed: {error}", vector.id));
+ let actual = match outcome {
+ RadrootsFoodAvailabilityProjectionOutcome::Focused(projection)
+ if vector.expected.get("projection").is_some() =>
+ {
+ json!({
+ "outcome": "focused",
+ "event_id": event_id,
+ "projection": projection_value(&projection),
+ })
+ }
+ RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) => json!({
+ "outcome": "focused",
+ "event_id": event_id,
+ "image_count": projection.images().len(),
+ "diagnostics": diagnostic_codes(projection.diagnostics()),
+ "first_raw_tag": projection.images().first().expect("first bounded image").raw_tag(),
+ "last_raw_tag": projection.images().last().expect("last bounded image").raw_tag(),
+ }),
+ RadrootsFoodAvailabilityProjectionOutcome::Excluded(partition) => json!({
+ "outcome": "excluded",
+ "event_id": event_id,
+ "partition": partition_name(partition),
+ }),
+ _ => panic!("{} returned an unsupported projection outcome", vector.id),
+ };
+ assert_eq!(actual, vector.expected, "{}", vector.id);
+}
+
+fn projection_invalid(vector: &Vector) {
+ let input = event_input(vector);
+ let verified = verified_event(&input.event, &vector.id);
+ let error = project_verified_food_availability_event(&verified)
+ .expect_err("invalid focused FoodAvailability projection vector must fail");
+ assert_eq!(
+ projection_error_value(&error),
+ vector.expected["error"],
+ "{}",
+ vector.id
+ );
+}
+
+fn verify_and_admit_valid(vector: &Vector) {
+ let input = event_input(vector);
+ let verified = verified_event(&input.event, &vector.id);
+ let outcome = verify_and_admit_food_availability_event(verified.into_event())
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector.id));
+ let admitted = match outcome {
+ RadrootsFoodAvailabilityAdmissionOutcome::Admitted(admitted) => *admitted,
+ RadrootsFoodAvailabilityAdmissionOutcome::Excluded(excluded) => {
+ panic!(
+ "{} was unexpectedly excluded as {:?}",
+ vector.id,
+ excluded.partition()
+ )
+ }
+ _ => panic!("{} returned an unsupported admission outcome", vector.id),
+ };
+ let actual = json!({
+ "outcome": "admitted",
+ "event_id": admitted.event().id_str(),
+ "projection": projection_value(admitted.projection()),
+ });
+ assert_eq!(actual, vector.expected, "{}", vector.id);
+}
+
+fn verify_and_admit_invalid(vector: &Vector) {
+ let input = event_input(vector);
+ let envelope = canonical_envelope(&input.event, &vector.id);
+ let error = verify_and_admit_food_availability_event(envelope)
+ .expect_err("invalid signature vector must fail verification");
+ assert_eq!(
+ admission_error_value(&error),
+ vector.expected["error"],
+ "{}",
+ vector.id
+ );
+}
+
+fn event_input(vector: &Vector) -> EventInput {
+ serde_json::from_value(vector.input.clone())
+ .unwrap_or_else(|error| panic!("{} event input is invalid: {error}", vector.id))
+}
+
+fn admission_error_value(error: &RadrootsFoodAvailabilityAdmissionError) -> Value {
+ json!({
+ "code": error.code(),
+ "message": error.to_string(),
+ })
+}
+
+fn projection_error_value(error: &RadrootsFoodAvailabilityProjectionError) -> Value {
+ json!({
+ "code": error.code(),
+ "message": error.to_string(),
+ })
+}
+
+fn projection_value(projection: &RadrootsInboundFoodAvailabilityProjection) -> Value {
+ let quantity = projection.quantity().map(|quantity| {
+ json!({
+ "amount": quantity.amount(),
+ "unit": quantity.unit().as_str(),
+ })
+ });
+ let images = projection
+ .images()
+ .iter()
+ .map(|image| {
+ let dimensions = image.dimensions().map(|dimensions| {
+ json!({
+ "width": dimensions.width(),
+ "height": dimensions.height(),
+ })
+ });
+ json!({
+ "raw_tag": image.raw_tag(),
+ "url": image.url(),
+ "dimensions": dimensions,
+ "diagnostics": diagnostic_codes(image.diagnostics()),
+ "qualifies": image.qualifies(),
+ })
+ })
+ .collect::<Vec<_>>();
+ json!({
+ "content": projection.content().as_str(),
+ "identifier": projection.identifier().as_str(),
+ "title": projection.title().as_str(),
+ "summary": projection.summary().as_str(),
+ "published_at": projection.published_at().as_u64(),
+ "location": projection.location().as_str(),
+ "price": {
+ "amount": projection.price().amount(),
+ "currency": projection.price().currency().as_str(),
+ "unit": projection.price().unit().as_str(),
+ },
+ "quantity": quantity,
+ "status": projection.status().as_str(),
+ "images": images,
+ "diagnostics": diagnostic_codes(projection.diagnostics()),
+ })
+}
+
+fn diagnostic_codes(diagnostics: &[RadrootsFoodAvailabilityImageDiagnostic]) -> Vec<&'static str> {
+ diagnostics
+ .iter()
+ .map(|diagnostic| diagnostic.code())
+ .collect()
+}
+
+fn partition_name(partition: RadrootsClassifiedListingPartition) -> &'static str {
+ match partition {
+ RadrootsClassifiedListingPartition::FocusedFoodAvailability => "focused_food_availability",
+ RadrootsClassifiedListingPartition::OperationalListing => "operational_listing",
+ RadrootsClassifiedListingPartition::GenericNip99 => "generic_nip99",
+ RadrootsClassifiedListingPartition::Ambiguous => "ambiguous",
+ }
+}
+
+fn revision_valid(vector: &Vector) {
+ let input = revision_input(vector);
+ let previous = verified_event(&input.previous, &vector.id);
+ let current = verified_event(&input.current, &vector.id);
+ validate_food_availability_revision(&previous, ¤t)
+ .unwrap_or_else(|error| panic!("{} revision failed: {error}", vector.id));
+ let actual = json!({
+ "result": "accepted",
+ "current_event_id": current.event().id_str(),
+ });
+ assert_eq!(actual, vector.expected, "{}", vector.id);
+}
+
+fn revision_invalid(vector: &Vector) {
+ let input = revision_input(vector);
+ let previous = verified_event(&input.previous, &vector.id);
+ let current = verified_event(&input.current, &vector.id);
+ let error = validate_food_availability_revision(&previous, ¤t)
+ .expect_err("invalid revision vector must fail");
+ assert_eq!(
+ revision_error_value(&error),
+ vector.expected["error"],
+ "{}",
+ vector.id
+ );
+}
+
+fn revision_input(vector: &Vector) -> RevisionInput {
+ serde_json::from_value(vector.input.clone())
+ .unwrap_or_else(|error| panic!("{} revision input is invalid: {error}", vector.id))
+}
+
+fn revision_error_value(error: &RadrootsFoodAvailabilityRevisionError) -> Value {
+ json!({
+ "code": error.code(),
+ "message": error.to_string(),
+ })
+}
+
+fn verified_event(value: &Value, vector_id: &str) -> RadrootsSignatureVerifiedEvent {
+ let raw = raw_event(value, vector_id);
+ let raw_json = serde_json::to_string(value).expect("raw event must serialize");
+ let event = NostrEvent::from_json(&raw_json)
+ .unwrap_or_else(|error| panic!("{vector_id} is not a NIP-01 event: {error}"));
+ assert_eq!(event.id.to_string(), raw.id, "{vector_id}");
+ assert_eq!(event.sig.to_string(), raw.sig, "{vector_id}");
+ assert_eq!(event.pubkey.to_string(), raw.pubkey, "{vector_id}");
+ assert_eq!(event.created_at.as_secs(), raw.created_at, "{vector_id}");
+ assert_eq!(u32::from(event.kind.as_u16()), raw.kind, "{vector_id}");
+ assert_eq!(
+ event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect::<Vec<_>>(),
+ raw.tags,
+ "{vector_id}"
+ );
+ assert_eq!(event.content, raw.content, "{vector_id}");
+ event
+ .verify()
+ .unwrap_or_else(|error| panic!("{vector_id} NIP-01 verification failed: {error}"));
+
+ let keys = keys_for_pubkey(&raw.pubkey, vector_id);
+ let message = Message::from_digest(event.id.to_bytes());
+ let deterministic = SECP256K1.sign_schnorr_no_aux_rand(&message, keys.key_pair(SECP256K1));
+ assert_eq!(
+ event.sig, deterministic,
+ "{vector_id} deterministic signature drift"
+ );
+
+ verify_nip01_event(canonical_envelope(value, vector_id))
+ .unwrap_or_else(|error| panic!("{vector_id} Radroots verification failed: {error}"))
+}
+
+fn canonical_envelope(value: &Value, vector_id: &str) -> RadrootsEventEnvelope {
+ let raw_json = serde_json::to_string(value).expect("raw event must serialize");
+ let wire = RadrootsNip01EventWire::parse_json(&raw_json)
+ .unwrap_or_else(|error| panic!("{vector_id} wire parsing failed: {error}"));
+ wire.verify_id()
+ .unwrap_or_else(|error| panic!("{vector_id} event id failed: {error}"));
+ wire.into_envelope()
+ .unwrap_or_else(|error| panic!("{vector_id} envelope conversion failed: {error}"))
+}
+
+fn raw_event(value: &Value, vector_id: &str) -> RawEvent {
+ serde_json::from_value(value.clone())
+ .unwrap_or_else(|error| panic!("{vector_id} raw event is invalid: {error}"))
+}
+
+fn directly_signed_verified_event(
+ mut raw: RawEvent,
+ vector_id: &str,
+) -> RadrootsSignatureVerifiedEvent {
+ let id = compute_canonical_nip01_event_id(
+ &raw.pubkey,
+ raw.created_at,
+ raw.kind,
+ &raw.tags,
+ &raw.content,
+ )
+ .unwrap_or_else(|error| panic!("{vector_id} direct event id failed: {error}"));
+ let nostr_id = nostr::EventId::from_hex(id.as_str())
+ .unwrap_or_else(|error| panic!("{vector_id} direct event id conversion failed: {error}"));
+ let keys = keys_for_pubkey(&raw.pubkey, vector_id);
+ let message = Message::from_digest(nostr_id.to_bytes());
+ let signature = SECP256K1.sign_schnorr_no_aux_rand(&message, keys.key_pair(SECP256K1));
+ raw.id = id.into_string();
+ raw.sig = signature.to_string();
+
+ let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: raw.id,
+ author: raw.pubkey,
+ created_at: raw.created_at,
+ kind: raw.kind,
+ tags: raw.tags,
+ content: raw.content,
+ sig: raw.sig,
+ })
+ .unwrap_or_else(|error| panic!("{vector_id} direct envelope failed: {error}"));
+ verify_nip01_event(event)
+ .unwrap_or_else(|error| panic!("{vector_id} direct verification failed: {error}"))
+}
+
+fn keys_for_pubkey(pubkey: &str, vector_id: &str) -> Keys {
+ for secret in [SECRET_KEY_ONE, SECRET_KEY_TWO] {
+ let keys = Keys::parse(secret).expect("fixed secret key must parse");
+ if keys.public_key().to_string() == pubkey {
+ return keys;
+ }
+ }
+ panic!("{vector_id} uses an ungoverned fixture pubkey {pubkey}")
+}
diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs
@@ -1942,7 +1942,10 @@ mod tests {
}
fn operational_listing_tags(d_tag: &str) -> Vec<Vec<String>> {
- vec![vec!["d".to_owned(), d_tag.to_owned()]]
+ vec![
+ vec!["d".to_owned(), d_tag.to_owned()],
+ vec!["radroots:primary_bin".to_owned(), "bin-1".to_owned()],
+ ]
}
fn head_coordinate_for_event(event: &RadrootsSignedEvent) -> RadrootsEventHeadCoordinate {
@@ -2752,7 +2755,7 @@ mod tests {
}
#[tokio::test]
- async fn malformed_addressable_heads_are_not_projected() {
+ async fn marker_free_classified_listings_are_unsupported() {
let store = RadrootsEventStore::open_memory().await.expect("open");
let event = signed_event(KIND_CLASSIFIED_LISTING, 16, Vec::new(), "{}");
@@ -2768,11 +2771,11 @@ mod tests {
assert_eq!(
receipt.contract_status,
- RadrootsEventContractStatus::Supported
+ RadrootsEventContractStatus::UnsupportedShape(KIND_CLASSIFIED_LISTING)
);
assert_eq!(
receipt.head_decision,
- RadrootsEventHeadStoreDecision::Malformed
+ RadrootsEventHeadStoreDecision::Unsupported
);
assert!(!receipt.projection_eligible);
assert!(!stored.projection_eligible);
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -23,6 +23,8 @@ const KNOWLEDGE_PUBLIC_SURFACE_RELATIVE: &str =
"contracts/conformance/vectors/knowledge/public_surface.v1.json";
const POST_CONFORMANCE_VECTOR_RELATIVE: &str =
"contracts/conformance/vectors/post/verified_profiles.v1.json";
+const FOOD_AVAILABILITY_CONFORMANCE_VECTOR_RELATIVE: &str =
+ "contracts/conformance/vectors/food_availability/profile.v1.json";
const RELEASES_ROOT_RELATIVE: &str = "contracts/releases";
const CHANGELOG_RELATIVE: &str = "CHANGELOG.md";
const REPLICA_CONTRACT_RELATIVE: &str = "contracts/replica.toml";
@@ -30,7 +32,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract";
const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION";
const REPLICA_TRANSFER_VERSION: u32 = 2;
const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3";
-const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 14] = [
+const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 15] = [
(
"contracts/conformance/vectors/blossom/bud11_claims.v1.json",
"crates/blossom/tests/fixtures/bud11_claims.v1.json",
@@ -56,6 +58,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 14] = [
"crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json",
),
(
+ "contracts/conformance/vectors/food_availability/profile.v1.json",
+ "crates/event_codec/tests/fixtures/food_availability_profile.v1.json",
+ ),
+ (
"contracts/conformance/vectors/operational_listing/build_draft.v1.json",
"crates/event_codec/tests/fixtures/operational_listing_build_draft.v1.json",
),
@@ -189,6 +195,39 @@ const REQUIRED_POST_PUBLIC_TYPES: [&str; 22] = [
"RadrootsPostAdmissionOutcome",
"RadrootsPostAdmissionError",
];
+const REQUIRED_FOOD_AVAILABILITY_PUBLIC_TYPES: [&str; 31] = [
+ "RadrootsBlossomByteVerifiedDescriptor",
+ "RadrootsNip01EventWireParts",
+ "RadrootsEventEnvelope",
+ "RadrootsSignatureVerifiedEvent",
+ "RadrootsClassifiedListingPartition",
+ "RadrootsAuthoredImage",
+ "RadrootsFoodAvailabilityError",
+ "RadrootsFoodContent",
+ "RadrootsFoodIdentifier",
+ "RadrootsFoodText",
+ "RadrootsFoodPublishedAt",
+ "RadrootsFoodCurrency",
+ "RadrootsFoodUnit",
+ "RadrootsFoodPrice",
+ "RadrootsFoodQuantity",
+ "RadrootsFoodAvailabilityStatus",
+ "RadrootsFoodImageDimensions",
+ "RadrootsFoodAvailabilityImage",
+ "RadrootsFoodAvailabilityDetailsParts",
+ "RadrootsFoodAvailabilityDetails",
+ "RadrootsFoodAvailabilityEncodeError",
+ "RadrootsFoodAvailabilityImageDiagnostic",
+ "RadrootsInboundFoodAvailabilityImage",
+ "RadrootsInboundFoodAvailabilityProjection",
+ "RadrootsFoodAvailabilityProjectionOutcome",
+ "RadrootsFoodAvailabilityProjectionError",
+ "RadrootsAdmittedFoodAvailabilityEvent",
+ "RadrootsExcludedClassifiedListingCandidate",
+ "RadrootsFoodAvailabilityAdmissionOutcome",
+ "RadrootsFoodAvailabilityAdmissionError",
+ "RadrootsFoodAvailabilityRevisionError",
+];
const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 12] = [
CalendarOperationExpectation {
key: "social_calendar_date_event_build_authored_draft",
@@ -676,6 +715,280 @@ const POST_VECTOR_EXPECTATIONS: [(&str, &str); 27] = [
"social.post.verify_and_admit_event.invalid",
),
];
+const FOOD_AVAILABILITY_OPERATION_EXPECTATIONS: [FoodAvailabilityOperationExpectation; 4] = [
+ FoodAvailabilityOperationExpectation {
+ key: "food_availability_build_authored_draft",
+ id: "food_availability.build_authored_draft",
+ inputs: &["RadrootsFoodAvailabilityDetails", "u64"],
+ outputs: &["RadrootsNip01EventWireParts"],
+ error_class: "encode_error",
+ signing: "none",
+ rust_modules: &[
+ "crates/event/src/food_availability.rs",
+ "crates/event_codec/src/food_availability/authored.rs",
+ ],
+ rust_types: &[
+ "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityDetails",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityDetailsParts",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityError",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityImage",
+ "radroots_event::media::RadrootsAuthoredImage",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::food_availability::authored::RadrootsFoodAvailabilityEncodeError",
+ ],
+ case_kinds: &[
+ "food_availability.build_authored_draft.valid",
+ "food_availability.build_authored_draft.invalid",
+ ],
+ },
+ FoodAvailabilityOperationExpectation {
+ key: "food_availability_project_verified_event",
+ id: "food_availability.project_verified_event",
+ inputs: &["RadrootsSignatureVerifiedEvent"],
+ outputs: &["RadrootsFoodAvailabilityProjectionOutcome"],
+ error_class: "parse_error",
+ signing: "none",
+ rust_modules: &[
+ "crates/event/src/classified_listing.rs",
+ "crates/event/src/food_availability.rs",
+ "crates/event_codec/src/food_availability/inbound.rs",
+ ],
+ rust_types: &[
+ "radroots_event::classified_listing::RadrootsClassifiedListingPartition",
+ "radroots_event::food_availability::RadrootsFoodAvailabilityError",
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityImageDiagnostic",
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityProjectionError",
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityProjectionOutcome",
+ "radroots_event_codec::food_availability::inbound::RadrootsInboundFoodAvailabilityImage",
+ "radroots_event_codec::food_availability::inbound::RadrootsInboundFoodAvailabilityProjection",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+ ],
+ case_kinds: &[
+ "food_availability.project_verified_event.valid",
+ "food_availability.project_verified_event.invalid",
+ ],
+ },
+ FoodAvailabilityOperationExpectation {
+ key: "food_availability_verify_and_admit_event",
+ id: "food_availability.verify_and_admit_event",
+ inputs: &["RadrootsEventEnvelope"],
+ outputs: &["RadrootsFoodAvailabilityAdmissionOutcome"],
+ error_class: "admission_error",
+ signing: "nip01",
+ rust_modules: &[
+ "crates/event_codec/src/food_availability/admission.rs",
+ "crates/event_codec/src/food_availability/inbound.rs",
+ "crates/event_codec/src/verification.rs",
+ ],
+ rust_types: &[
+ "radroots_event::RadrootsEventEnvelope",
+ "radroots_event_codec::food_availability::admission::RadrootsAdmittedFoodAvailabilityEvent",
+ "radroots_event_codec::food_availability::admission::RadrootsExcludedClassifiedListingCandidate",
+ "radroots_event_codec::food_availability::admission::RadrootsFoodAvailabilityAdmissionError",
+ "radroots_event_codec::food_availability::admission::RadrootsFoodAvailabilityAdmissionOutcome",
+ "radroots_event_codec::food_availability::inbound::RadrootsInboundFoodAvailabilityProjection",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+ ],
+ case_kinds: &[
+ "food_availability.verify_and_admit_event.valid",
+ "food_availability.verify_and_admit_event.invalid",
+ ],
+ },
+ FoodAvailabilityOperationExpectation {
+ key: "food_availability_validate_revision",
+ id: "food_availability.validate_revision",
+ inputs: &["RadrootsSignatureVerifiedEvent"],
+ outputs: &["Unit"],
+ error_class: "validation_error",
+ signing: "none",
+ rust_modules: &[
+ "crates/event_codec/src/food_availability/inbound.rs",
+ "crates/event_codec/src/food_availability/revision.rs",
+ ],
+ rust_types: &[
+ "radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityProjectionError",
+ "radroots_event_codec::food_availability::revision::RadrootsFoodAvailabilityRevisionError",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+ ],
+ case_kinds: &[
+ "food_availability.validate_revision.valid",
+ "food_availability.validate_revision.invalid",
+ ],
+ },
+];
+const FOOD_AVAILABILITY_CASE_KINDS: [&str; 8] = [
+ "food_availability.build_authored_draft.valid",
+ "food_availability.build_authored_draft.invalid",
+ "food_availability.project_verified_event.valid",
+ "food_availability.project_verified_event.invalid",
+ "food_availability.verify_and_admit_event.valid",
+ "food_availability.verify_and_admit_event.invalid",
+ "food_availability.validate_revision.valid",
+ "food_availability.validate_revision.invalid",
+];
+const FOOD_AVAILABILITY_VECTOR_EXPECTATIONS: [(&str, &str); 40] = [
+ (
+ "food_authored_unit_g_001",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_kg_002",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_lb_003",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_oz_004",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_each_005",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_dozen_006",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_bunch_007",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_punnet_008",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_bag_009",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_unit_basket_010",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_wire_budget_ascii_max_011",
+ "food_availability.build_authored_draft.valid",
+ ),
+ (
+ "food_authored_wire_budget_escaped_overflow_012",
+ "food_availability.build_authored_draft.invalid",
+ ),
+ (
+ "food_authored_future_published_at_013",
+ "food_availability.build_authored_draft.invalid",
+ ),
+ (
+ "food_admission_normalizes_decimal_currency_014",
+ "food_availability.project_verified_event.valid",
+ ),
+ (
+ "food_admission_optional_standard_tags_015",
+ "food_availability.verify_and_admit_event.valid",
+ ),
+ (
+ "food_admission_excludes_operational_before_validation_016",
+ "food_availability.project_verified_event.valid",
+ ),
+ (
+ "food_admission_excludes_generic_nip99_017",
+ "food_availability.project_verified_event.valid",
+ ),
+ (
+ "food_admission_rejects_ambiguous_markers_018",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_wrong_kind_019",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_core_tag_shape_020",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_prohibited_delivery_021",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_price_frequency_022",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_requires_price_unit_023",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_bounds_raw_decimal_digits_024",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_malformed_price_unit_025",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_quantity_unit_mismatch_026",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_status_027",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_rejects_future_published_at_028",
+ "food_availability.project_verified_event.invalid",
+ ),
+ (
+ "food_admission_preserves_ordered_image_diagnostics_029",
+ "food_availability.project_verified_event.valid",
+ ),
+ (
+ "food_admission_bounds_image_projection_030",
+ "food_availability.project_verified_event.valid",
+ ),
+ (
+ "food_admission_rejects_invalid_signature_031",
+ "food_availability.verify_and_admit_event.invalid",
+ ),
+ (
+ "food_revision_accepts_later_created_at_032",
+ "food_availability.validate_revision.valid",
+ ),
+ (
+ "food_revision_rejects_invalid_previous_033",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_rejects_invalid_current_034",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_rejects_identifier_coordinate_change_035",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_rejects_author_coordinate_change_036",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_rejects_published_at_change_037",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_rejects_older_created_at_038",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_equal_time_a_current_039",
+ "food_availability.validate_revision.invalid",
+ ),
+ (
+ "food_revision_equal_time_b_current_040",
+ "food_availability.validate_revision.valid",
+ ),
+];
const EVENT_BOUNDARY_MATRIX_RELATIVES: [&str; 2] = [
"contracts/event_boundary_matrix.md",
"docs/platform/canonical/open_source/radroots_v1_spec/02_public_contract_and_runtime/08_event_boundary_matrix.md",
@@ -876,6 +1189,19 @@ struct PostOperationExpectation {
case_kinds: &'static [&'static str],
}
+#[derive(Clone, Copy)]
+struct FoodAvailabilityOperationExpectation {
+ key: &'static str,
+ id: &'static str,
+ inputs: &'static [&'static str],
+ outputs: &'static [&'static str],
+ error_class: &'static str,
+ signing: &'static str,
+ rust_modules: &'static [&'static str],
+ rust_types: &'static [&'static str],
+ case_kinds: &'static [&'static str],
+}
+
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VersionPolicy {
@@ -1572,6 +1898,42 @@ const OPERATIONAL_LISTING_WITNESSES: [EventBoundarySourceWitness; 2] = [
},
];
+const FOOD_AVAILABILITY_WITNESSES: [EventBoundarySourceWitness; 6] = [
+ EventBoundarySourceWitness {
+ relative_path: "crates/event/src/food_availability.rs",
+ required_fragments: &[
+ "pub const RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID: &str",
+ "pub struct RadrootsFoodAvailabilityDetails",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event/src/kinds.rs",
+ required_fragments: &["pub const KIND_CLASSIFIED_LISTING: u32 = 30402;"],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/food_availability/authored.rs",
+ required_fragments: &["pub fn authored_food_availability_to_wire_parts("],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/food_availability/inbound.rs",
+ required_fragments: &[
+ "pub struct RadrootsInboundFoodAvailabilityProjection",
+ "pub fn project_verified_food_availability_event(",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/food_availability/admission.rs",
+ required_fragments: &[
+ "pub struct RadrootsAdmittedFoodAvailabilityEvent",
+ "pub fn verify_and_admit_food_availability_event(",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/food_availability/revision.rs",
+ required_fragments: &["pub fn validate_food_availability_revision("],
+ },
+];
+
const DVM_REQUEST_WITNESSES: [EventBoundarySourceWitness; 2] = [
EventBoundarySourceWitness {
relative_path: "crates/event/src/job_request.rs",
@@ -1740,7 +2102,7 @@ const RELAY_DOC_WITNESSES: [EventBoundarySourceWitness; 2] = [
},
];
-const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
+const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 42] = [
EventBoundaryExpectation {
domain: "profile",
kind: "0",
@@ -2048,6 +2410,18 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
witnesses: &RESOURCE_CAP_WITNESSES,
},
EventBoundaryExpectation {
+ domain: "food_availability",
+ kind: "30402",
+ radroots_type: "RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent",
+ rpc_methods: &[
+ "food_availability.build_authored_draft",
+ "food_availability.project_verified_event",
+ "food_availability.verify_and_admit_event",
+ "food_availability.validate_revision",
+ ],
+ witnesses: &FOOD_AVAILABILITY_WITNESSES,
+ },
+ EventBoundaryExpectation {
domain: "operational_listing",
kind: "30402",
radroots_type: "RadrootsOperationalListing",
@@ -4510,6 +4884,9 @@ fn validate_operations_contract(
validate_calendar_operation_authority(operations_manifest, &shared_types)?;
validate_post_operation_authority(operations_manifest, workspace_root)?;
}
+ if domains.contains("food_availability") {
+ validate_food_availability_operation_authority(operations_manifest, workspace_root)?;
+ }
Ok(())
}
@@ -4760,6 +5137,240 @@ fn validate_post_operation_sequence(
Ok(())
}
+fn validate_food_availability_operation_authority(
+ manifest: &OperationsContractManifest,
+ workspace_root: &Path,
+) -> Result<(), String> {
+ let vector = validate_conformance_vector_file(
+ &workspace_root.join(FOOD_AVAILABILITY_CONFORMANCE_VECTOR_RELATIVE),
+ &manifest.contract.version,
+ )?;
+ validate_food_availability_operation_inventory(manifest, &vector)
+}
+
+fn validate_food_availability_operation_inventory(
+ manifest: &OperationsContractManifest,
+ vector: &ConformanceVectorFile,
+) -> Result<(), String> {
+ let shared_types = collect_non_empty_set(
+ &manifest.shared_types.public,
+ "food availability operation shared_types.public",
+ )?;
+ for required in REQUIRED_FOOD_AVAILABILITY_PUBLIC_TYPES {
+ if !shared_types.contains(required) {
+ return Err(format!(
+ "food availability operation authority requires shared public type {required}"
+ ));
+ }
+ }
+
+ let expected_keys = FOOD_AVAILABILITY_OPERATION_EXPECTATIONS
+ .iter()
+ .map(|expectation| expectation.key.to_string())
+ .collect::<BTreeSet<_>>();
+ let actual_keys = manifest
+ .operations
+ .iter()
+ .filter(|(key, operation)| {
+ operation.domain == "food_availability"
+ || operation.conformance.vector == FOOD_AVAILABILITY_CONFORMANCE_VECTOR_RELATIVE
+ || key.starts_with("food_availability_")
+ || operation.id.starts_with("food_availability.")
+ })
+ .map(|(key, _)| key.clone())
+ .collect::<BTreeSet<_>>();
+ if actual_keys != expected_keys {
+ let missing = expected_keys
+ .difference(&actual_keys)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ let unexpected = actual_keys
+ .difference(&expected_keys)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ return Err(format!(
+ "food availability operation authority drift: missing {}; unexpected {}",
+ join_set(&missing),
+ join_set(&unexpected)
+ ));
+ }
+
+ let mut owners = BTreeMap::new();
+ for expected in FOOD_AVAILABILITY_OPERATION_EXPECTATIONS {
+ let operation = manifest
+ .operations
+ .get(expected.key)
+ .ok_or_else(|| format!("food availability operation {} is required", expected.key))?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "domain",
+ &operation.domain,
+ "food_availability",
+ )?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "id",
+ &operation.id,
+ expected.id,
+ )?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "stability",
+ &operation.stability,
+ "beta",
+ )?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "error_class",
+ &operation.error_class,
+ expected.error_class,
+ )?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "signing",
+ &operation.signing,
+ expected.signing,
+ )?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "transport",
+ &operation.transport,
+ "none",
+ )?;
+ if !operation.deterministic {
+ return Err(format!(
+ "food availability operation {} deterministic drift: expected true, got false",
+ expected.key
+ ));
+ }
+ validate_food_availability_operation_sequence(
+ expected.key,
+ "inputs",
+ &operation.inputs,
+ expected.inputs,
+ )?;
+ validate_food_availability_operation_sequence(
+ expected.key,
+ "outputs",
+ &operation.outputs,
+ expected.outputs,
+ )?;
+ validate_food_availability_operation_sequence(
+ expected.key,
+ "implementation.rust_modules",
+ &operation.implementation.rust_modules,
+ expected.rust_modules,
+ )?;
+ validate_food_availability_operation_sequence(
+ expected.key,
+ "implementation.rust_types",
+ &operation.implementation.rust_types,
+ expected.rust_types,
+ )?;
+ validate_food_availability_operation_scalar(
+ expected.key,
+ "conformance.vector",
+ &operation.conformance.vector,
+ FOOD_AVAILABILITY_CONFORMANCE_VECTOR_RELATIVE,
+ )?;
+ validate_operation_case_kinds(operation, vector)?;
+ if !operation
+ .conformance
+ .case_kinds
+ .iter()
+ .map(String::as_str)
+ .eq(expected.case_kinds.iter().copied())
+ {
+ return Err(format!(
+ "food availability operation {} conformance.case_kinds drift: expected {:?}, got {:?}",
+ expected.key, expected.case_kinds, operation.conformance.case_kinds
+ ));
+ }
+ for case_kind in &operation.conformance.case_kinds {
+ if let Some(previous) = owners.insert(case_kind.as_str(), expected.key) {
+ return Err(format!(
+ "food availability conformance case kind {case_kind} is multiply claimed by {previous} and {}",
+ expected.key
+ ));
+ }
+ }
+ }
+
+ let expected_case_kinds = FOOD_AVAILABILITY_CASE_KINDS
+ .into_iter()
+ .collect::<BTreeSet<_>>();
+ let actual_case_kinds = owners.keys().copied().collect::<BTreeSet<_>>();
+ if actual_case_kinds != expected_case_kinds {
+ return Err(format!(
+ "food availability conformance case-kind authority drift: expected {:?}, got {:?}",
+ expected_case_kinds, actual_case_kinds
+ ));
+ }
+
+ let mut actual_inventory = BTreeMap::new();
+ for entry in &vector.vectors {
+ if actual_inventory
+ .insert(entry.id.as_str(), entry.kind.as_str())
+ .is_some()
+ {
+ return Err(format!(
+ "food availability conformance vector inventory has duplicate id {}",
+ entry.id
+ ));
+ }
+ }
+ for kind in actual_inventory.values() {
+ if !owners.contains_key(kind) {
+ return Err(format!(
+ "food availability conformance vector kind {kind} is not claimed by exactly one operation"
+ ));
+ }
+ }
+ let expected_inventory = FOOD_AVAILABILITY_VECTOR_EXPECTATIONS
+ .into_iter()
+ .collect::<BTreeMap<_, _>>();
+ if actual_inventory != expected_inventory {
+ return Err(format!(
+ "food availability conformance vector inventory drift: expected {:?}, got {:?}",
+ expected_inventory, actual_inventory
+ ));
+ }
+ Ok(())
+}
+
+fn validate_food_availability_operation_scalar(
+ operation_key: &str,
+ field: &str,
+ actual: &str,
+ expected: &str,
+) -> Result<(), String> {
+ if actual != expected {
+ return Err(format!(
+ "food availability operation {operation_key} {field} drift: expected {expected}, got {actual}"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_food_availability_operation_sequence(
+ operation_key: &str,
+ field: &str,
+ actual: &[String],
+ expected: &[&str],
+) -> Result<(), String> {
+ if !actual
+ .iter()
+ .map(String::as_str)
+ .eq(expected.iter().copied())
+ {
+ return Err(format!(
+ "food availability operation {operation_key} {field} drift: expected {:?}, got {:?}",
+ expected, actual
+ ));
+ }
+ Ok(())
+}
+
fn validate_calendar_operation_authority(
manifest: &OperationsContractManifest,
shared_types: &BTreeSet<String>,
@@ -6098,6 +6709,19 @@ mod tests {
(manifest, vector)
}
+ fn current_food_availability_authority() -> (OperationsContractManifest, ConformanceVectorFile)
+ {
+ let root = workspace_root();
+ let manifest =
+ parse_toml::<OperationsContractManifest>(&root.join("contracts/operations.toml"))
+ .expect("current operations manifest");
+ let vector = parse_json::<ConformanceVectorFile>(
+ &root.join(FOOD_AVAILABILITY_CONFORMANCE_VECTOR_RELATIVE),
+ )
+ .expect("current FoodAvailability conformance vector");
+ (manifest, vector)
+ }
+
fn current_knowledge_manifest_authority() -> (String, ConformanceVectorFile) {
let root = workspace_root();
let manifest = fs::read_to_string(root.join(KNOWLEDGE_MANIFEST_RELATIVE))
@@ -7001,6 +7625,81 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"]
}
#[test]
+ fn food_availability_operation_authority_rejects_contract_drift() {
+ let (manifest, vector) = current_food_availability_authority();
+ validate_food_availability_operation_inventory(&manifest, &vector)
+ .expect("current FoodAvailability operation authority");
+
+ let (mut manifest, vector) = current_food_availability_authority();
+ manifest
+ .operations
+ .remove("food_availability_build_authored_draft");
+ let error = validate_food_availability_operation_inventory(&manifest, &vector)
+ .expect_err("missing FoodAvailability operation must fail");
+ assert!(
+ error.contains("food availability operation authority drift"),
+ "{error}"
+ );
+
+ let (mut manifest, vector) = current_food_availability_authority();
+ manifest
+ .shared_types
+ .public
+ .retain(|value| value != "RadrootsFoodAvailabilityRevisionError");
+ let error = validate_food_availability_operation_inventory(&manifest, &vector)
+ .expect_err("missing FoodAvailability public type must fail");
+ assert!(
+ error.contains(
+ "food availability operation authority requires shared public type RadrootsFoodAvailabilityRevisionError"
+ ),
+ "{error}"
+ );
+
+ let (mut manifest, vector) = current_food_availability_authority();
+ manifest
+ .operations
+ .get_mut("food_availability_validate_revision")
+ .expect("revision operation")
+ .conformance
+ .case_kinds
+ .pop();
+ let error = validate_food_availability_operation_inventory(&manifest, &vector)
+ .expect_err("missing FoodAvailability case kind must fail");
+ assert!(error.contains("conformance.case_kinds drift"), "{error}");
+ }
+
+ #[test]
+ fn food_availability_operation_authority_rejects_vector_inventory_drift() {
+ let (manifest, mut vector) = current_food_availability_authority();
+ vector
+ .vectors
+ .iter_mut()
+ .find(|entry| entry.id == "food_admission_normalizes_decimal_currency_014")
+ .expect("normalization vector")
+ .id = "food_admission_normalizes_decimal_currency_replacement".to_string();
+ let error = validate_food_availability_operation_inventory(&manifest, &vector)
+ .expect_err("same-count FoodAvailability vector replacement must fail");
+ assert!(
+ error.contains("food availability conformance vector inventory drift"),
+ "{error}"
+ );
+
+ let (manifest, mut vector) = current_food_availability_authority();
+ vector.vectors.push(ConformanceVectorEntry {
+ id: "food_unclaimed_case".to_string(),
+ kind: "food_availability.unclaimed.valid".to_string(),
+ input: Value::Object(Default::default()),
+ expected: Value::Object(Default::default()),
+ });
+ let error = validate_food_availability_operation_inventory(&manifest, &vector)
+ .expect_err("unclaimed FoodAvailability vector kind must fail");
+ assert!(
+ error.contains("is not claimed by exactly one operation"),
+ "{error}"
+ );
+ }
+
+ #[test]
fn version_governance_rejects_contract_workspace_and_lock_drift() {
let root = create_synthetic_workspace("version_governance_drift");
let mut bundle = load_contract_bundle(&root).expect("load contract");