From 3d30261e743b2dad2855fc5ccddb27ee4876c2bc Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 16:53:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-125=20tier1+2=20iter=204):=20semantic-?= =?UTF-8?q?events=20MCP=20endpoint=20per=20=C2=A72.1.d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/semantic-events//latest exposes the three ADR-125 §2.1.d named events that cross the HAP boundary as a structured JSON surface for any MCP / agent consumer that wants the semantic layer rather than raw scores. Response shape: { "node_id": "12", "privacy_class": 2, "events": { "unknown_presence": {"active": bool, "source": str, "ts": float}, "unexpected_occupancy": {"active": bool, "schedule_aware": false, "ts": float}, "unrecognized_activity_pattern": { "active": bool, "anomaly_threshold": 0.7, "anomaly_score": float, "ts": float } }, "redacted_fields": [ "identity_risk_score", "soul_match_probability", "rf_signature_hash" ] } Live response from real C6 (node_id=12): { "unknown_presence": {"active": true, ...}, "unexpected_occupancy": {"active": true, "schedule_aware": false, ...}, "unrecognized_activity_pattern": {"active": false, "anomaly_score": 0.0, ...} } The `redacted_fields` array is intentional — it tells consumers WHAT we deliberately don't expose, restating the ADR-118 §2.5 / ADR-125 §2.1.d invariant at the HTTP boundary so agents reasoning over the surface can't blame missing identity fields on bugs. `unexpected_occupancy.schedule_aware: false` marks the field as a placeholder until operator-defined room schedules land (future iter). Agents that branch on this can fall back to raw occupancy until then. Refs ADR-125 §2.1.d (semantic-events naming contract). Co-Authored-By: claude-flow --- scripts/ruview-sensing-server.py | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/scripts/ruview-sensing-server.py b/scripts/ruview-sensing-server.py index 8750cab7..0ebc0ef1 100644 --- a/scripts/ruview-sensing-server.py +++ b/scripts/ruview-sensing-server.py @@ -99,6 +99,55 @@ def bfld_scan_for(node_id: str) -> dict | None: _PATH_VITALS = re.compile(r"^/api/v1/vitals/([^/]+)/latest$") _PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$") _PATH_BFLD_SUBSCRIBE = re.compile(r"^/api/v1/bfld/([^/]+)/subscribe$") +_PATH_SEMANTIC = re.compile(r"^/api/v1/semantic-events/([^/]+)/latest$") + + +def semantic_events_for(node_id: str) -> dict | None: + """ADR-125 §2.1.d semantic-event surface. + + The three named events that cross the HAP boundary. Each one is a + boolean + last-fire timestamp. Agents subscribe to this endpoint + rather than reasoning over raw scores — the naming is the contract. + """ + f = _load_feature() + if f is None or f.get("node_id") != node_id: + return None + presence = bool(f.get("presence", False)) + anomaly = float(f.get("anomaly_score") or 0.0) + return { + "node_id": f["node_id"], + "privacy_class": int(f.get("privacy_class", 2)), + "events": { + "unknown_presence": { + "active": presence, + "source": "BFLD presence_score (rolling 3s avg ≥ 0.30)", + "ts": f["ts"], + }, + "unexpected_occupancy": { + # Placeholder: schedule-aware gating is future work. + # For now we surface raw occupancy and mark the gate + # as `schedule_aware=False` so agents know not to + # equate this with the full §2.1.d intent yet. + "active": presence, + "schedule_aware": False, + "ts": f["ts"], + }, + "unrecognized_activity_pattern": { + "active": anomaly >= 0.7, + "anomaly_threshold": 0.7, + "anomaly_score": anomaly, + "ts": f["ts"], + }, + }, + # ADR-125 §2.1.d invariant restated at the HTTP boundary: + # identity_risk_score, soul_match_probability, and rf_signature_hash + # are NEVER published from this endpoint. + "redacted_fields": [ + "identity_risk_score", + "soul_match_probability", + "rf_signature_hash", + ], + } class Handler(BaseHTTPRequestHandler): @@ -183,6 +232,17 @@ class Handler(BaseHTTPRequestHandler): self._json(200, r) return + m = _PATH_SEMANTIC.match(path) + if m: + node_id = m.group(1) + r = semantic_events_for(node_id) + if r is None: + self._json(503, {"error": f"no recent semantic events for {node_id}", + "hint": "watcher running? node_id correct?"}) + return + self._json(200, r) + return + self._json(404, {"error": "not found", "path": path}) def do_POST(self) -> None: