feat(adr-125 tier1+2 iter 4): semantic-events MCP endpoint per §2.1.d

GET /api/v1/semantic-events/<node_id>/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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 16:53:47 -04:00
parent 63b77f7602
commit 3d30261e74
1 changed files with 60 additions and 0 deletions

View File

@ -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: