xmemcli 1.7.0__tar.gz → 1.7.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 1.7.0
3
+ Version: 1.7.7
4
4
  Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
5
  Keywords: api,cli,llm,memory,xmemory
6
6
  Requires-Python: <4,>=3.12
@@ -15,7 +15,7 @@ Full command reference is included below.
15
15
  ## Usage
16
16
 
17
17
  ```text
18
- xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
18
+ xmemcli auth login [URL] [--auto-approve] [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
19
19
  xmemcli auth logout
20
20
  xmemcli auth status
21
21
  xmemcli read <query>
@@ -37,9 +37,9 @@ xmemcli trace calls --session-id S [--stage S] [--model M] [--outcome succeeded|
37
37
  xmemcli trace call <llm_call_id>
38
38
  ```
39
39
 
40
- Global flags / env: `--api-key` / `$XMEM_API_KEY`, `--instance-id` / `$XMEM_INSTANCE_ID`, `--api-url` / `$XMEM_API_URL`, `--rc-dir` / `$XMEM_RC_DIR`, `--binding-dir` / `$XMEM_BINDING_DIR`, `--json`, `--verbose`. See `xmemcli help options`. `trace …` additionally take `--console-url` / `$XMEM_CONSOLE_URL` (the Console origin that serves them; inferred for the public and local pairs).
40
+ Global flags / env: `--api-key` / `$XMEM_API_KEY`, `--instance-id` / `$XMEM_INSTANCE_ID`, `--api-url` / `$XMEM_API_URL`, `--rc-dir` / `$XMEM_RC_DIR`, `--binding-dir` / `$XMEM_BINDING_DIR`, `--json`, `--verbose`. See `xmemcli help options`. `trace …` and `auth login` additionally take `--console-url` / `$XMEM_CONSOLE_URL` (the Console origin; inferred for the public, stg, pp and local pairs and the `/console` sibling of a `/xmem` base).
41
41
 
42
- Credential resolution: `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` (walk up from `--rc-dir` toward `$HOME`). Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default. The `mcp` transport resolves its own origin the same way: `--mcp-url` → `$XMEM_MCP_URL` → rc `mcp_url` → default, where `mcp_url` is what the API told `auth login` about itself.
42
+ Credential resolution: `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` (walk up from `--rc-dir` toward `$HOME`). Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default. The `mcp` transport resolves its own origin the same way: `--mcp-url` → `$XMEM_MCP_URL` → rc `mcp_url` → the API base's `/mcp` sibling or hosted pair (stg, pp, dk) → default, where `mcp_url` is what the API told `auth login` about itself.
43
43
 
44
44
  `context` is the exception: it runs unattended from a session-start hook, with no person and no chosen directory behind it, so it resolves the credential from `$HOME` **only** — a project `.xmemrc.json` is used only when `--api-key` / `$XMEM_API_KEY` or `--rc-dir` / `$XMEM_RC_DIR` names it explicitly. That stops a cloned repository shipping the key and origin its own context is fetched with.
45
45
 
@@ -87,37 +87,49 @@ xmemcli trace calls --session-id "$SESSION" --stage 'benchmark.*' # a session'
87
87
  xmemcli trace call "$LLM_CALL_ID" # one LLM call whole: prompt, reply, stats
88
88
  ```
89
89
 
90
- Paging is a cursor the previous page returns:
90
+ Paging is a cursor the previous page returns. Pipe a page to `jq` with `printf '%s\n'`, never `echo`: under zsh or dash `echo` rewrites backslash escapes inside the JSON. Stop on a non-zero exit: an error is never the last page.
91
91
 
92
92
  ```bash
93
- PAGE=$(xmemcli trace ls --instance "$ID" --limit 100)
93
+ PAGE=$(xmemcli trace ls --instance "$ID" --limit 100) || exit
94
94
  # `select(.has_more_operations)` so the last page yields an empty string rather than the literal `null:null`,
95
95
  # which `--cursor` refuses.
96
- NEXT=$(echo "$PAGE" | jq -r 'select(.has_more_operations) | "\(.next_before_ts_ms):\(.next_before_trace_id)"')
96
+ NEXT=$(printf '%s\n' "$PAGE" | jq -r 'select(.has_more_operations) | "\(.next_before_ts_ms):\(.next_before_trace_id)"')
97
97
  [ -n "$NEXT" ] && xmemcli trace ls --instance "$ID" --limit 100 --cursor "$NEXT"
98
98
  ```
99
99
 
100
- `--search` pages too, with the Inspect endpoint's three cursor fields — follow `has_more`, since a page can come back short:
100
+ `--search` pages too, with the Inspect endpoint's three cursor fields. Follow `has_more` until it is `false`: a page can come back short, even empty, and still have more. Each page is a full search on the Console, so walk with `--limit 500`, the Console's cap:
101
101
 
102
102
  ```bash
103
- PAGE=$(xmemcli trace ls --search --text "Ada")
104
- # `select(.has_more)` so the last page yields an empty string rather than the literal `null:null:null`,
105
- # which `--cursor` refuses.
106
- NEXT=$(echo "$PAGE" | jq -r 'select(.has_more) | "\(.next_before_sort_at_ms):\(.next_before_trace_id):\(.next_before_instance_id)"')
107
- [ -n "$NEXT" ] && xmemcli trace ls --search --text "Ada" --cursor "$NEXT"
103
+ CURSOR=
104
+ while :; do
105
+ # Any non-zero exit ends the walk without its last page: exit 3 with stage `paging_unsupported`, say, is a
106
+ # Console that ignored the cursor.
107
+ if [ -z "$CURSOR" ]; then
108
+ PAGE=$(xmemcli trace ls --search --text "Ada" --limit 500) || exit
109
+ else
110
+ PAGE=$(xmemcli trace ls --search --text "Ada" --limit 500 --cursor "$CURSOR") || exit
111
+ fi
112
+ printf '%s\n' "$PAGE" | jq -c '.merged[]'
113
+ case "$(printf '%s\n' "$PAGE" | jq -r '.has_more')" in
114
+ true) ;;
115
+ false) break ;;
116
+ *) echo "no has_more in the answer: this Console does not page" >&2; break ;;
117
+ esac
118
+ CURSOR=$(printf '%s\n' "$PAGE" | jq -r '"\(.next_before_sort_at_ms):\(.next_before_trace_id):\(.next_before_instance_id)"')
119
+ done
108
120
  ```
109
121
 
110
122
  `trace calls` pages the same way, with the same three cursor fields:
111
123
 
112
124
  ```bash
113
- PAGE=$(xmemcli trace calls --session-id "$SESSION")
114
- NEXT=$(echo "$PAGE" | jq -r 'select(.has_more) | "\(.next_before_sort_at_ms):\(.next_before_trace_id):\(.next_before_instance_id)"')
125
+ PAGE=$(xmemcli trace calls --session-id "$SESSION") || exit
126
+ NEXT=$(printf '%s\n' "$PAGE" | jq -r 'select(.has_more) | "\(.next_before_sort_at_ms):\(.next_before_trace_id):\(.next_before_instance_id)"')
115
127
  [ -n "$NEXT" ] && xmemcli trace calls --session-id "$SESSION" --cursor "$NEXT"
116
128
  ```
117
129
 
118
130
  `--include-llm` adds model-call hits to a free-text search (`--text`, optionally with `--session-id` or `--hours-back`), for a super admin's key only; any other key gets the operations it gets without the flag and no calls. The CLI refuses the flag with a clause flag or an instance, user or key scope, where the Console's search finds no model call. When a page ends on one, its `llm_call_id` is the cursor's third field, so repeat the flag on every page of that walk.
119
131
 
120
- A search with no filter shaping it is the Console's recent-activity view, which reaches back 180 days; when older operations exist, its last page says `truncated` and the CLI warns. A key sees the operations of the instances it can list — never more than its owner sees in the Console; a super admin's key sees what a super admin sees. Full reference: [](#trace--the-consoles-operation-traces).
132
+ A search with no filter shaping it is the Console's recent-activity view, which reaches back only as far as its window (currently 180 days); when older operations exist, its last page says `truncated` and the CLI warns. A key sees the operations of the instances it can list — never more than its owner sees in the Console; a super admin's key sees what a super admin sees. Full reference: [](#trace--the-consoles-operation-traces).
121
133
 
122
134
  ## Output personality — JSON vs pretty
123
135
 
@@ -163,19 +175,20 @@ xmemcli version
163
175
  xmemcli auth login
164
176
  ```
165
177
 
166
- Version: **1.7.0**.
178
+ Version: **1.7.7**.
167
179
 
168
180
 
169
181
  ---
170
182
 
171
183
  # `xmemcli` auth flow — PKCE handoff, email sign-in, and credentials
172
184
 
173
- The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may even *initiate* key acquisition, but a key is only ever issued after **one explicit human approval** — a click in the browser (PKCE handoff) or an approval of a sign-in email (`--email`). A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`, and the CLI prints only its prefix.
185
+ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may even *initiate* key acquisition, but a key is only ever issued after **one explicit human approval** — a click in the browser (PKCE handoff) or an approval of a sign-in email (`--email`). The one exception derives from such a key rather than replacing the approval: `org create key` lets a key that was already approved mint a key no wider than itself, for the same user, on one of its own clusters, deactivated with it (see below). A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`, or written to the 0600 `--out` file of `org create key`, and the CLI prints only its prefix.
174
186
 
175
187
  Two acquisition paths share `xmemcli auth login`:
176
188
 
177
189
  - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
178
190
  - **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
