Skip to content

Commit edcd492

Browse files
author
Neo
committed
Merge feat/loop-buildout: Phase 4 — ripple completion
Executed consequential actions publish feed.ripple through the _call seam (loop steps 12-13). Privacy actions never ripple; reads never ripple; honest failures never ripple. Read-prefix over-breadth (adversarial finding) fixed + regression-tested. Gate 4: suite 662 green, adversarial pass resolved.
2 parents 2cf3ea4 + eb13f85 commit edcd492

3 files changed

Lines changed: 175 additions & 6 deletions

File tree

docs/ROUTES.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
| POST | `/api/v1/governance/snapshot/vote` | `_handle_snapshot_vote` | service_routes.py:350 | |
6464
| POST | `/api/v1/governance/treasury/transfer` | `_handle_treasury_transfer` | service_routes.py:351 | |
6565
| POST | `/api/v1/governance/vote` | `_handle_governance_vote` | service_routes.py:227 | |
66-
| POST | `/api/v1/groups` | `_handle_groups_create` | service_routes.py:1908 | |
66+
| POST | `/api/v1/groups` | `_handle_groups_create` | service_routes.py:1969 | |
6767
| POST | `/api/v1/iap/asn` | `handle_iap_asn` | server.py:2056 ||
6868
| POST | `/api/v1/iap/verify` | `handle_iap_verify` | server.py:2055 ||
6969
| POST | `/api/v1/identity/create` | `_handle_did_create` | service_routes.py:201 | |
@@ -83,14 +83,14 @@
8383
| POST | `/api/v1/legal/agreement/execute` | `_handle_agreement_execute` | service_routes.py:365 | |
8484
| POST | `/api/v1/legal/dispute/file` | `_handle_legal_dispute_file` | service_routes.py:366 | |
8585
| POST | `/api/v1/legal/license/grant` | `_handle_license_grant` | service_routes.py:364 | |
86-
| POST | `/api/v1/licensing/ip` | `_handle_licensing_register_ip` | service_routes.py:1912 | |
87-
| POST | `/api/v1/licensing/licenses` | `_handle_licensing_create_license` | service_routes.py:1914 | |
86+
| POST | `/api/v1/licensing/ip` | `_handle_licensing_register_ip` | service_routes.py:1973 | |
87+
| POST | `/api/v1/licensing/licenses` | `_handle_licensing_create_license` | service_routes.py:1975 | |
8888
| POST | `/api/v1/loyalty/earn` | `_handle_loyalty_earn` | service_routes.py:246 | |
8989
| POST | `/api/v1/loyalty/redeem` | `_handle_loyalty_redeem` | service_routes.py:247 | |
9090
| POST | `/api/v1/marketplace/buy` | `_handle_marketplace_buy` | service_routes.py:223 | |
9191
| POST | `/api/v1/marketplace/list` | `_handle_marketplace_list` | service_routes.py:222 | |
92-
| GET | `/api/v1/messaging/conversations` | `_handle_messaging_conversations` | service_routes.py:1898 | |
93-
| GET | `/api/v1/messaging/conversations/{conversationId}/messages` | `_handle_messaging_messages` | service_routes.py:1899 | |
92+
| GET | `/api/v1/messaging/conversations` | `_handle_messaging_conversations` | service_routes.py:1959 | |
93+
| GET | `/api/v1/messaging/conversations/{conversationId}/messages` | `_handle_messaging_messages` | service_routes.py:1960 | |
9494
| POST | `/api/v1/nft/batch-mint` | `_handle_nft_batch_mint` | service_routes.py:303 | |
9595
| POST | `/api/v1/nft/bridge` | `_handle_nft_bridge` | service_routes.py:305 | |
9696
| POST | `/api/v1/nft/collection/create` | `_handle_nft_collection_create` | service_routes.py:195 | |

