# ADR-0107: E27 JSON Dispatcher Contract (Route Extraction, Seq Correlation, Broadcast Rules)

Status: Draft
Date: 2025-12-24
Related:
- DDR-0036 (Session Public API)
- Dealer API JSON message examples (control_pc214d_app_DealerAPI_UL_CHM.pdf)
- E27 API Comms Protocol (schema-0 examples)

## Context

DDR-0036 defines the Session public API and its boundaries. In particular:
- Once Session is ACTIVE, Session.recv_json()/pump_once() deliver a JSON object (dict) or raise.
- Session performs HELLO on connect and maintains framing/deframing, CRC, resync, crypto, and JSON parsing.
- Session does not orchestrate linking or authentication; those are out of scope.

We need a message dispatcher above Session that:
- Routes inbound JSON dicts to appropriate domain handlers
- Supports directed response matching via seq correlation
- Supports broadcast messages
- Does not introduce hidden protocol orchestration or transport dependencies

The Dealer API documentation indicates:
- `seq == 0` is a broadcast message
- `seq > 0` participates in directed request/response correlation
- Response “command name” may differ from request “command name” (seq is the stable join key)
- Some special-case messages (HELLO family) may not place `seq` at the root

## Decision

### 1) Dispatcher Input Contract

Dispatcher SHALL accept inbound E27 messages as:

- msg: dict

The dispatcher SHALL be invoked from DDR-0036 mechanisms:
- Session.on_message(msg)
- Session.pump_once() loop (caller calls dispatcher on returned dict)

Dispatcher SHALL NOT depend on sockets, framing, CRC, crypto, or JSON parsing logic.

### 2) Root Meta Keys

The dispatcher SHALL treat these root keys as “meta” and ignore them for route extraction:

- seq
- session_id

No other keys are ignored by default.

### 3) Route Key Format

A route key SHALL be a 2-tuple:

- RouteKey := (domain: str, name: str)

Where:
- domain is determined from the root (see §4)
- name is determined from the domain object (see §5)

### 4) Domain Extraction Rules

Let ROOT_NON_META_KEYS be the set of keys in msg excluding {"seq","session_id"}.

- If len(ROOT_NON_META_KEYS) == 1:
    domain = the only key in ROOT_NON_META_KEYS
- If len(ROOT_NON_META_KEYS) == 0:
    domain = "__root__"
    name   = "__empty__"
    record DispatchError: "No domain keys present at root."
- If len(ROOT_NON_META_KEYS) > 1:
    domain = "__root__"
    name   = "__multi__"
    record DispatchError: "Multiple domain keys present at root."

### 5) Name Extraction Rules (within a domain)

Let v = msg[domain].

- If v is a dict:
    - If len(v.keys()) == 1:
        name = the only key in v
    - If len(v.keys()) == 0:
        name = "__empty__"
        record DispatchError: "Domain object is empty."
    - If len(v.keys()) > 1:
        name = "__root__"
        record DispatchError: "Domain object contains multiple keys; domain-level handler may inspect."
- If v is a boolean and v == True:
    name = "__bool__"
    record no error (common shorthand in API examples such as {"cs_param":{"get_trouble":true}})
- Otherwise:
    name = "__value__"
    record DispatchError: "Unexpected domain value type; domain-level handler may inspect."

### 6) Message Kind Classification (Broadcast vs Directed)

The dispatcher SHALL classify messages by root seq only:

- If "seq" not in msg:
    kind = UNKNOWN
- Else if msg["seq"] == 0:
    kind = BROADCAST
- Else if msg["seq"] > 0:
    kind = DIRECTED
- Else:
    kind = UNKNOWN, record DispatchError: "Invalid seq value."

IMPORTANT: No nested-seq heuristics are permitted. The dispatcher SHALL NOT search for seq inside domain objects (e.g., hello.seq). These bootstrap/special-case messages remain routable but not correlatable.

### 7) Seq Correlation (Pending Requests)

The dispatcher MAY maintain a pending request registry keyed by seq:

- pending: Dict[int, PendingRequest]

Matching is seq-first:

- If kind == DIRECTED and seq in pending:
    classification = RESPONSE
    response_match = pending[seq] (pop or retain per registry policy)
- If kind == DIRECTED and seq not in pending:
    classification = UNSOLICITED
- If kind == BROADCAST:
    classification = BROADCAST
