From a8b2ddbdda32960359570ffcf6ef93480de8c723 Mon Sep 17 00:00:00 2001 From: refined-element Date: Mon, 20 Jul 2026 16:58:24 -0400 Subject: [PATCH 1/2] test(conformance): add cross-port drift check (price-tag, negotiable-floor, discover #41) Introduces a CI-enforced port-drift conformance suite shared across the three Agent SDK ports. Golden vectors (input -> expected output) live in language- neutral JSON under conformance/vectors/; tests/test_conformance.py runs them through this port's own AgentCapability.from_nostr_event and AgentManager.discover. Covers three security-critical shared behaviors: - price-tag amount parsing (valid / malformed-reject / no-price / negative) - negotiable-floor parsing (valid / malformed-reject) - discover() batch resilience to one malformed relay payload (ledger #41): bad-price, missing-committed-field, non-dict-payload A shared CHECKSUMS file (identical in all three repos) pins the vectors so the copies cannot silently drift; the test verifies it over LF-normalized bytes. This port already conforms to every golden value, so no production code changed. See conformance/README.md for the design and sync mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CYXrdWWjKo5AyGqFAfrdgK --- conformance/CHECKSUMS | 3 + conformance/README.md | 125 +++++++++++ conformance/vectors/discover-resilience.json | 25 +++ conformance/vectors/negotiable-floor.json | 37 ++++ conformance/vectors/price-tag.json | 49 +++++ tests/test_conformance.py | 210 +++++++++++++++++++ 6 files changed, 449 insertions(+) create mode 100644 conformance/CHECKSUMS create mode 100644 conformance/README.md create mode 100644 conformance/vectors/discover-resilience.json create mode 100644 conformance/vectors/negotiable-floor.json create mode 100644 conformance/vectors/price-tag.json create mode 100644 tests/test_conformance.py diff --git a/conformance/CHECKSUMS b/conformance/CHECKSUMS new file mode 100644 index 0000000..a9bb16d --- /dev/null +++ b/conformance/CHECKSUMS @@ -0,0 +1,3 @@ +6e71737bd26abcd7106758de38316f477cfc69e792c153ac4d4ceca547841aba discover-resilience.json +729c3b00dde43bb34f25765bce56b263deee74b38cfc9ec7de620f6245f7b833 negotiable-floor.json +70cb62fb764804cd0193bfc7eedd7a8b1a83b0d07a8d836467bcecc1829b7a2b price-tag.json diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..deda915 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,125 @@ +# Port-drift conformance check + +This directory is the **cross-port conformance suite** for the three Lightning +Enable Agent SDK ports: + +- Python — `le-agent-sdk` (`F:\le-agent-sdk-python`, default branch `master`) +- .NET — `LightningEnable.AgentSdk` (`F:\le-agent-sdk-dotnet`, default branch `main`) +- TypeScript — `le-agent-sdk` (`F:\le-agent-sdk-ts`, default branch `main`) + +## Why this exists (the oracle) + +An audit of the three ports found that **wherever the ports of a shared behavior +disagreed, at least two of the three were wrong.** This held on every divergence +found (auth bypass, preimage fabrication, `max_amount` gap, ratings filter, the +`#41` price-tag divergence, the `#61` negotiable-floor divergence). + +That observation is turned into a CI-enforced check here: for each +security-critical shared behavior we define **golden vectors** (input -> expected +output) in language-neutral JSON, and each port ships a **conformance test** that +runs those same vectors through *its own* implementation. If any port diverges, +that port's own CI goes red. Drift is caught automatically instead of by manual +cross-reading. + +## What is covered (first version) + +| Vectors file | Behavior | Entry point | +|---|---|---| +| `vectors/price-tag.json` | Parsing a capability `price` tag amount | `AgentCapability.fromNostrEvent` / `from_nostr_event` / `FromNostrEvent` | +| `vectors/negotiable-floor.json` | Parsing the `["negotiable","floor",""]` branch | same | +| `vectors/discover-resilience.json` | `discover()` batch resilience to one malformed payload (ledger #41) | `AgentManager.discover` + each port's relay-ingest layer | + +Each vectors file is self-describing: it names the behavior, the entry point, the +outcome vocabulary, and every vector's expected outcome. + +### Findings this suite encodes + +- **Price parsing agrees across all three ports.** Valid amounts parse; `abc`, + `10.5`, `100abc` are rejected (throw); a bare `["price"]` records no price; and + a **negative** amount (`-5`) is currently **accepted by all three**. Whether a + negative price *should* be rejected is a genuine design question — flagged in + `price-tag.json` as `designQuestion`, **not** silently changed on one side. +- **Negotiable-floor did NOT agree.** Python and .NET reject a malformed floor + amount (throw -> the event is skipped). TypeScript used `parseInt()`, which + returns `NaN` for `"abc"` and silently truncates `"10.5"`->`10` / + `"100abc"`->`100`, and never throws. Two ports reject, one keeps a bogus value: + by the oracle, the one that keeps it is the bug (ledger #61). The golden REJECTS + a malformed floor and the TypeScript port was fixed to conform (a `NaN` floor is + worse than useless: every price-floor comparison against `NaN` is false, so a + malformed floor silently passes downstream instead of being rejected). + +## How each port wires it into CI + +The conformance test is an ordinary test file, so each repo's existing test job +picks it up with no workflow change: + +- **Python** — `tests/test_conformance.py`, run by `pytest tests/` (`.github/workflows/test.yml`). +- **.NET** — `tests/LightningEnable.AgentSdk.Tests/ConformanceTests.cs`, run by + `dotnet test`. The vectors are linked into the test project and copied next to + the test DLL (``). +- **TypeScript** — `tests/conformance.test.ts`, run by `vitest run` (`npm test`). + +Each port reads the JSON from this canonical directory (`conformance/vectors/`) at +the repo root, so the test and the vectors can never point at stale copies. + +## How the vectors stay in sync across the three repos + +These are three separate repositories (no monorepo), so the vectors are +**physically copied** into each one at the same path. Two things keep the copies +honest: + +1. **A single source of truth.** The canonical copy lives in the **Python** repo + (`le-agent-sdk-python/conformance/`). To change a vector, edit it there, then + copy `conformance/vectors/*.json` verbatim into the other two repos in the same + change set. +2. **A shared checksum guard.** `conformance/CHECKSUMS` lists the SHA-256 of each + vectors file and is **byte-identical in all three repos**. Every port's + conformance test recomputes the checksums of its local vectors (over + **LF-normalized** bytes, so CRLF checkouts on Windows CI don't matter) and + asserts they match `CHECKSUMS`. Because the same `CHECKSUMS` constant is present + in every repo and each repo's JSON must match it, the JSON is transitively + identical across all three. If someone edits a vector in one repo only, that + repo's checksum test fails; if they also update `CHECKSUMS` but forget a repo, + the forgotten repo fails. Either way CI catches the drift. + +Regenerate `CHECKSUMS` after changing any vector: + +```sh +# from the repo root, on any port +cd conformance +python - <<'PY' +import hashlib, pathlib +lines = [] +for p in sorted(pathlib.Path("vectors").glob("*.json")): + data = p.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") + lines.append(f"{hashlib.sha256(data).hexdigest()} {p.name}") +pathlib.Path("CHECKSUMS").write_text("\n".join(lines) + "\n") +print("\n".join(lines)) +PY +``` + +## How to extend + +**Add a behavior to this suite:** + +1. Add a `vectors/.json` file (same self-describing shape: `behavior`, + `entrypoint`, an outcome vocabulary, and a `vectors`/`scenarios` array). +2. First DISCOVER what each port actually does for every vector (read the code / + run it). Where all three agree, that agreed output is the golden. Where they + diverge: if the correct behavior is unambiguous, set the golden to it and FIX + the outlier port (failing-vector-first: make it red, fix, green); if it is a + real design question, set the golden to the current agreement and surface the + question rather than picking a side. +3. Add an assertion block for the new file to each port's conformance test. +4. Regenerate `CHECKSUMS` and copy the new vectors + `CHECKSUMS` into all three + repos in one change set. + +**Extend to the `l402-*` three-port trio:** the same pattern transplants directly. +The `l402-client-*` libraries share security-critical parsing too — bolt11 invoice +amount decoding, `max_amount` budget enforcement, the L402 `WWW-Authenticate` +challenge parse. Stand up a parallel `conformance/` in the `l402-*` repos with +`vectors/invoice-amount.json`, `vectors/max-amount-budget.json`, +`vectors/l402-challenge.json`, and the identical CHECKSUMS-guard + single-source +copy mechanism. Candidate next behaviors already visible in these SDK ports (all +still using bare `parseInt`/`int()` and therefore worth pinning): budget-tag, +attestation-rating, and request-expiration parsing. diff --git a/conformance/vectors/discover-resilience.json b/conformance/vectors/discover-resilience.json new file mode 100644 index 0000000..8f81c0e --- /dev/null +++ b/conformance/vectors/discover-resilience.json @@ -0,0 +1,25 @@ +{ + "behavior": "discover-batch-resilience", + "reference": "le-agent-sdk ledger #41", + "entrypoint": "AgentManager.discover (per-event), plus each port's relay-ingest layer", + "description": "A discovery batch of [valid, malformed, valid] relay payloads MUST return the valid capabilities. One malformed or hostile payload must never abort the batch and take every valid capability down with it (a single-hostile-relay DoS). The skip must fail closed and LOUDLY (a warning naming the offending payload), never silently. Each malformed kind is dropped at the layer where a given port first meets it (parse, authenticity, or relay-frame merge); the shared contract is the scenario set + expectedSurvivors, and each port realizes it at its own layer (see conformance/README.md).", + "batchLayout": ["valid", "malformed", "valid"], + "expectedSurvivors": 2, + "scenarios": [ + { + "name": "bad-price", + "malformed": "a capability event whose price tag amount is unparseable ([\"price\",\"abc\"])", + "droppedAt": "per-event capability parse (fromNostrEvent throws, discover skips just that event)" + }, + { + "name": "missing-committed-field", + "malformed": "a relay payload missing / mistyping a field committed by the Nostr event id, so it cannot be authenticated or frame-parsed", + "droppedAt": "authenticity check (py/ts) or wire-frame parse (.NET TryParseEventMessage) — before the payload is trusted" + }, + { + "name": "non-dict-payload", + "malformed": "a relay payload that is not an event object at all (e.g. a bare string)", + "droppedAt": "relay merge (py/ts queryRelays) or wire-frame parse (.NET TryParseEventMessage)" + } + ] +} diff --git a/conformance/vectors/negotiable-floor.json b/conformance/vectors/negotiable-floor.json new file mode 100644 index 0000000..1b70db7 --- /dev/null +++ b/conformance/vectors/negotiable-floor.json @@ -0,0 +1,37 @@ +{ + "behavior": "negotiable-floor-parsing", + "reference": "le-agent-sdk ledger #61 (typescript floor uses parseInt -> keeps NaN/truncated)", + "entrypoint": "AgentCapability.fromNostrEvent (ts) / from_nostr_event (py) / FromNostrEvent (.NET)", + "description": "How each port parses the ['negotiable','floor',''] branch of a capability. python and .NET reject a malformed floor amount (throw -> the whole event is skipped by discover). typescript used parseInt(), which returns NaN for 'abc' and silently truncates '10.5'->10 / '100abc'->100 and NEVER throws, so it kept a bogus minPriceSats. The golden REJECTS a malformed floor: minPriceSats must never be NaN/truncated, because a NaN floor passes every price-floor comparison downstream (every comparison against NaN is false).", + "outcomes": { + "ok": "Parse succeeds; 'negotiable' and 'minPriceSats' (nullable) equal the expected values.", + "reject": "The capability-parse entrypoint throws (ValueError / Error / FormatException)." + }, + "vectors": [ + { + "name": "valid-floor", + "tags": [["d", "conformance"], ["negotiable", "floor", "5000"]], + "expect": { "outcome": "ok", "negotiable": true, "minPriceSats": 5000 } + }, + { + "name": "negotiable-false", + "tags": [["d", "conformance"], ["negotiable", "false"]], + "expect": { "outcome": "ok", "negotiable": false, "minPriceSats": null } + }, + { + "name": "reject-floor-non-numeric", + "tags": [["d", "conformance"], ["negotiable", "floor", "abc"]], + "expect": { "outcome": "reject" } + }, + { + "name": "reject-floor-decimal", + "tags": [["d", "conformance"], ["negotiable", "floor", "10.5"]], + "expect": { "outcome": "reject" } + }, + { + "name": "reject-floor-trailing-suffix", + "tags": [["d", "conformance"], ["negotiable", "floor", "100abc"]], + "expect": { "outcome": "reject" } + } + ] +} diff --git a/conformance/vectors/price-tag.json b/conformance/vectors/price-tag.json new file mode 100644 index 0000000..66887d6 --- /dev/null +++ b/conformance/vectors/price-tag.json @@ -0,0 +1,49 @@ +{ + "behavior": "price-tag-parsing", + "reference": "le-agent-sdk ledger #41 (price-tag divergence)", + "entrypoint": "AgentCapability.fromNostrEvent (ts) / from_nostr_event (py) / FromNostrEvent (.NET)", + "description": "How each port parses a capability 'price' tag amount. Driven through the public capability-parse entrypoint (not the lower-level fromTag/ParseSats helper) because that is the one uniform public surface across all three ports and the real production code path. A malformed amount MUST be rejected (throw), never silently coerced to NaN/0/truncated, because a bogus price sails through every downstream budget/affordability check.", + "outcomes": { + "ok": "Parse succeeds; the first parsed price amount equals priceSats. 'unit'/'model' are asserted only by ports that model them (python, typescript); the .NET port stores a single integer PriceSats and ignores unit/model.", + "reject": "The capability-parse entrypoint throws (ValueError / Error / FormatException).", + "no-price": "Parse succeeds but records no price (python/typescript: empty pricing list; .NET: PriceSats == 0, its default)." + }, + "vectors": [ + { + "name": "valid-minimal", + "tags": [["d", "conformance"], ["price", "100"]], + "expect": { "outcome": "ok", "priceSats": 100, "unit": "sats", "model": "per-request" } + }, + { + "name": "valid-unit-and-model", + "tags": [["d", "conformance"], ["price", "100", "msat", "per-call"]], + "expect": { "outcome": "ok", "priceSats": 100, "unit": "msat", "model": "per-call" } + }, + { + "name": "reject-non-numeric", + "tags": [["d", "conformance"], ["price", "abc"]], + "expect": { "outcome": "reject" } + }, + { + "name": "reject-decimal", + "tags": [["d", "conformance"], ["price", "10.5"]], + "expect": { "outcome": "reject" } + }, + { + "name": "reject-trailing-suffix", + "tags": [["d", "conformance"], ["price", "100abc"]], + "expect": { "outcome": "reject" } + }, + { + "name": "missing-amount", + "tags": [["d", "conformance"], ["price"]], + "expect": { "outcome": "no-price" } + }, + { + "name": "negative-amount-accepted", + "designQuestion": "All three ports currently ACCEPT a negative price (-5). This is the current cross-port agreement, so the golden encodes it, but whether a negative advertised price should be rejected is an open DESIGN question surfaced by this suite, not a bug to be silently fixed on one side.", + "tags": [["d", "conformance"], ["price", "-5"]], + "expect": { "outcome": "ok", "priceSats": -5, "unit": "sats", "model": "per-request" } + } + ] +} diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..1ad7778 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,210 @@ +"""Port-drift conformance tests (python port). + +Runs the shared golden vectors in ``conformance/vectors/`` through THIS port's +own implementation. The same vectors run in the .NET and TypeScript ports; any +port that diverges from the golden fails its own CI, so drift between the three +ports is caught automatically instead of by manual cross-reading. + +See ``conformance/README.md`` for the design, the sync mechanism, and how to +extend the suite. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from pathlib import Path + +import pytest + +from le_agent_sdk.agent.manager import AgentManager +from le_agent_sdk.models.capability import AgentCapability +from le_agent_sdk.nostr.event import NostrEvent + +# --- Vector loading --------------------------------------------------------- + +_CONFORMANCE_DIR = Path(__file__).resolve().parent.parent / "conformance" +_VECTORS_DIR = _CONFORMANCE_DIR / "vectors" + + +def _load(name: str) -> dict: + return json.loads((_VECTORS_DIR / name).read_text(encoding="utf-8")) + + +_PRICE = _load("price-tag.json") +_FLOOR = _load("negotiable-floor.json") +_DISCOVER = _load("discover-resilience.json") + + +def _capability_from_tags(tags: list[list[str]]) -> AgentCapability: + """Parse a capability through the public entrypoint the vectors target.""" + event = { + "id": "conformance", + "pubkey": "p", + "created_at": 1, + "kind": AgentCapability.KIND, + "content": "", + "tags": tags, + } + return AgentCapability.from_nostr_event(event) + + +# --- Sync guard ------------------------------------------------------------- + + +def test_vectors_match_shared_checksums(): + """The local vectors must match the shared CHECKSUMS byte-for-byte. + + CHECKSUMS is identical across all three repos, so this transitively pins the + python copy to the .NET and TypeScript copies. Hashing is over LF-normalized + bytes so a CRLF checkout (Windows CI) does not spuriously fail. + """ + expected = {} + for line in (_CONFORMANCE_DIR / "CHECKSUMS").read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + digest, name = line.split() + expected[name] = digest + + assert expected, "CHECKSUMS is empty" + + for path in sorted(_VECTORS_DIR.glob("*.json")): + data = path.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") + got = hashlib.sha256(data).hexdigest() + assert got == expected.get(path.name), ( + f"{path.name} does not match shared CHECKSUMS " + f"(edit the canonical copy + regenerate CHECKSUMS in all repos)" + ) + + +# --- price-tag parsing ------------------------------------------------------ + + +@pytest.mark.parametrize("vector", _PRICE["vectors"], ids=lambda v: v["name"]) +def test_price_tag(vector): + tags = vector["tags"] + expect = vector["expect"] + outcome = expect["outcome"] + + if outcome == "reject": + with pytest.raises(Exception): + _capability_from_tags(tags) + return + + cap = _capability_from_tags(tags) + + if outcome == "no-price": + assert cap.pricing == [], f"{vector['name']}: expected no price recorded" + return + + assert outcome == "ok", f"unknown outcome {outcome!r}" + assert cap.pricing, f"{vector['name']}: expected a parsed price" + price = cap.pricing[0] + assert price.amount == expect["priceSats"] + if "unit" in expect: + assert price.unit == expect["unit"] + if "model" in expect: + assert price.model == expect["model"] + + +# --- negotiable-floor parsing ---------------------------------------------- + + +@pytest.mark.parametrize("vector", _FLOOR["vectors"], ids=lambda v: v["name"]) +def test_negotiable_floor(vector): + tags = vector["tags"] + expect = vector["expect"] + outcome = expect["outcome"] + + if outcome == "reject": + with pytest.raises(Exception): + _capability_from_tags(tags) + return + + assert outcome == "ok", f"unknown outcome {outcome!r}" + cap = _capability_from_tags(tags) + assert cap.negotiable is expect["negotiable"] + assert cap.min_price_sats == expect["minPriceSats"] + + +# --- discover() batch resilience (ledger #41) ------------------------------- + +_PRIV_A = "11" * 32 +_PRIV_B = "22" * 32 +_PRIV_POISON = "33" * 32 + + +def _signed(d_tag: str, price: str, priv: str) -> dict: + return NostrEvent.create( + kind=AgentCapability.KIND, + content="valid", + tags=[["d", d_tag], ["price", price]], + private_key=priv, + ) + + +def _build_batch(scenario_name: str) -> list: + """Realize a [valid, malformed, valid] batch for a shared scenario. + + Valid events are genuinely signed so the real authenticity check passes them. + The malformed payload is dropped by the real pipeline at the layer this port + first meets it. + """ + valid_a = _signed("svc-a", "100", _PRIV_A) + valid_b = _signed("svc-b", "200", _PRIV_B) + + if scenario_name == "bad-price": + malformed = _signed("svc-poison", "abc", _PRIV_POISON) + elif scenario_name == "missing-committed-field": + # Missing pubkey/created_at/tags/content -> NostrEvent.verify() subscripts + # them while computing the id and raises -> dropped as unauthenticatable. + malformed = {"id": "bad-missing", "kind": AgentCapability.KIND} + elif scenario_name == "non-dict-payload": + malformed = "not-a-dict" + else: # pragma: no cover - guards against an unhandled new scenario + raise AssertionError(f"unhandled scenario {scenario_name!r}") + + return [valid_a, malformed, valid_b] + + +async def _run_discover(payloads: list) -> list[AgentCapability]: + """Inject a raw per-relay payload list and run the real discover pipeline.""" + mgr = AgentManager() + + async def fake_query_relay(url, filters, timeout): + return list(payloads) + + from unittest.mock import patch + + with patch.object(mgr, "_query_relay", side_effect=fake_query_relay): + return await mgr.discover() + + +def test_discover_resilience_scenarios_are_covered(): + """Every scenario in the shared manifest must be exercised below.""" + names = {s["name"] for s in _DISCOVER["scenarios"]} + assert names == {"bad-price", "missing-committed-field", "non-dict-payload"} + assert _DISCOVER["expectedSurvivors"] == 2 + + +@pytest.mark.parametrize( + "scenario", _DISCOVER["scenarios"], ids=lambda s: s["name"] +) +@pytest.mark.asyncio +async def test_discover_resilience(scenario, caplog): + expected_survivors = _DISCOVER["expectedSurvivors"] + batch = _build_batch(scenario["name"]) + + with caplog.at_level(logging.WARNING): + caps = await _run_discover(batch) + + assert len(caps) == expected_survivors, ( + f"{scenario['name']}: one malformed payload aborted the batch" + ) + assert {c.service_id for c in caps} == {"svc-a", "svc-b"} + # Fail closed, LOUDLY: the malformed payload's skip must be logged. + assert any( + record.levelno == logging.WARNING for record in caplog.records + ), f"{scenario['name']}: malformed payload was skipped silently" From bb4db1a8a307ede59be750dbbadeb494034ac2fc Mon Sep 17 00:00:00 2001 From: refined-element Date: Wed, 22 Jul 2026 22:38:35 -0400 Subject: [PATCH 2/2] fix(capability): reject negative price/floor amounts (ledger #69) A capability price-tag amount and negotiable-floor amount must be a non-negative integer. A negative amount was previously accepted by all three ports and flagged as an open designQuestion in the conformance vectors; that question is now decided (#69): a negative price/floor is never meaningful and accepting it is a fail-open smell, so it is rejected on the same path as any other malformed amount (throw -> the event is skipped by discover()). Zero stays valid (a free service). - Add shared _parse_sats_amount() helper used by BOTH the price parse (AgentPricing.from_tag) and the floor parse (from_nostr_event) so they cannot drift; it raises the same ValueError as int() on malformed input. - Conformance vectors: flip negative-amount-accepted -> reject (negative-amount-rejected), add negative-floor-rejected, and pin zero-accepted / zero-floor-accepted; remove the designQuestion flag; regenerate CHECKSUMS. Co-Authored-By: Claude Opus 4.8 (1M context) --- conformance/CHECKSUMS | 4 ++-- conformance/README.md | 16 ++++++++++++---- conformance/vectors/negotiable-floor.json | 16 ++++++++++++++-- conformance/vectors/price-tag.json | 16 +++++++++++----- src/le_agent_sdk/models/capability.py | 22 ++++++++++++++++++++-- 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/conformance/CHECKSUMS b/conformance/CHECKSUMS index a9bb16d..b1de334 100644 --- a/conformance/CHECKSUMS +++ b/conformance/CHECKSUMS @@ -1,3 +1,3 @@ 6e71737bd26abcd7106758de38316f477cfc69e792c153ac4d4ceca547841aba discover-resilience.json -729c3b00dde43bb34f25765bce56b263deee74b38cfc9ec7de620f6245f7b833 negotiable-floor.json -70cb62fb764804cd0193bfc7eedd7a8b1a83b0d07a8d836467bcecc1829b7a2b price-tag.json +5365bc6f7e61384ad71fd25543c0f70cfe12fb1fe156a5fc0aeb91768871e6e6 negotiable-floor.json +749b2ca0f17f52e6b0fb81ce0c2a6b0a0f1bc45f20c2848ad8cf582893fa0513 price-tag.json diff --git a/conformance/README.md b/conformance/README.md index deda915..3769353 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -36,9 +36,14 @@ outcome vocabulary, and every vector's expected outcome. - **Price parsing agrees across all three ports.** Valid amounts parse; `abc`, `10.5`, `100abc` are rejected (throw); a bare `["price"]` records no price; and - a **negative** amount (`-5`) is currently **accepted by all three**. Whether a - negative price *should* be rejected is a genuine design question — flagged in - `price-tag.json` as `designQuestion`, **not** silently changed on one side. + `0` is valid (a free service). A **negative** amount (`-5`) was originally + **accepted by all three** and flagged in `price-tag.json` as an open + `designQuestion`. That question is now **decided (ledger #69, 2026-07-22): + a negative price/floor is rejected** — it is never meaningful and accepting it + is a fail-open smell, so it is treated like any other malformed amount (throw -> + the event is skipped). The golden now REJECTS `-5` (`negative-amount-rejected`) + and pins `0` as valid (`zero-accepted`); all three ports were tightened to + conform. - **Negotiable-floor did NOT agree.** Python and .NET reject a malformed floor amount (throw -> the event is skipped). TypeScript used `parseInt()`, which returns `NaN` for `"abc"` and silently truncates `"10.5"`->`10` / @@ -46,7 +51,10 @@ outcome vocabulary, and every vector's expected outcome. by the oracle, the one that keeps it is the bug (ledger #61). The golden REJECTS a malformed floor and the TypeScript port was fixed to conform (a `NaN` floor is worse than useless: every price-floor comparison against `NaN` is false, so a - malformed floor silently passes downstream instead of being rejected). + malformed floor silently passes downstream instead of being rejected). The floor + amount is parsed with the **same** non-negative-integer rules as the price + amount, so a **negative** floor is rejected too (ledger #69; golden + `negative-floor-rejected`, with `0` pinned valid by `zero-floor-accepted`). ## How each port wires it into CI diff --git a/conformance/vectors/negotiable-floor.json b/conformance/vectors/negotiable-floor.json index 1b70db7..5a82272 100644 --- a/conformance/vectors/negotiable-floor.json +++ b/conformance/vectors/negotiable-floor.json @@ -1,8 +1,8 @@ { "behavior": "negotiable-floor-parsing", - "reference": "le-agent-sdk ledger #61 (typescript floor uses parseInt -> keeps NaN/truncated)", + "reference": "le-agent-sdk ledger #61 (typescript floor uses parseInt -> keeps NaN/truncated), #69 (reject negative amount)", "entrypoint": "AgentCapability.fromNostrEvent (ts) / from_nostr_event (py) / FromNostrEvent (.NET)", - "description": "How each port parses the ['negotiable','floor',''] branch of a capability. python and .NET reject a malformed floor amount (throw -> the whole event is skipped by discover). typescript used parseInt(), which returns NaN for 'abc' and silently truncates '10.5'->10 / '100abc'->100 and NEVER throws, so it kept a bogus minPriceSats. The golden REJECTS a malformed floor: minPriceSats must never be NaN/truncated, because a NaN floor passes every price-floor comparison downstream (every comparison against NaN is false).", + "description": "How each port parses the ['negotiable','floor',''] branch of a capability. The floor amount MUST be a non-negative integer, parsed with the SAME rules as the price-tag amount. python and .NET reject a malformed floor amount (throw -> the whole event is skipped by discover). typescript used parseInt(), which returns NaN for 'abc' and silently truncates '10.5'->10 / '100abc'->100 and NEVER throws, so it kept a bogus minPriceSats. The golden REJECTS a malformed floor AND a negative floor: minPriceSats must never be NaN/truncated/negative, because a NaN floor passes every price-floor comparison downstream (every comparison against NaN is false) and a negative floor is meaningless. Zero is valid (negotiable down to free).", "outcomes": { "ok": "Parse succeeds; 'negotiable' and 'minPriceSats' (nullable) equal the expected values.", "reject": "The capability-parse entrypoint throws (ValueError / Error / FormatException)." @@ -13,6 +13,12 @@ "tags": [["d", "conformance"], ["negotiable", "floor", "5000"]], "expect": { "outcome": "ok", "negotiable": true, "minPriceSats": 5000 } }, + { + "name": "zero-floor-accepted", + "note": "A floor of zero is valid (negotiable down to free). Pins that rejecting negatives (ledger #69) did not over-reject zero.", + "tags": [["d", "conformance"], ["negotiable", "floor", "0"]], + "expect": { "outcome": "ok", "negotiable": true, "minPriceSats": 0 } + }, { "name": "negotiable-false", "tags": [["d", "conformance"], ["negotiable", "false"]], @@ -32,6 +38,12 @@ "name": "reject-floor-trailing-suffix", "tags": [["d", "conformance"], ["negotiable", "floor", "100abc"]], "expect": { "outcome": "reject" } + }, + { + "name": "negative-floor-rejected", + "note": "Negatives are rejected by decision (ledger #69, 2026-07-22): a negative floor is never meaningful and is a fail-open smell, so it is treated like any other malformed amount (throw -> the event is skipped). Zero stays valid (see zero-floor-accepted).", + "tags": [["d", "conformance"], ["negotiable", "floor", "-5"]], + "expect": { "outcome": "reject" } } ] } diff --git a/conformance/vectors/price-tag.json b/conformance/vectors/price-tag.json index 66887d6..28fc36f 100644 --- a/conformance/vectors/price-tag.json +++ b/conformance/vectors/price-tag.json @@ -1,8 +1,8 @@ { "behavior": "price-tag-parsing", - "reference": "le-agent-sdk ledger #41 (price-tag divergence)", + "reference": "le-agent-sdk ledger #41 (price-tag divergence), #69 (reject negative amount)", "entrypoint": "AgentCapability.fromNostrEvent (ts) / from_nostr_event (py) / FromNostrEvent (.NET)", - "description": "How each port parses a capability 'price' tag amount. Driven through the public capability-parse entrypoint (not the lower-level fromTag/ParseSats helper) because that is the one uniform public surface across all three ports and the real production code path. A malformed amount MUST be rejected (throw), never silently coerced to NaN/0/truncated, because a bogus price sails through every downstream budget/affordability check.", + "description": "How each port parses a capability 'price' tag amount. Driven through the public capability-parse entrypoint (not the lower-level fromTag/ParseSats helper) because that is the one uniform public surface across all three ports and the real production code path. A price amount MUST be a non-negative integer: a malformed amount (non-numeric / decimal / trailing-suffix) AND a negative amount are both rejected (throw), never silently coerced to NaN/0/truncated or accepted as-is, because a bogus or negative price sails through every downstream budget/affordability check. Zero is valid (a free service).", "outcomes": { "ok": "Parse succeeds; the first parsed price amount equals priceSats. 'unit'/'model' are asserted only by ports that model them (python, typescript); the .NET port stores a single integer PriceSats and ignores unit/model.", "reject": "The capability-parse entrypoint throws (ValueError / Error / FormatException).", @@ -19,6 +19,12 @@ "tags": [["d", "conformance"], ["price", "100", "msat", "per-call"]], "expect": { "outcome": "ok", "priceSats": 100, "unit": "msat", "model": "per-call" } }, + { + "name": "zero-accepted", + "note": "Zero is a valid advertised price (a free service). Pins that rejecting negatives (ledger #69) did not over-reject zero.", + "tags": [["d", "conformance"], ["price", "0"]], + "expect": { "outcome": "ok", "priceSats": 0, "unit": "sats", "model": "per-request" } + }, { "name": "reject-non-numeric", "tags": [["d", "conformance"], ["price", "abc"]], @@ -40,10 +46,10 @@ "expect": { "outcome": "no-price" } }, { - "name": "negative-amount-accepted", - "designQuestion": "All three ports currently ACCEPT a negative price (-5). This is the current cross-port agreement, so the golden encodes it, but whether a negative advertised price should be rejected is an open DESIGN question surfaced by this suite, not a bug to be silently fixed on one side.", + "name": "negative-amount-rejected", + "note": "Negatives are rejected by decision (ledger #69, 2026-07-22): a negative advertised price is never meaningful and is a fail-open smell, so it is treated like any other malformed amount (throw -> the event is skipped). Was previously accepted by all three ports and flagged as an open designQuestion; the question is now decided. Zero stays valid (see zero-accepted).", "tags": [["d", "conformance"], ["price", "-5"]], - "expect": { "outcome": "ok", "priceSats": -5, "unit": "sats", "model": "per-request" } + "expect": { "outcome": "reject" } } ] } diff --git a/src/le_agent_sdk/models/capability.py b/src/le_agent_sdk/models/capability.py index db3fe87..11e84ba 100644 --- a/src/le_agent_sdk/models/capability.py +++ b/src/le_agent_sdk/models/capability.py @@ -6,6 +6,24 @@ from typing import Any, Optional +def _parse_sats_amount(value: str) -> int: + """Parse a non-negative integer sats amount, or raise ``ValueError``. + + A price/floor amount MUST be a non-negative integer. ``int()`` already rejects + non-numeric / decimal / trailing-suffix values (``ValueError``); this + additionally rejects a **negative** amount (ledger #69): a negative advertised + price/floor is never meaningful and accepting it is a fail-open smell. The + rejection raises the *same* ``ValueError`` as any other malformed amount so the + parse-error path (``discover()``'s per-event skip) treats it identically. Zero + is valid (a free service). Shared by BOTH the price-tag and negotiable-floor + parse so the two can never drift apart. + """ + amount = int(value) + if amount < 0: + raise ValueError(f"Sats amount must be non-negative, got: {value!r}") + return amount + + @dataclass class AgentPricing: """Pricing information for an agent capability.""" @@ -23,7 +41,7 @@ def from_tag(cls, tag: list[str]) -> AgentPricing: """Parse from a Nostr 'price' tag: ['price', amount, unit, model].""" if len(tag) < 2: raise ValueError(f"Invalid price tag: {tag}") - amount = int(tag[1]) + amount = _parse_sats_amount(tag[1]) unit = tag[2] if len(tag) > 2 else "sats" model = tag[3] if len(tag) > 3 else "per-request" return cls(amount=amount, unit=unit, model=model) @@ -92,7 +110,7 @@ def from_nostr_event(cls, event: dict[str, Any]) -> AgentCapability: cap.negotiable = True elif tag[1] == "floor" and len(tag) > 2: cap.negotiable = True - cap.min_price_sats = int(tag[2]) + cap.min_price_sats = _parse_sats_amount(tag[2]) return cap