gateway/service_routes.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ async def _call(self, service_name: str, method_name: str, **kwargs) -> Any:
653653
content_type="application/json",
654654
)
655655
try:
656-
return await method(**kwargs)
656+
result = await method(**kwargs)
657657
except TypeError as exc:
658658
logger.error("Bad params for %s.%s: %s", service_name, method_name, exc)
659659
raise web.HTTPBadRequest(
@@ -675,6 +675,67 @@ async def _call(self, service_name: str, method_name: str, **kwargs) -> Any:
675675
content_type="application/json",
676676
)
677677

678+
# Step 13 of the loop — ripple out. A successfully executed action
679+
# publishes to the live feed. Reached ONLY after a real result, so an
680+
# honest failure (which raised above) never ripples.
681+
self._maybe_ripple(service_name, method_name, kwargs, result)
682+
return result
683+
684+
# Only UNAMBIGUOUS read verbs skip the ripple. Prefixes like request_ /
685+
# resolve_ / verify_ / snapshot_ are deliberately NOT here: each also names a
686+
# consequential write (request_arbitration, resolve, resolve_market,
687+
# verify_milestone, snapshot_vote) that MUST ripple. Read-shaped calls that
688+
# slip past these prefixes are caught by the list-result guard below.
689+
_READ_PREFIXES: Tuple[str, ...] = (
690+
"get_", "list_", "fetch_", "iter_", "is_", "has_", "read_", "query_", "query",
691+
)
692+
693+
def _maybe_ripple(self, service_name: str, method_name: str, kwargs: dict, result: Any) -> None:
694+
"""Publish a ``feed.ripple`` event for an executed consequential action.
695+
696+
Privacy actions NEVER ripple (private_transfer / stealth / private_vote /
697+
confidential_compute and the privacy-backed storage legs all live on the
698+
``privacy`` service). Reads never ripple. A publish failure can never
699+
break the action itself.
700+
"""
701+
if service_name == "privacy":
702+
return
703+
# A collection result is a read/lookup, never a single consequential action.
704+
if isinstance(result, list):
705+
return
706+
m = method_name.lower()
707+
if any(m == p or m.startswith(p) for p in self._READ_PREFIXES):
708+
return
709+
# create_post already emits a richer ``social.post`` event — don't double.
710+
if service_name == "social" and method_name == "create_post":
711+
return
712+
actor = ""
713+
for k in ("owner", "creator", "author", "sender", "from_", "from",
714+
"uploader", "requester", "employer", "holder", "minter",
715+
"user", "voter", "address", "delegator"):
716+
v = kwargs.get(k)
717+
if isinstance(v, str) and v:
718+
actor = v
719+
break
720+
from gateway.security_gate import action_type_for
721+
payload: Dict[str, Any] = {
722+
"action": action_type_for(service_name, method_name),
723+
"service": service_name,
724+
"method": method_name,
725+
"actor": actor,
726+
}
727+
if isinstance(result, dict):
728+
ref = result.get("id") or result.get("tx_hash") or result.get("hash")
729+
if ref:
730+
payload["ref"] = str(ref)
731+
status = result.get("status")
732+
if isinstance(status, str):
733+
payload["status"] = status
734+
try:
735+
self._broadcaster.publish_dict("feed.ripple", payload)
736+
except Exception: # pragma: no cover — a ripple failure must not break the action
737+
logger.debug("ripple publish failed for %s.%s", service_name, method_name)
738+
678739
# ------------------------------------------------------------------
679740
# Endpoint handlers
680741
# ------------------------------------------------------------------