- If kind == UNKNOWN:
    classification = UNKNOWN

Route extraction SHALL run for all messages regardless of classification.

Rationale: Dealer API examples show response routes may differ from request routes; seq is the stable join key.

### 8) Handler Invocation Model

Handlers register on RouteKey (domain,name). Dispatcher SHALL:
- Dispatch to any handlers registered for (domain,name)
- Additionally dispatch to domain-level handlers registered for (domain,"__root__") for:
  - name=="__root__"
  - name=="__empty__"
  - name=="__value__"
  - ambiguity cases
- Not dispatch to handlers for "__root__/__multi__" or "__root__/__empty__" unless explicitly registered (useful for diagnostics)

Handlers SHALL be pure with respect to transport:
- They MUST NOT read/write sockets or call Session methods directly.
- They MAY return “intent” objects for higher layers to act on (future), but dispatcher itself does not execute I/O.

### 9) Error Policy

Unknown domain/name SHALL NOT raise. Dispatcher records DispatchError with clear English and returns.

Dispatcher MAY raise only for programmer errors (e.g., msg is not a dict), though Session ACTIVE guarantees dict delivery per DDR-0036.

### 10) Known Domains (Diagnostics Only)

Dispatcher MAY expose a small, built-in known-domain set for diagnostics/logging only:

KNOWN_DOMAINS = {
  "area",
  "cs_param",
  "bus_io_dev",
  "hello",
  "net_dev",
  "api_link",
  "FIND",
}

This set SHALL NOT affect routing outcomes (only warnings/metrics).

## Consequences

- Routing is deterministic, testable, and does not rely on transport or framing behavior.
- Broadcast and correlation semantics are mechanical and consistent with Dealer API documentation.
- Response correlation is robust even when response route differs from request route.
- Special bootstrap messages (HELLO family) are routable but not correlatable (no nested-seq heuristics).



# test/test_e27_dispatcher_route_contract.py (Draft)

import pytest


# --- Contract helpers (pure functions) ---

META_KEYS = {"seq", "session_id"}

def classify_kind(msg: dict) -> str:
    if "seq" not in msg:
        return "UNKNOWN"
    try:
        seq = msg["seq"]
    except Exception:
        return "UNKNOWN"
    if not isinstance(seq, int):
        return "UNKNOWN"
    if seq == 0:
        return "BROADCAST"
    if seq > 0:
        return "DIRECTED"
    return "UNKNOWN"


def extract_route(msg: dict) -> tuple[str, str, list[str]]:
    """
    Returns (domain, name, errors)
    Contract per ADR-0107.
    """
    errors: list[str] = []

    if not isinstance(msg, dict):
        raise TypeError("msg must be a dict")

    root_keys = [k for k in msg.keys() if k not in META_KEYS]
    if len(root_keys) == 0:
        errors.append("No domain keys present at root.")
        return ("__root__", "__empty__", errors)
    if len(root_keys) > 1:
        errors.append("Multiple domain keys present at root.")
        return ("__root__", "__multi__", errors)

    domain = root_keys[0]
    v = msg.get(domain)

    if isinstance(v, dict):
        inner_keys = list(v.keys())
        if len(inner_keys) == 0:
            errors.append("Domain object is empty.")
            return (domain, "__empty__", errors)
        if len(inner_keys) == 1:
            return (domain, inner_keys[0], errors)
        errors.append("Domain object contains multiple keys; domain-level handler may inspect.")
        return (domain, "__root__", errors)

    if isinstance(v, bool) and v is True:
        # Rare; most bool shorthand appears one level down, but keep this defensive.
        return (domain, "__bool__", errors)

    errors.append("Unexpected domain value type; domain-level handler may inspect.")
    return (domain, "__value__", errors)


# --- Test vectors (fixtures seeded from PDF/protocol/log examples) ---