191
+ - **Zero-click browser handoff** (`--auto-approve`): the same PKCE handoff and the same page, opened with **`?auto_approve=1`**; the Console then shows no controls, obtains a code carrying an *intent* to mint a key named **`CLI auto-approved login (<host>, <timestamp>)`**, and navigates the tab to the loopback callback by itself; the key is created when the CLI redeems that code with its verifier. See [Zero-click handoff](#zero-click-handoff----auto-approve) below.
179
192
 
180
193
  > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
181
194
 
@@ -207,6 +220,23 @@ CLI ◄── local callback ── redeems code + verifier → writes .xmemr
207
220
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
208
221
  ```
209
222
 
223
+ ## Zero-click handoff — `--auto-approve`
224
+
225
+ `xmemcli auth login [<url>] --auto-approve` is for a scripted onboarding that must not stop for a click. The logic is the browser handoff's, step for step — verifier and challenge, an ephemeral loopback listener named in the URL, `POST /cli/handoff` for a signed code, the code on the callback, `POST /cli/token` with the verifier, the `0600` rc — with one inversion: **the key is created at the redeem, not before it**. The Console creates nothing; it asks the server for a code that lets the holder of the verifier mint a key, and only the CLI that started the login holds the verifier.
226
+
227
+ 1. The CLI opens **`/xmemcli/<challenge>?callback_url=http://127.0.0.1:<port>/callback&auto_approve=1&host=<hostname>`**. The parameter is what selects the zero-click surface; without it the same URL is the manual page. The callback is always named here, even for the legacy port the manual page would infer: this surface refuses a link without one.
228
+ 2. If the browser holds no Console session, the Console's auth wall sends the human to sign in — email code, Google, GitHub or the deployment's OIDC provider, whichever they use — and returns them to the same URL with its query intact (`?next=`). That return is for an existing account: an address signing in for the first time is sent to the Welcome page to create its account, which drops `next`, so the CLI waits out its timeout and the login must be re-run once onboarded. Nothing about the identity provider reaches the CLI; a session cookie is all the surface needs, and it waits for nothing else.
229
+ 3. With a session, the surface `POST`s `/cli/handoff` an **issue intent**: the challenge and the key name **`CLI auto-approved login (<hostname>, YYYY-MM-DDTHH:MM:SSZ)`** — the email path's `CLI email login (<hostname>, <timestamp>)` shape with the method named — and no key. The hostname travels as `&host=` on the login URL, cleaned by the same rule on both ends (letters, digits, `-._`, at most 32, else `unknown-host`).
230
+ 4. The server, cookie-authed, decides whom the code may mint for: the session's account and organisation, the permission to create a key for oneself (`api_key.create_own` or `create_all`), and the role — the one named `Default`, else the organisation's only role, else a 409 rather than a guess. It signs those ids, the name, the session's email and its authentication time into the 120-second code (`stash_intent`): the same JWT as the manual handoff's, with no `enc_key` because there is no key. **Nothing is created.**
231
+ 5. The surface **navigates** the tab (`location.replace`) to `http://127.0.0.1:<port>/callback?code=…&state=<challenge>`. That is the very request the manual page's "Finish the login in a new tab" link makes on Safari — a top-level navigation, which WebKit's mixed-content rule does not cover and Chrome's Local Network Access permission does not gate — issued by script in the same tab instead of by a click on an anchor. Same-tab navigation needs no user activation (only new windows do), so it is the one delivery that is click-free on every engine, and no fallback link is needed. `replace`, not `assign`, so Back cannot land on a page that starts another handoff.
232
+ 6. The CLI's listener takes the code, checks `state`, and `POST`s `/cli/token` with the verifier. **This is where the key is born** (`_mint_from_intent`): the server checks the challenge against the verifier and creates the key through the same service the API Keys page uses (`create_api_key`), attached to every cluster, as that user, with the session's authentication time so a revocation since the handoff is caught inside the create. What the handoff decided under the session is decided again **inside that service's transaction, with the owner's row locked**, not by the endpoint before it: the account still exists, is active, still carries the signed email (a rename since the handoff moves the revocation cutoff to the old address, which a cutoff check keyed by the current one would never consult), and still holds `api_key.create_own` or `create_all` through its role — read with the role row locked, the lock a permission removal takes (`remove_role_permission`, `update_role`), so the removal is either visible to the mint or waits for the key to exist. A check made before that lock could be overtaken by either change committing between it and the insert. A super admin, read under the same lock, needs no permission row and binds any role, as the API Keys endpoint grants one. **An intent mints exactly once**: each carries an id, the key it mints records that id in `api_keys.handoff_intent_id` under a unique partial index, and a second redeem — concurrent, or after the first key was deactivated or renamed — fails on that index inside its own insert. Rows in `api_keys` are never hard-deleted, so a consumed id stays consumed. Only that index's violation is a redeem; any other integrity failure of the insert (a role deleted underneath it) is an error, not a claim that a key exists. That second redeem is answered **409**, naming the key and its prefix (names carry no uniqueness; same-second logins from one host are named alike): the caller proved the verifier, and what it needs is the key's name to find it under API → API Keys and deactivate it if it is still active, since a stored key's plaintext cannot be served again. The CLI prints that message verbatim and stops, instead of calling the code expired. A redeem whose reply is lost after the request was sent (a read timeout or a reset, which urllib raises bare rather than as `URLError`) is sent once more with the same code to the same URL, which yields the key if the first never arrived or that 409 if it did; lost twice, the CLI stops and says a key may exist to deactivate, rather than letting the callback worker die and the login wait out its timeout. The login's deadline bounds the wait for the callback, not the redeem: a callback captured in time — the listener's handler publishes the capture the moment it stores it, so the last instant before the deadline counts — is redeemed within the redeem's own bound (two sends at the redeem timeout, plus a second) even past the deadline, since the redeem is what makes the key exist, and a redeem that outlives that bound ends the login with the same ambiguity stated, as exit 2 rather than the timeout's 5. A callback that arrives after Ctrl+C, or after a pasted key won, is not redeemed at all; a pasted key is not used while a redeem is in flight, since that redeem's key is the one that will exist; Ctrl+C during a redeem says a key may exist, with its prefix when the reply landed in time. None of this touches the login without the flag. A super admin, whose session names no organisation, is issued the key in their own. The CLI writes the rc exactly as the manual path does. Its JSON output carries `"auto_approve": true` beside `"method": "pkce"`.
233
+
234
+ The surface shows only where it is or why it stopped; every failure it can show ends with "No key was issued.", which is true by construction. The one retry a person has is to re-run the login command they started, with its own flags (a positional URL, `--api-url` and `--console-url`, a pinned `--callback-port`) and `--auto-approve`; the copyable line on that surface is the command's simplest form, not a substitute for those flags. The flag cannot combine with `--email` (exit 2): that path has no page to approve on. The CLI's done page, which the tab lands on, says "Code received" and names the terminal for the result, because it is served before the redeem; it carries `Referrer-Policy: no-referrer` as a header and a meta tag, since its URL holds the signed code and the brand stylesheet and fonts it loads come from other hosts.
235
+
236
+ **Why this needs no probe, and what remains.** The manual page probes the listener (`/permission-probe` with the `X-Xmemcli` marker) before it mints in the background, because there a key already exists when the code is handed out. This surface cannot probe — a probe from a page load has no user activation, and Chrome denies it without asking — and does not need to: an authored `?auto_approve=1` link opened by a signed-in person delivers a code to a port on **their own** loopback, and unless something there redeems it with the matching verifier, nothing is persisted. A closed port, a browser error page, a link opened twice: none of them leaves a key behind. What the code exposes if captured is ids, a key name, an email and a timestamp, for 120 seconds, redeemable only with the verifier that matches its challenge.
237
+
238
+ The residual is the authored link itself: there the verifier belongs to whoever wrote the link, so anything able to read that loopback port on the victim's machine — another account's process on a shared box, a port forwarded in with `ssh -L` — could relay the code to the author, who redeems it for a key on the victim's account. This is **accepted**: a process on the victim's loopback already implies code execution as them, the same line the manual page's probe draws (`cli_handoff.js`), and the key such a relay yields is named for its origin and revocable on the API Keys page. The honest guard against it would be one click, which is what the flag exists to remove.
239
+
210
240
  ## Email sign-in — `--email`
211
241
 
212
242
  `xmemcli auth login --email <addr> [--org-name <name>]` acquires the key with **one human action and no browser on this machine** — the flow an agent-driven IDE uses. The CLI takes the waiting-device role of the Console's cross-device email sign-in; every endpoint below already served the Console before this flag existed. **The fully headless path requires the server to have cross-device hand-off enabled**; a server without it only offers the typed six-digit code, so a run with no interactive terminal is refused there (after cancelling the attempt it had to start to find out).
@@ -217,7 +247,9 @@ AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never hand
217
247
  4. With the session, the CLI runs the Console onboarding's core sequence: `GET /api/check-user`; when the address has no account, `GET /api/suggest-slug` (server-derived org name, `--org-name` overrides; ignored for an existing account) and `POST /api/public-signup` — **note that creating a new organisation requires the deployment to allow public registration, which the hosted service currently does not**: an existing account gets a key, a new address gets the server's "organisation creation is not available" refusal (the Console's admin-fallback signup arm is not implemented here); then `GET /api/accounts/users/by-email`, `GET /api/accounts/api-roles` (the seeded default role, or the org's only role — an ambiguous set is refused, never guessed), and `POST /api/accounts/api-keys` with **no cluster list** — the key attaches to every cluster the org has at mint time. Clusters created later are attached by a server-side backfill that repairs only keys already linked to every other cluster in the org; an org with no clusters at mint time gets a key attached to nothing. The key is named **`CLI email login (<hostname>, <timestamp>)`** so every mint stays individually identifiable and revocable on the Console's API Keys page.
218
248
  5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
219
249
 
220
- The wait is bounded by `--timeout` — 600 seconds for this path by default, which is also a **ceiling**: nothing stays claimable past the server's pending window, so larger values are clamped. The emailed one-click link and six-digit code themselves expire at about five minutes; the wait continues past that because an approval given in time stays claimable, and the terminal says when the emailed artifacts have lapsed. The Console origin must be `https` (or a loopback host, for local development). The Console must also match the API origin the key will be used against: with a non-default `--api-url` the CLI only infers a Console where the layout determines one — a single-origin `/xmem` base names its `/console` sibling on the same host, and that sibling beats `$XMEM_CONSOLE_URL` on the same call — and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url`; `$XMEM_CONSOLE_URL` only when `--api-url` was not given). Because that inference exists, a single-origin deployment's rendered sign-in command names the API alone; every other layout still renders the `--api-url`/`--console-url` pair, since naming one without the other is worse than naming neither (`api/agent_setup_renderer.py`), and an explicitly named Console that provably mismatches the API origin (mixed loopback-ness, or a default-production half paired with a non-default one) is refused rather than trusted.
250
+ That login key is attached to every cluster the org has at mint time. `org create key --name … --role … --cluster … --out FILE` later mints a second key, with the key already in use, bound to exactly one of those clusters and to a role no wider than the one in use. The owner must still hold `api_key.create_own` or `create_all`. The server accepts this only from the release that lets a key mint keys, which follows the release that taught every node to contain a key-minted key; before that it answers 403, and the CLI says so. The plaintext goes only to `FILE`, created 0600 before the mint and never over an existing file; stdout carries the prefix and the path, and nothing is written to `.xmemrc.json`. The new key records the key that minted it: it expires no later than that key, keeps its one cluster, and is deactivated when that key is. Login itself is unchanged.
251
+
252
+ The wait is bounded by `--timeout` — 600 seconds for this path by default, which is also a **ceiling**: nothing stays claimable past the server's pending window, so larger values are clamped. The emailed one-click link and six-digit code themselves expire at about five minutes; the wait continues past that because an approval given in time stays claimable, and the terminal says when the emailed artifacts have lapsed. The Console origin must be `https` (or a loopback host, for local development). The Console must also match the API origin the key will be used against: with a non-default `--api-url` the CLI only infers a Console where the layout determines one — a single-origin `/xmem` base names its `/console` sibling on the same host, and that sibling beats `$XMEM_CONSOLE_URL` on the same call — a hosted deployment this client knows (`api.stg` / `api.pp.xmemory.ai`) names its `console.` host, and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url`; `$XMEM_CONSOLE_URL` only when `--api-url` was not given). Because that inference exists, a single-origin deployment's rendered sign-in command names the API alone; every other layout still renders the `--api-url`/`--console-url` pair, since naming one without the other is worse than naming neither (`api/agent_setup_renderer.py`), and an explicitly named Console that provably mismatches the API origin (mixed loopback-ness, a default-production half paired with a non-default one, or a stg / pp half paired with anything but its own origin; a single-origin `/console` beside its own `/xmem` on one origin is one deployment, even on a stg or pp host, while the production halves are still held to their defaults) is refused rather than trusted.
221
253
 
222
254
  When the server offers no matching code on the request reply (cross-device hand-off disabled, no public Console origin configured, or a transient degradation), there is nothing for a headless starter to claim — the emailed link then signs in the *approving* browser — so the CLI falls back to the emailed **six-digit code typed into the terminal** (`POST /api/kingdom-signin-verify`), still a single human action. That fallback needs an interactive stdin; a non-interactive run cancels the attempt it just started (the mode is only known from the server's reply, after the email is on its way) and refuses with instructions — the typed code dies with that cancel, but on such a server the one-click link has nothing durable to revoke and stays redeemable in a browser until it expires (about five minutes), which the terminal states plainly.
223
255
 
@@ -231,7 +263,7 @@ Those three keys are required and must be strings. Anything else in the file is
231
263
 
232
264
  Since 1.1.0 the file may also carry **`mcp_url`** — the MCP resource the API reported for itself when the key was issued, written only when the server names one. It is what lets `xmemcli mcp` reach an on-premise MCP host: that host is in no table the client was built with, and the rc is the one anchor a cloned `.mcp.json` does not control. A blank or non-string value is read as absent. It outranks the single-origin sibling inference below — a deployment naming itself beats a shape we derived — and both beat the built-in default. The mount path is part of the destination: `https://host/mcp` authorises that mount, not the sibling services beside it — and the instance id is canonicalised as a UUID before it is appended, so nothing following the mount can walk back out of it. Every lookup that reads `mcp_url` refuses redirects rather than merely dropping the credential across them, because what it learns is a host the key is later sent to. Every mutation of the file — write, in-place update, and logout's delete — is serialised under an exclusive lock on its containing directory, and an update is additionally bound to the identity *and* origin it was decided from. What protects an in-place update is that comparison, which is portable; the lock narrows what remains — the moment between comparing and replacing — where the platform offers one. A failed record is a warning, a failed de-authorisation fails the command. The enrichment write after login is conditional on the same comparison, so logging out or signing in again while it is in flight is not undone by it.
233
265
 
234
- Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin. A base URL's path is preserved, never stripped — on a single-origin deployment the API lives at `https://host/xmem`, and that mount is part of every request the CLI builds; query, fragment, and userinfo on a base URL refuse normalisation rather than being stripped, so sibling inference cannot run on a shape the caller did not supply. Console resolution for login: `--console-url` → Console pinned by a positional (or mistaken `--api-url`) single-origin URL → `$XMEM_CONSOLE_URL` when `--api-url` was *not* given → sibling inference from the call's `--api-url` when it applies → sibling inference from `$XMEM_API_URL` → default. An explicit `--api-url` suppresses `$XMEM_CONSOLE_URL` for that invocation (a five-host `--api-url` without `--console-url` is a usage error, not a fall-through to the env Console). A named API origin from `$XMEM_API_URL` is held to the same infer-or-refuse rule, so an env-only non-default API without a Console cannot mint at the hosted Console and record that key against the foreign API; an inferred sibling that is remote HTTP is refused under the same HTTPS-or-loopback rule as a positional login URL. An explicitly named Console (flag, env, or pinned) is also cross-checked against the API origin on this path — the browser redeem would fail safe on a wrong Console, but the interactive paste fallback presents the pasted key to it as a bearer — and a provably mismatched pair (mixed loopback-ness, or a production half paired with a non-production one) is refused before either worker starts, in the same words as the email sign-in. The same resolved Console is passed to the post-login `/whoami` that records `mcp_url`, so a leftover `$XMEM_CONSOLE_URL` cannot steer that lookup either. When `--api-url` is present, `$XMEM_API_URL` is not consulted for Console inference. Because a mounted base cannot also serve the `/api`-prefixed spelling of a route, the compatibility candidate in `whoami_urls` / `token_redeem_urls` (`{base}/api/whoami`, `{base}/api/cli/token` — a root-served deployment's second form) is offered only for a path-less base. The MCP bridge resolves its endpoint as `--mcp-url` → `$XMEM_MCP_URL` → the `/mcp` sibling of the effective API base (`--api-url` → `$XMEM_API_URL` → rc → default) → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
266
+ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin. A base URL's path is preserved, never stripped — on a single-origin deployment the API lives at `https://host/xmem`, and that mount is part of every request the CLI builds; query, fragment, and userinfo on a base URL refuse normalisation rather than being stripped, so sibling inference cannot run on a shape the caller did not supply. Console resolution for login: `--console-url` → Console pinned by a positional (or mistaken `--api-url`) single-origin URL → `$XMEM_CONSOLE_URL` when `--api-url` was *not* given → sibling or known-deployment inference from the call's API base (`--api-url` → `$XMEM_API_URL` → rc `api_url`) when it applies → the production Console only for the exact production API base; anything else is refused. The rc is read for the API origin only when no API is named on the call, and a file that cannot be read or decoded names no origin. An explicit `--api-url` suppresses `$XMEM_CONSOLE_URL` for that invocation (a five-host `--api-url` without `--console-url` is a usage error, not a fall-through to the env Console). A named API origin from `$XMEM_API_URL` or the rc is held to the same infer-or-refuse rule (a refusal for an rc origin names the file and how to sign in to production instead), so an env-only non-default API without a Console cannot mint at the hosted Console and record that key against the foreign API; an inferred sibling that is remote HTTP is refused under the same HTTPS-or-loopback rule as a positional login URL. An explicitly named Console (flag, env, or pinned) is also cross-checked against the API origin on this path — the browser redeem goes to that same Console, so a wrong one would mint the key, and the interactive paste fallback presents the pasted key to it as a bearer — and a provably mismatched pair (mixed loopback-ness, a production half paired with a non-production one, or a stg / pp half paired with anything but its own origin; a single-origin `/console` beside its own `/xmem` on one origin is one deployment, even on a stg or pp host, while the production halves are still held to their defaults) is refused before either worker starts, in the same words as the email sign-in. When the API comes from the rc: an origin that is neither https nor loopback (`localhost`, `127.0.0.1`, `[::1]`) is refused (naming a bare origin with `--api-url` beside an https `--console-url` accepts it), and if that API names its own Console (a `/xmem` base its `/console`, a local `:8000` its `:8002`, stg / pp their table Console), a named Console must be that one, in any spelling of that origin (case, a default port, trailing dots on a named host, `localhost` / `127.0.0.1` / `[::1]` for a loopback one) but on the same mount — any other is refused (exit **2**), so a `$XMEM_CONSOLE_URL` exported for one deployment cannot mint a key into the rc of a deployment whose Console is known. Naming the API on the call makes the pair the caller's: `--api-url` with the Console in `--console-url`, or `$XMEM_API_URL` with it in `--console-url` or `$XMEM_CONSOLE_URL`. `auth status`, `trace` and the `org` permission probe hold a named Console to the same rule while the call names no API: the first two exit **2**, saying how to keep that Console, and the probe declines to send the key and reports the original 403. There, naming the API on the call (`--api-url` or `$XMEM_API_URL`, which must match the rc's origin the key is bound to) lifts the rule, and the Console still comes from `--console-url` (`trace`) or `$XMEM_CONSOLE_URL` — unlike login, `--api-url` does not turn `$XMEM_CONSOLE_URL` off for these. The same resolved Console is passed to the post-login `/whoami` that records `mcp_url`, so a leftover `$XMEM_CONSOLE_URL` cannot steer that lookup either. When `--api-url` is present, `$XMEM_API_URL` is not consulted for Console inference. Because a mounted base cannot also serve the `/api`-prefixed spelling of a route, the compatibility candidate in `whoami_urls` / `token_redeem_urls` (`{base}/api/whoami`, `{base}/api/cli/token` — a root-served deployment's second form) is offered only for a path-less base. The MCP bridge resolves its endpoint as `--mcp-url` → `$XMEM_MCP_URL` → the rc's `mcp_url` → the `/mcp` sibling or hosted pair (stg, pp, dk; `_login.HOSTED_DEPLOYMENTS`) of the effective API base (`--api-url` → `$XMEM_API_URL` → rc → default) → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
235
267
 
236
268
  ## Identity — `/whoami`
237
269
 
@@ -243,11 +275,12 @@ The `AuthApiKey` body also carries **`mcp_url`**, this deployment's own MCP orig
243
275
 
244
276
  | Threat | Mitigation |
245
277
  | --- | --- |
246
- | Key in AI transcript | localhost callback / in-process mint → `0600` store; login stdout shows `key_prefix` only |
278
+ | Key in AI transcript | localhost callback / in-process mint → `0600` store; login stdout shows `key_prefix` only; `org create key` writes its key only to a new `0600` `--out` file and prints the prefix and path |
247
279
  | Key in URL | URL carries code only; key over TLS on handoff/token |
248
280
  | Captured code replay | PKCE verifier never leaves CLI; code is TTL-bounded |
249
281
  | CSRF on callback | state = PKCE challenge; redeem requires verifier |
250
282
  | Wrong Console page | CLI button only on `/xmemcli/<challenge>` |
283
+ | Authored `/xmemcli/…?auto_approve=1` link opened while signed in | the zero-click surface creates no key: it obtains a code carrying an *intent* and navigates to the person's **own** loopback; the key is minted only when `/cli/token` is redeemed with the matching verifier, and an intent mints exactly once (unique index on `api_keys.handoff_intent_id`). A closed port or a repeated link persists nothing. **Accepted residual:** the link's author holds that verifier, so a process able to read the victim's loopback port could relay the code to them — the same local-code-execution line the manual page's probe draws; the resulting key is named for its origin and revocable |
251
284
  | Session token outliving the login | the email-path session cookie is held in process memory only — never printed, never on disk — and is discarded when the process exits; the token itself stays server-valid until its ~2-day expiry, so the containment is the CLI never letting it out of memory, not revocation |
252
285
  | Session cookie over cleartext | the email path refuses non-`https` Console origins (loopback excepted), refuses every redirect on its cookie-carrying requests, and fails fast when a tunnelled Console marks its cookie `Secure` for an `http://` origin |
253
286
  | Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
@@ -263,9 +296,9 @@ Each login binds its own port and names it in the login URL, so concurrent login
263
296
 
264
297
  # `xmemcli` — capabilities reference
265
298
 
266
- **Version:** `1.7.0` (see `xmemcli version`)
299
+ **Version:** `1.7.7` (see `xmemcli version`)
267
300
 
268
- `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
301
+ `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff), an approval of a sign-in email (`--email`), or, with `--auto-approve`, the Console session the human already holds in that browser (signed in before, or right then through the Console's own email, Google, GitHub or OIDC sign-in). The one exception derives from such a key rather than replacing the approval: `org create key` lets a key that was already approved mint a key no wider than itself, for the same user, on one of its own clusters, deactivated with it. Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
269
302
 
270
303
  ```bash
271
304
  xmemcli … # installed CLI
@@ -281,7 +314,7 @@ Every command below has dedicated help — see [Help](#help).
281
314
 
282
315
  | Command | Description |
283
316
  |---------|-------------|
284
- | **`write`** `[text]` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted; by default new objects and links to them are allowed and anything else fails, and under `--scope-mode drop` only the objects a `--scope` names may be created and everything else is skipped; works with either extraction mode. `--scope-mode drop` (needs `--scope`) skips out-of-scope changes instead of failing and lists them under `changes.skipped_out_of_scope` (a server change, not a CLI one: past 100 entries the server folds the rest into lines shown with `folded=true` and no key or fields); it creates only records a `--scope` names by primary key, and such a record need not be stored yet — create-or-update exactly the named record. `reject` is the server default and is not sent. Instead of text, a **structured write** sends `structured_mutations` — deterministic creates, updates and deletes with no extraction: `--mutations-file PATH` or `--mutations-stdin` (one JSON array, same shape as the API's `structured_mutations`; see [Structured writes](#structured-writes)) and/or `--delete TYPE:ID` (repeatable, `--scope` syntax: `Person:<xuid>` or `Person:name=Ada`; appended after the file's entries). Text and mutations are exclusive, as are the file and stdin forms; mutations refuse `--scope` and `--extraction deep`; empty or whitespace-only text is refused (exit **2**, before anything is read or sent). A text write's output lists what it extracted as `Type(identifier)` tags (`extracted`); a structured write's does not. Both carry the server's `changes` block (`created` / `updated` with per-field `old_value` → `new_value` / `deleted`, and any other bucket such as `created_keyless_objects`) whenever the write changed something, and on a drop-mode write that skipped everything, where the block carries `skipped_out_of_scope` alone. A `--no-wait` answer carries only the `write_id`; `write-status` reports the `changes` once the write completes. |
317
+ | **`write`** `[text]` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted; by default new objects and links to them are allowed and anything else fails, and under `--scope-mode drop` only the objects a `--scope` names may be created and everything else is skipped; works with either extraction mode. `--scope-mode drop` (needs `--scope`) skips out-of-scope changes instead of failing and lists them under `changes.skipped_out_of_scope` (a server change, not a CLI one: past 100 entries the server folds the rest into lines shown with `folded=true` and no key or fields); it creates only records a `--scope` names by primary key, and such a record need not be stored yet — create-or-update exactly the named record. `reject` is the server default and is not sent. Instead of text, a **structured write** sends `structured_mutations` — deterministic creates, updates and deletes with no extraction: `--mutations-file PATH` or `--mutations-stdin` (one JSON array, same shape as the API's `structured_mutations`; see [Structured writes](#structured-writes)) and/or `--delete TYPE:ID` (repeatable, `--scope` syntax: `Person:<xuid>` or `Person:name=Ada`; appended after the file's entries). Text and mutations are exclusive, as are the file and stdin forms; mutations refuse `--scope` and `--extraction deep`; empty or whitespace-only text is refused (exit **2**, before anything is read or sent). A text write's output lists what it extracted as `Type(identifier)` tags (`extracted`); a structured write's does not. Both carry the server's `changes` block (`created` / `updated` with per-field `old_value` → `new_value` / `deleted`, and any other bucket such as `created_keyless_objects`) whenever the write changed something, and on a drop-mode write that skipped everything, where the block carries `skipped_out_of_scope` alone; a scoped write that left a scoped record untouched carries it too, with `untouched_scope_records`. A `--no-wait` answer carries only the `write_id`; `write-status` reports the `changes` once the write completes. |
285
318
  | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. A completed write's non-empty `changes` block is kept, as on `write`. |
286
319
  | **`read`** `<query>` | Read from active instance. `--read-mode single\|raw\|xresponse`, `--scope TYPE:ID` (repeatable), `--scope-relations`, `--related-types` (also return `related_types`: per object type the read touched, the fields it did not return and the object types a declared relation links it to; needs `instance.get_own` on the key), `--related-types-depth N` (1, 2 or 3, refused as a usage error otherwise; follow the relations N levels out, each catalog entry saying its distance; implies `--related-types`). |
287
320
 
@@ -294,7 +327,7 @@ Requires **`--instance-id`** or **`$XMEM_INSTANCE_ID`** for read/write (or auto-
294
327
  - **`object_mutation`** — `{"object_mutation": {"object_type": "Person", "create": {"key": {"name": "Ada"}, "values": {"age": 36}}}}`. `update` takes the same `key` + `values` (the key may be `{"xuid": "…"}`; a null value clears a field); `delete` takes `{"key": {…}}` alone.
295
328
  - **`relation_mutation`** — `{"relation_mutation": {"relation_type": "works_at", "create": {"endpoints": [{"object_name": "person", "key": {"name": "Ada"}}, {"object_name": "company", "key": {"name": "ACME"}}]}}}`. `create` lists every participant role, each keyed by primary key or `{"xuid": "…"}`, with no `key` and no `values`. `delete` takes either `endpoints` (a subset may be given) or a top-level `"key": {"xuid": "…"}` — never both — and `"allow_bulk_delete": true` to remove every matched link (without it, a delete matching more than one link is refused). Relation `update` is always refused: re-link by deleting and creating.
296
329
 
297
- `--delete TYPE:ID` appends the object `delete` above; each key field may appear once and none may be empty. A delete that matches no record is a no-op, not an error: exit **0** and no `deleted` line — in a terminal the write reads `write <id> ok · nothing matched or changed`.
330
+ `--delete TYPE:ID` appends the object `delete` above; each key field may appear once and none may be empty. A delete that matches no record is not an error: exit **0** and no `deleted` line. The server lists it under `changes.unmatched_deletes`, which a terminal prints as `note: unmatched_deletes Task task_id='T-999'`; a server that predates that bucket reports nothing, and the write then reads `write <id> ok · no changes reported`.
298
331
 
299
332
  Every current server accepts structured writes; an older one that had them switched off (`execution.structured_writes_enabled`, since removed) answers **400** `Structured writes are not enabled on this deployment.`; a server that predates structured writes answers **422** with two errors, both reported: `Field required (field: text); Extra inputs are not permitted (field: structured_mutations)`. Both exit **3** with `stage: "request"`.
300
333
 
@@ -309,6 +342,8 @@ Permission-filtered view of what this API key can see.
309
342
  | **`org list clusters`** | Clusters visible to this credential |
310
343
  | **`org list instances`** | Instances visible to this credential, as a bare array of records. A key minted with `data.read` but not `instance.get_own` (the supported shape for `trace …`) gets the ids only — the same set `trace ls` is scoped to — as a **degraded document** rather than an array: `{"items": [{"id": …}, …], "degraded": true, "missing_permission": "instance.get_own"}`, with the warning on stderr and in the document's `warnings`. The shape changes so a script cannot read an ids-only listing as a complete one; an array is never wrapped just to carry a warning. |
311
344
  | **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
345
+ | **`org key`** `[name-or-prefix]` | One key's role, its effective permission codes (the role plus a super-admin widening), and the clusters it is bound to when this credential can name every one of them. If it cannot, that field is unavailable, not a shorter list and not an empty one. A missing field is unavailable, not an empty grant. With no argument, the key this command is using, which the server marks in the list (`is_caller`); a server that predates that mark is matched by the stored prefix, and two keys that share it ask for a name. A selector is a name or a prefix of the stored prefix; a longer value is not a key, and an error keeps only the first eight characters of an `xmem_` selector. |
346
+ | **`org create key`** `--name` `--role` `--cluster` `--out` | Mint a key for this user bound to exactly one cluster this key can already see. `--cluster` is a name or an id; `--role` is the role's name (`org key` shows the role of each key you hold), or its id when two roles share the name. The new role cannot grant a permission this key lacks, and the owner must hold `api_key.create_own` or `create_all`. The key is written only to `--out`, a new file created 0600 (an existing file is refused); output carries `key_prefix` and `key_file`, never the key, and nothing goes to `.xmemrc.json`. The new key keeps that one cluster (clusters created later are not added), expires no later than this key, and is deactivated when this key is, or unlinked from the cluster when this key is. To contain a leaked key, deactivate it in the Console: what it minted goes with it. A lost reply or a 5xx says a key may still exist; a 404 means `--cluster` is not linked to this key; a 422 means the server predates this command, which needs a server from the same release as this CLI; a bare 403 means the server does not let a key create keys yet (that arrives in a later server release than this CLI). |
312
347
 
313
348
  ---
314
349
 
@@ -506,31 +541,31 @@ learn; the CLI composes the Console's search clauses itself.
506
541
 
507
542
  | Command | Console view | Description |
508
543
  |---------|--------------|-------------|
509
- | **`trace ls`** | Operations tab / Home | Operations, newest first, across every instance this credential can see (nothing given — the Home view), or the `--instance ID` given (repeatable, or a comma list; default `--instance-id` / `$XMEM_INSTANCE_ID`). Returns `operations[]`, `counts`, `instance_counts`, `series`, and the paging cursor `next_before_ts_ms` + `next_before_trace_id` (with `has_more_operations`). Flags: `--trace-type T` (repeatable / comma; `write`, `read`, `create`, `update`, `evolution` — `delete` is `--search` only), `--hours-back N` (server default 24, `0` = unbounded) or `--start-ts` / `--end-ts` (epoch seconds), `--user-id`, `--api-key-id`, `--include-console-reads`, `--bucket-ms`, `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID` (the previous page's two halves, joined with a colon), `--counts-only` (`ops_limit=0`: what Home fetches — counters and series, no rows; refused together with `--limit` / `--cursor`, which would have nothing to page). |
510
- | **`trace ls --search`** | Inspect | The Inspect search, for the filters only it can answer: `--text` (free text), `--request` / `--response` (text on either side of the operation), `--object NAME` / `--field NAME` (repeatable; all must match), `--status S` (repeatable / comma; any may match: `ok`, `error`, `stale`, `timed_out`, `internal_error`, `quota_exceeded`, `in_progress`, `schema-gap`), `--trace-id ID` (repeatable / comma), `--session-id`; plus `--instance` (at most one — the Inspect scope), `--trace-type` (also `delete`), `--hours-back` (a floor applied to the hits *after* the search — Inspect's own recency windows decide the candidate pool, so this narrows a result, unlike the query window it is on `trace ls`), `--user-id`, `--api-key-id`, `--include-llm` (1.7.0: also model-call hits, in a free-text search — see below), `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID:INSTANCE_ID` (the previous page's three cursor fields, joined with colons). Returns the Inspect document: `merged[]` hits with `kind`, `href` (`/write/{trace}`, `/read/{trace}`, `/create/…`, `/update/…`, `/evolution/…`, or `#` for an instance deletion, which has no detail page), `trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id` / `api_key_name` / `api_key_prefix`, `status`, `time_taken_ms`, `match_items`; with `--include-llm`, for a super admin, model-call hits too: `kind: llm_call`, `trace_id` (the operation that made the call), `llm_call_id`, `llm_operation_type`, `href` (that operation's pane, or `#`), and `llm_sub_stage`, `llm_provider` and `session_id` when the call recorded them (the first two from a Console of 2026-10-08 or later); no `instance_id` and no `status`. Open one with `trace call <llm_call_id>`. Then the paging fields `has_more`, `next_before_sort_at_ms`, `next_before_trace_id`, `next_before_instance_id`, `truncated`. |
511
- | **`trace calls`** | — (no Console view) | The LLM calls of one session, newest first, a page at a time. **Super admins only**: any other credential gets 403 → exit **3**, stage `permission`. `--session-id` (required), `--stage S` (exact, or a prefix with a trailing `*`, e.g. `benchmark.*`), `--model M` (the model slot, or the model that served the call), `--outcome` (`succeeded`, `failed` — an error that is not a refusal — or `refused`), `--hours-back N` (server default 720, i.e. 30 days; `0` = everything kept), `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID:INSTANCE_ID` (the previous page's three cursor fields: the call time, its operation and its call id). Returns `llm_calls[]` — per call `llm_call_id`, `operation_id`, `operation_type`, `instance_id`, `session_id`, `sub_stage`, `model_type`, `model_id`, `provider`, `provider_type`, `called_at` / `called_at_ms`, `time_taken_ms`, token counts, `is_success`, `is_refusal`, `is_cached`, `error_message`, `finish_reason`, `gateway_request_id`, `client_attempts`, `gateway_repairs`, `reissue_of_llm_call_id`, `reissue_reason`, the requested settings, the applied ones (`applied_temperature`, `applied_thinking`, `applied_effort`) and the prompt-cache counts (`cache_read_tokens`, `cache_write_tokens`); never the prompt or the reply (an `error_message` may quote the gateway's detail about a reply) — and the paging fields `has_more`, `next_before_sort_at_ms`, `next_before_trace_id`, `next_before_instance_id`, `truncated`. |
544
+ | **`trace ls`** | Operations tab / Home | Operations, newest first, across every instance this credential can see (nothing given — the Home view), or the `--instance ID` given (repeatable, or a comma list; default `--instance-id` / `$XMEM_INSTANCE_ID`). Returns `operations[]`, `counts`, `instance_counts`, `series`, and the paging cursor `next_before_ts_ms` + `next_before_trace_id` (with `has_more_operations`). Flags: `--trace-type T` (repeatable / comma; `write`, `read`, `create`, `update`, `evolution` — `delete` is `--search` only), `--hours-back N` (24 when not given, the Console's default for this list; `0` = unbounded) or `--start-ts` / `--end-ts` (epoch seconds), `--user-id`, `--api-key-id`, `--include-console-reads`, `--bucket-ms`, `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID` (the previous page's two halves, joined with a colon), `--counts-only` (`ops_limit=0`: what Home fetches — counters and series, no rows; refused together with `--limit` / `--cursor`, which would have nothing to page). |
545
+ | **`trace ls --search`** | Inspect | The Inspect search, for the filters only it can answer: `--text` (free text), `--request` / `--response` (text on either side of the operation), `--object NAME` / `--field NAME` (repeatable; all must match), `--status S` (repeatable / comma; any may match: `ok`, `error`, `stale`, `timed_out`, `internal_error`, `quota_exceeded`, `in_progress`, `schema-gap`), `--trace-id ID` (repeatable / comma), `--session-id`; plus `--instance` (at most one — the Inspect scope), `--trace-type` (also `delete`), `--hours-back` (a floor applied to the hits *after* the search — Inspect's own recency windows decide the candidate pool, so this narrows a result, unlike the query window it is on `trace ls`), `--user-id`, `--api-key-id`, `--include-llm` (1.7.0: also model-call hits, in a free-text search — see below), `--limit N` (default 100, Console cap 500; a page can hold a few more rows than that when hits tie on time, trace id and instance id with its last one, and fewer when the Console narrows it), `--cursor TS_MS:TRACE_ID:INSTANCE_ID` (the previous page's three cursor fields, joined with colons). Returns the Inspect document: `merged[]` hits with `kind`, `href` (`/write/{trace}`, `/read/{trace}`, `/create/…`, `/update/…`, `/evolution/…`, or `#` for an instance deletion, which has no detail page), `trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id` / `api_key_name` / `api_key_prefix`, `status`, `quota_kind` (a `quota_exceeded` write or read only: `daily_quota_exceeded` or `monthly_quota_exceeded`; absent from an older Console), `time_taken_ms`, `match_items`; with `--include-llm`, for a super admin, model-call hits too: `kind: llm_call`, `trace_id` (the operation that made the call), `llm_call_id`, `llm_operation_type`, `href` (that operation's pane, or `#`), and `llm_sub_stage`, `llm_provider` and `session_id` when the call recorded them (the first two from a Console of 2026-10-08 or later); no `instance_id` and no `status`. Open one with `trace call <llm_call_id>`. Then the paging fields `has_more`, `next_before_sort_at_ms`, `next_before_trace_id`, `next_before_instance_id`, `truncated`. |
546
+ | **`trace calls`** | — (no Console view) | The LLM calls of one session, newest first, a page at a time. **Super admins only**: any other credential gets 403 → exit **3**, stage `permission`. `--session-id` (required), `--stage S` (exact, or a prefix with a trailing `*`, e.g. `benchmark.*`), `--model M` (the model slot, or the model that served the call), `--outcome` (`succeeded`, `failed` — an error that is not a refusal — or `refused`), `--hours-back N` (server default 720, i.e. 30 days; `0` = everything kept), `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID:INSTANCE_ID` (the previous page's three cursor fields: the call time, its operation and its call id). Returns `llm_calls[]` — per call `llm_call_id`, `operation_id`, `operation_type`, `instance_id`, `session_id`, `sub_stage`, `model_type`, `model_id`, `provider`, `provider_type`, `called_at` / `called_at_ms`, `time_taken_ms`, token counts, `is_success`, `is_refusal`, `is_cached`, `error_message`, `finish_reason`, `gateway_request_id`, `client_attempts`, `gateway_repairs`, `reissue_of_llm_call_id`, `reissue_reason`, the requested settings, the applied ones (`applied_temperature`, `applied_thinking`, `applied_effort`), the prompt-cache counts (`cache_read_tokens`, `cache_write_tokens`) and the prompt renderer (`prompt_family`, `prompt_generator`, `prompt_schema_provider`, `prompt_version`); never the prompt or the reply (an `error_message` may quote the gateway's detail about a reply) — and the paging fields `has_more`, `next_before_sort_at_ms`, `next_before_trace_id`, `next_before_instance_id`, `truncated`. |
512
547
  | **`trace call`** `<llm_call_id>` | — (no Console view) | One LLM call whole, as `{"found": true, "llm_call": {…}}` (so `jq .llm_call.prompt`): the same fields as a `trace calls` row plus `prompt`, `json_schema`, `result`, `raw_result` (the reply as the model wrote it, when the gateway repaired it) and `rejected_attempts`. It does not list the calls that re-issued this one (`trace get` on the operation shows those as `reissued_as_llm_call_ids`). If the call's stats never landed, `llm_call` carries only `llm_call_id`, `operation_id` and those logged fields. **Super admins only**, as above. An unknown id — the Console's `{"found": false}` — is `stage: "not_found"`, exit **3**; so is a listed call whose logged half (prompt and reply) never landed. |
513
- | **`trace get`** `<trace_id>` | Detail pane | One operation's full document. `--trace-type` (`operation_type` on a `trace ls` row; the first path segment of a `--search` hit's `href`) makes it one request; without it the five detail endpoints are probed in order `write, read, create, update, evolution` and the first that knows the trace answers. A deletion's `DL0000_aaa` id is answered without a request: it has no detail page. A miss — the Console's `{"found": false}`, or its 404 for a trace outside your scope — is `stage: "not_found"`, exit **3**. |
548
+ | **`trace get`** `<trace_id>` | Detail pane | One operation's full document. `--trace-type` (`operation_type` on a `trace ls` row; the first path segment of a `--search` hit's `href`) makes it one request; without it the five detail endpoints are probed in order `write, read, create, update, evolution` and the first that knows the trace answers. A deletion's `DL00000000_aaaaaa` (or original `DL0000_aaa`) id is answered without a request: it has no detail page. A write's document carries `extracted_objects` (what extraction produced) and `cleaned_objects` (what the write kept after cleaning, rendered as a synchronous SDK `/write` answer renders it, by the caller's own primary keys; the answer may list fields and a relation's endpoints in another order), each a list holding one JSON document. A keyless object's `#N` in `cleaned_objects` is numbered within that field, as in the `/write` answer (over the cleaned objects, plus a structured write's relation endpoints), so it can name another object than the same `#N` in the document's `diff_plan`, `identity_changes` and `written_objects`: a keyed object joins across these fields by its primary key, and a keyless one cannot be matched across them by `#N`. `cleaned_objects` is `[]` for an edit, a failed write, a write still in progress, a write that cleaned nothing or whose objects could not be rendered, a write on an instance without a schema, and writes recorded before the Console stored it. Both go to everyone who may open the write. A miss — the Console's `{"found": false}`, or its 404 for a trace outside your scope — is `stage: "not_found"`, exit **3**. |
514
549
 
515
550
  **`--search` is opt-in, on purpose.** The Inspect search runs text predicates across the trace store with a candidate pool of thousands and a merge — markedly more expensive than the indexed, cursor-paged `trace ls`. Passing a search-only flag (`--text`, `--request`, `--response`, `--object`, `--field`, `--status`, `--trace-id`, `--session-id`) without `--search` is a usage error (exit **2**) whose message says exactly that, so an agent reasons about whether it needs the search or whether `trace ls` narrowed by `--instance` / `--trace-type` / `--hours-back` plus `trace get` will do. Conversely `--counts-only`, `--start-ts` / `--end-ts`, `--bucket-ms` are refused with `--search` because the Inspect endpoint cannot honour them, and `--include-console-reads` because Inspect's only spelling of that toggle is a bare phrase inside `q` itself — which is also why a free-text value containing the words "include console reads" is refused rather than sent (the Console would flip the toggle and strip the phrase from the query).
516
551
 
517
- **Breaking change in 1.4.0:** `trace ls --search` now asks the Console for one *page* (100 rows by default) instead of the whole capped result, under the Console's paging version 2 (`paging=2`), in which every search mode pages. A single call is no longer the complete answer: loop on `has_more`, and do not read a row count, or an absent `warnings` array, as completeness. Against a Console that predates paging the CLI says so in a warning, because that Console still answers whole and capped. What it tests is the absence of `has_more`, not the rows: a Console that pages sends the paging fields even when it found nothing, and an empty answer from one that does not is exactly where a row count misleads.
552
+ **Breaking change in 1.4.0:** `trace ls --search` now asks the Console for one *page* (100 rows by default) instead of the whole capped result, under the Console's paging version 2 (`paging=2`), in which every search mode pages. A single call is no longer the complete answer: loop on `has_more`, and do not read a row count, or an absent `warnings` array, as completeness. A Console that predates paging still answers whole and capped, and the CLI tells it apart by the absence of `has_more`, not by the rows: a Console that pages sends the paging fields even when it found nothing. What it then warns about depends on what that Console reports. One that reports `truncated` gets a warning only when it says `truncated: true`, naming the ceiling of its mode; `truncated: false` is a whole answer and gets none. One too old to report `truncated` gets a warning on every answer: the CLI's own estimate when the row count reaches a ceiling, otherwise that this Console does not page searches and its answer may be capped.
518
553
 
519
- **Paging a search — read this before scripting `--search`.** `trace ls --search` pages like `trace ls`, with the Inspect endpoint's own keyset cursor: pass the previous page's `next_before_sort_at_ms`, `next_before_trace_id` and `next_before_instance_id` back as `--cursor TS_MS:TRACE_ID:INSTANCE_ID` until `has_more` is `false`. A model-call hit has no instance, so when a page ends on one, `next_before_instance_id` is its `llm_call_id`: pass it back as it came, and give `--include-llm` on every page of that walk, since the cursor is a place in the result that flag shapes. Follow `has_more`, never the row count: the Console narrows each page after its row limits (type and status resolution, console-read exclusion), so a page can hold fewer rows than `--limit` — even none — and still have more. Paging reaches every match, with one exception the Console reports itself: a search with no filter that shapes the query (only scope such as `--instance`, `--session-id`, `--user-id`, `--api-key-id` or `--hours-back`) is the Console's recent-activity view, which reaches back 180 days. When this credential has older operations than that, the last page carries `truncated: true` and the CLI emits a **warning** (stderr, and folded into the JSON document's `warnings` array) naming the way past it — a filter such as `--trace-type` or `--text`. That flag comes from the endpoint, which knows what it could not reach; the CLI no longer guesses at a cut from how many rows came back. Asking for a page changes two things worth knowing before you script it: a free-text search answers from one candidate window per page rather than the exhaustive second pass the whole-result path ran, so a rare needle can give a short — even empty — first page with `has_more: true` where one call used to find the match; and a paged request is all-or-nothing, so a Console-side source failure is a `502` rather than a partial answer at exit 0. While a Console deploy is rolling out, a `--cursor` request can reach a node that does not page yet; it ignores the cursor, and the CLI exits **3** saying so rather than hand back its newest rows as the next page — retry the same `--cursor`. The CLI does not ask for the Console's whole-result `totals` (`include_totals`, which the counts and charts on the Console's instance tab and session page draw); to count a search's matches, walk its pages.
554
+ **Paging a search — read this before scripting `--search`.** `trace ls --search` pages like `trace ls`, with the Inspect endpoint's own keyset cursor: pass the previous page's `next_before_sort_at_ms`, `next_before_trace_id` and `next_before_instance_id` back as `--cursor TS_MS:TRACE_ID:INSTANCE_ID` until `has_more` is `false`. A model-call hit has no instance, so when a page ends on one, `next_before_instance_id` is its `llm_call_id`: pass it back as it came, and give `--include-llm` on every page of that walk, since the cursor is a place in the result that flag shapes. Follow `has_more`, never the row count: the Console narrows each page after its row limits (type and status resolution, console-read exclusion), so a page can hold fewer rows than `--limit` — even none — and still have more. Paging reaches every match with two exceptions. Free text (`--text`) shorter than three characters does not match the text inside finished operations — a write's text and diff plan, a read's query and result, an operation's error message: the Console's text indexes on them hold three-character runs (trigrams), and rather than scan every payload it lets a needle with none match nothing there. The Console counts only ASCII characters toward the three, so a short needle with accented letters can miss the same way. Such a needle still matches an id, an exact object, field or type name, and an evolution's stage or error. The other exception the Console reports itself: a search with no filter that shapes the query (only scope such as `--instance`, `--session-id`, `--user-id`, `--api-key-id` or `--hours-back`) is the Console's recent-activity view, which reaches back only as far as its window (currently 180 days). When this credential has older operations than that, the last page carries `truncated: true` and the CLI emits a **warning** (stderr, and folded into the JSON document's `warnings` array) naming the way past it — a filter such as `--trace-type` or `--text`. That flag comes from the endpoint, which knows what it could not reach; against a Console that pages, the CLI does not guess at a cut from how many rows came back. Only a Console too old to report `truncated` still gets the CLI's own estimate from the row count, and that warning says it is an estimate. Asking for a page changes two things worth knowing before you script it: a free-text search answers from one candidate window per page rather than the exhaustive second pass the whole-result path ran, so a rare needle can give a short — even empty — first page with `has_more: true` where one call used to find the match; and a paged request is all-or-nothing, so a Console-side source failure is a `502`, or a `503` when ClickHouse refused the query for resources, rather than a partial answer at exit 0. A `--cursor` request answered without the paging fields reached a Console that does not page: a node not yet updated while a deploy rolls out, or a Console that no longer pages searches. It ignored the cursor, so the CLI exits **3** with `stage: "paging_unsupported"` rather than hand back its newest rows as the next page. Retry after a short wait; if it persists, restart the walk without `--cursor`. Each page is a full search on the Console, run again from the cursor, so a walk costs about one search per page: walk with `--limit 500`, the Console's cap, to make fewer calls. A clause-only search (a clause flag such as `--object`, `--field`, `--request`, `--response`, `--status` or `--trace-type`, and no `--text`) reaches up to `--limit` candidates per call — 100 by default, 500 at most — where the whole capped answer of a Console before paging reached 5 000 per source; paging reaches the rest. The CLI follows no page on its own: it returns the page it was given, even an empty one with `has_more: true`, and a script loops. The CLI does not ask for the Console's whole-result `totals` (`include_totals`, which the counts and charts on the Console's instance tab and session page draw); to count a search's matches, walk its pages.
520
555
 
521
556
  **Model-call hits (`--include-llm`, 1.7.0).** Opt-in: without the flag a search asks for none, and its request and answer are what they were before 1.7.0. With it, a free-text search (`--text`) also looks through model calls — text in their prompt or error message, or the whole of their call id, operation id, session id or gateway request id — and returns the ones it matches beside the operations, newest first with them, so calls take places on each page. The Console looks through model calls only in a free-text search, and a model call has no instance, user or key to scope it by, so the CLI refuses `--include-llm` without `--text`, with a clause flag (`--trace-type`, `--status`, `--object`, `--field`, `--trace-id`, `--request`, `--response`), and with an instance, user or key scope (`--instance` or a bound `--instance-id` / `$XMEM_INSTANCE_ID`, `--user-id`, `--api-key-id`): each of those would answer with no model call whatever matched. `--session-id` and `--hours-back` work with it. A model call carries its prompt and error, so the Console serves these hits to a caller with no instance restriction only — a super admin's key — and leaves them out for every other credential rather than refuse the search: a member's key gets the same operations it gets without the flag, and no model-call hits. `auth status` says whether this credential is a super admin's. Each hit names its call: `trace call <llm_call_id>` opens it whole, and `trace get <trace_id>` the operation it belongs to.
522
557
 
523
- **LLM calls of a session (1.6.0).** The list holds the calls recorded under that session; a call whose session was not recorded with it is not listed, so treat the list as the session's recorded calls, not a guaranteed complete count. The API records each call's session with the call (a session over 256 characters only best-effort, through the Console's lookup by operation); a per-step benchmark call is listed only when its runner sent a session with it, and a schema generation or instance creation call only when its operation was recorded with a session. `trace calls` pages exactly like `trace ls --search`: follow `has_more`, pass the three `next_before_*` fields back as `--cursor`. On the last page the Console sets `truncated: true` when calls matching the filters exist before the `--hours-back` window, and the CLI warns (stderr, and the JSON document's `warnings` array) — widen the window, or pass `0` for everything kept. The older text fields (`sub_stage`, `model_type`, `provider`) read `""` when nothing reported them, as in `trace get`; every newer one (`instance_id`, `model_id`, `provider_type`, `finish_reason`, `reissue_of_llm_call_id`, `reissue_reason`, `schema_property_order`, `applied_temperature`, `applied_thinking`, `applied_effort`) is `null` then, so `select(.reissue_of_llm_call_id != null)` keeps only re-issues, as it does on `trace get`. The counts, times and outcome flags every call has (`time_taken_ms`, the token counts, `is_success`, `is_refusal`, `is_cached`) are always numbers and booleans; a setting or count only some calls report (`max_tokens`, `temperature`, `no_cache`, `client_attempts`, `cache_read_tokens`, `cache_write_tokens`), `error_message` and `gateway_request_id` are `null` when nothing reported them, and so, in `trace call`, are `session_id`, `json_schema`, `result`, `raw_result` (null for every reply the gateway served as written) and `rejected_attempts`. These commands need a Console that serves them: an older one does not accept a key on these paths and answers **401**, which the CLI reports as exit **4** `auth`.
558
+ **LLM calls of a session (1.6.0).** The list holds the calls recorded under that session; a call whose session was not recorded with it is not listed, so treat the list as the session's recorded calls, not a guaranteed complete count. The API records each call's session with the call (a session over 256 characters only best-effort, through the Console's lookup by operation); a per-step benchmark call is listed only when its runner sent a session with it, and a schema generation or instance creation call only when its operation was recorded with a session. `trace calls` pages exactly like `trace ls --search`: follow `has_more`, pass the three `next_before_*` fields back as `--cursor`. On the last page the Console sets `truncated: true` when calls matching the filters exist before the `--hours-back` window, and the CLI warns (stderr, and the JSON document's `warnings` array) — widen the window, or pass `0` for everything kept. The older text fields (`sub_stage`, `model_type`, `provider`) read `""` when nothing reported them, as in `trace get`; every newer one (`instance_id`, `model_id`, `provider_type`, `finish_reason`, `reissue_of_llm_call_id`, `reissue_reason`, `schema_property_order`, `applied_temperature`, `applied_thinking`, `applied_effort`, `prompt_family`, `prompt_generator`, `prompt_schema_provider`, `prompt_version`) is `null` then, so `select(.reissue_of_llm_call_id != null)` keeps only re-issues, as it does on `trace get`. The counts, times and outcome flags every call has (`time_taken_ms`, the token counts, `is_success`, `is_refusal`, `is_cached`) are always numbers and booleans; a setting or count only some calls report (`max_tokens`, `temperature`, `no_cache`, `client_attempts`, `cache_read_tokens`, `cache_write_tokens`), `error_message` and `gateway_request_id` are `null` when nothing reported them, and so, in `trace call`, are `session_id`, `json_schema`, `result`, `raw_result` (null for every reply the gateway served as written) and `rejected_attempts`. These commands need a Console that serves them: an older one does not accept a key on these paths and answers **401**, which the CLI reports as exit **4** `auth`.
524
559
 
525
560
  **Two document shapes.** `trace ls` returns the Operations-tab document; `trace ls --search` returns the Inspect document (above). They differ because the Console's two backends differ and this CLI does not reshape what the Console serves. Both carry the same identifiers per row (`trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id`, `status`), which is what `trace get` needs next.
526
561
 
527
562
  **Every operation row carries its internal ids.** `operations[]` rows have `operation_type`, `trace_id`, `instance_id`, `timestamp`, `status`, `is_success`, `error_code` (super admins only: it is the server's own cause code, finer than the API's error codes), `session_id`, `user_id`, `user_email`, `api_key_id`, `api_key_name`, `api_key_prefix`, `time_taken_ms`, `text_snippet`, `object_names`, `field_names`.
528
563
 
529
- > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. For that reason `--user-id` refuses a value containing `@` as a usage error (exit **2**) on both `trace ls` and `trace ls --search`, rather than sending a filter that can only ever return an empty result at exit 0. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects` (an array holding one `{objects, relations}` document, failed writes included; empty when no extracted objects reached the journal: a write that failed before extraction finished or extracted nothing, or one that timed out), `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries` (the rows owned by the read's instance, for every caller who may open the read), and two tiers of gated fields. **Super admin only** — the accounts API decides, for a key as for a browser session (a key counts only when its owner and its creator are both active super admins): `llm_calls[]` (each model call the operation made; fields below), `step_decisions[]` (what each step of the operation decided, per item; below), `full_error_message`, the write's and read's cause code `error_code`, raw token counts, and unsanitised xuids. **Callers with no instance restriction at all** — a super admin, or a browser session on a `KINGDOM_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
564
+ > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and the Console refuses a key's `User:` search clause naming a whole address (`User:sarah@example.com`) with a `400` `user_email_filter_unsupported_for_api_key`; filter by `user_id` instead. For that reason `--user-id` refuses a value containing `@` as a usage error (exit **2**) on both `trace ls` and `trace ls --search`, rather than sending a filter no key can resolve. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects` (an array holding one `{objects, relations}` document, failed writes included; empty when no extracted objects reached the journal: a write that failed before extraction finished or extracted nothing, or one that timed out), `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries` (the rows owned by the read's instance, for every caller who may open the read), and two tiers of gated fields. **Super admin only** — the accounts API decides, for a key as for a browser session (a key counts only when its owner and its creator are both active super admins): `llm_calls[]` (each model call the operation made; fields below), `step_decisions[]` (what each step of the operation decided, per item; below), `full_error_message`, the write's and read's cause code `error_code`, raw token counts, and unsanitised xuids. **Callers with no instance restriction at all** — a super admin, or a browser session on a `KINGDOM_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
530
565
 
531
- **Each `llm_calls[]` entry**, oldest first, is a call made under the operation's id that names the document's instance, or names none (calls recorded before the API sent one, and work that runs for no instance, such as a schema generation): trace ids are not unique, so another instance's operation can share the id. Its fields: `llm_call_id`; `sub_stage` (the pipeline stage that made the call); `model_type` (the model slot asked for) and `provider` (the one that served it); `called_at`; `time_taken_ms`; `prompt_tokens`, `completion_tokens`, `reasoning_tokens` (part of the completion, not added to it), `total_tokens`; `is_cached`, `is_success`, `is_refusal`, `error_message`, `gateway_request_id`; `prompt`, `json_schema`, `result`. Then what explains a call's outcome:
566
+ **Each `llm_calls[]` entry**, oldest first, is a call made under the operation's id that names the document's instance, or names none (calls recorded before the API sent one, and work that runs for no instance, such as a schema generation): trace ids are not unique, so another instance's operation can share the id. A call that names no instance carries `names_no_instance: true`, since it is listed by the operation id alone and may be that other operation's; a call that names the document's instance carries no such key. Its fields: `llm_call_id`; `sub_stage` (the pipeline stage that made the call); `model_type` (the model slot asked for) and `provider` (the one that served it); `called_at`; `time_taken_ms`; `prompt_tokens`, `completion_tokens`, `reasoning_tokens` (part of the completion, not added to it), `total_tokens`; `is_cached`, `is_success`, `is_refusal`, `error_message`, `gateway_request_id`; `prompt`, `json_schema`, `result`. Then what explains a call's outcome:
532
567
 
533
- - `reissue_of_llm_call_id` and `reissue_reason`: this call re-issues the earlier call with that id, which failed with that error code. A call sent to another model after it was refused, blocked for its content, or answered with replies that kept failing its schema; a reply cut at its output ceiling sent again with a higher one; and on writes the same overflow sent to another model at the same ceiling: all are re-issues. `reissued_as_llm_call_ids` is the other direction: the calls in the same list that re-issue this one. So either call leads to the other, with no second request.
568
+ - `reissue_of_llm_call_id` and `reissue_reason`: this call re-issues the earlier call with that id, which failed with that error code. A call sent to another model after it was refused, blocked for its content, or answered with replies that kept failing its schema, and the intent pipeline's verification call sent to its fallback model after a provider error; a reply cut at its output ceiling sent again with a higher one; on writes the same overflow sent to another model at the same ceiling; and the intent pipeline's operations call cut at its ceiling sent again to the same model at the same ceiling: all are re-issues. `reissued_as_llm_call_ids` is the other direction: the calls in the same list that re-issue this one. So either call leads to the other, with no second request.
534
569
  - `max_tokens`, `temperature`, `schema_property_order`, `no_cache`: the settings the call requested, not necessarily what the model ran with: depending on the model and its settings, no temperature is sent at all. `schema_property_order` is also null for a call sent without a schema.
535
570
  - `finish_reason`: why the model stopped, in the provider's own words, not normalised. `end_turn`, `stop`, `STOP` and `tool_use` are normal stops; `max_tokens`, `length` or `MAX_TOKENS` means the reply hit its output ceiling, and `model_context_window_exceeded` means the prompt and reply outgrew the context window. On a failure it is set where the model side reports it: always for those two, and for a refusal, a content filter or safety stop, or a schema miss after the model side's own retry where it reports those too; null otherwise. Read a refusal from `is_refusal`. One exception to "the provider's words": a cached Gemini reply (`is_cached: true`) first stored before 2026-05-06 reads `stop` whatever Gemini reported.
536
571
  - `rejected_attempts`: the replies rejected before this call's answer or error (one that missed the schema, say), as an array; a recorded value that is not valid JSON is served as its text.
@@ -539,11 +574,12 @@ learn; the CLI composes the Console's search clauses itself.
539
574
  - `gateway_repairs`: what was done to the reply before it was served, in a fixed order (not the order it happened in): `repaired` (fields written as JSON text were decoded), `json_recovered` (the first of several JSON values was kept), `omitted_scaffolding:<field>` (a reasoning field the reply left out), `schema_not_enforced` (no grammar held the reply to its schema), `strict_fallback` (a strict request was refused and a second, non-strict one answered) and `answered_in_text` (the reply came as text, not as the tool call it was asked for); on a call the caller asked the gateway to hedge, also `hedged` (a second copy of the call was sent) and `hedge_served` (that copy's answer was served). The last three of the first six say how the schema reached the model rather than that the reply was changed, and the hedge flags say how the answer was fetched, so a non-empty list does not by itself mean a repair. A fresh reply reports them, so on a fresh successful reply that names its `model_id`, `[]` means none of these flags applied; on a cache hit or a call that names no model, `[]` means not reported. A failed call reports them where the model side reports what it did before the failure, so there `[]` means none applied or not reported. Re-encoding a reply (key order, whitespace, very large integers rounded) is never reported, and `schema_not_enforced` records the model side's own choice, not a verified grammar.
540
575
  - `applied_temperature`, `applied_thinking`, `applied_effort`: what the model side actually sent the provider for the attempt that answered, or on a failure its last attempt: the temperature as sent, the thinking mode (`adaptive`, `enabled`, `disabled`, `level:<x>`, `budget:<n>`) and the effort, or `none` when it sent nothing for one; `other` stands for a value outside the model side's fixed lists. They can differ from the requested settings: a slot that drops temperature, thinking turned off on the retry after a reply missed its schema. Null when not reported (a cache hit, a call that failed before reaching a model, or calls recorded before these fields existed).
541
576
  - `cache_read_tokens`, `cache_write_tokens`: how much of `prompt_tokens` the provider read from and wrote to its prompt cache; parts of `prompt_tokens`, never added to it. Null when none or not reported.
577
+ - `prompt_generator`, `prompt_schema_provider`, `prompt_family`, `prompt_version`: the prompt renderer the call's model slot resolved to when it was made: the two classes the slot's prompts and response schema are rendered with, as `module.QualifiedName`; the model family a Claude slot's prompts are picked by (null for other providers, whose prompts are picked per slot); and a 16-hex-digit version of the classes' source. The version changes whenever that source or any project module it imports changes, the model-call code included, so a new version does not by itself mean the prompt text changed; it never changes with the instance's schema. The version is null for a call the API made before it had computed it (the first moments after a restart, or after a repoint to a renderer no slot used before) or where it could not read the source. All four are null for a call that renders no prompt, for a call whose caller wrote the messages (a per-step benchmark call of purpose `judge`, `extract` or `synthesis`, and the LLM-as-judge endpoint), and for calls recorded before the API sent them. A `prompt_eval` call names the renderer of the API node that served it, which during a deploy can differ from the one whose `benchmark_prompts/prepare` rendered its messages.
542
578
  - `raw_result`: the reply as the model wrote it, where a repair or recovery (`repaired`, `json_recovered`) changed its value; `result` holds the changed one. Null otherwise, and not reported on a cache hit or a failed call (a cache hit replays the repaired reply only).
543
579
 
544
580
  A value the call did not record is `null`, and `reissued_as_llm_call_ids` and `gateway_repairs` are `[]`; calls recorded before these fields existed read that way throughout.
545
581
 
546
- **Each `step_decisions[]` entry** is one record of what one step of the operation decided, oldest first (by `recorded_at`, then `sub_stage`). A step records each time it runs and decides something (a step that decided nothing records nothing), so one operation can hold several records of one step, and every record is served; none is merged into another. A write's deletion check records in its own process and again where the diff engine applies its gate, and a write retried whole under the same trace id (a stale-schema retry, a stall requeue, a stuck write claimed again) records every step again. Writes, reads and schema evolution record today. A write's extraction records too, under `extraction.` steps named like its model calls (`extraction.structured_output`, `extraction.description_merger`, `extraction.batch_field_extractor`, `extraction.field_verifier`, `extraction.relations_extractor`, `extraction.relations_verifier`, `extraction.deduplication`): what code decided about a reply (candidates folded into one, a reply set aside so the extracted values stand, connections dropped, a candidate left out of the write, a group of types split), and the verifiers' own rejections. Field verification records once per object type, when that type's field processing ends; a write that fails before then records none for that type. Extraction's items have the diff engine's shape: `kind` is `merge`, `object`, `relation`, `relation_type` or `type_group`; `decision` is `folded`, `skipped`, `kept_unverified`, `key_restored`, `rejected`, `uncastable`, `dropped`, `split` or `escalation_disabled`; `detail` holds field names, candidate numbers and counts (`candidate_count`, `candidate_numbers`, `kept_number`, `ignored`, `basis`, `filled_fields`, `conflicting_fields`, `fields`, `nulled`, `omitted`, `connection`, `field`, `candidates_per_link`, `relations`, `types`, `halves`, `refused_max_tokens`, `error_code`), and no field's value except an object's key: in `key`, in a dropped relation's `links` (`Type(field='value')` per link), and a `description` for an object that has no key. `/extract` records its extraction the same way; it has no detail document of its own, so its records show only on a write's document when that write, on the same instance, reuses its trace id. Schema evolution records under `operation_kind` `evolution`: review, decide and apply each journal their steps' records once, when the stage ends, under their own trace, so an apply's show on its update document (its trace is the update id), and the detached pass that resolves an apply's gaps afterwards journals its own under the same trace. Its steps are the two model calls' names, `gap analyzer` (gaps never analysed or stored with no concepts, rows a reply left pending, concepts the parser dropped) and `semantic gate` (what the gate may do with each suggestion, candidates kept without a verdict, verdicts downgraded or not acted on), and the rule steps `evolution.backlog`, `evolution.resolution_filter`, `evolution.clustering`, `evolution.consolidation`, `evolution.prior_decisions`, `evolution.prerequisites`, `evolution.evidence_retirement` and `evolution.apply`; `kind` is `gap` (named by its feedback id in `key` and its concept's object and field), `suggestion` (its fingerprint in `key`, its change's names, and the gaps it was built from in `detail.gaps`, the first 20 with `gaps_total` when there are more), `cluster`, `decision`, `backlog`, `reply_entry`, `build` or `gate`, and no item carries what a user wrote. What a transaction did (a gap resolved, stored with no concepts, deleted or retired) is recorded only once it commits, and a row only when that step's own write or delete reached it. A build retried on a stale schema records both attempts: once the build runs again, every item the stage notes until it ends, the build's and the stage's own after it, carries `detail.attempt` (`2`), so an item without `attempt` was noted before the build ran again, and a `retried` item under `evolution.consolidation` records that it did; an `omitted` item is a marker, not a decision, and carries no `attempt`. A read records what the reader's own rules decided, under the name of the model call whose reply a decision is about (`read.question_decomposer`, `read.sql_syntax_fixer`, `read.empty_result_reviewer`, `read.overfetch_reviewer`, `read.answer_formatter`) or under a rule step (`read.scope`, `read.vector_grounding`, `read.sql_rewrites`, `read.sql_guard`, `read.sql_retries`, `read.answer`, and `read.other` for a family no step claims yet); a decision about a SQL statement a model call wrote goes under `read.sql_rewrites`, with the calling step in `detail.phase` where the rule names it. A read step that decided more than 101 times keeps its first and last 50 decisions, with one item (`decision` `omitted`, `detail.count`) where the rest were left out, cut further from the middle when they do not fit one record. A read journals its records once, when it ends, so a read still running has none yet, and a read retried on a stale schema runs again under the same trace id and records every step again; its items have `kind` (the decision family, such as `sql_guard_refused` or `distinct_order_by_case_dropped`), `decision` (`applied` or `not_applied`), `reason` where the rule says why, and `detail.part`, the part of a decomposed question the decision was made for, counted from 1, where it was made for one. `sub_stage` names the step in the `llm_calls[].sub_stage` vocabulary, so a step's decisions and its model calls join on it (a re-issued call journals under its step's name with a suffix such as `.refusal_fallback`, so it joins on the name as a prefix; the reader's rule steps and `extraction.deduplication` have no model calls of their own) (`diff_engine.deletion_check`, `diff_engine.intent_rules`, `diff_engine.value_span_containment`, `diff_engine.intent_verification_deep`, `diff_engine.alias_confirmation`, `diff_engine.object_resolution`, `diff_engine.plan_construction`, and the `read.` steps above). A step whose decisions act on another step's calls names itself the same way: `diff_engine.intent_rules` acts on the replies under `diff_engine.object_and_relations_deletion_detection` and `diff_engine.batch_field_erasure_detection`. The records hold what the engine decided that the operation's `llm_calls[]` (their prompts and replies) and its `diff_plan` do not already show, so the model's own answers are read in `llm_calls[]`, not here. `decision_count` is the step's true total and `decisions_truncated` marks a record cut to fit (for a read step, also one cut to its first and last 50 decisions), so `decisions` can hold fewer items than the count. `decisions` is the items as the step recorded them, a JSON array, or `null` when the stored text is not one (the count still shows). A pane serves at most 500 records and 4 MiB of `decisions` text, the newest; when it cut older ones it adds `step_decisions_truncated: true` beside the array, and otherwise the key is absent. A trace id a client reuses across writes serves every write's records together, ordered by time with nothing between the writes. The diff engine's items share one shape: `kind` (what the item is: `intent_gate`, `object`, `identity_change`, `merge`, `filtered_deletion`, `filtered_update`, `relation`, `deletion`, `alias`, `scope`, `detection`) and `decision` (what was decided: `deletion_dropped`, `converted`, `rejected`, `kept`, `bound`, `matched`, `left_out`, …), then, where set, `object_type` and `key` (the primary-key values as `field='value'`), `index` (the object's position in the diff engine's sorted object list, not in `extracted_objects[]`), `xuid` (the stored row), `reason`, and `detail` (anything else the step decided). `diff_engine.intent_rules` logs what the engine's rules did to the detection model's answer, in the order they acted, so an item can be dropped and later restored; `diff_engine.plan_construction` says what building the plan decided after every earlier step, which the plan shows only by leaving it out (a merge the relation-budget re-check refused or re-admitted after alias binding, a deletion a rename overrides, an unlink dropped for a row a confirmed delete does not run on or a lookalike row the write does not bind, a move whose old link stays with the replacement guard's cause, a dropped merge or one already a single row, what a scoped write left out). A deletion that verification rejected reads `kind: "deletion"`, `decision: "rejected"` under `diff_engine.intent_verification_deep`, with what the object became and why; the proposal itself is the detection model's reply in `llm_calls[]`. Another part of the system may record items of its own shape here; they are served as recorded. Every detail pane serves its operation's records the same way; the write, read and evolution panes serve them for an operation still running or stale too (create and update have no such pane). A step that refuses the write still records what it decided first: the detector its rules, resolution what it found, plan construction the merges, unlinks and scope it changed. A caller who is not a super admin gets neither `step_decisions` nor `step_decisions_truncated`, exactly as for `llm_calls[]`.
582
+ **Each `step_decisions[]` entry** is one record of what one step of the operation decided, oldest first (by `recorded_at`, then `sub_stage`). A step records each time it runs and decides something (a step that decided nothing records nothing), so one operation can hold several records of one step, and every record is served; none is merged into another. A write's deletion check records in its own process and again where the diff engine applies its gate, and a write retried whole under the same trace id (a stale-schema retry, a stall requeue, a stuck write claimed again) records every step again. Writes, reads and schema evolution record today. A write's extraction records too, under `extraction.` steps named like its model calls (`extraction.structured_output`, `extraction.description_merger`, `extraction.batch_field_extractor`, `extraction.field_verifier`, `extraction.relations_extractor`, `extraction.relations_verifier`, `extraction.deduplication`): what code decided about a reply (candidates folded into one, a reply set aside so the extracted values stand, connections dropped, a candidate left out of the write, a group of types split), and the verifiers' own rejections. Field verification records once per object type, when that type's field processing ends; a write that fails before then records none for that type. When the judge in front of field verification is configured but does not run on a write whose detection found something, the write records why, once, under the stage the judge's calls would journal under (`extraction.verification_judge`, or `shadow.extraction.verification_judge` in shadow mode): one item, `kind` `judge`, `decision` `not_run`, with `detail.not_run_reason` (`sampled_out`, `pinned_provider` or `config_unavailable`) and `detail.mode`. Extraction's items have the diff engine's shape: `kind` is `merge`, `object`, `relation`, `relation_type` or `type_group`; `decision` is `folded`, `skipped`, `not_held` (the verifier found the text does not state the object holds, so it is marked as one that does not), `kept_unverified`, `verifier_skipped` (field verification did not run because the judge in front of it cleared the object), `key_restored`, `rejected`, `uncastable`, `dropped`, `split` or `escalation_disabled`; `detail` holds field names, candidate numbers and counts (`candidate_count`, `candidate_numbers`, `kept_number`, `ignored`, `basis`, `filled_fields`, `conflicting_fields`, `relations_repointed`, `fields`, `nulled`, `omitted`, `connection`, `field`, `candidates_per_link`, `relations`, `types`, `halves`, `refused_max_tokens`, `error_code`), and no field's value except an object's key: in `key`, in a dropped relation's `links` (`Type(field='value')` per link), and a `description` for an object that has no key. `/extract` records its extraction the same way; it has no detail document of its own, so its records show only on a write's document when that write, on the same instance, reuses its trace id. A write's apply records what its merges did to the stored rows, under `write.merge_application`, once the write commits (a write that rolls back records none): one item per merge action, `kind` `merge`, named by the surviving row (the diff engine's `merge` items are named by the row merged away instead): its `object_type`, its `key` (absent for a type without a key, for a key with any part NULL or missing, which could name another row, and when the two rows were one) and its `xuid`, with `decision` `applied` and, in `detail`, the row merged away (`merged_away_xuid`), `relations_repointed`, `relations_dropped` by reason (`linking_the_merged_rows`: a relation between the two merged rows; `removed_by_this_write`; `duplicate`: one the survivor already held) and `filled_fields`, the survivor's empty fields filled from the merged-away row, by name; or `decision` `nothing_to_merge` with why in `reason` (the two rows were one, or the row to merge away was already gone). Schema evolution records under `operation_kind` `evolution`: review, decide and apply each journal their steps' records once, when the stage ends, under their own trace, so an apply's show on its update document (its trace is the update id), and the detached pass that resolves an apply's gaps afterwards journals its own under the same trace. Its steps are the two model calls' names, `gap analyzer` (gaps never analysed or stored with no concepts, rows a reply left pending, concepts the parser dropped) and `semantic gate` (what the gate may do with each suggestion, candidates kept without a verdict, verdicts downgraded or not acted on), and the rule steps `evolution.backlog`, `evolution.resolution_filter`, `evolution.clustering`, `evolution.consolidation`, `evolution.prior_decisions`, `evolution.prerequisites`, `evolution.evidence_retirement` and `evolution.apply`; `kind` is `gap` (named by its feedback id in `key` and its concept's object and field), `suggestion` (its fingerprint in `key`, its change's names, and the gaps it was built from in `detail.gaps`, the first 20 with `gaps_total` when there are more), `cluster`, `decision`, `backlog`, `reply_entry`, `build` or `gate`, and no item carries what a user wrote. What a transaction did (a gap resolved, stored with no concepts, deleted or retired) is recorded only once it commits, and a row only when that step's own write or delete reached it. A build retried on a stale schema records both attempts: once the build runs again, every item the stage notes until it ends, the build's and the stage's own after it, carries `detail.attempt` (`2`), so an item without `attempt` was noted before the build ran again, and a `retried` item under `evolution.consolidation` records that it did; an `omitted` item is a marker, not a decision, and carries no `attempt`. A read records what the reader's own rules decided, under the name of the model call whose reply a decision is about (`read.question_decomposer`, `read.sql_syntax_fixer`, `read.empty_result_reviewer`, `read.overfetch_reviewer`, `read.answer_formatter`) or under a rule step (`read.scope`, `read.vector_grounding`, `read.sql_rewrites`, `read.sql_guard`, `read.sql_retries`, `read.answer`, and `read.other` for a family no step claims yet); a decision about a SQL statement a model call wrote goes under `read.sql_rewrites`, with the calling step in `detail.phase` where the rule names it. A read step that decided more than 101 times keeps its first and last 50 decisions, with one item (`decision` `omitted`, `detail.count`) where the rest were left out, cut further from the middle when they do not fit one record. A read journals its records once, when it ends, so a read still running has none yet, and a read retried on a stale schema runs again under the same trace id and records every step again; its items have `kind` (the decision family, such as `sql_guard_refused` or `distinct_order_by_case_dropped`), `decision` (`applied` or `not_applied`), `reason` where the rule says why, and `detail.part`, the part of a decomposed question the decision was made for, counted from 1, where it was made for one. `sub_stage` names the step in the `llm_calls[].sub_stage` vocabulary, so a step's decisions and its model calls join on it (a re-issued call journals under its step's name with a suffix such as `.refusal_fallback`, so it joins on the name as a prefix; the reader's rule steps, `extraction.deduplication` and `write.merge_application` have no model calls of their own) (`diff_engine.keyless_binding`, `diff_engine.deletion_check`, `diff_engine.intent_rules`, `diff_engine.value_span_containment`, `diff_engine.intent_verification_deep`, `diff_engine.alias_confirmation`, `diff_engine.object_resolution`, `diff_engine.unwritten_identity_verification`, `diff_engine.plan_construction`, the intent pipeline's `diff_engine.intent_gate`, `diff_engine.intent_operations`, `diff_engine.operations_repair` and `diff_engine.operations_verification`, and the `read.` steps above). A model call made in shadow mode, beside the work it never changes and never charged, carries the prefix `shadow.` on its step's name; the intent pipeline and the field verifier's judge are the steps that run in shadow today. The field verifier's judge, which can stand in front of `extraction.field_verifier` on a DEEP write or `/extract`, makes its calls under `extraction.verification_judge`, or `shadow.extraction.verification_judge` in shadow mode, and records under that same stage, once per object type, one item per object it was asked about: `kind` `object`, `decision` `flagged` (a field at or over its threshold, or one no question covered), `cleared` or `not_judged` (no verdict, which counts as every field flagged), with `detail.field_scores` (the flagged fields the judge scored, and their scores), `detail.unjudged_fields` (flagged fields no question covered), `detail.score`, `detail.field` and `detail.question` (the top score and where it came from), `detail.threshold`, `detail.questions_asked` and `detail.verifier` (`ran`, `ran on the flagged fields and the key`, or `skipped`). In on mode the verifier is asked only about the flagged fields and the key; in shadow mode it sees every field, and the item says what on mode would have sent it. A `verifier_skipped` item under `extraction.field_verifier` is an object an `extraction.verification_judge` call of the same operation cleared; every question and answer is in that call in `llm_calls[]`. The intent pipeline names its model calls by its step names above, with the prefix `shadow.` in shadow mode (`diff_engine.operations_repair` the operations step asked once more about the entries its checks dropped); `diff_engine.intent_operations` records what the write did with the answer in on mode only, where it stands in for `diff_engine.intent_rules`, and in either mode (with the prefix in shadow mode), once the run has finished, one item when the operations call ran out of its output budget and was asked once more: `kind` `intent_operations`, `decision` `retry_answered` or `retry_failed`, and `detail.model`, `detail.error` (the first call's code) and, on `retry_failed`, `detail.failure` (the type of the error the step then failed with); a run the write's grace cancelled with the retry in flight, or one whose gate closed or failed, records none, and the retried call is the step's `llm_calls[]` entry whose `reissue_reason` is `provider_output_token_limit_exceeded`. `diff_engine.operations_repair` (with the prefix in shadow mode) records one item, once the run has finished, when the operations step asked its model once more about the entries its checks dropped: `kind` `intent_operations`, `decision` `repaired` (the second reply stood), `repair_rejected` (it was not the first reply with those entries fixed, so the first stood with its drops) or `repair_failed` (the repair met an error, so the first reply stood with its drops), and `detail.model` (the model the repair asked last: the one that gave the first reply, or its fallback after an outage) and, on `repair_failed`, `detail.failure` (the type of the error it met); a run whose gate closed records none, and so does a reply over the cap, which is never repaired: the write falls back to the engine. `diff_engine.intent_gate` (with the prefix in shadow mode) records one item when the gate's deadline, the write's grace, opened it: `kind` `intent_gate`, `decision` `opened_by_deadline`, and `detail.deadline_seconds`, `detail.unanswered_calls` (the gate calls cut off) and `detail.sentence_count`. A step whose decisions act on another step's calls names itself the same way: `diff_engine.intent_rules` acts on the replies under `diff_engine.object_and_relations_deletion_detection` and `diff_engine.batch_field_erasure_detection`. The records hold what the engine decided that the operation's `llm_calls[]` (their prompts and replies) and its `diff_plan` do not already show, so the model's own answers are read in `llm_calls[]`, not here. `decision_count` is the step's true total and `decisions_truncated` marks a record cut to fit (for a read step, also one cut to its first and last 50 decisions), so `decisions` can hold fewer items than the count. `decisions` is the items as the step recorded them, a JSON array, or `null` when the stored text is not one (the count still shows). A pane serves at most 500 records and 4 MiB of `decisions` text, the newest; when it cut older ones it adds `step_decisions_truncated: true` beside the array, and otherwise the key is absent. A trace id a client reuses across writes serves every write's records together, ordered by time with nothing between the writes. The diff engine's items share one shape: `kind` (what the item is: `intent_gate`, `intent_operations`, `intent_verification`, `object`, `identity_change`, `merge`, `filtered_deletion`, `filtered_update`, `relation`, `deletion`, `alias`, `relabel`, `scope`, `detection`, `operation`, `intent_pipeline`) and `decision` (what was decided: `deletion_dropped`, `converted`, `rejected`, `kept`, `bound`, `not_bound`, `matched`, `left_out`, …), then, where set, `object_type` and `key` (the primary-key values as `field='value'`), `index` (the object's position in the diff engine's sorted object list, not in `extracted_objects[]`), `xuid` (the stored row), `reason`, and `detail` (anything else the step decided). `diff_engine.keyless_binding` records one `object` item per record of a keyed type that states no key but another field: `bound` to the one stored row it names (its `key` and `xuid`), or `not_bound` with why in `detail.outcome` (a record one stored row matches in every field it states carries that row's `key` and `xuid` when it is left `not_identifying`, matching only in yes/no and listed-value fields and never put to the verifier, or `rejected` or `unverified`, the verifier not confirming the text refers to it); `same_key_group` (the write holds records of different types with the same key, so nothing of it is looked up), `key_stated_for_another_type` (a record of another type in the write states the key of the row it would bind to), `key_of_another_type` (a stored row of another type has that key) and `key_check_unread` (the stored rows could not be read, or the key has more fields than the check looks up) are a temporary net against extraction copying a record's identifier onto another type; a binding left unbound for one of the last three carries the row it would have bound to (its `key` and `xuid`; a record not asked about its closest rows for them lists those rows in `detail.other_type_rows` instead), and the other types in `detail.other_types`: for `key_stated_for_another_type` and `key_of_another_type`, and for a record set aside unasked as `key_check_unread` whose rows stand down for different reasons, the types its rows' keys were found under. The same-key groups and the stated-key check go once extraction no longer returns such copies; the stored-key check goes only once the copies earlier writes stored are cleaned up too, or a scan finds none. A stored copy whose key differs only in case, surrounding spaces or accents is not found. In on mode, Phase 1's `record_where` lookup may also ask the verifier under `diff_engine.keyless_binding` (on a Claude 5 model, when one stored row matches and the answer would be used once it is confirmed), about a rename's or a merge's record the text names by its fields whose extracted record carries the operation's new key, and records no item of its own: what came of it shows under `diff_engine.intent_operations`, where a bound rename is named by the stored row's key, or as `record_where_unbound` with `detail.sub` `stated_key`, or as the fallback a bound answer met later (such as `rename_old_key_unmatched`, or `rename_or_merge_rejected` when Phase 1.5 did not confirm every rename and merge of the answer); under `intent_pipeline_failures: refuse` a bound answer whose translation fails, or whose key-only lookup could not run, is refused instead (a failure of the verified relabel's own work, `relabel_failed`, still goes to the engine). In on mode `diff_engine.intent_operations` says what the write did with the intent pipeline's answer: an `operation` item per operation it applied, its `decision` the operation's kind (`delete`, `unlink`, `move`, `clear`, `rename`, `merge`, `bulk_delete`, `bulk_update`) and its `reason` the passage that asks for it, then `not_applied` for one the write could not carry out (a delete of a record by the new name a rename of the same write gives it reads "it is also the target of a rename", as the engine's own rule says it) and `dropped` for one the pipeline dropped, each with why and `detail.position` (its place in the model's reply), then an `object` item `skip` for each record of a keyed type that states no key and that the write skipped, and `erase_refused` for each record bound to a stored row by its other fields whose clears were not applied (`detail.fields`); a `clear` item therefore names what the pipeline asked for, not what was written. When the engine's own detection decides the write instead, the step records one `intent_pipeline` item, `decision` `engine_decides`, whose `detail.fallback` names why (its `reason` says it in words) and beside which the engine's own steps record as with the pipeline off: `missing` (no result reached the write), `unreadable`, `translation` (the rows the operations name could not be read), `dropped` (the pipeline dropped an operation it proposed for a reason other than its verifier's rejection, so its answer is not the whole change), `verifier_rejected` (a temporary net: the pipeline's verifier rejected a delete, a clear, an unlink or a move it proposed, which may be about how the operation names its records rather than the change; a rejected bulk delete or update does not count, and a rename it rejects, or a merge while `execution.entity_merge` is on, goes to the engine as `rename_or_merge`), `record_where_unbound` (it names a record by the field values the text states, a key stated in part among them, and once verified, binding it through the write's own extraction found no single record for it; a writer that predates the binding decided it before the pipeline's verifier was asked), `unresolved` (it saw a change whose records it could not name: a record described rather than named, records selected through a relation, every link of a kind), `rename_or_merge` (it holds a rename sharing a record with a merge, or renames that swap, cycle, fork or converge, decided before the pipeline's verifier is asked, or the verifier rejected a rename, or a merge while merges are on), `rename_old_key_unmatched` (it renames a record that no single stored record has by the name the text gives), `merge_loser_unmatched` (it merges away a record that no single stored record has by the name the text gives), `rename_onto_stored` (it renames a record onto a name another stored record already has), `rename_record_partly_stated` (the write states a record without the rest of a key a rename names, so whether it is that rename's old record, its new record or another one is unknown), `clear_in_question`, `unlink_in_question`, `move_in_question`, `repoint_in_question` (temporary nets: while the text holds a question mark, it clears a field, unlinks records of a relation type the extraction holds a relation of, or moves a relation, or, whatever it holds, the extraction holds a relation of a type keyed on some of its ends but not all, which written re-points the stored link those ends share, as with the pipeline off; Phase 1.5 cannot verify or take back any of these), `delete_with_values` (it deletes a record the write's own extraction gives values other than its fields' defaults), `unlink_replaced` (it unlinks records while the write's own extraction holds another record the link may have moved to, which no new link joins to the unlink's other ends and no other operation ends or removes), `key_rewritten` (an operation's key was read as the extraction's spelling), `unmatched_key_only` (the gate opened, while the extraction holds a record by its key alone that the answer would write and no stored row has; a delete's record, a rename's old and new records, a merge's survivor and a move's new end are not looked up), `key_only_lookup_failed` (the same, but whether those records are stored could not be read), `end_not_stored` (an unlink's end, or a move's old or kept end, binds no single stored row: none has its key, or several do, so the stored link would stay), `rename_or_merge_rejected` (the write's second verification did not confirm every rename and merge of the pipeline's answer, so the engine's own detection decided it again from the start; a temporary safety net, until the pipeline binds the records its operations name itself; it goes together with `unmatched_key_only` and the end guard, which would otherwise send most such writes to the engine), `end_not_extracted` (a move's new end is neither a stored row of its type nor a record the write's extraction holds, by its key: a key the pipeline made up, such as a name or part of an id), `empty_answer_check_found_intent` (a temporary net: the answer holds no operation while Phase 0's gate, which read the deletion check the write ran, is open), `date_field` (an operation names a field in which the write's extraction resolved a date token, or dropped one that named no day, which the pipeline does not yet state as the extraction resolves it; a temporary safety net, until the pipeline resolves those dates too), or the step a run without an answer stopped at (`gate`, `operations`, `verification`, `executor`, `late`, `not_run`). A write of an instance that pins its write text to a provider is no fallback: its instance's own mode is `off`, so the step records one `intent_pipeline` item, `decision` `instance_mode`, with `detail.mode` `off` and `detail.sub` `pinned_provider`, and the engine's own steps record beside it as with the pipeline off. Beside `fallback`, `detail.sub` gives why as one code with no value, when one is known (for `unmatched_key_only` what the records were, `relation_end`, `not_an_end`, `partial_key` or `empty_answer`; a failed stage's exception type, a not-run reason, the drop kinds and reasons, the rejected kinds, whether `rename_or_merge` was decided before or after verification), `detail.dropped_operations` lists each operation the pipeline dropped (`position` in the model's reply, `operation` kind when readable, `reason`, and `part`, a code for what within the reason failed, when known), and `detail.unresolved_changes` each change it could not name the records of (`position`, `evidence`, the passage that asks for it, `object_type` when the model gave one, and `shape`, a code, when the model gave one). `dropped_operations` carries no field values; an `unresolved_changes` entry's `evidence` is a passage of the write's own text, as an `operation` item's `reason` is. `diff_engine.intent_rules` logs what the engine's rules did to the detection model's answer, in the order they acted, so an item can be dropped and later restored (a rename whose old record is stated by part of a composite key is an `identity_change` item `completed`, bound to the one stored row its stated part matches and named by that row's `xuid`, or `not_bound`, left as stated so the write goes as it would without the step: no stored row or several match, the one row disagrees with the new record, a stored row with a NULL or blank key part matches, the database could not be asked, a value is not one its field can hold, the new record is of another type than the rename's, no index serves a lookup by the stated part, another rename or a merge lands on the same new record, or something else in the write may name the row or the key, its kind in `detail.named_by`); `diff_engine.plan_construction` says what building the plan decided after every earlier step, which the plan shows only by leaving it out (a merge the relation-budget re-check refused or re-admitted after alias binding, a deletion a rename overrides, an unlink dropped for a row a confirmed delete does not run on or a lookalike row the write does not bind, a move whose old link stays with the replacement guard's cause, a dropped merge or one already a single row, what a scoped write left out). A deletion that verification rejected reads `kind: "deletion"`, `decision: "rejected"` under `diff_engine.intent_verification_deep`, with what the object became and why; the proposal itself is the detection model's reply in `llm_calls[]`. Another part of the system may record items of its own shape here; they are served as recorded. Every detail pane serves its operation's records the same way; the write, read and evolution panes serve them for an operation still running or stale too (create and update have no such pane). A step that refuses the write still records what it decided first: the detector its rules, resolution what it found, plan construction the merges, unlinks and scope it changed. A caller who is not a super admin gets neither `step_decisions` nor `step_decisions_truncated`, exactly as for `llm_calls[]`.
547
583
 
548
584
  **A scoped key gets `[]` for `ddl_queries` / `schema_generation`, not the rows.** Those two come from records that carry no instance ownership — the schema generation's trace id is even client-supplied — so they cannot be scoped to an instance and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran none". `llm_calls[]` behaves the same way (gated on super admin) and always has. `sql_queries` is split: statements recorded with their owning instance reach every caller who may open the read, even when another instance reused the trace id, while statements recorded before ownership was tracked carry no owner and reach unrestricted callers only. A scoped key therefore sees `[]` for reads that old. To let a script tell the two apart, the Console sets a sibling boolean next to each — `sql_queries_withheld`, `ddl_queries_withheld`, `schema_generation_withheld` — that is `true` exactly when a row that exists was withheld from this caller: for `ddl_queries` / `schema_generation` that is every scoped caller, for `sql_queries` a scoped caller on a read that has ownerless rows. So `[]` with `…_withheld: false` is "none recorded", `[]` with `…_withheld: true` is "not yours to see". **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
549
585
 
@@ -555,7 +591,7 @@ A value the call did not record is `null`, and `reissued_as_llm_call_ids` and `g
555
591
  - When the Console **cannot** work out that scope — its accounts API is unreachable, answers non-2xx, is undecodable, or is not configured — it answers **503** with `{"error": "instance_scope_unresolved", "reason": "accounts_…"}` (or `accounts_unavailable` when the key's own lookup failed the same way) instead of an empty document. The CLI reports that as **exit 3, stage `unavailable`**, with a message that says the scope could not be resolved and that this is *not* "no operations": a zero-row answer from a Console that could not decide would have read as a quiet instance.
556
592
  - No new permission is involved: the Console answers a key as it would answer its owner's browser session, narrowed to the key's instances, and treats it as a super admin's only when its owner and its creator are both active super admins. It never mints a session for a key. Filters that need to look other users up (`User:`-style email filters) are not offered: an API key can identify only itself, never list or look up users — filter by `--user-id`.
557
593
 
558
- **The Console origin.** `trace …` talk to the Console, a second origin. It is `--console-url` → `$XMEM_CONSOLE_URL` → inferred: `https://console.xmemory.ai` for the default API, `:8002` for a local `:8000`. A key bound to any other API origin gets a usage error (exit **2**) rather than a guess — the credential-binding rule holds across origins.
594
+ **The Console origin.** `trace …` talk to the Console, a second origin. It is `--console-url` → `$XMEM_CONSOLE_URL` → inferred: `https://console.xmemory.ai` for the default API, the `/console` sibling of a `/xmem` base, `https://console.stg.xmemory.ai` / `https://console.pp.xmemory.ai` for the stg / pp APIs, `:8002` for a local `:8000`. A key bound to any other API origin gets a usage error (exit **2**) rather than a guess — the credential-binding rule holds across origins.
559
595
 
560
596
  **Parity.** These commands are the Console's data path, not a copy of it. If the Console gains a filter or a field, it is added to the same list here and is available; the MCP server exposing the same data is deferred and will follow the same rule (see the note in the MCP server source).
561
597
 
@@ -565,9 +601,9 @@ A value the call did not record is `null`, and `reissued_as_llm_call_ids` and `g
565
601
 
566
602
  | Command | Description |
567
603
  |---------|-------------|
568
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). An explicitly named Console (`--console-url`, `$XMEM_CONSOLE_URL`, or the one pinned by a single-origin URL) is cross-checked against the API origin before the browser opens or a pasted key is sent anywhere — a provably mismatched pair (mixed loopback-ness, or a production half paired with a non-production one) is a usage error (exit **2**), on the interactive paste path as much as on the browser path. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
604
+ | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--auto-approve` (1.7.4+: the same page opened with `?auto_approve=1&host=<hostname>`; once the browser holds a Console session the Console hands the CLI a code with no click, and a key named `CLI auto-approved login (<hostname>, <timestamp>)` is minted when the CLI redeems it at `/cli/token`, once per login; refused beside `--email`; the payload carries `auto_approve: true`), `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). An explicitly named Console (`--console-url`, `$XMEM_CONSOLE_URL`, or the one pinned by a single-origin URL) is cross-checked against the API origin before the browser opens or a pasted key is sent anywhere — a provably mismatched pair (mixed loopback-ness, a production half paired with a non-production one, or a stg / pp half paired with anything but its own origin; a single-origin `/console` beside its own `/xmem` on one origin is one deployment, even on a stg or pp host, while the production halves are still held to their defaults) is a usage error (exit **2**), on the interactive paste path as much as on the browser path. When the API comes from the rc: an origin that is neither https nor loopback (`localhost`, `127.0.0.1`, `[::1]`) is refused (naming a bare origin with `--api-url` beside an https `--console-url` accepts it), and if that API names its own Console (a `/xmem` base its `/console`, a local `:8000` its `:8002`, stg / pp their table Console), a named Console must be that one, in any spelling of that origin (case, a default port, trailing dots on a named host, `localhost` / `127.0.0.1` / `[::1]` for a loopback one) but on the same mount — any other is refused (exit **2**), so a `$XMEM_CONSOLE_URL` exported for one deployment cannot mint a key into the rc of a deployment whose Console is known. Naming the API on the call makes the pair the caller's: `--api-url` with the Console in `--console-url`, or `$XMEM_API_URL` with it in `--console-url` or `$XMEM_CONSOLE_URL`. With no `--api-url`, `$XMEM_API_URL` or positional URL, the API origin is the `api_url` of the nearest `.xmemrc.json` (see the 1.7.1 note below), and both the browser and the `--email` path print that origin and the file it came from before anything is sent — except for the production default, where nothing is printed. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
569
605
  | **`auth logout`** | Remove active `.xmemrc.json` |
570
- | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix + `super_admin` (whether the key is a super admin's, meaning its owner and its creator are both active super admins — what decides which Console view `trace …` return) + `permissions` (the key's own capability codes; `trace …` requires `data.read` among them). |
606
+ | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix + `super_admin` (whether the key is a super admin's, meaning its owner and its creator are both active super admins — what decides which Console view `trace …` return) + `permissions` (the key's own capability codes; `trace …` requires `data.read` among them). A Console named in `$XMEM_CONSOLE_URL` is cross-checked against the API origin before the key is sent to it (see the 1.7.1 note below); a mismatched one exits **2**. |
571
607
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
572
608
  | **`version`** | CLI release version (no network) |
573
609
  | **`status`** | Local health: version + whether `.xmemrc.json` exists, and the `mcp_url` it declares when it declares one (no network) |
@@ -692,6 +728,7 @@ condition, and the hook author is the one reading stderr.
692
728
  | **402** (quota exhausted) | **6** | `quota` | Quota window exhausted — human must extend quota in Console |
693
729
  | **any status** with `errors[0].code == WRITE_STILL_PROCESSING` | **8** | `processing` | Write is still running — the write did not fail. Matched on the **code**, whatever the status: a keepalive-flattened **200** and an unflattened **500** both exit 8, so this row overrides the 4xx/5xx row above. Poll `write-status <write_id>`; a retry starts a second write |
694
730
  | **503** with body `{"error": "instance_scope_unresolved" \| "accounts_unavailable", "reason": …}` | **3** | `unavailable` | The Console could not resolve what the key may see (its accounts API is unreachable, non-2xx, undecodable, or not configured). **Not** "no operations" and **not** a bad credential — nothing was listed or checked; retry once accounts recovers. Recognised on the body, so it is named the same on every command that meets it. |
731
+ | **200** to `trace ls --search --cursor …` with no `has_more` | **3** | `paging_unsupported` | The Console ignored the cursor and answered with its newest rows, so the answer is **not** the next page: a node not yet updated while a Console deploy rolls out, or a Console that no longer pages searches. Retry after a short wait; if it persists, restart the walk without `--cursor`. |
695
732
 
696
733
  #### How to interpret failures
697
734
 
@@ -707,7 +744,19 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
707
744
 
708
745
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
709
746
 
710
- **New in 1.6.1:** a removal that names a record nobody stores is reported under `changes.unmatched_deletes`; nothing was deleted for it. In a terminal each one prints as a `note:` line, next to the other advisory records — `note: unmatched_deletes Task task_id='T-999'` — where 1.6.0 printed it among the change lines. `--json` output is unchanged: the block was already kept verbatim. A server that predates the bucket never sends it, so this works against any server.
747
+ **New in 1.7.7:** `org key [name-or-prefix]` shows one key's role, its effective permission codes (the role plus a super-admin widening) and the clusters it is bound to, read from `GET /api-keys`; with no argument, the key in use, which the server marks with `is_caller` (a server that predates the mark is matched by the stored prefix, and two keys that share it ask for a name). A field the server does not give, or clusters this credential cannot all name, are reported unavailable, never as an empty grant or a shorter list, and a selector matches only as a prefix of the stored prefix. `org create key --name --role --cluster --out` mints a key for this user bound to exactly one cluster the key in use can already see, with a role no wider than its own; the key is written only to the new 0600 `--out` file and output carries its prefix and path, never the key. It needs a server that lets an API key create keys, which follows this release: until then the server answers **403** before minting anything, and the CLI says the server does not let a key create keys yet. Help and usage errors no longer show internal parser names such as `org_create_target`.
748
+
749
+ **New in 1.7.6:** `auth login --auto-approve` reads a stored callback only together with its capture signal (under the listener's capture lock), so a callback that landed in time can no longer be seen before its timestamp and mistaken for none; a fix to 1.7.5's deadline handling, with no other change.
750
+
751
+ **New in 1.7.5:** `auth login --auto-approve` no longer cuts a browser callback off at the deadline: a callback captured in time (the listener publishes the capture the moment it lands) is redeemed within the redeem's own bound (two sends of the code, each at the redeem timeout, plus a second), since on the zero-click path the redeem is what makes the key exist; a redeem that outlives that bound ends with the ambiguity stated (a key may exist; deactivate it under API → API Keys) as exit **2**, not the timeout's exit 5, since a retry loop on 5 would mint a key per retry; a callback that arrives after Ctrl+C or after a pasted key won is not redeemed at all, a pasted key is not used while a redeem is in flight, and Ctrl+C during a redeem says a key may exist. The login without the flag keeps its deadline exactly as before. The 409 for a repeat redeem names the key's prefix beside its name, since two same-second logins from one host are named alike. The `--auto-approve` prompt says a first-time address goes through onboarding and must run the command again.
752
+
753
+ **New in 1.7.4:** `auth login --auto-approve` is the zero-click browser handoff for a scripted onboarding: the CLI opens the usual `/xmemcli/<challenge>` page with `?auto_approve=1&host=<hostname>`, and once the browser holds a Console session — an existing one, or one created right then through the Console's own email, Google, GitHub or OIDC sign-in, after which `?next=` returns to the same URL (an address with no account yet lands on the Welcome page instead and must re-run the login once onboarded) — the Console obtains a code carrying an intent to mint a key named `CLI auto-approved login (<hostname>, <timestamp>)` (the email path's shape, with the method named) and navigates the tab to the loopback callback itself; nothing on that surface is clickable, and the delivery works on Safari as well as Chrome and Firefox because it is a top-level navigation, not a fetch. The listener, the `state` check, the redeem with the verifier and the rc write are the ones every browser login uses; the payload carries `method: pkce` and `auto_approve: true`. The key is created only when the CLI redeems the code with its verifier (`POST /cli/token`), never before, and exactly once per login: a second redeem of the same code is answered **409** naming the key (exit **2** in the CLI, with that message), since a stored key's plaintext cannot be served again — find it under API → API Keys, deactivate it if it is still active, and run the login again. A redeem whose reply is lost after the request was sent is retried once with the same code, which yields the key or that 409; lost twice, the CLI stops with the ambiguity stated (a key may exist; deactivate it under API → API Keys) rather than waiting out its timeout. A failed or abandoned handoff leaves no key behind. The flag is refused beside `--email` (exit **2**). Needs a Console that knows the parameter; an older one runs the manual page for the same URL, which still finishes with a click. The loopback listener's done page is in the Console's style; it still says "Code received" and names the terminal for the result, since it is served before the redeem.
754
+
755
+ **New in 1.7.3:** `changes.relationship_outcomes` reports proposed relationships that are absent when the write completes: `replaced` when the stored endpoints differ from the proposal, or `rejected` when no row for the proposal remains (also a restated link the write removed). `unavailable` means the write already committed in an earlier attempt and its relationship report is unavailable; the write succeeded and no mutations were repeated. Each of these prints as a `note: relationship_outcomes …` line after the changes, naming the relationship and its endpoints; surviving links stay under `created.relations`. One entry is different: an `unavailable` entry with an empty name and no endpoints means the whole changes summary of that earlier attempt is unavailable, object changes and keyless handles included, so empty change blocks do not mean nothing changed. It prints as `note: write report unavailable: the write committed; its complete changes summary is unavailable; no mutations were repeated`. `--json` preserves the server's report verbatim, and older servers that omit the bucket continue to work.
756
+
757
+ **New in 1.7.2:** a `trace ls --search --cursor …` answered without the paging fields exits **3** with `stage: "paging_unsupported"`, where 1.7.1 reported `stage: "request"` and advised retrying the same cursor; the advice now covers a Console that no longer pages too (retry after a short wait, and if it persists restart the walk without `--cursor`). A plain `trace ls --cursor` with more than two parts is a usage error (exit **2**) saying a three-part cursor belongs to `--search`, where 1.7.1 sent everything after the first colon as the trace id. A cursor time past 2299-12-31, the Console's own bound, is a usage error too, where the Console answered a bare 400 (or a plain listing served its first page again). `trace …` wait up to 70 seconds between reads of a Console answer, where they gave up at 30 — enough for one Console query's read queue and deadline; a search that runs several queries in a row can still take longer. A scoped write also reports the stored scoped records no action of it touched under `changes.untouched_scope_records` — usually text that did not name them, such as "delete this record", or only restated their stored values on a write the diff engine ran, which used to return an empty `changes` that read as success. In a terminal each one prints as a `note:` line, next to the other advisory records — `note: untouched_scope_records Task task_id='T-1'` — where 1.7.1 printed it among the change lines. `--json` output is unchanged: the block was already kept verbatim. A server that predates the bucket never sends it, so this works against any server.
758
+
759
+ **New in 1.6.1:** a removal the server could not tie to exactly one stored record is reported under `changes.unmatched_deletes`; nothing was deleted for it. In a terminal each one prints as a `note:` line, next to the other advisory records — `note: unmatched_deletes Task task_id='T-999'` — where 1.6.0 printed it among the change lines. `--json` output is unchanged: the block was already kept verbatim. A server that predates the bucket never sends it, so this works against any server.
711
760
 
712
761
  **New in 1.6.0:** `trace calls` lists the LLM calls of one session, a page at a time, and `trace call` opens one call whole — for super admins, as the Console decides. Both need a Console that serves them; an older one answers **401**, exit **4** (`auth`), for a key that is fine.
713
762
 
@@ -735,6 +784,8 @@ Two consequences worth knowing. Connect instructions from the API assume **1.0.0
735
784
 
736
785
  **Compatibility change in 1.0.0:** console instance page URLs for login (above), immediate ``api_url`` staging in ``.xmemrc.json``, and **1.0.0** as the minimum version named by connect setup instructions. **Revised in 1.1.1:** staging writes only a placeholder when no rc exists (an existing credential file is left alone until login succeeds); single-origin connect instructions name **1.1.1** as their minimum — earlier releases let `$XMEM_CONSOLE_URL` steer the sign-in Console; and HTTPS-or-loopback now applies to every single-origin login URL, including instance pages, not only the new `/console` and `/xmem` forms. A mistaken `--api-url http://host/xmem` is refused the same way.
737
786
 
787
+ **Compatibility change in 1.7.1:** a bare `auth login` (no `--api-url`, `$XMEM_API_URL` or positional URL) now signs in to the API origin recorded in the nearest `.xmemrc.json` — found by walking up from `--rc-dir`, `$XMEM_RC_DIR` or the working directory, ending at `~/.xmemrc.json`. 1.7.0 ignored that file: it signed in to production and wrote the new, production-bound key into an rc in the working directory or `--rc-dir`. `auth login --email` already followed the rc, but refused a stg or pp origin as having no known Console. A home rc bound to stg, pp or a local stack therefore makes a fresh project's login mint a key there; pass `--api-url https://api.xmemory.ai` to sign in to production instead. An rc origin with no known Console (one that is not production, stg, pp, a `/xmem` base or a local `:8000`), or one that is not a usable http(s) URL, is now refused (exit **2**, naming the file) where it used to sign in to production. Both login paths print the rc-chosen origin and the file before anything is sent (not for the production default). The Consoles of `https://api.stg.xmemory.ai` and `https://api.pp.xmemory.ai` are inferred for `auth login` and `trace`. Their MCP origins, and dk's (`https://dk-mcp.xmemory.ai`), are inferred for `mcp` when no MCP origin is named or recorded in the rc — including for a key named with `--api-key` and `--api-url`, which 1.7.0 sent to the production MCP. The pairing check is stricter everywhere it runs: a stg or pp Console with any API but its own origin, or the reverse, is refused whatever path, query or userinfo either half carries, unless the two are a single-origin `/console` beside its own `/xmem`; so is a percent sign, backslash or non-ASCII character in either half's host or userinfo. `auth login`, `trace` and `auth status` exit **2** on it; the `org` permission probe declines to send the key and reports the original 403. When the API comes from the rc: an origin that is neither https nor loopback (`localhost`, `127.0.0.1`, `[::1]`) is refused (naming a bare origin with `--api-url` beside an https `--console-url` accepts it), and if that API names its own Console (a `/xmem` base its `/console`, a local `:8000` its `:8002`, stg / pp their table Console), a named Console must be that one, in any spelling of that origin (case, a default port, trailing dots on a named host, `localhost` / `127.0.0.1` / `[::1]` for a loopback one) but on the same mount — any other is refused (exit **2**), so a `$XMEM_CONSOLE_URL` exported for one deployment cannot mint a key into the rc of a deployment whose Console is known. Naming the API on the call makes the pair the caller's: `--api-url` with the Console in `--console-url`, or `$XMEM_API_URL` with it in `--console-url` or `$XMEM_CONSOLE_URL`. `auth status`, `trace` and the `org` permission probe hold a named Console to the same rule while the call names no API: the first two exit **2**, saying how to keep that Console, and the probe declines to send the key and reports the original 403. There, naming the API on the call (`--api-url` or `$XMEM_API_URL`, which must match the rc's origin the key is bound to) lifts the rule, and the Console still comes from `--console-url` (`trace`) or `$XMEM_CONSOLE_URL` — unlike login, `--api-url` does not turn `$XMEM_CONSOLE_URL` off for these. Explicit origins are held to the same shape: an `--api-url` / `$XMEM_API_URL`, or the API of a positional single-origin URL, with userinfo, a query, a fragment, a non-http(s) scheme, a percent-encoded or non-ASCII host (spell an internationalised host as `xn--…`), or a host that is neither dot-separated labels of letters, digits, `-` and `_` nor a bracketed IP address, is refused (exit **2**) even when no Console is named, where 1.7.0 recorded it. A recorded `api_url` keeps an IPv6 literal's brackets, and the host as it was written (a trailing dot is recorded, and compared as the same host); 1.7.0 and 1.7.1 read each other's files. `auth status` refuses an `api_url` it cannot parse (exit **2**, naming the rc it came from) where 1.7.0 crashed, exited 3 or, for one such as `http://::1:8000`, went on to use it and exited 0. `auth login` refuses (exit **2**), before it writes or mints anything, an rc directory (`--rc-dir`, `$XMEM_RC_DIR` or the working directory) that is unwritable or unreadable; one whose nearest existing parent is a file, a broken symlink, a symlink loop or unwritable; and a working directory that no longer exists when the rc directory is relative to it. 1.7.0 minted the key and then could not save it (or, on Python 3.12, crashed on a loop). A login that saves nothing, answered by a matching `--api-key` or rc, is not refused. A leading `~` in `--rc-dir`, `$XMEM_RC_DIR`, `--binding-dir` or `$XMEM_BINDING_DIR` is now the home directory for every command, where 1.7.0 read and wrote a directory literally named `~` when the shell left it unexpanded (`--rc-dir=~/x`). If such a directory exists (`./~/x`), it is still the one used, so a credential or binding 1.7.0 wrote there keeps being read, and `auth logout` removes that one; move its files to the home path and delete it to switch. The browser login now moves a malformed rc it replaces aside to `.xmemrc.json.invalid` (mode 0600), as the email login already did, where 1.7.0 overwrote it. Both paths warn about the malformed file before signing in, and refuse (exit **2**) before minting when every backup name beside it is taken; if the file still cannot be moved after the mint, the login exits **3**, naming the new key's prefix, and overwrites nothing. On 1.7.0 and older, signing in to pp or stg needs `--api-url` **and** `--console-url` together: a browser login from an rc bound to pp with `--console-url` alone is refused (`--email` accepted it), and with no flags the login goes to production.
788
+
738
789
  See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
739
790
 
740
791
  ---
@@ -822,7 +873,7 @@ These apply **before** any subcommand (`xmemcli help options`).
822
873
 
823
874
  | `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
824
875
  | `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
825
- | `--console-url` (accepted by `trace …` only; `$XMEM_CONSOLE_URL` is read by `auth status` too) | `XMEM_CONSOLE_URL` | Console origin serving the trace views (inferred for the public and local API pairs; required for any other API origin). A named Console that provably does not pair with the API origin is refused; the refusal names the setting that supplied it, since only `trace` accepts the flag |
876
+ | `--console-url` (accepted by `trace …` and `auth login`; `$XMEM_CONSOLE_URL` is read by `auth status` too) | `XMEM_CONSOLE_URL` | Console origin serving the trace views (inferred for the public, stg, pp and local API pairs and the `/console` sibling of a `/xmem` base; required for any other API origin). A named Console that provably does not pair with the API origin is refused; the refusal names the setting that supplied it, since `auth status` does not accept the flag |
826
877
  | `--json` | — | Force one JSON document on stdout |
827
878
  | `--verbose` | — | Add `trace_id` / `console_url` when present (never SQL, diff plans, or internals) |
828
879
 
@@ -842,7 +893,7 @@ and each delivery mechanism turned out to be a fresh bypass.
842
893
 
843
894
  **Origin resolution:** `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **with one escape hatch**: when the key comes from `--api-key` / `$XMEM_API_KEY` (not from the rc), the rc's `api_url` is **not** consulted and the origin falls back to the built-in default (`https://api.xmemory.ai`) unless you also set `--api-url` / `$XMEM_API_URL`. That keeps an explicitly supplied key from being silently paired with an unrelated origin in a project `.xmemrc.json`. If `$XMEM_API_KEY` is set without `$XMEM_API_URL`, the CLI warns once per invocation.
844
895
 
845
- **MCP origin resolution (`mcp` only):** `--mcp-url` → `$XMEM_MCP_URL` → the rc's `mcp_url` → default (`https://mcp.xmemory.ai`). The rc rung follows the same escape hatch as the API origin: a key named with `--api-key` / `$XMEM_API_KEY` never inherits an origin from a project file.
896
+ **MCP origin resolution (`mcp` only):** `--mcp-url` → `$XMEM_MCP_URL` → the rc's `mcp_url` → the API base's `/mcp` sibling (single-origin) or its hosted pair (stg, pp, dk) → default (`https://mcp.xmemory.ai`). The rc rung follows the same escape hatch as the API origin: a key named with `--api-key` / `$XMEM_API_KEY` never inherits an origin from a project file.
846
897
 
847
898
  **Request identification (new in 1.3.0):** every outbound request carries `X-Xmemory-Client: xmemcli/<version> (python <version>; <system>-<machine>)`, e.g. `xmemcli/1.3.0 (python 3.12.4; Linux-x86_64)`. Each field is reduced to letters, digits, `.`, `_` and `-`, so nothing an OS or a patched interpreter reports can forge the note's own punctuation or fail the request when it is sent; a field with nothing usable left reads `unknown`. No hostname is included. The server uses this header to attribute traffic to a client. The CLI sets no `User-Agent` of its own, then or now: the installed `urllib` opener supplies its default, so the `User-Agent` the server sees is `Python-urllib/<version>` and the identity comes from the header above. Direct API requests from 1.2.0 and earlier send neither, so they record `client_name: python-urllib` -- indistinguishable from any third-party Python caller. Reading a drop in that bucket as third-party traffic leaving is therefore wrong; it is old CLIs upgrading. Three outcomes are distinct and must not be read as one figure: a recognised header, the `other` bucket a present but unrecognised header lands in, and an absent property, which means neither header named a client. Before 1.3.0 the email sign-in exchange was the only path that named itself, and it sent a bare `xmemcli/<version>` with no platform hint; it now sends the same header and the same value as every other path. What the server records for it still differs, though, and not because of the CLI: the console-facing calls (email sign-in, token redemption, whoami) reach the accounts API through the console, which rebuilds the request on its own HTTP client and forwards only Cookie, Content-Type, Authorization and Host. `X-Xmemory-Client` does not survive that hop, so those land as `console`. The attribution described above is what direct API calls record.
848
899