lib

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

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:
MCHANGELOG.md | 20++++++++++++++------
Acontracts/conformance/vectors/food_availability/profile.v1.json | 3222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/knowledge/manifest_and_decode.v1.json | 4++--
Mcontracts/event_boundary_matrix.md | 41++++++++++++++++++++++++++++++++---------
Mcontracts/events/social-events.md | 71++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.json | 2+-
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.sha256 | 2+-
Mcontracts/operations.toml | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 13+++++++++++++
Mcrates/authority/src/authorization.rs | 8++++----
Mcrates/authority/src/error.rs | 17+++++++++++------
Mcrates/event/README | 25++++++++++++++-----------
Mcrates/event/src/classified_listing.rs | 24++++++++++++++++++++++--
Mcrates/event/src/contract.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/event/src/food_availability.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/Cargo.toml | 3++-
Mcrates/event_codec/README | 35+++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/food_availability/admission.rs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/food_availability/authored.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/food_availability/inbound.rs | 873+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/food_availability/mod.rs | 4++++
Acrates/event_codec/src/food_availability/revision.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/lib.rs | 1+
Mcrates/event_codec/src/manifest.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/event_codec/tests/fixtures/food_availability_profile.v1.json | 3222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/food_availability_conformance.rs | 756+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/store.rs | 11+++++++----
Mtools/xtask/src/contract.rs | 703++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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, &current) + .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, &current) + .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, &current) + .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");