Skip to content

Add LHP-v2 coordination service and shared client#13

Draft
Svaag wants to merge 6 commits into
mainfrom
feat/agentic-coordination-v2
Draft

Add LHP-v2 coordination service and shared client#13
Svaag wants to merge 6 commits into
mainfrom
feat/agentic-coordination-v2

Conversation

@Svaag

@Svaag Svaag commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What changed

  • adds LHP-v2 contracts for loop registration, sanitized case projections, scope-hashed handoffs, claims, approvals, results, verification, and bounded RT-2 probe plans
  • adds a signed async coordinator client shared by SOC, NOC, Engineering, Knowledge, and Observatory
  • adds an optional FastAPI/Postgres coordinator with transactional state, claim leases, replay protection, immutable transition events, and a trace outbox
  • normalizes the package version to 0.9.0 and commits generated schemas and a lockfile

Why

The existing loops use pair-specific LHP copies and GitHub labels. This provides one neutral, capability-based handoff authority without turning the trace collector or NOC CaseService into the organizational control plane.

Safety

  • per-loop HMAC identities are scoped server-side
  • approvals bind the canonical handoff scope hash
  • the coordinator stores sanitized projections; private loop state remains source-owned
  • the collector remains best-effort telemetry only

Validation

  • uv run ruff check .
  • uv run mypy agent_core
  • uv run --extra coordinator pytest -q — 75 passed

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🏅 Score: 85
🧪 PR contains tests
🔒 Security concerns

Sensitive information exposure:
The trace outbox in db.py (line 618-634) forwards unsanitized summary fields from handoff events to the collector. These summaries can contain arbitrary text from handoff envelopes, approval rationale, or operation requests, potentially leaking secrets or sensitive data into telemetry. No sanitization is applied before serialization.

⚡ Recommended focus areas for review

Data Leak

The trace outbox (lines 618-634) serializes the full HandoffEvent into the collector payload, including the summary field. This summary is populated from user-provided strings in the handoff envelope (e.g., summary, rationale, result.summary) without sanitization. If a loop embeds secrets, tokens, or raw telemetry data in these fields, they will be forwarded to the collector's trace endpoint, violating the invariant that raw logs, secrets, and live telemetry must not appear in trace records.

session.add(
    OutboxRow(
        event_id=event.event_id,
        payload={
            "event_type": "handoff_transition",
            "summary": event.summary or f"{row.handoff_id}: {event.event_type}",
            "handoff_id": row.handoff_id,
            "case_id": row.case_id,
            "payload": {
                "handoff_event": event.model_dump(mode="json"),
                "source_loop": row.source_loop,
                "target_loop": row.target_loop,
                "capability": row.capability,
            },
        },
        created_at=event.created_at,
    )
Auth Bypass

The allow_insecure_dev flag (line 115, 196-203) completely bypasses HMAC signature verification when set to True. If this flag is accidentally enabled in production (e.g., via env var HYRULE_COORDINATOR_ALLOW_INSECURE_DEV=true), any actor with network access can impersonate any loop, granting full read/write access to all handoffs, cases, and approvals.

async def authenticate(self, request: Request, store: CoordinatorStore) -> str:
    body = await request.body()
    if len(body) > MAX_BODY_BYTES:
        raise HTTPException(status_code=413, detail="coordinator payload exceeds 64 KiB")
    identity = request.headers.get("x-agent-loop-identity", "").strip().lower()
    if not identity:
        raise HTTPException(status_code=401, detail="missing loop identity")
    if self.allow_insecure_dev:
        return identity
    key_id = request.headers.get("x-agent-loop-key-id", "").strip()
    timestamp = request.headers.get("x-agent-loop-timestamp", "").strip()
    nonce = request.headers.get("x-agent-loop-nonce", "").strip()
    signature = request.headers.get("x-agent-loop-signature", "").strip()
    secret = self.keys.get(identity, {}).get(key_id, "")
    if not secret or not timestamp or not nonce or not signature:
        raise HTTPException(
            status_code=401, detail="missing or unknown loop signing credentials"
        )
    try:
        parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    except ValueError as exc:
        raise HTTPException(status_code=401, detail="invalid loop timestamp") from exc
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=UTC)
    now = datetime.now(UTC)
    if abs((now - parsed.astimezone(UTC)).total_seconds()) > MAX_CLOCK_SKEW_SECONDS:
        raise HTTPException(status_code=401, detail="stale loop timestamp")
    expected = build_signature(
        secret=secret,
        method=request.method,
        path=request.url.path,
        timestamp=timestamp,
        nonce=nonce,
        key_id=key_id,
        body=body,
    )
    if not hmac.compare_digest(expected, signature):
        raise HTTPException(status_code=401, detail="invalid loop signature")
    if not await store.register_nonce(identity, nonce, now):
        raise HTTPException(status_code=409, detail="replayed loop nonce")
    return identity

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Handle duplicate key conflicts gracefully

