pairing — binding a channel and a sender to an autonomy levelPairing identifies a channel+sender, not a physical person. The same family member writing to Metnos via Telegram and via Signal are two distinct, independent pairings, each with its own autonomy level. This naturally enables pairing the same person at different levels depending on the channel (Telegram = Full because the phone is always with them; mail = ReadOnly because it might have been read by someone else).
Architecture ch. 12 treats pairing as a branch of the «identity and perimeter» node: Metnos does not know who you are in any registry sense, it only knows that the pair (channel, sender_id) was associated, once, with an autonomy level, and works on that.
Three autonomy levels, one single dimension (see Architecture chapter 4 for the rationale):
ReadOnly — the sender can write, Metnos acknowledges but does not execute. Designed for occasional family members or sensitive channels.Supervised — the sender gets a complete turn, but actions with external side effects fall under control (reserved for v1.2+; today it behaves like Full as far as run_turn is concerned, but the level is already persisted).Full — the sender has full authority: Metnos executes the turn and replies.
The whole module lives in /opt/myclaw/runtime/pairing.py (354 lines). The two public types are a dataclass and an exception:
@dataclass
class Pairing:
channel: str
sender_id: str
autonomy_level: str
paired_at: str
paired_by: str
last_seen: str | None = None
revoked_at: str | None = None
class PairingError(Exception):
pass
Pairing is what queries return and what the caller inspects. PairingError wraps every error in the module (code format, signature, expiry, double consume) so that the caller can handle them with a single except. See pairing.py:64-76.
Relevant constants, in pairing.py:36-40:
| Constant | Value | Meaning |
|---|---|---|
VALID_LEVELS | ("ReadOnly", "Supervised", "Full") | Tuple of the only accepted levels. Generating a code with a level outside this tuple raises ValueError. |
CODE_PREFIX | "PAIR." | Fixed prefix of the code: it makes the string instantly recognisable (by a human eye or a regex) as a pairing code, not some other kind of token. |
DEFAULT_TTL_S | 300 | Five minutes. Lifetime of the code between issuance and consumption. Configurable per call, but deliberately short by default. |
PROTOCOL_VERSION | 1 | Payload version. Verify rejects payloads with a different v. When the structure changes (likely in v1.2+) we bump here and handle the branching. |
DEFAULT_DB_PATH | ~/.local/state/metnos/pairings.db | Default location of the registry. Override via env METNOS_PAIRINGS_DB (useful for tests). |
The code is a printable string, copyable by hand if needed. The shape:
PAIR.<base64url(payload_json)>.<base64url(signature)>
The payload is a deterministic JSON (sorted keys, separators with no spaces) with five fields:
{
"v": 1,
"id": "<uuid12>",
"autonomy": "<ReadOnly|Supervised|Full>",
"exp": <unix epoch in seconds>,
"iss": "author"
}
The signature is Ed25519 over the bytes of the serialised JSON, produced with the author key in ~/.config/metnos/keys/ (see sign.py:28-29, 58-60). Verification scans every trusted public key in that same directory (list_trusted_publics in sign.py:66-76) and accepts the code if at least one verification passes. This way an older Metnos remains able to accept codes signed by a new key as long as the new public is added to the directory.
Example of a real code (TTL 5 minutes, ReadOnly level):
PAIR.eyJhdXRvbm9teSI6IlJlYWRPbmx5IiwiZXhwIjoxNzA5OTk4ODg4LCJpZCI6IjA0YzNhYThiYjFiNCIsImlzcyI6ImF1dGhvciIsInYiOjF9.5xV3M7T...kWQ
Typical length: ~180 characters. You can copy-paste it, send it via iMessage, or read out the last eight syllables aloud.
Two tables, defined in pairing.py:43-61:
CREATE TABLE IF NOT EXISTS pairings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
sender_id TEXT NOT NULL,
autonomy_level TEXT NOT NULL,
paired_at TEXT NOT NULL,
paired_by TEXT NOT NULL,
last_seen TEXT,
revoked_at TEXT,
UNIQUE(channel, sender_id)
);
CREATE TABLE IF NOT EXISTS consumed_codes (
code_id TEXT PRIMARY KEY,
consumed_at TEXT NOT NULL,
channel TEXT NOT NULL,
sender_id TEXT NOT NULL
);
| Table | Role |
|---|---|
pairings | One row per (channel, sender_id) pair ever paired. The UNIQUE constraint guarantees a single active pairing per pair; consuming a new code performs an upsert and clears revoked_at (see pairing.py:188-197). last_seen is optional and is updated by the daemon on every message (audit). A non-empty revoked_at means the pairing is inactive. |
consumed_codes | Single-shot tracking. Every consumed code leaves a row with its code_id (the 12 hex from the payload). Replay attempts fail inside BEGIN IMMEDIATE (see pairing.py:178-186). No cleanup: the table only grows by the number of pairings done in the lifetime of the instance, negligible. |
The default file lives in ~/.local/state/metnos/pairings.db; the directory is created on first access (pairing.py:94-100). For tests we set METNOS_PAIRINGS_DB to a temporary path, getting an isolated database per test case.
Every function accepts an optional keyword-only db_path, so the caller can point at a different registry (useful for tests and for hypothetical registry-per-tenant scenarios). Without db_path, METNOS_PAIRINGS_DB is used, or the default.
| Function | Signature | What it does |
|---|---|---|
generate_code | (autonomy_level, *, ttl_seconds=300, issued_by="author") -> str | Builds the payload, signs with the key indicated by issued_by, returns the string PAIR.<...>.<...>. Raises ValueError if the level is outside VALID_LEVELS. See pairing.py:105-120. |
consume_code | (code, channel, sender_id, *, db_path=None) -> Pairing | Atomic via BEGIN IMMEDIATE (pairing.py:178-197): verifies signature, expiry, deduplicates against consumed_codes, upserts into pairings. Returns the resulting pairing or raises PairingError with a specific cause. |
get_pairing | (channel, sender_id, *, db_path=None) -> Pairing | None | Returns the active pairing (revoked_at IS NULL) or None. See pairing.py:209-221. |
is_paired | (channel, sender_id, *, db_path=None) -> bool | Boolean wrapper around get_pairing. pairing.py:224-225. |
get_autonomy | (channel, sender_id, *, db_path=None) -> str | None | Just the level, for callers who do not want the full dataclass. pairing.py:228-231. |
touch_last_seen | (channel, sender_id, *, db_path=None) -> None | Updates last_seen to the current ISO timestamp. Called by the daemon on every successful turn. pairing.py:234-244. |
list_pairings | (*, include_revoked=False, db_path=None) -> list[Pairing] | All rows, optionally including revoked ones. Order: paired_at DESC. pairing.py:247-259. |
revoke | (channel, sender_id, *, db_path=None) -> bool | Sets revoked_at on the active pairing. Returns True if it changed something, False if the pair did not exist or was already revoked. pairing.py:262-273. |
bootstrap_default_chat_id | (channel, sender_id, *, db_path=None) -> Pairing | Auto-pair Full the first time the default_chat_id writes, only if no other pairings exist for that channel. Raises PairingError otherwise. See pairing.py:276-301. |
consume_code. The important invariant is that the same code cannot be used twice, not even by two threads or two processes that receive it by coincidence at the same instant. The SQLite transaction with BEGIN IMMEDIATE serialises every concurrent attempt: the first one wins, the second finds the row in consumed_codes and fails with "code already consumed".
The module is directly executable. Commands (pairing.py:317-350):
python3 -m pairing generate <ReadOnly|Supervised|Full> [ttl=5m] python3 -m pairing consume <code> <channel> <sender_id> python3 -m pairing list [--include-revoked] python3 -m pairing revoke <channel> <sender_id>
TTL accepts three suffixes (pairing.py:306-314): 60s, 5m, 1h. Without a suffix, the value is interpreted as seconds.
# 1. Roberto generates a 10-minute ReadOnly code
$ python3 -m pairing generate ReadOnly 10m
PAIR.eyJhdXRvbm9teSI6IlJlYWRPbmx5IiwiZXhwIjox...
# 2. he passes it via iMessage to the family member
# 3. the family member writes on Telegram: /pair PAIR.eyJ...
# the daemon consumes it and replies "Paired as ReadOnly. Welcome."
# 4. Roberto verifies
$ python3 -m pairing list
{"channel": "telegram", "sender_id": "12345678", "autonomy_level": "ReadOnly", ...}
{"channel": "telegram", "sender_id": "99887766", "autonomy_level": "Full", ...}
# 5. one week later, revoke
$ python3 -m pairing revoke telegram 12345678
revoked
The daemon lives in /opt/myclaw/runtime/channels/daemon.py. The handle_message function (daemon.py:121-165) is where pairing and runtime meet. The decision sequence:
/pair (see the PAIR_COMMAND constant in daemon.py:36), the daemon goes into _handle_pair_command (daemon.py:108-119): consumes the code, replies "Paired as <level>. Welcome." on success, "Pairing failed: <reason>" otherwise. No runtime turn starts here, even if pairing succeeded: the user must send a second message to activate Metnos.pairing.get_pairing(channel.name, sender_id) is consulted. If it returns None, the daemon attempts the bootstrap (see below). If the bootstrap also fails, it replies with UNPAIRED_REPLY (daemon.py:37-40) and ends the turn.touch_last_seen is called for audit.LEVEL_BLOCKS_RUN = {"ReadOnly"} (daemon.py:41): polite reply (LEVEL_REPLY_BLOCKED, daemon.py:42-45), no run_turn.run_turn(msg.text) is called, the result is formatted, and the reply is sent on the channel.
The _try_bootstrap function (daemon.py:93-106) handles the case «Roberto installs Metnos on his new phone and writes to himself»: with no prior pairing, the first message from default_chat_id (read from credentials.env via the TelegramChannel) is auto-paired as Full. Three conditions must all be true:
bootstrap_default_sender flag is True (default; disabled with --no-bootstrap);sender_id matches the default_chat_id declared by the channel;bootstrap_default_chat_id refuses otherwise, see pairing.py:283-287).This way the bootstrap is safe: once a single pairing exists on the channel, further calls fail and the bootstrap loses its effect. Even Roberto, after pairing himself the first time, can no longer use the bootstrap to pair anybody else: family members must receive an explicit code.
run_turn
If run_turn raises, the daemon does not die: it catches the exception, logs the stack trace, replies to the sender with the message "(internal error: <type>: <text>)" and continues the loop with the next message (daemon.py:153-155). The pairing is not touched: the same user on the next message is still paired, still authorised to request more turns.
Five guarantees, each existing for a concrete threat:
| Guarantee | Protects against |
|---|---|
Ed25519 signature on the payload (pairing.py:117-120; verification in pairing.py:137-149) guarantees that the code really originates from Roberto's author key. | An attacker who writes to the daemon sending made-up codes. Even if a well-intentioned family member discovers the format, they cannot self-elevate: generating a code requires the private key, which lives only on Roberto's server. |
Short TTL by default (5 minutes, DEFAULT_TTL_S) and check in pairing.py:159-160. | A code that falls into the wrong hands after issuance (intercepted mail, screenshot sent by mistake). Five minutes is enough for a legitimate consume, too little for an opportunistic reuse hours or days later. |
Single-shot via the consumed_codes table (pairing.py:180-186). | The same code being intercepted and used by multiple senders. The first one to send it «wins»: the others receive "code already consumed". |
Immediate revoke (pairing.py:262-273) and revoked_at IS NULL check in get_pairing (pairing.py:213-216). | A lost device or a family member with whom relations have broken down. Revocation takes effect on the very next message: no persistent session has to be invalidated separately. |
| No code broadcast: Metnos never sends the code on the same channel as the pairing; it is up to Roberto to carry it out-of-band (iMessage, voice, slip of paper). | The channel itself, if compromised. A potential interception of the Telegram channel does not reveal the codes produced. |
Deliberately missing: rate-limit on failed /pair attempts (an attacker could spam made-up codes hoping for collisions, but with the Ed25519 signature space success is cosmologically improbable and every attempt leaves a log). See limits.
Cluster pairing: 9 cases, all green. They cover the nominal flows and the explicit breaks of the model.
| # | Case | What it verifies |
|---|---|---|
| 1 | generate + consume round-trip | Code created with generate_code("Full", 5m), consumed with consume_code(code, "telegram", "u1"), returns a valid Pairing with the expected fields. |
| 2 | expired code fails | TTL = -1 second: consume_code raises PairingError("code expired"). |
| 3 | double consume fails | The same code consumed a second time raises PairingError("code already consumed"). |
| 4 | tampered code fails | Replacing one byte in the payload (and re-base64), the signature does not verify and PairingError("code signature not verified") is raised. |
| 5 | invalid format fails | Arbitrary strings (no prefix, too many dots, broken base64) raise PairingError with different messages. |
| 6 | revoke makes is_paired false | After revoke, is_paired on the same pair returns False; the record persists but with revoked_at set. |
| 7 | bootstrap only on empty channel | First call: Full pairing created. Second call on the same channel (even with a different sender_id): PairingError("bootstrap refused"). |
| 8 | list_pairings filters revoked | Default include_revoked=False: only rows with revoked_at IS NULL. With True: all of them. |
| 9 | invalid level in generate | generate_code("Admin", ...) raises ValueError before signing anything. |
channels module (daemon): 9/9 with a sub-cluster of 5 cases that explicitly exercises the pairing flow between daemon and module:
UNPAIRED_REPLY, no run_turn;/pair <valid code> → reply "Paired as <level>. Welcome.", pairing written in DB, no run_turn;/pair <expired/tampered code> → reply "Pairing failed: <cause>", no pairing written;run_turn; last_seen is not touched (the update only fires after the level check).
All tests run in isolation with their own METNOS_PAIRINGS_DB in /tmp, and with an ad-hoc test keypair, so neither the registry nor the author's development keys are touched.
Five declared limits, some by deliberate design, others deferred to v1.2+:
| Limit | When it lifts |
|---|---|
Single-process SQLite registry. The file can be opened by multiple processes thanks to SQLite locks, but there is no specific tuning; the operational model assumes a single metnos-server. | When more than one process writes (e.g. Telegram daemon + mail daemon in parallel): WAL is explicitly enabled and lock timeouts are reconsidered. |
No push notification on generated pair. Roberto generates the code via CLI; the recipient discovers the outcome only when sending /pair. The «pending pair» is not visible on the daemon. | When an admin UI exists (status mail, /admin command on the channel itself). For now, list via CLI is enough. |
| No in-place upgrade of the level. To raise a family member from ReadOnly to Full you must generate a new code and have it consumed again (the upsert replaces the level). | When a CLI promote command or a specific workflow demands it. Today the overhead is minimal (one extra command) and the benefit of having a single entry point (consume with a signed code) is greater. |
| No automatic expiry of pairings. Once the code is consumed, the pairing lives on until manually revoked. | When there is a concrete need (temporary family member, weekend guest): an expires_at could be added to the pairing itself (today only the code has exp; the pairing born from it is perpetual). |
Minimal audit. paired_by contains the name of the key that signed the code (today always "author"); there is no record of who generated the codes, nor a structured log of consume_code failures (they live only in the daemon log). | When more than one trusted key exists (e.g. one key per device) and distinguishing who signs what becomes useful. code_audit and consume_audit tables will be introduced. |
The module is deliberately small (354 lines in a single file, one external dependency beyond the standard library: cryptography via sign.py). There is no interchangeable «store» abstraction layer, there is no formal protocol for the code other than JSON+base64+signature. The choices are guided by the simplicity principle (see memory feedback_simplicity_first): complexity is added when the problem demands it, not before.
Pairing is the first piece of the «perimeter» node that becomes executable: anything that relies on identifying the sender (rate-limit per level, scope of executors permitted per level, audit of who started what) rests on this primitive.