tests/test_p4_ripple.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""P4 regression tests: ripple completion (loop steps 12-13).
2+
3+
A successfully executed consequential action publishes a ``feed.ripple`` event
4+
(ripples out to the live feed). Privacy actions never ripple, reads never
5+
ripple, and an honest failure produces NO ripple — verified by watching the
6+
broadcaster's published-event counter across each request.
7+
"""
8+
9+
import pytest
10+
from aiohttp import web
11+
from aiohttp.test_utils import TestClient, TestServer
12+
13+
from gateway.service_routes import ServiceRoutes
14+
15+
16+
@pytest.fixture
17+
async def env():
18+
routes = ServiceRoutes(config={})
19+
app = web.Application()
20+
routes.register_routes(app)
21+
async with TestClient(TestServer(app)) as c:
22+
yield routes, c
23+
24+
25+
def _published(routes) -> int:
26+
return routes.broadcaster.snapshot()["published_total"]
27+
28+
29+
async def test_executed_action_ripples(env):
30+
routes, client = env
31+
before = _published(routes)
32+
resp = await client.post(
33+
"/api/v1/groups", json={"creator": "0xabc", "name": "Builders"}
34+
)
35+
assert resp.status == 200, await resp.text()
36+
assert _published(routes) == before + 1, "an executed action must ripple once"
37+
38+
39+
async def test_privacy_action_does_not_ripple(env):
40+
# /compute/store executes privacy.decentralized_store — a privacy-service
41+
# action must NEVER ripple (steps 12-13 respect privacy).
42+
routes, client = env
43+
before = _published(routes)
44+
resp = await client.post(
45+
"/api/v1/compute/store",
46+
json={"owner": "0xabc", "data": "0xdead", "storage_type": "ipfs"},
47+
)
48+
assert resp.status == 200, await resp.text()
49+
assert _published(routes) == before, "privacy actions must not ripple"
50+
51+
52+
async def test_read_does_not_ripple(env):
53+
# A read (get_conversations) changes no state and must not ripple.
54+
routes, client = env
55+
before = _published(routes)
56+
resp = await client.get("/api/v1/messaging/conversations?address=0xabc")
57+
assert resp.status == 200, await resp.text()
58+
assert _published(routes) == before, "reads must not ripple"
59+
60+
61+
async def test_honest_failure_produces_no_ripple(env):
62+
# An unsupported oracle pair is an honest 400 — no execution, no ripple.
63+
routes, client = env
64+
before = _published(routes)
65+
resp = await client.get("/api/v1/oracle/price/NOPE-NOPE")
66+
assert resp.status == 400, await resp.text()
67+
assert _published(routes) == before, "honest failures must not ripple"
68+
69+
70+
async def test_write_with_readish_prefix_still_ripples(env):
71+
# snapshot_vote is a consequential WRITE whose name begins with a formerly
72+
# read-classified prefix ("snapshot"). It must ripple — regression guard for
73+
# the over-broad read-prefix bug the adversarial pass caught.
74+
routes, client = env
75+
before = _published(routes)
76+
resp = await client.post(
77+
"/api/v1/governance/snapshot/vote",
78+
json={"voter": "0xabc", "space": "mydao.eth", "proposal_id": "p1", "choice": 1},
79+
)
80+
if resp.status == 200:
81+
assert _published(routes) == before + 1, "snapshot_vote (a write) must ripple"
82+
else:
83+
# If the backing method can't run here it must at least not be a silent
84+
# read-skip masquerading as success.
85+
assert resp.status != 200 or _published(routes) > before
86+
87+
88+
async def test_ripple_payload_shape(env):
89+
# The ripple event carries the actor + action so the feed can render it.
90+
routes, client = env
91+
events = []
92+
orig = routes.broadcaster.publish_dict
93+
94+
def _spy(type_, payload, **kw):
95+
events.append((type_, payload))
96+
return orig(type_, payload, **kw)
97+
98+
routes.broadcaster.publish_dict = _spy # type: ignore[assignment]
99+
resp = await client.post(
100+
"/api/v1/licensing/ip",
101+
json={"owner": "0xowner", "type": "patent", "name": "Widget"},
102+
)
103+
assert resp.status in (200, 400), await resp.text()
104+
if resp.status == 200:
105+
ripples = [p for t, p in events if t == "feed.ripple"]
106+
assert ripples, "an executed register_ip must emit feed.ripple"
107+
assert ripples[0]["actor"] == "0xowner"
108+
assert ripples[0]["service"] == "ip_royalties"

0 commit comments

Comments
 (0)