The insertion of HandoffRow and IdempotencyRow can raise IntegrityError due to race
conditions (e.g., duplicate handoff_id or idempotency key). Wrap the insertions in a
try-except block to catch IntegrityError and raise a ValueError with a descriptive
message, preventing unhandled 500 errors.

agent_core/coordinator/db.py [251-315]

 async def create_handoff(self, envelope: HandoffEnvelope) -> HandoffRecord:
     async with self.sessions() as session, session.begin():
         existing_id = (
             await session.execute(
                 select(IdempotencyRow.handoff_id).where(
                     IdempotencyRow.source_loop == envelope.source_loop,
                     IdempotencyRow.idempotency_key == envelope.idempotency_key,
                 )
             )
         ).scalar_one_or_none()
         if existing_id:
             row = await session.get(HandoffRow, existing_id)
             if row is None:
                 raise RuntimeError("idempotency record references a missing handoff")
             return self._record(row)
         if await session.get(HandoffRow, envelope.handoff_id) is not None:
             raise ValueError("handoff_id already exists")
         ...
-        session.add(row)
-        session.add(
-            IdempotencyRow(...)
-        )
-        ...
+        try:
+            session.add(row)
+            session.add(
+                IdempotencyRow(...)
+            )
+            ...
+        except IntegrityError:
+            raise ValueError("handoff_id or idempotency key conflict")
         return self._record(row)
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies a potential IntegrityError scenario in create_handoff and proposes graceful handling. As an error handling improvement, it prevents unhandled 500 errors and has moderate impact (score ≤8).

Low
Prevent unhandled duplicate case insertion

Inserting a new CaseRow can raise IntegrityError if a concurrent request inserts the
same case_id. Catch IntegrityError and raise a ValueError to provide a clear error
and avoid a 500 response.

agent_core/coordinator/db.py [209-231]

 async def put_case(self, projection: CaseProjection) -> CaseProjection:
     async with self.sessions() as session, session.begin():
         row = await session.get(CaseRow, projection.case_id, with_for_update=True)
         if row is not None:
             ...
         else:
-            session.add(
-                CaseRow(...)
-            )
+            try:
+                session.add(
+                    CaseRow(...)
+                )
+            except IntegrityError:
+                raise ValueError("case_id already exists")
         return projection
Suggestion importance[1-10]: 6

__

Why: This is a valid error handling improvement for put_case to catch IntegrityError on duplicate case_id insertion. Though with_for_update reduces the race, the addition of explicit handling is prudent and has moderate impact (score ≤8).

Low
Make observed_at required in heartbeat

The observed_at field is critical for heartbeat timeliness and ordering. Without it,
the system cannot determine when the heartbeat was sent, which may lead to stale
state detection failures. Add observed_at to the required array.

agent_core/contracts/schemas/LoopHeartbeat.schema.json [41-43]

 "required": [
-  "loop_id"
+  "loop_id",
+  "observed_at"
 ]
Suggestion importance[1-10]: 6

__

Why: The suggestion is valid as observed_at is important for heartbeat timeliness, but making it required is a design choice that may not be critical for correctness. The PR author may have intentionally left it optional.

Low
Make verified_at required in verification result

The verified_at timestamp is essential for audit trails and ordering of verification
results. Omitting it can cause confusion in downstream consumers. Add verified_at to
the required fields.

agent_core/contracts/schemas/VerificationResult.schema.json [184-187]

 "required": [
   "handoff_id",
-  "verdict"
+  "verdict",
+  "verified_at"
 ]
Suggestion importance[1-10]: 6

__

Why: While verified_at is useful for audit trails, the schema already includes it with a default of null. The suggestion is reasonable but not a bug fix; it's a design preference.

Low
Make created_at required in handoff event

The created_at field is necessary for event ordering and time-based queries. Without
it, events cannot be reliably sequenced. Add created_at to the required fields.

agent_core/contracts/schemas/HandoffEvent.schema.json [112-118]

 "required": [
   "handoff_id",
   "event_type",
   "actor_id",
   "to_status",
-  "handoff_version"
+  "handoff_version",
+  "created_at"
 ]
Suggestion importance[1-10]: 6

__

Why: created_at is important for event ordering, but the schema currently allows it to be omitted. The suggestion is valid but reflects a design decision rather than a critical issue.

Low

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant