ouroboros/tests/test_client_surface.py
Ouroboros 350575fbc7 Admit Swarm roots directly and retain addressing truth on reload
Implement the ten owner-approved routing steps on integration base 4101c46d.
Remove the retired ephemeral Swarm actor, its routing-only orchestration,
wait/history projections and UI family. Keep native conversations parallel,
Presence custody, P1 free collection and dialogue, P4 receipts, P5 terminal
honesty and P7 shutdown behavior.

Use the existing promotion/reservation path with exact canonical owner text,
attachments, origin and room binding. Preserve first-Main client identity.
Retain complete generic tool invocation/error observations in existing metrics
and authored summaries so one UI seam keeps addressing-only cards hidden on
reconnect/reload; old records preserve their historical fallback. Preserve raw
assistant salvage bytes through the provider-death path.

Protected scope: ouroboros/tools/registry.py, registry_core.py,
registry_guards.py and tool_context.py lose only the extinct CW3 subject and
its final context field, per raw batch2 Q5, batch3 1A and PR-3. Other live
guards remain. size_ratchet_manifest.py is script-generated shrink-only
maintenance; loop_forced_finalization stays in its existing BAND on this base.
No BIBLE, SYSTEM, SAFETY, runtime policy or frozen ABI change relative to base.

Owner 2026-09-12 16:29 explicitly approved the additive generic counts in
existing metrics and authored-summary/history, with no new store/API/model.
Prepared source commits 8c516c0c through 741631d and staged tree 9f0aac remain
preserved in their original clone; this is the private combined candidate.

Validation: targeted host/P1/P4/P5/Presence/provider tests, 117 shared JS
regressions plus addressing narration, Ruff F, docs/generated inventories,
and both real size-ratchet nodes pass. Final cold browser scenarios and
external authority run on this immutable candidate after this checkpoint.

(cherry picked from commit 8416d94472f5326627c0b9ac59fa03d6d6e7d6d0)
2026-09-12 19:42:06 +03:00

529 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Owner Surface Fact: per-message client_surface provenance behavior.
Covers the ingress normalizer, chat.jsonl persistence, bus survival, mailbox
round-trip, the loop's change-note, producer wiring pins, the history-endpoint
negative (the field never replays to the SPA), and the prompt-fact parity
binding SYSTEM.md's product claims to the client code that implements them.
"""
from __future__ import annotations
import ast
import asyncio
import json
import pathlib
from types import SimpleNamespace
from ouroboros.client_surface import (
CLIENT_SURFACE_UA_LIMIT,
client_surface_identity,
normalize_client_surface,
)
REPO = pathlib.Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# Normalizer: closed keys, disclosed bounds, honest emptiness
# ---------------------------------------------------------------------------
def test_normalizer_keeps_closed_keys_and_drops_unknown():
fact = normalize_client_surface({
"pywebview": True,
"ua": " TestShell/1.0 ",
"viewport": {"w": "1200", "h": 800},
"narrow_layout": False,
"coarse_pointer": True,
"captured_at": "2026-08-18T00:00:00Z",
"evil_extra": "dropped",
"cookies": {"a": 1},
})
assert fact == {
"pywebview": True,
"ua": "TestShell/1.0",
"viewport": {"w": 1200, "h": 800},
"narrow_layout": False,
"coarse_pointer": True,
"captured_at": "2026-08-18T00:00:00Z",
}
def test_normalizer_empty_and_non_dict_are_none():
assert normalize_client_surface({}) is None
assert normalize_client_surface(None) is None
assert normalize_client_surface("desktop") is None
assert normalize_client_surface({"unknown_only": True}) is None
def test_normalizer_bounds_are_disclosed_not_silent():
fact = normalize_client_surface({"ua": "x" * 2000})
assert fact is not None
ua = fact["ua"]
assert len(ua) <= CLIENT_SURFACE_UA_LIMIT
# The strict-bound SSOT keeps the omission marker INSIDE the limit.
assert "chars" in ua or "…" in ua, "oversized ua must carry a disclosed marker"
def test_normalizer_clamps_viewport_and_bounds_timestamp():
fact = normalize_client_surface({
"viewport": {"w": -5, "h": 10_000_000},
"captured_at": "t" * 300,
})
assert fact["viewport"] == {"w": 0, "h": 100_000}
assert len(fact["captured_at"]) <= 64
def test_normalizer_bad_viewport_is_dropped_not_fatal():
fact = normalize_client_surface({"pywebview": True, "viewport": {"w": "nan", "h": None}})
assert fact == {"pywebview": True}
# stock json.loads accepts the Infinity literal; int(inf) raises
# OverflowError which must be swallowed like every other bad viewport
# (proven adversarial finding: an escape here dropped the whole message).
fact = normalize_client_surface({"pywebview": True, "viewport": {"w": float("inf"), "h": 5}})
assert fact == {"pywebview": True}
fact = normalize_client_surface({"viewport": {"w": float("-inf"), "h": float("nan")}})
assert fact is None
def test_normalizer_accepts_only_real_booleans():
# "false"/"0" strings would coerce to True and invert the fact — drop them.
assert normalize_client_surface({"pywebview": "false"}) is None
assert normalize_client_surface({"coarse_pointer": "0", "narrow_layout": 1}) is None
assert normalize_client_surface({"pywebview": False}) == {"pywebview": False}
# ---------------------------------------------------------------------------
# Identity: what counts as a surface CHANGE (resize/rotation never does)
# ---------------------------------------------------------------------------
def test_identity_excludes_viewport_and_narrow_layout():
a = {"pywebview": False, "coarse_pointer": False, "ua": "U", "viewport": {"w": 100, "h": 50}, "narrow_layout": False}
b = {"pywebview": False, "coarse_pointer": False, "ua": "U", "viewport": {"w": 900, "h": 50}, "narrow_layout": True}
assert client_surface_identity(a) == client_surface_identity(b)
def test_identity_changes_on_bridge_pointer_ua_or_channel():
base = {"pywebview": False, "coarse_pointer": False, "ua": "U"}
assert client_surface_identity(base) != client_surface_identity({**base, "pywebview": True})
assert client_surface_identity(base) != client_surface_identity({**base, "coarse_pointer": True})
assert client_surface_identity(base) != client_surface_identity({**base, "ua": "V"})
assert client_surface_identity({"channel": "cli"}) != client_surface_identity({"channel": "api_command"})
assert client_surface_identity(None) is None
assert client_surface_identity({}) is None
# A fact carrying NONE of the identity keys has no identity at all — it
# must not collapse to the all-empty tuple and fake a "DIFFERENT surface".
assert client_surface_identity({"narrow_layout": True, "viewport": {"w": 1, "h": 1}}) is None
# ---------------------------------------------------------------------------
# chat.jsonl persistence (writer half)
# ---------------------------------------------------------------------------
def test_log_chat_persists_client_surface_column(tmp_path, monkeypatch):
import supervisor.message_bus as mb
monkeypatch.setattr(mb, "DATA_DIR", tmp_path)
(tmp_path / "logs").mkdir(parents=True, exist_ok=True)
fact = {"pywebview": True, "ua": "TestShell/1.0"}
mb.log_chat("in", 1, 1, "hello", source="web", client_surface=fact)
mb.log_chat("in", 1, 1, "bare", source="web")
rows = [json.loads(line) for line in (tmp_path / "logs" / "chat.jsonl").read_text().splitlines()]
assert rows[0]["client_surface"] == fact
assert "client_surface" not in rows[1]
# ---------------------------------------------------------------------------
# Bus survival: nested in task_metadata through the get_updates whitelist
# ---------------------------------------------------------------------------
def test_client_surface_survives_bus_inside_task_metadata():
from supervisor.message_bus import LocalChatBridge
bridge = LocalChatBridge()
fact = {"pywebview": False, "ua": "Phone/1.0", "received_at": "2026-08-18T00:00:00Z"}
bridge.enqueue_local_message(
"follow-up", chat_id=1, user_id=1, source="web",
task_metadata={"client_surface": fact},
)
updates = bridge.get_updates(offset=0, timeout=1)
assert updates, "message must come back out of the bus"
meta = updates[0]["message"]["task_metadata"]
assert meta["client_surface"] == fact
# ---------------------------------------------------------------------------
# Mailbox round-trip + the loop's change-note
# ---------------------------------------------------------------------------
def test_mailbox_round_trip_carries_client_surface(tmp_path):
from ouroboros.owner_mailbox import drain_owner_entries, write_owner_message
fact = {"pywebview": False, "coarse_pointer": True, "ua": "Phone/1.0"}
assert write_owner_message(tmp_path, "from phone", "t1", msg_id="m1", client_surface=fact)
assert write_owner_message(tmp_path, "bare", "t1", msg_id="m2")
entries = drain_owner_entries(tmp_path, task_id="t1", seen_ids=set())
by_id = {e["msg_id"]: e for e in entries}
assert by_id["m1"]["client_surface"] == fact
assert "client_surface" not in by_id["m2"]
def _ctx_with_surface(fact):
return SimpleNamespace(task_metadata={"client_surface": fact} if fact else {})
def test_owner_surface_note_only_on_identity_change():
from ouroboros.client_surface import owner_surface_note as _owner_surface_note
desktop = {"pywebview": True, "coarse_pointer": False, "ua": "Shell/1"}
phone = {"pywebview": False, "coarse_pointer": True, "ua": "Phone/1"}
ctx = _ctx_with_surface(desktop)
# Same identity as the task's own start fact: silence.
assert _owner_surface_note(ctx, dict(desktop)) == ""
# Real device change: a loud note carrying the raw fact.
note = _owner_surface_note(ctx, phone)
assert "DIFFERENT client surface" in note and "Phone/1" in note
# Same new surface again: silence (baseline advanced to last-seen).
assert _owner_surface_note(ctx, dict(phone)) == ""
def test_owner_surface_note_viewport_resize_is_not_a_change():
from ouroboros.client_surface import owner_surface_note as _owner_surface_note
base = {"pywebview": True, "coarse_pointer": False, "ua": "Shell/1",
"viewport": {"w": 1400, "h": 900}, "narrow_layout": False}
resized = {**base, "viewport": {"w": 700, "h": 900}, "narrow_layout": True}
ctx = _ctx_with_surface(base)
assert _owner_surface_note(ctx, resized) == ""
def test_owner_surface_note_first_fact_without_baseline_is_neutral():
from ouroboros.client_surface import owner_surface_note as _owner_surface_note
ctx = _ctx_with_surface(None)
note = _owner_surface_note(ctx, {"pywebview": False, "ua": "Phone/1"})
assert "sent from client surface" in note
assert "DIFFERENT" not in note
# And no note at all when there is nothing to compare or say.
assert _owner_surface_note(None, {"ua": "x"}) == ""
assert _owner_surface_note(ctx, None) == ""
# ---------------------------------------------------------------------------
# Producer wiring pins (the dead-wire class: a consumer test alone stays green
# while the producer never sends — pin the producers structurally)
# ---------------------------------------------------------------------------
def _function_calls(tree: ast.AST, func_name: str) -> set[str]:
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
return {
n.func.id if isinstance(n.func, ast.Name) else getattr(n.func, "attr", "")
for n in ast.walk(node)
if isinstance(n, ast.Call)
}
raise AssertionError(f"function {func_name} not found")
def test_all_three_routing_producers_attach_client_surface():
# tools/control.py re-exports them; the producers live in the routing leaf.
tree = ast.parse((REPO / "ouroboros" / "tools" / "control_routing.py").read_text(encoding="utf-8"))
for producer in ("_promote_chat_to_task", "_route_to_project", "_steer_task"):
calls = _function_calls(tree, producer)
assert "_attach_client_surface" in calls, (
f"{producer} no longer attaches client_surface — the fact silently "
"dies at this transition (the is_desktop dead-wire class)"
)
def test_owner_surface_note_baseline_uses_the_shared_projection():
"""Codex scope C1: the mailbox baseline and the context render must read ONE
projection — a CLI/API-admitted task (channel fact stamped at admission)
whose first web follow-up arrives is a surface CHANGE, never a neutral
first fact."""
from ouroboros.client_surface import owner_client_fact, owner_surface_note
ctx = SimpleNamespace(task_metadata={"client_surface": {"channel": "cli"}})
note = owner_surface_note(ctx, {"pywebview": False, "ua": "Mozilla/5.0 test"})
assert "DIFFERENT" in note, "known channel baseline must make a web fact a CHANGE"
# The context render consumes the same projection function (no inline twin).
context_src = (REPO / "ouroboros" / "context.py").read_text(encoding="utf-8")
assert "owner_client_fact(" in context_src
# The projection reads ONLY the producer-assembled fact: metadata.source is
# overloaded (scheduler) and must never be dressed up as an owner surface.
assert owner_client_fact({"client_surface": {"pywebview": True}}) == {"pywebview": True}
for source in ("cli", "scheduled_task", "skill_scheduled_task", "web"):
assert owner_client_fact({"source": source}) is None
assert owner_client_fact({}) is None
assert owner_client_fact(None) is None
def test_schedule_template_can_never_smuggle_a_surface_fact():
"""Codex scope round 3: POST /api/schedules accepted caller metadata and
_task_from_schedule copied it — a forged client_surface (incl. a fake
received_at) became a machine task's owner_client. The key is now in the
RESERVED_TEMPLATE_FIELDS SSOT: admission rejects it loudly, and the
producer filter strips it from records persisted before the rule."""
from ouroboros.schedule_contract import RESERVED_TEMPLATE_FIELDS
from supervisor import queue as squeue
assert "client_surface" in RESERVED_TEMPLATE_FIELDS
record = {
"id": "sched-forged",
"name": "forged surface",
"task": {
"text": "machine work",
"metadata": {
"client_surface": {"pywebview": True, "received_at": "forged"},
"harmless": "kept",
},
},
}
task = squeue._task_from_schedule(record)
assert "client_surface" not in task["metadata"], (
"a schedule-fired machine task must never wear an owner surface"
)
assert task["metadata"]["harmless"] == "kept"
from ouroboros.client_surface import owner_client_fact
assert owner_client_fact(task["metadata"]) is None
def test_external_admission_stamps_channel_and_never_forwards_caller_surface():
"""Codex scope round 2 N3: /api/tasks metadata is caller-controlled and
client_surface is not a reserved key — the admission must OVERWRITE any
caller-built descriptor with its caller-declared channel stamp (a fake
received_at would impersonate a host stamp)."""
tasks_src = (REPO / "ouroboros" / "gateway" / "tasks.py").read_text(encoding="utf-8")
stamp = 'metadata["client_surface"] = {"channel": str(metadata.get("source") or "api_task")}'
assert stamp in tasks_src, "external admission no longer stamps/overwrites the channel fact"
assert tasks_src.index('metadata.setdefault("source"') < tasks_src.index(stamp), (
"the stamp must read the already-defaulted source"
)
def test_ws_ingress_normalizes_and_stamps_received_at():
ws_src = (REPO / "ouroboros" / "gateway" / "ws.py").read_text(encoding="utf-8")
assert "normalize_client_surface" in ws_src
assert 'client_surface["received_at"] = utc_now_iso()' in ws_src, (
"the ws chat branch no longer stamps the host received_at beside the "
"client-reported captured_at — the provenance pair breaks silently"
)
def test_attach_client_surface_copies_by_value_and_skips_absence():
from ouroboros.tools.control import _attach_client_surface
fact = {"pywebview": True, "ua": "Shell/1"}
evt: dict = {}
_attach_client_surface(SimpleNamespace(task_metadata={"client_surface": fact}), evt)
assert evt["client_surface"] == fact
assert evt["client_surface"] is not fact, "must copy by value, never share the dict"
for ctx in (
SimpleNamespace(task_metadata={}),
SimpleNamespace(task_metadata={"client_surface": {}}),
SimpleNamespace(task_metadata={"client_surface": "bogus"}),
SimpleNamespace(task_metadata=None),
SimpleNamespace(),
):
evt = {}
_attach_client_surface(ctx, evt)
assert "client_surface" not in evt
def test_promotion_lands_client_surface_under_metadata(monkeypatch, tmp_path):
"""Behavioral landing test: the promoted task carries the fact under
task['metadata'] even when no metadata dict pre-existed (only force_plan
creates one) — a top-level landing would be invisible to the renderer."""
import types
import supervisor.workers as workers
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
pending = []
ctx = types.SimpleNamespace(
enqueue_task=lambda task: pending.append(dict(task)) or task,
load_state=lambda: {"owner_chat_id": 1},
)
fact = {"pywebview": False, "coarse_pointer": True, "ua": "Phone/1"}
workers.promote_chat_to_task(
{"task_id": "surf-land", "objective": "Build", "client_surface": fact},
ctx,
)
assert len(pending) == 1
assert pending[0]["metadata"]["client_surface"] == fact
assert "client_surface" not in pending[0], "fact must not land top-level"
def test_route_owner_message_stamps_channel_for_non_web_ingress(monkeypatch):
"""Behavioral producer test for the host {channel: source} stamp: a
telegram-like bridge message renders a channel fact, a plain web message
stays an honest absence (§9a №9)."""
import server
captured = []
class ImmediateThread:
def __init__(self, target, args=(), kwargs=None, daemon=False):
self.target, self.args, self.kwargs = target, args, kwargs or {}
def start(self):
self.target(*self.args, **self.kwargs)
monkeypatch.setattr(server.threading, "Thread", ImmediateThread)
monkeypatch.setattr(server, "_reserved_project_for_chat", lambda ctx, chat_id: {})
monkeypatch.setattr(server, "_addressable_root_tasks", lambda ctx, _pid: [])
monkeypatch.setattr("ouroboros.projects_registry.list_projects", lambda _root: [])
monkeypatch.setattr(server, "_decision_turn_metadata", lambda ctx, chat_id, cmid, md: md)
def make_ctx():
return SimpleNamespace(
DRIVE_ROOT=None,
consciousness=SimpleNamespace(
inject_observation=lambda *_: None, pause=lambda: None, resume=lambda: None
),
get_chat_agent=lambda: SimpleNamespace(_busy=True),
handle_chat_direct=lambda *a, **kw: captured.append(kw.get("task_metadata")),
)
server._route_owner_message(SimpleNamespace(), make_ctx(), {
"chat_id": 1, "text": "привет из телеги", "client_message_id": "m1",
"task_metadata": None, "log_text": "привет из телеги",
"origin_message_ref": {"chat_id": 1, "client_message_id": "m1"},
"source": "skill:telegram",
})
assert captured[-1]["client_surface"] == {"channel": "skill:telegram"}
server._route_owner_message(SimpleNamespace(), make_ctx(), {
"chat_id": 1, "text": "обычный веб", "client_message_id": "m2",
"task_metadata": None, "log_text": "обычный веб",
"origin_message_ref": {"chat_id": 1, "client_message_id": "m2"},
"source": "web",
})
assert "client_surface" not in (captured[-1] or {})
# A real browser fact is never overwritten by the channel stamp.
server._route_owner_message(SimpleNamespace(), make_ctx(), {
"chat_id": 1, "text": "с фактом", "client_message_id": "m3",
"task_metadata": {"client_surface": {"pywebview": True}}, "log_text": "с фактом",
"origin_message_ref": {"chat_id": 1, "client_message_id": "m3"},
"source": "skill:telegram",
})
assert captured[-1]["client_surface"] == {"pywebview": True}
# A synthetic A2A chat (negative id) is machine traffic: same skill source,
# NO owner surface stamp (codex scope round 2 N2).
server._route_owner_message(SimpleNamespace(), make_ctx(), {
"chat_id": -42, "text": "a2a ping", "client_message_id": "m4",
"task_metadata": None, "log_text": "a2a ping",
"origin_message_ref": {"chat_id": -42, "client_message_id": "m4"},
"source": "skill:a2a",
})
assert "client_surface" not in (captured[-1] or {}), (
"machine-to-machine traffic must never wear an owner_client fact"
)
def test_steering_and_project_mailbox_writers_pass_client_surface():
# Tripwire complement to the behavioral tests above (the mailbox writers are
# exercised via write_owner_message round-trip; these pins catch a dropped
# kwarg at the two forwarding call sites).
steering = (REPO / "supervisor" / "steering.py").read_text(encoding="utf-8")
# The project mailbox belongs to owner routing; the ordinary inbound log
# now belongs to message_bus.record_inbound_message. Pin each actual owner
# separately so an unrelated extra forwarding site cannot hide a dropped one.
writers = {
"project mailbox": REPO / "ouroboros" / "server_owner_routing.py",
"inbound chat journal": REPO / "supervisor" / "message_bus.py",
}
assert "client_surface=" in steering, "steer mailbox write dropped client_surface"
for owner, path in writers.items():
assert "client_surface=(" in path.read_text(encoding="utf-8"), f"{owner} dropped client_surface"
def test_inbound_message_owner_persists_the_original_client_surface(tmp_path, monkeypatch):
from supervisor import message_bus
monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path)
monkeypatch.setattr(message_bus, "load_state", lambda: {"session_id": "surface-fixture"})
surface = {"pywebview": False, "ua": "Phone/1.0"}
message = {"source": "web", "task_metadata": {"client_surface": surface}}
ref = message_bus.record_inbound_message(
SimpleNamespace(), message, chat_id=1, user_id=1,
client_message_id="surface-message", text="owner input", ts="2026-09-07T00:00:00Z",
)
row = json.loads((tmp_path / "logs" / "chat.jsonl").read_text(encoding="utf-8"))
assert row["client_surface"] == surface
assert row["client_message_id"] == ref["client_message_id"] == "surface-message"
assert row["text"] == "owner input" and row["direction"] == "in"
def test_presentation_env_is_stripped_by_benchmark_server_runner():
runner = (REPO / "devtools" / "benchmarks" / "common" / "server_runner.py").read_text(encoding="utf-8")
assert '"OUROBOROS_PRESENTATION",' in runner, (
"isolated benchmark servers must not inherit the operator's desktop posture"
)
# ---------------------------------------------------------------------------
# History endpoint negative: the field never replays to the SPA (R3)
# ---------------------------------------------------------------------------
def test_chat_history_does_not_emit_client_surface(tmp_path):
from ouroboros.gateway.history import make_chat_history_endpoint
logs = tmp_path / "logs"
logs.mkdir()
(logs / "progress.jsonl").write_text("", encoding="utf-8")
(logs / "chat.jsonl").write_text(
json.dumps({
"ts": "2026-08-18T00:00:00Z",
"direction": "in",
"chat_id": 1,
"user_id": 1,
"text": "hello from phone",
"source": "web",
"client_surface": {"pywebview": False, "ua": "Phone/1.0"},
}) + "\n",
encoding="utf-8",
)
endpoint = make_chat_history_endpoint(tmp_path)
response = asyncio.run(endpoint(SimpleNamespace(query_params={"limit": "10"})))
payload = json.loads(response.body.decode("utf-8"))["messages"]
rec = next(item for item in payload if item.get("text") == "hello from phone")
assert "client_surface" not in rec, (
"client_surface is model/forensics provenance; the SPA knows its own "
"surface and the history projection deliberately omits it"
)
# ---------------------------------------------------------------------------
# Prompt-fact parity: SYSTEM.md product claims stay bound to the code (AC8)
# ---------------------------------------------------------------------------
def test_system_prompt_reload_claim_is_code_bound():
system_md = (REPO / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
if "reload-on-SHA" not in system_md and "reloads itself" not in system_md:
return # the claim was removed together with its obligation
ws_js = (REPO / "web" / "modules" / "ws.js").read_text(encoding="utf-8")
assert "sha-change" in ws_js or "Reload on SHA change" in ws_js, (
"SYSTEM.md claims the UI auto-reloads on SHA change, but the ws.js "
"reload path is gone — the prompt now states a false product fact "
"(the exact failure class this feature exists to close)"
)
def test_frontend_sends_raw_observables_without_device_taxonomy():
chat_js = (REPO / "web" / "modules" / "chat.js").read_text(encoding="utf-8")
surface_js = (REPO / "web" / "modules" / "client_surface.js").read_text(encoding="utf-8")
# The send site imports AND actually spreads the snapshot field into the
# frame (the import alone is the dead-wire class this feature closes); the
# module measures at send time and never labels devices.
assert "client_surface.js" in chat_js
assert "...clientSurfaceField()," in chat_js
assert "clientSurfaceSnapshot" in surface_js and "matchMedia" in surface_js
assert "client_surface" in surface_js
for label in ("mobile_browser", "desktop_app", "browser_tab"):
for src_name, src in (("chat.js", chat_js), ("client_surface.js", surface_js)):
assert label not in src, (
f"device taxonomy label {label!r} crept into {src_name} — raw "
"observables only, the model classifies"
)