diff --git a/examples/muse-discord/README.md b/examples/muse-discord/README.md
new file mode 100644
index 0000000..8a9b2d7
--- /dev/null
+++ b/examples/muse-discord/README.md
@@ -0,0 +1,167 @@
+# Proposal + mock: Muse as a caller, Discord as the upstream
+
+**Scope: this is a proposal plus a mock, not a runnable end-to-end
+Muse -> Discord client today.** It sketches the caller side of issue #6 —
+a Muse custom-connector helper that would reach Discord only through
+auth-proxy, so every outbound call is policy-checked, audit-logged, and —
+once `require_approval` from issue #5 exists — held for the owner's
+approval before it executes.
+
+It cannot reach Discord end-to-end as submitted, for reasons outside this
+directory: `runtime_pubkey` is unset in `identity.toml` (the session plane
+refuses hello until it is set), the forwarder always sends `Bearer` while
+Discord bot tokens need `Bot` (see "Known gap" below), and the example
+listener is loopback-only while the remote Muse transport is still open
+in #6. Nothing here touches the proxy core.
+
+## Pieces
+
+```mermaid
+flowchart LR
+ subgraph agent["Muse (agent host)"]
+ H["connector helper
(caller.py shape)"]
+ end
+ subgraph owner["Owner-controlled host"]
+ P["auth-proxy
policy / swap / audit"]
+ R["registrar
issues + revokes surrogates"]
+ end
+ A["approval plane
owner's device (#5)"]
+ D["discord.com"]
+
+ H -->|"caller key in path,
surrogate in header"| P
+ R -->|"register sha256(surrogate)"| P
+ P -.->|"write parked (#5)"| A
+ A -.->|"approve / deny"| P
+ P -->|"real bot token swapped in"| D
+```
+
+## Files
+
+| File | What it is |
+|---|---|
+| `identity.toml` | Example proxy config (with placeholder digests only): `muse` identity, `discord` route, credential reference, grants. |
+| `caller.py` | Proposal caller (Python, stdlib only). Shape for a connector helper once #5/#6 settle. |
+| `mock_proxy.py` | **Contract sketch for #5, not the real proxy.** Stand-in implementing the sketched caller-facing contract, including the proposed approval flow. It omits canonicalization, policy evaluation, the registry, audit and forwarding. No real Discord call ever happens. |
+| `test_caller.py` | Tests of the caller against the mock only: allow, park → approved, park → denied, park → expired, bad surrogate. Green here does not verify the real proxy. |
+
+## Wire protocol (as implemented by the proxy today)
+
+From `crates/ap-client` and `crates/ap-proxy/src/pipeline.rs`:
+
+- Requests are sent to `{base}/ap//` in
+ origin form. The caller key is `apk_` + 64 lowercase hex; the config stores
+ only its sha256.
+- The session surrogate is `aps_` + 64 lowercase hex, sent in the route's
+ surrogate header (default `authorization`) as `Bearer `. The
+ proxy registry holds `sha256(surrogate)` per session.
+- The proxy canonicalizes the request, checks caller key → identity, route,
+ surrogate registration and the grant (identity × credential × route ×
+ method × path; a path ending in `/` is a prefix), strips every client auth
+ header, sets exactly one credential header with the real secret, and
+ forwards upstream. The caller never sees the real credential.
+
+Approval flow (proposed in #5, sketched here — NOT an agreed contract):
+a write whose grant requires approval would be answered `202` with a
+`request_id` **and the proxy-owned marker header
+`X-Auth-Proxy-Approval: parked`** instead of being forwarded. The marker is
+what distinguishes a parked request from a legitimate upstream `202`:
+`caller.py` treats a bare `202` without that header as an ordinary upstream
+response and returns it as-is. The outcome is caller-scoped —
+`GET {base}/ap//requests/` — and reports `parked`,
+`executed` (with the upstream response), `denied`, or `expired`. `caller.py`
+polls it. This shape (marker, result schema, poll authorization, expiry /
+revocation) is still open in #5; once #5 settles it, the caller and the mock
+must be re-derived from that spec rather than freezing this sketch.
+
+## Generating keys
+
+```sh
+python3 - <<'EOF'
+import hashlib, secrets
+key = "apk_" + secrets.token_hex(32)
+print("caller key (give to the caller):", key)
+print("caller_key_sha256 (config): ", hashlib.sha256(key.encode()).hexdigest())
+print("surrogate (per session): ", "aps_" + secrets.token_hex(32))
+EOF
+```
+
+## Policy walkthrough (`identity.toml`)
+
+- **Reads are granted**: `GET` on `/api/v10/channels/` and `/api/v10/guilds/`
+ (prefixes). This covers reading message history for mention triage.
+- **Writes are present but commented out.** Phase 1 policy only allows or
+ denies, so an enabled write grant would execute immediately, with no
+ approval. Uncomment the write grants only together with the
+ `require_approval` action from #5 — or knowingly, accepting immediate
+ execution.
+- Write grants are per channel in prefix form: config validation rejects
+ non-root grant paths that do not end in `/`, so the grant is
+ `/api/v10/channels//messages/` — not the exact create-message path.
+ That prefix also matches sub-paths such as message edits under the same
+ channel, so scope it knowingly. Replies use the same endpoint with a
+ `message_reference` in the JSON body. Policy cannot see the body; the
+ approval step is what reviews it. That split is intentional.
+
+## Known gap: credential scheme (`Bot` vs `Bearer`)
+
+The forwarder always sets the credential header as `Bearer `
+(`crates/ap-proxy/src/forward.rs`, `credential_value`). Discord bot tokens
+must be sent as `Bot `, so a live Discord upstream cannot authenticate
+against Phase 1 as shipped — for reads and writes alike. Storing `Bot `
+as the secret does not help; the prefix is prepended unconditionally.
+
+Minimal change that would unblock the read path (not in this PR, it touches
+the proxy core): a per-route credential scheme, e.g.
+`credential_scheme = "Bot"`, defaulting to `"Bearer"`, validated against a
+small allowlist. Even with that change, this example would still need a set
+`runtime_pubkey`, a reachable (non-loopback-only) caller transport from #6,
+and — for writes — the settled #5 contract before it could run end-to-end.
+
+## Sessions for a Muse caller
+
+Issue #6 asks how sessions are issued when the caller is Muse. This example
+assumes the registrar split from the session plane design:
+
+- The registrar is a small component on the owner's device or home host. It
+ holds the identity's runtime Ed25519 key (`runtime_pubkey` in
+ `identity.toml`), generates the surrogate per session, and registers its
+ sha256 with the proxy. The agent receives only the surrogate.
+- Revoke is the same lever as for other callers: unregister the surrogate
+ (Muse disconnect, session end) and it stops working immediately, together
+ with any requests it has parked (#5).
+- The remote-caller transport (Muse's host is not the proxy's host) is the
+ open part of #6: the caller plane must be reachable by the agent without
+ being reachable by the world — in the owner's deployment that is a
+ tailnet-only listener. The proxy itself does not change for this; the
+ caller key plus registered surrogate remain the authentication.
+
+## Try it
+
+```sh
+python3 test_caller.py # mock-only contract: allow / approve / deny / expire / refusal
+ # (generates ephemeral keys and passes them to the mock)
+python3 mock_proxy.py & # or run the mock yourself: it prints fresh ephemeral
+ # MOCK_CALLER_KEY / MOCK_SURROGATE values on startup
+AP_BASE=http://127.0.0.1: \
+AP_CALLER_KEY= \
+AP_SURROGATE= \
+python3 caller.py get-messages 2222222222222222222 5
+```
+
+(No reusable key material is committed in this directory. Keys are
+generated ephemerally — by `test_caller.py` via `secrets.token_hex(32)`, or
+by the mock at startup — and `identity.toml` carries only a placeholder
+digest.)
+
+## Security notes
+
+- The caller key travels in the URL path by design of the session plane; it
+ is an identifier-grade authenticator that is useless without a registered
+ surrogate, and vice versa. Treat proxy access logs as carrying it.
+- Neither the caller key nor the surrogate can be exchanged for the real
+ credential: the swap happens only inside the proxy, per request, against a
+ matching grant.
+- Fail closed at every layer you control: an unmatched path, an unregistered
+ surrogate, or a missing runtime key is a refusal, never a fallback to a
+ direct upstream call. `caller.py` has no direct-to-Discord code path at
+ all.
diff --git a/examples/muse-discord/caller.py b/examples/muse-discord/caller.py
new file mode 100644
index 0000000..9ac8b8e
--- /dev/null
+++ b/examples/muse-discord/caller.py
@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+"""Proposal caller: a Muse custom-connector helper talking to auth-proxy.
+
+Scope: this is a proposal plus mock (see README.md), not a runnable
+end-to-end Muse -> Discord client today. The read wire format below
+matches the proxy as shipped; the approval flow is a proposed contract
+for issue #5 that has NOT been agreed or implemented in the proxy yet.
+
+This mirrors the wire protocol implemented by crates/ap-client and enforced by
+crates/ap-proxy/src/pipeline.rs:
+
+ * The request goes to {AP_BASE}/ap//
+ in origin form. The caller key (apk_ + 64 lowercase hex) travels in the path;
+ the proxy config stores only its sha256.
+ * The session surrogate (aps_ + 64 lowercase hex) travels in the route's
+ surrogate header (default "authorization") as "Bearer ".
+ * The caller never holds the real upstream credential. The proxy checks the
+ caller key, the surrogate registration and the grant, then swaps the
+ surrogate for the real credential and forwards upstream.
+
+Approval flow (PROPOSED in issues #5 and #6, NOT yet implemented by the proxy
+and NOT yet agreed): a request whose grant says require_approval would be
+answered "202 Accepted" ONLY when it also carries the proxy-owned marker
+header "X-Auth-Proxy-Approval: parked", together with a request id. The
+caller then polls {AP_BASE}/ap//requests/ until the
+parked request is executed, denied, or expired. A bare 202 without that
+header is treated as an ordinary upstream 202 response, not as a parked
+request -- upstream APIs may legitimately return 202 themselves. Once #5
+settles the marker, response schema, poll authorization and expiry /
+revocation semantics, this sketch should be re-derived from that spec.
+
+Environment:
+ AP_BASE proxy base URL (default http://127.0.0.1:18080)
+ AP_CALLER_KEY apk_... caller key (required)
+ AP_SURROGATE aps_... surrogate (required)
+ AP_ROUTE route name (default "discord")
+ AP_POLL_TIMEOUT seconds to wait for an approval outcome (default 300)
+ AP_POLL_INTERVAL seconds between polls (default 2)
+
+Exit codes: 0 executed, 2 proxy refusal or upstream error, 3 approval denied,
+4 approval expired, 5 approval wait timed out, 64 usage/config error.
+"""
+
+import json
+import math
+import os
+import re
+import sys
+import time
+import urllib.error
+import urllib.request
+
+KEY_RE = re.compile(r"^apk_[0-9a-f]{64}$")
+SURROGATE_RE = re.compile(r"^aps_[0-9a-f]{64}$")
+SNOWFLAKE_RE = re.compile(r"^\d+$")
+APPROVAL_HEADER = "x-auth-proxy-approval"
+APPROVAL_MARKER = "parked"
+
+
+def die(message: str, code: int = 64) -> "SystemExit":
+ print(f"caller: {message}", file=sys.stderr)
+ raise SystemExit(code)
+
+
+def _parse_poll_value(name: str, default: str) -> float:
+ raw = os.environ.get(name, default)
+ try:
+ value = float(raw)
+ except (TypeError, ValueError):
+ die(f"{name} must be a finite non-negative number, got {raw!r}")
+ if not math.isfinite(value) or value < 0:
+ die(f"{name} must be a finite non-negative number, got {raw!r}")
+ return value
+
+
+def _require_snowflake(value: str, what: str) -> str:
+ if not SNOWFLAKE_RE.match(value or ""):
+ die(f"{what} must be a numeric (snowflake) id, got {value!r}")
+ return value
+
+
+def _require_limit(value: str) -> str:
+ if not SNOWFLAKE_RE.match(value or ""):
+ die(f"limit must be digits only, got {value!r}")
+ return value
+
+
+def _has_control(text: str) -> bool:
+ return any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
+
+
+def _validate_internal_path(path: str) -> str:
+ # Internal call sites may use a query string (e.g. ?limit=), but a
+ # fragment or control characters are never legitimate.
+ if not path.startswith("/") or "#" in path or _has_control(path):
+ die(f"path must be an origin-form path starting with '/' with no fragment or control characters, got {path!r}")
+ return path
+
+
+def _validate_user_path(path: str) -> str:
+ # User-supplied `request` paths: no query or fragment injection either.
+ if not path.startswith("/") or "#" in path or "?" in path or _has_control(path) or " " in path:
+ die(f"path must be an origin-form path starting with '/' with no query, fragment, space or control characters, got {path!r}")
+ return path
+
+
+class Caller:
+ def __init__(self) -> None:
+ self.base = os.environ.get("AP_BASE", "http://127.0.0.1:18080").rstrip("/")
+ self.caller_key = os.environ.get("AP_CALLER_KEY", "")
+ self.surrogate = os.environ.get("AP_SURROGATE", "")
+ self.route = os.environ.get("AP_ROUTE", "discord")
+ self.poll_timeout = _parse_poll_value("AP_POLL_TIMEOUT", "300")
+ self.poll_interval = _parse_poll_value("AP_POLL_INTERVAL", "2")
+ if not KEY_RE.match(self.caller_key):
+ die("AP_CALLER_KEY must be apk_ followed by 64 lowercase hex characters")
+ if not SURROGATE_RE.match(self.surrogate):
+ die("AP_SURROGATE must be aps_ followed by 64 lowercase hex characters")
+
+ def _url(self, path: str) -> str:
+ return f"{self.base}/ap/{self.caller_key}/{self.route}{path}"
+
+ def _send(self, method: str, url: str, body: dict | None) -> tuple[int, dict, str]:
+ data = json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(url, data=data, method=method)
+ req.add_header("Authorization", f"Bearer {self.surrogate}")
+ if data is not None:
+ req.add_header("Content-Type", "application/json")
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ headers = {k.lower(): v for k, v in resp.headers.items()}
+ return resp.status, headers, resp.read().decode("utf-8", "replace")
+ except urllib.error.HTTPError as err:
+ headers = {k.lower(): v for k, v in err.headers.items()} if err.headers else {}
+ return err.code, headers, err.read().decode("utf-8", "replace")
+ except urllib.error.URLError as err:
+ die(f"cannot reach the proxy at {self.base}: {err.reason}", 2)
+
+ def request(self, method: str, path: str, body: dict | None = None) -> tuple[int, str]:
+ """One upstream call through the proxy, following the proposed approval flow."""
+ _validate_internal_path(path)
+ status, headers, text = self._send(method, self._url(path), body)
+ if status != 202:
+ return status, text
+ # F1: a bare 202 is NOT a parked request. Only a 202 carrying the
+ # proxy-owned approval marker header is treated as parked; any
+ # other 202 is an ordinary upstream response and is returned as-is.
+ if (headers.get(APPROVAL_HEADER) or "").strip().lower() != APPROVAL_MARKER:
+ return status, text
+ try:
+ request_id = json.loads(text)["request_id"]
+ except (ValueError, KeyError, TypeError):
+ die(f"proxy answered a marked 202 without a request_id: {text}", 2)
+ print(f"caller: parked for approval as {request_id}", file=sys.stderr)
+ return self._await_outcome(request_id)
+
+ def _await_outcome(self, request_id: str) -> tuple[int, str]:
+ poll_url = f"{self.base}/ap/{self.caller_key}/requests/{request_id}"
+ deadline = time.monotonic() + self.poll_timeout
+ while True:
+ status, _headers, text = self._send("GET", poll_url, None)
+ if status != 200:
+ return status, text
+ try:
+ outcome = json.loads(text)
+ except ValueError:
+ die(f"unparsable outcome for {request_id}: {text}", 2)
+ state = outcome.get("status")
+ if state == "executed":
+ result = outcome.get("result") or {}
+ return int(result.get("status", 200)), json.dumps(result.get("body", ""))
+ if state == "denied":
+ print(f"caller: {request_id} was denied by the owner", file=sys.stderr)
+ raise SystemExit(3)
+ if state == "expired":
+ print(f"caller: {request_id} expired without a decision", file=sys.stderr)
+ raise SystemExit(4)
+ if time.monotonic() > deadline:
+ print(f"caller: still parked after {self.poll_timeout:g}s", file=sys.stderr)
+ raise SystemExit(5)
+ time.sleep(self.poll_interval)
+
+
+def main(argv: list[str]) -> int:
+ caller = Caller()
+ if len(argv) < 2:
+ die(__doc__.splitlines()[0] + "\nusage: caller.py ...")
+ command, args = argv[1], argv[2:]
+
+ if command == "get-messages":
+ if not args:
+ die("usage: caller.py get-messages [limit]")
+ channel = _require_snowflake(args[0], "channel-id")
+ limit = _require_limit(args[1]) if len(args) > 1 else "20"
+ status, text = caller.request(
+ "GET", f"/api/v10/channels/{channel}/messages?limit={limit}"
+ )
+ elif command == "send":
+ if len(args) < 2:
+ die("usage: caller.py send [--reply-to ]")
+ channel = _require_snowflake(args[0], "channel-id")
+ payload: dict = {"content": args[1]}
+ if "--reply-to" in args:
+ idx = args.index("--reply-to")
+ if idx + 1 >= len(args):
+ die("usage: caller.py send --reply-to (missing message-id)")
+ ref = _require_snowflake(args[idx + 1], "message-id")
+ payload["message_reference"] = {"message_id": ref}
+ status, text = caller.request(
+ "POST", f"/api/v10/channels/{channel}/messages", payload
+ )
+ elif command == "request":
+ if len(args) < 2:
+ die("usage: caller.py request [json-body]")
+ path = _validate_user_path(args[1])
+ if not re.fullmatch(r"[A-Za-z]+", args[0] or ""):
+ die(f"method must be alphabetic, got {args[0]!r}")
+ try:
+ body = json.loads(args[2]) if len(args) > 2 else None
+ except ValueError as exc:
+ die(f"invalid JSON body: {exc}")
+ status, text = caller.request(args[0].upper(), path, body)
+ else:
+ die(f"unknown command: {command}")
+
+ if 200 <= status < 300:
+ print(text)
+ return 0
+ print(f"caller: proxy/upstream answered {status}: {text}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/examples/muse-discord/identity.toml b/examples/muse-discord/identity.toml
new file mode 100644
index 0000000..2516a8c
--- /dev/null
+++ b/examples/muse-discord/identity.toml
@@ -0,0 +1,77 @@
+# Example identity file: Muse as a caller, Discord as the upstream.
+#
+# This file follows the schema in crates/ap-core/src/config.rs and the layout of
+# deploy/k8s/identity-configmap.yaml. Copy it into your deployment, replace the marked values,
+# and keep the raw caller key and the bot token out of the file: the config holds only the
+# sha256 of the caller key, and the credential is a store reference.
+#
+# Status of the write path: the POST/PATCH/DELETE grants at the bottom are commented out on
+# purpose. Phase 1 policy has only allow/deny, so enabling them today would let the caller
+# write immediately. Enable them only together with the require_approval action proposed in
+# issue #5, so writes park until the owner approves them. See README.md in this directory.
+
+version = 1
+
+[listen]
+proxy = "127.0.0.1:18080"
+
+[[identity]]
+name = "muse"
+# Placeholder only -- no real or example digest is committed here. REPLACE
+# with the sha256 of your own generated caller key (see README.md,
+# "Generating keys"); the raw key (apk_...) is held by the caller only.
+caller_key_sha256 = ""
+# runtime_pubkey = "..." # base64 Ed25519 public key of the registrar that opens sessions for
+# this identity. For Muse the registrar is a component on the owner's device (issue #6). Hello
+# is refused until this is set.
+
+[[route]]
+name = "discord"
+host = "discord.com"
+# port, error_shape, surrogate_header and credential_header keep their defaults:
+# 443, generic, "authorization" and "authorization".
+# Known gap: the forwarder always sets the credential header as "Bearer "
+# (crates/ap-proxy/src/forward.rs), but Discord bot tokens must be sent as "Bot ".
+# A live Discord pilot needs a per-route credential scheme; see README.md, "Known gap".
+
+[[credential]]
+id = "discord-bot"
+# Store reference for the bot token, in the same syntax as the kiro example: the file-backed
+# store resolves k8s:: to the token material on the proxy host. The token itself
+# never appears in this file and is never sent to the caller.
+source = "k8s:auth-proxy-discord:bot_token"
+routes = ["discord"]
+
+# Reads: allowed. Path entries ending in "/" are prefixes (crates/ap-core/src/config.rs);
+# "/api/v10/channels/" covers channel reads and message history.
+[[grant]]
+identity = "muse"
+credential = "discord-bot"
+route = "discord"
+methods = ["GET"]
+paths = ["/api/v10/channels/", "/api/v10/guilds/"]
+
+# Writes: DO NOT enable before require_approval (issue #5) exists, unless you accept that
+# matching requests execute immediately. Path granularity note: config validation
+# (crates/ap-core/src/config.rs, valid_path) rejects any non-root grant path that does
+# not end in "/", so grants must use the prefix form below -- an exact create-message
+# path without the trailing slash would make the proxy refuse to load this config.
+# The prefix form also matches sub-paths below messages/ (e.g. message edits and
+# deletes under that channel), so scope and review it accordingly: one prefix grant
+# per channel the bot may post to. Replies use the same endpoint with a
+# message_reference in the JSON body; the policy layer cannot see the body, which
+# is exactly what the approval step reviews.
+#
+# [[grant]]
+# identity = "muse"
+# credential = "discord-bot"
+# route = "discord"
+# methods = ["POST"]
+# paths = ["/api/v10/channels/1111111111111111111/messages/"] # REPLACE with the channel id; prefix form, trailing "/" required
+#
+# [[grant]]
+# identity = "muse"
+# credential = "discord-bot"
+# route = "discord"
+# methods = ["PATCH", "DELETE"]
+# paths = ["/api/v10/channels/1111111111111111111/messages/"] # prefix: edits/deletes of own messages
diff --git a/examples/muse-discord/mock_proxy.py b/examples/muse-discord/mock_proxy.py
new file mode 100644
index 0000000..8aa884e
--- /dev/null
+++ b/examples/muse-discord/mock_proxy.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""Mock auth-proxy -- CONTRACT SKETCH for issue #5, NOT the real proxy.
+
+This mock is a contract sketch for the approval flow proposed in #5/#6.
+It is NOT a stand-in that verifies the real proxy: it deliberately omits
+canonicalization, policy evaluation, the session registry, audit and
+upstream forwarding (see "Simplifications" below). Tests that pass against
+this mock therefore say little about the real proxy in crates/ -- they only
+exercise caller.py against this sketched contract. Once #5 settles the
+approval contract, this mock should be re-derived from that spec, and a
+separate test should run the caller against the real proxy.
+
+Implements just enough of the caller-facing shape to exercise caller.py:
+
+ * /ap// addressing and caller-key checking
+ (sha256 compared, like crates/ap-proxy/src/pipeline.rs),
+ * the surrogate in "Authorization: Bearer aps_..." form,
+ * an allow-listed read answered with canned upstream data,
+ * the proposed approval flow: a write is parked with
+ "202 + X-Auth-Proxy-Approval: parked + request_id", and
+ GET /ap//requests/ reports
+ parked / executed / denied / expired. A bare 202 without the marker
+ header is NOT a parked request (caller.py returns it as upstream data).
+
+Simplifications, deliberately: one identity, one route, one registered
+surrogate, no canonicalization, no policy evaluation, no registry, no
+audit, and parked requests live in memory. The "upstream" is fabricated;
+no Discord call ever happens, and the mock "executes" a parked write by
+inventing the upstream response.
+
+No reusable credential material is committed in this file. The caller key
+and surrogate are ephemeral: test_caller.py generates them with
+secrets.token_hex(32) and passes them in via the environment; a manual run
+generates fresh ones at startup and prints them.
+
+Test-only admin endpoints (NOT part of the proposed contract; they stand in
+for the owner's approval plane):
+
+ POST /_mock/approve/ approve and "execute" the parked write
+ POST /_mock/deny/ deny it
+
+Environment:
+ MOCK_TTL seconds a parked request awaits a decision (default 60)
+ MOCK_PORT port to bind, 0 = ephemeral (default 0); the bound port is
+ printed as "MOCK_PORT=" on stdout, followed by the
+ ephemeral MOCK_CALLER_KEY / MOCK_SURROGATE in use.
+ MOCK_CALLER_KEY apk_... caller key to accept (default: generated)
+ MOCK_SURROGATE aps_... surrogate to accept (default: generated)
+"""
+
+import hashlib
+import json
+import os
+import re
+import secrets
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+# Ephemeral key material only -- nothing reusable is committed here.
+EXAMPLE_CALLER_KEY = os.environ.get("MOCK_CALLER_KEY") or ("apk_" + secrets.token_hex(32))
+EXAMPLE_SURROGATE = os.environ.get("MOCK_SURROGATE") or ("aps_" + secrets.token_hex(32))
+CALLER_SHA256 = hashlib.sha256(EXAMPLE_CALLER_KEY.encode()).hexdigest()
+SURROGATE_SHA256 = hashlib.sha256(EXAMPLE_SURROGATE.encode()).hexdigest()
+SURROGATE_FORM = re.compile(r"^Bearer (aps_[0-9a-f]{64})$")
+CHANNEL_MESSAGES = re.compile(r"^/api/v10/channels/(\d+)/messages$")
+
+TTL = float(os.environ.get("MOCK_TTL", "60"))
+_lock = threading.Lock()
+_parked: dict[str, dict] = {}
+_counter = [0]
+
+
+def _json(handler: BaseHTTPRequestHandler, status: int, payload, extra_headers: dict | None = None) -> None:
+ body = json.dumps(payload).encode()
+ handler.send_response(status)
+ handler.send_header("Content-Type", "application/json")
+ handler.send_header("Content-Length", str(len(body)))
+ for key, value in (extra_headers or {}).items():
+ handler.send_header(key, value)
+ handler.end_headers()
+ handler.wfile.write(body)
+
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *args): # keep test output clean
+ pass
+
+ # -- helpers ---------------------------------------------------------
+ def _read_json(self) -> dict:
+ length = int(self.headers.get("Content-Length") or 0)
+ if not length:
+ return {}
+ try:
+ return json.loads(self.rfile.read(length) or b"{}")
+ except ValueError:
+ return {}
+
+ def _check_caller(self, caller_key: str) -> bool:
+ digest = hashlib.sha256(caller_key.encode()).hexdigest()
+ if digest != CALLER_SHA256:
+ _json(self, 403, {"code": "caller_rejected"})
+ return False
+ match = SURROGATE_FORM.match(self.headers.get("Authorization") or "")
+ if not match:
+ _json(self, 403, {"code": "surrogate_malformed"})
+ return False
+ if hashlib.sha256(match.group(1).encode()).hexdigest() != SURROGATE_SHA256:
+ _json(self, 403, {"code": "surrogate_unknown"})
+ return False
+ return True
+
+ # -- routing ---------------------------------------------------------
+ def do_POST(self): # noqa: N802 (http.server naming)
+ if self.path.startswith("/_mock/"):
+ return self._admin(self.path)
+ parts = self.path.split("/", 4)
+ if len(parts) < 5 or parts[1] != "ap":
+ return _json(self, 404, {"code": "route_unknown"})
+ _empty, _ap, caller_key, route, rest = parts
+ if not self._check_caller(caller_key):
+ return
+ upstream_path = "/" + rest.split("?")[0]
+ match = CHANNEL_MESSAGES.match(upstream_path) if route == "discord" else None
+ if not match:
+ return _json(self, 404, {"code": "route_unknown"})
+ body = self._read_json()
+ with _lock:
+ _counter[0] += 1
+ request_id = f"req_{_counter[0]:04d}"
+ _parked[request_id] = {
+ "status": "parked",
+ "channel_id": match.group(1),
+ "body": body,
+ "expires_at": time.monotonic() + TTL,
+ "result": None,
+ }
+ # Proposed #5 marker: parked responses carry the proxy-owned header
+ # in addition to the 202 status, so a bare upstream 202 is not
+ # mistaken for a parked request.
+ return _json(self, 202, {"request_id": request_id, "status": "parked"},
+ extra_headers={"X-Auth-Proxy-Approval": "parked"})
+
+ def do_GET(self): # noqa: N802
+ parts = self.path.split("/", 4)
+ if len(parts) < 4 or parts[1] != "ap":
+ return _json(self, 404, {"code": "route_unknown"})
+ caller_key, route = parts[2], parts[3]
+ if not self._check_caller(caller_key):
+ return
+ if route == "requests":
+ request_id = parts[4] if len(parts) > 4 else ""
+ return self._outcome(request_id)
+ if route != "discord" or len(parts) < 5:
+ return _json(self, 404, {"code": "route_unknown"})
+ upstream_path = "/" + parts[4].split("?")[0]
+ if CHANNEL_MESSAGES.match(upstream_path):
+ return _json(self, 200, [{
+ "id": "1570000000000000000",
+ "channel_id": upstream_path.split("/")[4],
+ "content": "canned message from the mock upstream",
+ "author": {"id": "1499476122885423204", "username": "mock-bot"},
+ }])
+ return _json(self, 404, {"code": "route_unknown"})
+
+ # -- approval outcome + test-only admin ------------------------------
+ def _outcome(self, request_id: str) -> None:
+ with _lock:
+ entry = _parked.get(request_id)
+ if entry is None:
+ return _json(self, 404, {"code": "request_unknown"})
+ if entry["status"] == "parked" and time.monotonic() > entry["expires_at"]:
+ entry["status"] = "expired"
+ payload = {"request_id": request_id, "status": entry["status"]}
+ if entry["status"] == "executed":
+ payload["result"] = entry["result"]
+ return _json(self, 200, payload)
+
+ def _admin(self, path: str) -> None:
+ bits = path.strip("/").split("/")
+ if len(bits) != 3 or bits[1] not in ("approve", "deny"):
+ return _json(self, 404, {"code": "admin_unknown"})
+ action, request_id = bits[1], bits[2]
+ with _lock:
+ entry = _parked.get(request_id)
+ if entry is None or entry["status"] != "parked":
+ return _json(self, 409, {"code": "not_parked"})
+ if action == "deny":
+ entry["status"] = "denied"
+ else:
+ entry["status"] = "executed"
+ entry["result"] = {
+ "status": 201,
+ "body": {
+ "id": "1570000000000000001",
+ "channel_id": entry["channel_id"],
+ "content": entry["body"].get("content", ""),
+ "message_reference": entry["body"].get("message_reference"),
+ "via": "mock-upstream",
+ },
+ }
+ return _json(self, 200, {"request_id": request_id, "status": entry["status"]})
+
+
+def main() -> None:
+ port = int(os.environ.get("MOCK_PORT", "0"))
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
+ print(f"MOCK_PORT={server.server_address[1]}", flush=True)
+ print(f"MOCK_CALLER_KEY={EXAMPLE_CALLER_KEY}", flush=True)
+ print(f"MOCK_SURROGATE={EXAMPLE_SURROGATE}", flush=True)
+ server.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/muse-discord/test_caller.py b/examples/muse-discord/test_caller.py
new file mode 100644
index 0000000..e07455e
--- /dev/null
+++ b/examples/muse-discord/test_caller.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Tests: caller.py against mock_proxy.py ONLY (contract sketch for #5).
+
+These tests exercise the mock, which is a contract sketch for the proposed
+#5 approval flow -- they do NOT run the caller against the real proxy and
+therefore do not verify canonicalization, policy evaluation, the session
+registry, audit or upstream forwarding in crates/. Green here means the
+caller follows the sketched contract, not that the real proxy behaves
+the same way.
+
+Covers the outcome paths of the sketched caller contract:
+
+ 1. allow-listed read executes immediately (200),
+ 2. a write parks (marked 202), the owner approves, the caller sees the
+ executed upstream response,
+ 3. a write parks, the owner denies, the caller exits 3,
+ 4. a write parks, nobody decides, the request expires, the caller exits 4,
+ 5. a bad surrogate is refused by the mock (exit 2).
+
+No reusable credential material is committed: the caller key and surrogate
+below are generated ephemerally per run with secrets.token_hex(32) and
+passed to the mock at startup via its environment.
+
+Run: python3 test_caller.py (stdlib only, binds loopback ports only)
+"""
+
+import json
+import os
+import secrets
+import subprocess
+import sys
+import time
+import urllib.request
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+CALLER_KEY = "apk_" + secrets.token_hex(32)
+SURROGATE = "aps_" + secrets.token_hex(32)
+CHANNEL = "2222222222222222222"
+
+failures = []
+
+
+def check(name: str, condition: bool, detail: str = "") -> None:
+ print(f"{'PASS' if condition else 'FAIL'} {name}" + (f" ({detail})" if detail and not condition else ""))
+ if not condition:
+ failures.append(name)
+
+
+def start_mock(ttl: str) -> tuple[subprocess.Popen, str]:
+ env = dict(os.environ, MOCK_TTL=ttl, MOCK_PORT="0",
+ MOCK_CALLER_KEY=CALLER_KEY, MOCK_SURROGATE=SURROGATE)
+ proc = subprocess.Popen(
+ [sys.executable, os.path.join(HERE, "mock_proxy.py")],
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, text=True,
+ )
+ line = proc.stdout.readline()
+ port = line.strip().split("=", 1)[1]
+ return proc, f"http://127.0.0.1:{port}"
+
+
+def run_caller(base: str, args: list[str], surrogate: str = SURROGATE,
+ wait: bool = True) -> subprocess.Popen | subprocess.CompletedProcess:
+ env = dict(os.environ, AP_BASE=base, AP_CALLER_KEY=CALLER_KEY,
+ AP_SURROGATE=surrogate, AP_POLL_INTERVAL="0.3", AP_POLL_TIMEOUT="15")
+ proc = subprocess.Popen(
+ [sys.executable, os.path.join(HERE, "caller.py"), *args],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True,
+ )
+ if wait:
+ out, err = proc.communicate(timeout=30)
+ return subprocess.CompletedProcess(args, proc.returncode, out, err)
+ return proc
+
+
+def admin(base: str, action: str, request_id: str) -> dict:
+ req = urllib.request.Request(f"{base}/_mock/{action}/{request_id}", data=b"", method="POST")
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ return json.loads(resp.read())
+
+
+def main() -> int:
+ mock_a, base_a = start_mock("60")
+ mock_b, base_b = start_mock("1.5")
+ try:
+ # 1. allow-listed read
+ res = run_caller(base_a, ["get-messages", CHANNEL, "5"])
+ check("read executes immediately", res.returncode == 0 and "canned message" in res.stdout,
+ f"rc={res.returncode} out={res.stdout!r} err={res.stderr!r}")
+
+ # 2. park -> approve -> executed
+ proc = run_caller(base_a, ["send", CHANNEL, "hello via approval", "--reply-to", "1570000000000000000"], wait=False)
+ time.sleep(1.0)
+ admin(base_a, "approve", "req_0001")
+ out, err = proc.communicate(timeout=30)
+ body = json.loads(out) if out.strip() else {}
+ check("approved write executes", proc.returncode == 0
+ and body.get("content") == "hello via approval"
+ and body.get("message_reference", {}).get("message_id") == "1570000000000000000",
+ f"rc={proc.returncode} out={out!r} err={err!r}")
+
+ # 3. park -> deny
+ proc = run_caller(base_a, ["send", CHANNEL, "never send this"], wait=False)
+ time.sleep(1.0)
+ admin(base_a, "deny", "req_0002")
+ proc.communicate(timeout=30)
+ check("denied write exits 3", proc.returncode == 3, f"rc={proc.returncode}")
+
+ # 4. park -> expire (short-TTL mock)
+ res = run_caller(base_b, ["send", CHANNEL, "too slow"])
+ check("expired write exits 4", res.returncode == 4, f"rc={res.returncode} err={res.stderr!r}")
+
+ # 5. bad surrogate refused
+ res = run_caller(base_a, ["get-messages", CHANNEL], surrogate="aps_" + "0" * 64)
+ check("bad surrogate refused (exit 2)", res.returncode == 2 and "surrogate_unknown" in res.stderr,
+ f"rc={res.returncode} err={res.stderr!r}")
+ finally:
+ mock_a.terminate()
+ mock_b.terminate()
+
+ print(f"\n{'ALL PASS' if not failures else 'FAILURES: ' + ', '.join(failures)}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())