@pytest.mark.parametrize(
    "msg, expected_kind, expected_route",
    [
        # A1: cs_param / bool request (Dealer API PDF)
        (
            {"seq": 21, "session_id": 65536, "cs_param": {"get_trouble": True}},
            "DIRECTED",
            ("cs_param", "get_trouble"),
        ),
        # A2: cs_param / dict request (Dealer API PDF)
        (
            {"seq": 1, "session_id": 65536, "cs_param": {"set_attribs": {"cs_id": 1}}},
            "DIRECTED",
            ("cs_param", "set_attribs"),
        ),
        # A3: area get_table_info request (Dealer API PDF)
        (
            {"seq": 101, "session_id": 1371493314, "area": {"get_table_info": True}},
            "DIRECTED",
            ("area", "get_table_info"),
        ),
        # A4: area get_table_info response (Dealer API PDF)
        (
            {"seq": 101, "area": {"get_table_info": {"error_code": 0}}},
            "DIRECTED",
            ("area", "get_table_info"),
        ),
        # A5: request vs response name mismatch (Dealer API PDF)
        (
            {"seq": 1, "session_id": 65536, "area": {"set_alarm_state": {"area_id": 1, "alarm_event": "FIRE"}}},
            "DIRECTED",
            ("area", "set_alarm_state"),
        ),
        (
            {"seq": 1, "area": {"set_status": {"area_id": 1, "error_code": 0}}},
            "DIRECTED",
            ("area", "set_status"),
        ),
        # B6: broadcast by seq==0 (Dealer API PDF)
        (
            {"seq": 0, "session_id": 65536, "area": {"get_num_not_rdy_zones": {"area_id": 1}}},
            "BROADCAST",
            ("area", "get_num_not_rdy_zones"),
        ),
        # C7: schema-0 net_dev.link request (E27 comms protocol)
        (
            {"seq": 1, "session_id": 1234567, "net_dev": {"link": {"mn": "1234"}}},
            "DIRECTED",
            ("net_dev", "link"),
        ),
        # C8: api_link request (schema-0; multi-key domain object -> __root__)
        (
            {"seq": 113, "api_link": {"pass": "37F4C243", "mn": "100", "sn": "1"}},
            "DIRECTED",
            ("api_link", "__root__"),
        ),
        # C9: hello response with nested seq; no nested-seq heuristics => kind UNKNOWN
        (
            {"hello": {"seq": 10, "session_id": 2244432638, "error_code": 0}},
            "UNKNOWN",
            ("hello", "__root__"),
        ),
    ],
)
def test_route_extraction_contract(msg, expected_kind, expected_route):
    kind = classify_kind(msg)
    domain, name, errors = extract_route(msg)

    assert kind == expected_kind
    assert (domain, name) == expected_route

    # Contract sanity: ambiguity/error routes should carry at least one error.
    if (domain, name) in {("__root__", "__empty__"), ("__root__", "__multi__")}:
        assert errors
    # Domain multi-key -> __root__ should also record an error.
    if name == "__root__" and domain not in {"hello"}:
        # hello is expected to be __root__ because it has multiple fields; still an error by contract.
        assert errors


@pytest.mark.parametrize(
    "msg, expected_route, expected_error_substr",
    [
        # E11: multiple non-meta root keys
        (
            {"seq": 5, "area": {"get_table_info": True}, "cs_param": {"get_trouble": True}},
            ("__root__", "__multi__"),
            "Multiple domain keys present at root.",
        ),
        # E12: no non-meta root keys
        (
            {"seq": 5, "session_id": 1},
            ("__root__", "__empty__"),
            "No domain keys present at root.",
        ),
        # E13: domain value empty dict
        (
            {"seq": 5, "area": {}},
            ("area", "__empty__"),
            "Domain object is empty.",
        ),
    ],
)
def test_route_extraction_ambiguity(msg, expected_route, expected_error_substr):
    domain, name, errors = extract_route(msg)
    assert (domain, name) == expected_route
    assert any(expected_error_substr in e for e in errors)


def test_no_nested_seq_heuristics():
    msg = {"hello": {"seq": 10, "session_id": 2244432638, "error_code": 0}}
    assert classify_kind(msg) == "UNKNOWN"


def test_seq_correlation_is_seq_first_route_second():
    """
    This is a contract-level test illustrating the rule:
    - correlation is by seq, not by route name equality
    """
    pending = {1: {"request": ("area", "set_alarm_state")}}  # minimal pending record
    msg = {"seq": 1, "area": {"set_status": {"area_id": 1, "error_code": 0}}}

    # classify
    assert classify_kind(msg) == "DIRECTED"
    route = extract_route(msg)[:2]
    assert route == ("area", "set_status")

    # correlate by seq
    assert 1 in pending
    match = pending[1]
    assert match["request"] == ("area", "set_alarm_state")
