xmemcli 1.7.6__tar.gz → 1.7.8__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.
- {xmemcli-1.7.6 → xmemcli-1.7.8}/PKG-INFO +17 -9
- {xmemcli-1.7.6 → xmemcli-1.7.8}/README.md +16 -8
- {xmemcli-1.7.6 → xmemcli-1.7.8}/pyproject.toml +1 -1
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/__init__.py +1 -1
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_binding.py +1 -2
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_binding_cmd.py +1 -3
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_email_login.py +2 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_help.py +6 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_login.py +1 -1
- xmemcli-1.7.8/xmemcli/_org_cluster.py +499 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_output.py +10 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_pretty.py +44 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_rc.py +4 -12
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_trace.py +1 -1
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_version.py +1 -1
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/cli.py +30 -1
- xmemcli-1.7.6/xmemcli/_org_cluster.py +0 -153
- {xmemcli-1.7.6 → xmemcli-1.7.8}/.gitignore +0 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_context_cmd.py +0 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_envelope.py +0 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_instance_schema.py +0 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_mcp_bridge.py +0 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_text.py +0 -0
- {xmemcli-1.7.6 → xmemcli-1.7.8}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.8
|
|
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
|
|
@@ -175,14 +175,14 @@ xmemcli version
|
|
|
175
175
|
xmemcli auth login
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
Version: **1.7.
|
|
178
|
+
Version: **1.7.8**.
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
---
|
|
182
182
|
|
|
183
183
|
# `xmemcli` auth flow — PKCE handoff, email sign-in, and credentials
|
|
184
184
|
|
|
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`). 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.
|
|
186
186
|
|
|
187
187
|
Two acquisition paths share `xmemcli auth login`:
|
|
188
188
|
|
|
@@ -247,6 +247,8 @@ The residual is the authored link itself: there the verifier belongs to whoever
|
|
|
247
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.
|
|
248
248
|
5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
|
|
249
249
|
|
|
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. A temporary mint limit answers 429 and a busy server 503, neither creating a key; from 1.7.8 the CLI says so, and it tells that refusal apart from the server's refusal of the key itself by the generic `Insufficient permissions` text alone, so the server may reword its own. 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
|
+
|
|
250
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.
|
|
251
253
|
|
|
252
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.
|
|
@@ -273,7 +275,7 @@ The `AuthApiKey` body also carries **`mcp_url`**, this deployment's own MCP orig
|
|
|
273
275
|
|
|
274
276
|
| Threat | Mitigation |
|
|
275
277
|
| --- | --- |
|
|
276
|
-
| 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 |
|
|
277
279
|
| Key in URL | URL carries code only; key over TLS on handoff/token |
|
|
278
280
|
| Captured code replay | PKCE verifier never leaves CLI; code is TTL-bounded |
|
|
279
281
|
| CSRF on callback | state = PKCE challenge; redeem requires verifier |
|
|
@@ -294,9 +296,9 @@ Each login binds its own port and names it in the login URL, so concurrent login
|
|
|
294
296
|
|
|
295
297
|
# `xmemcli` — capabilities reference
|
|
296
298
|
|
|
297
|
-
**Version:** `1.7.
|
|
299
|
+
**Version:** `1.7.8` (see `xmemcli version`)
|
|
298
300
|
|
|
299
|
-
`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). 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.
|
|
300
302
|
|
|
301
303
|
```bash
|
|
302
304
|
xmemcli … # installed CLI
|
|
@@ -340,6 +342,8 @@ Permission-filtered view of what this API key can see.
|
|
|
340
342
|
| **`org list clusters`** | Clusters visible to this credential |
|
|
341
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. |
|
|
342
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, except the server's own **503** (busy), which created nothing; a **429** is a temporary mint limit whose message names it, and a **400** a request the server refuses (such as a `--name` over 255 characters): neither created a key, and a 429 or 503 is safe to retry after a short wait. A 404 means `--cluster` is not linked to this key; a 422 that names `api_role_name` means the server predates this command, which needs a server from the same release as this CLI; a 403 saying only `Insufficient permissions` means the server does not let a key create keys yet (that arrives in a later server release than this CLI), and any other 403 that it will not mint this key: the role, the owner's permission, this key or the cluster. |
|
|
343
347
|
|
|
344
348
|
---
|
|
345
349
|
|
|
@@ -557,11 +561,11 @@ learn; the CLI composes the Console's search clauses itself.
|
|
|
557
561
|
|
|
558
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`.
|
|
559
563
|
|
|
560
|
-
> **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 `
|
|
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 `CONSOLE_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
|
|
561
565
|
|
|
562
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:
|
|
563
567
|
|
|
564
|
-
- `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;
|
|
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.
|
|
565
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.
|
|
566
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.
|
|
567
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.
|
|
@@ -575,7 +579,7 @@ learn; the CLI composes the Console's search clauses itself.
|
|
|
575
579
|
|
|
576
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.
|
|
577
581
|
|
|
578
|
-
**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` 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 three model calls by its step names above, with the prefix `shadow.` in shadow mode; `diff_engine.intent_operations` records decisions in on mode only, where it stands in for `diff_engine.intent_rules`, and `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`, `object`, `identity_change`, `merge`, `filtered_deletion`, `filtered_update`, `relation`, `deletion`, `alias`, `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 `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`), `unresolved` (it saw a change whose records it could not name: a record described rather than named, a key stated in part, 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), `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), or the step a run without an answer stopped at (`gate`, `operations`, `verification`, `executor`, `late`, `not_run`). Beside `fallback`, `detail.dropped_operations` lists each operation the pipeline dropped (`position` in the model's reply, `operation` kind when readable, `reason`), and `detail.unresolved_changes` each change it could not name the records of (`position`, `evidence`, the passage that asks for it, and `object_type` 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[]`.
|
|
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`, `unlink_all`, `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, an unlink_all 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, some but not all links of a kind, or all of them handed over to another record; every one of them ending, but those the text keeps, is an `unlink_all`), `rename_or_merge` (it holds a rename or an unlink_all sharing a record with a merge, renames that swap, cycle, fork or converge, or an unlink_all whose record another operation names in its relation, 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), `unlink_all_with_links` (it ends every link of a kind that a record has, while the write's own extraction links that record by that kind to a record it does not keep, or holds another record of its type that nothing accounts for, as a transfer the answer misread would), `unlink_all_names_an_end` (the extraction holds a record of the kind an unlink_all ends that it does not keep, as when the text names the one link it ends, or the unlink_all keeps a record at only some of the roles its type takes), `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, a move's new end and a record an unlink_all keeps 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, an unlink_all's own record, 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), `kept_record_not_stored` (a record an unlink_all keeps binds no single stored row, so its link could not be told from the others and could end; a record the write creates counts too when the unlink_all would end a stored link, which could be that record under another spelling), `kept_link_missing` (a record an unlink_all keeps has no link to the unlink_all's record, stored or in the write, so the record would be left with no link of the kind), `unlink_all_partial_fetch` (an unlink_all's record's links were read from a fetch cut short, so the links not read 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[]`.
|
|
579
583
|
|
|
580
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.
|
|
581
585
|
|
|
@@ -740,6 +744,10 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
|
|
|
740
744
|
|
|
741
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.
|
|
742
746
|
|
|
747
|
+
**New in 1.7.8:** `org create key` reads the answers of the server release that lets a key create keys. Its own **503** (busy) now says no key was created and the command is safe to run again, where a bare 503 from a proxy still says a key may exist; a **429** (a temporary mint limit, named by the server's message) and a **400** (such as a `--name` over 255 characters) say no key was created. A 403 is read as "this server does not let a key create keys yet" only when it says nothing but the generic `Insufficient permissions`; any other 403 is the server's refusal of this key's mint, whatever its wording, so the server may reword that refusal without a CLI release. A 422 is blamed on a server that predates the command only when it names `api_role_name`.
|
|
748
|
+
|
|
749
|
+
**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`.
|
|
750
|
+
|
|
743
751
|
**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.
|
|
744
752
|
|
|
745
753
|
**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.
|
|
@@ -167,14 +167,14 @@ xmemcli version
|
|
|
167
167
|
xmemcli auth login
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
Version: **1.7.
|
|
170
|
+
Version: **1.7.8**.
|
|
171
171
|
|
|
172
172
|
|
|
173
173
|
---
|
|
174
174
|
|
|
175
175
|
# `xmemcli` auth flow — PKCE handoff, email sign-in, and credentials
|
|
176
176
|
|
|
177
|
-
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.
|
|
177
|
+
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.
|
|
178
178
|
|
|
179
179
|
Two acquisition paths share `xmemcli auth login`:
|
|
180
180
|
|
|
@@ -239,6 +239,8 @@ The residual is the authored link itself: there the verifier belongs to whoever
|
|
|
239
239
|
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.
|
|
240
240
|
5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
|
|
241
241
|
|
|
242
|
+
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. A temporary mint limit answers 429 and a busy server 503, neither creating a key; from 1.7.8 the CLI says so, and it tells that refusal apart from the server's refusal of the key itself by the generic `Insufficient permissions` text alone, so the server may reword its own. 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.
|
|
243
|
+
|
|
242
244
|
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.
|
|
243
245
|
|
|
244
246
|
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.
|
|
@@ -265,7 +267,7 @@ The `AuthApiKey` body also carries **`mcp_url`**, this deployment's own MCP orig
|
|
|
265
267
|
|
|
266
268
|
| Threat | Mitigation |
|
|
267
269
|
| --- | --- |
|
|
268
|
-
| Key in AI transcript | localhost callback / in-process mint → `0600` store; login stdout shows `key_prefix` only |
|
|
270
|
+
| 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 |
|
|
269
271
|
| Key in URL | URL carries code only; key over TLS on handoff/token |
|
|
270
272
|
| Captured code replay | PKCE verifier never leaves CLI; code is TTL-bounded |
|
|
271
273
|
| CSRF on callback | state = PKCE challenge; redeem requires verifier |
|
|
@@ -286,9 +288,9 @@ Each login binds its own port and names it in the login URL, so concurrent login
|
|
|
286
288
|
|
|
287
289
|
# `xmemcli` — capabilities reference
|
|
288
290
|
|
|
289
|
-
**Version:** `1.7.
|
|
291
|
+
**Version:** `1.7.8` (see `xmemcli version`)
|
|
290
292
|
|
|
291
|
-
`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). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
|
|
293
|
+
`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.
|
|
292
294
|
|
|
293
295
|
```bash
|
|
294
296
|
xmemcli … # installed CLI
|
|
@@ -332,6 +334,8 @@ Permission-filtered view of what this API key can see.
|
|
|
332
334
|
| **`org list clusters`** | Clusters visible to this credential |
|
|
333
335
|
| **`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. |
|
|
334
336
|
| **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
|
|
337
|
+
| **`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. |
|
|
338
|
+
| **`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, except the server's own **503** (busy), which created nothing; a **429** is a temporary mint limit whose message names it, and a **400** a request the server refuses (such as a `--name` over 255 characters): neither created a key, and a 429 or 503 is safe to retry after a short wait. A 404 means `--cluster` is not linked to this key; a 422 that names `api_role_name` means the server predates this command, which needs a server from the same release as this CLI; a 403 saying only `Insufficient permissions` means the server does not let a key create keys yet (that arrives in a later server release than this CLI), and any other 403 that it will not mint this key: the role, the owner's permission, this key or the cluster. |
|
|
335
339
|
|
|
336
340
|
---
|
|
337
341
|
|
|
@@ -549,11 +553,11 @@ learn; the CLI composes the Console's search clauses itself.
|
|
|
549
553
|
|
|
550
554
|
**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`.
|
|
551
555
|
|
|
552
|
-
> **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 `
|
|
556
|
+
> **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 `CONSOLE_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
|
|
553
557
|
|
|
554
558
|
**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:
|
|
555
559
|
|
|
556
|
-
- `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;
|
|
560
|
+
- `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.
|
|
557
561
|
- `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.
|
|
558
562
|
- `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.
|
|
559
563
|
- `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.
|
|
@@ -567,7 +571,7 @@ learn; the CLI composes the Console's search clauses itself.
|
|
|
567
571
|
|
|
568
572
|
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.
|
|
569
573
|
|
|
570
|
-
**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` 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 three model calls by its step names above, with the prefix `shadow.` in shadow mode; `diff_engine.intent_operations` records decisions in on mode only, where it stands in for `diff_engine.intent_rules`, and `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`, `object`, `identity_change`, `merge`, `filtered_deletion`, `filtered_update`, `relation`, `deletion`, `alias`, `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 `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`), `unresolved` (it saw a change whose records it could not name: a record described rather than named, a key stated in part, 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), `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), or the step a run without an answer stopped at (`gate`, `operations`, `verification`, `executor`, `late`, `not_run`). Beside `fallback`, `detail.dropped_operations` lists each operation the pipeline dropped (`position` in the model's reply, `operation` kind when readable, `reason`), and `detail.unresolved_changes` each change it could not name the records of (`position`, `evidence`, the passage that asks for it, and `object_type` 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[]`.
|
|
574
|
+
**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`, `unlink_all`, `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, an unlink_all 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, some but not all links of a kind, or all of them handed over to another record; every one of them ending, but those the text keeps, is an `unlink_all`), `rename_or_merge` (it holds a rename or an unlink_all sharing a record with a merge, renames that swap, cycle, fork or converge, or an unlink_all whose record another operation names in its relation, 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), `unlink_all_with_links` (it ends every link of a kind that a record has, while the write's own extraction links that record by that kind to a record it does not keep, or holds another record of its type that nothing accounts for, as a transfer the answer misread would), `unlink_all_names_an_end` (the extraction holds a record of the kind an unlink_all ends that it does not keep, as when the text names the one link it ends, or the unlink_all keeps a record at only some of the roles its type takes), `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, a move's new end and a record an unlink_all keeps 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, an unlink_all's own record, 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), `kept_record_not_stored` (a record an unlink_all keeps binds no single stored row, so its link could not be told from the others and could end; a record the write creates counts too when the unlink_all would end a stored link, which could be that record under another spelling), `kept_link_missing` (a record an unlink_all keeps has no link to the unlink_all's record, stored or in the write, so the record would be left with no link of the kind), `unlink_all_partial_fetch` (an unlink_all's record's links were read from a fetch cut short, so the links not read 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[]`.
|
|
571
575
|
|
|
572
576
|
**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.
|
|
573
577
|
|
|
@@ -732,6 +736,10 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
|
|
|
732
736
|
|
|
733
737
|
**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.
|
|
734
738
|
|
|
739
|
+
**New in 1.7.8:** `org create key` reads the answers of the server release that lets a key create keys. Its own **503** (busy) now says no key was created and the command is safe to run again, where a bare 503 from a proxy still says a key may exist; a **429** (a temporary mint limit, named by the server's message) and a **400** (such as a `--name` over 255 characters) say no key was created. A 403 is read as "this server does not let a key create keys yet" only when it says nothing but the generic `Insufficient permissions`; any other 403 is the server's refusal of this key's mint, whatever its wording, so the server may reword that refusal without a CLI release. A 422 is blamed on a server that predates the command only when it names `api_role_name`.
|
|
740
|
+
|
|
741
|
+
**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`.
|
|
742
|
+
|
|
735
743
|
**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.
|
|
736
744
|
|
|
737
745
|
**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.
|
|
@@ -871,8 +871,7 @@ def merge_binding_files(paths: list[Path], *, user_path: Path | None) -> Binding
|
|
|
871
871
|
if "engage" in floor:
|
|
872
872
|
entry["engage"] = floor["engage"]
|
|
873
873
|
instances = [
|
|
874
|
-
_freeze(entry, origin[instance_id], tier_origin.get(instance_id))
|
|
875
|
-
for instance_id, entry in merged.items()
|
|
874
|
+
_freeze(entry, origin[instance_id], tier_origin.get(instance_id)) for instance_id, entry in merged.items()
|
|
876
875
|
]
|
|
877
876
|
# Deterministic rendering order: eager first, then alphabetical. Declaration
|
|
878
877
|
# order across several files is not a meaningful sort key.
|
|
@@ -219,9 +219,7 @@ def _drain_binding_warnings(args: argparse.Namespace) -> None:
|
|
|
219
219
|
xmem_module()._note_cli_warning(args, message)
|
|
220
220
|
|
|
221
221
|
|
|
222
|
-
def _warn_if_autoload_is_getting_crowded(
|
|
223
|
-
args: argparse.Namespace, entries: list[dict[str, Any]], scope: str
|
|
224
|
-
) -> None:
|
|
222
|
+
def _warn_if_autoload_is_getting_crowded(args: argparse.Namespace, entries: list[dict[str, Any]], scope: str) -> None:
|
|
225
223
|
"""Count `autoload` entries after an edit and mention it once past the threshold.
|
|
226
224
|
|
|
227
225
|
Counted from what `upsert_entry` just wrote rather than by re-reading the file.
|
|
@@ -55,6 +55,8 @@ from ._version import CLI_NAME, CLIENT_HEADER, client_identity
|
|
|
55
55
|
|
|
56
56
|
# Console-origin endpoints. The five sign-in routes authenticate by cookie proofs alone; the
|
|
57
57
|
# rest require the minted session cookie, which the opener's in-memory jar replays.
|
|
58
|
+
# Keep the established wire URLs so this CLI also works with pre-rename servers. New Consoles
|
|
59
|
+
# serve both spellings; changing these paths would require users to upgrade their server first.
|
|
58
60
|
SIGNIN_REQUEST_PATH = "/api/kingdom-signin-request"
|
|
59
61
|
SIGNIN_VERIFY_PATH = "/api/kingdom-signin-verify"
|
|
60
62
|
SIGNIN_CLAIM_PATH = "/api/kingdom-signin-claim"
|
|
@@ -23,6 +23,7 @@ _INTERNAL_DESTS = frozenset(
|
|
|
23
23
|
"instance_command",
|
|
24
24
|
"org_command",
|
|
25
25
|
"org_list_target",
|
|
26
|
+
"org_create_target",
|
|
26
27
|
"cluster_command",
|
|
27
28
|
"cluster_list_target",
|
|
28
29
|
"auth_command",
|
|
@@ -121,6 +122,8 @@ _OVERVIEW_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
|
121
122
|
("org list clusters", "Clusters visible to this credential"),
|
|
122
123
|
("org list instances", "Instances visible to this credential"),
|
|
123
124
|
("org list keys", "API keys visible to this credential"),
|
|
125
|
+
("org key", "One key's role, effective permissions, and the clusters it is bound to"),
|
|
126
|
+
("org create key", "Create a key for this user, bound to one cluster"),
|
|
124
127
|
),
|
|
125
128
|
),
|
|
126
129
|
(
|
|
@@ -647,6 +650,9 @@ def _format_cli_parse_error(message: str) -> str:
|
|
|
647
650
|
|
|
648
651
|
if message.startswith("the following arguments are required:"):
|
|
649
652
|
missing = message.split(":", 1)[1].strip()
|
|
653
|
+
if missing in _INTERNAL_DESTS:
|
|
654
|
+
# A subparser's dest (`org_create_target`) is a parser name, not something the user can type.
|
|
655
|
+
return f"{CLI_NAME}: missing subcommand\n\nRun `{CLI_NAME} help` for the commands and their subcommands."
|
|
650
656
|
return f"{CLI_NAME}: missing required argument {missing}"
|
|
651
657
|
|
|
652
658
|
if message.startswith("unrecognized arguments:"):
|
|
@@ -241,7 +241,7 @@ def is_single_origin_login_path(url: str) -> bool:
|
|
|
241
241
|
# outcome here would mean holding the response open across the redeem, so the body sends the person to
|
|
242
242
|
# the terminal for the result, and the heading states the common case, which on the zero-click path is
|
|
243
243
|
# a redeem seconds after the mint.
|
|
244
|
-
# The Console's standalone-page template (tools/xtracer/
|
|
244
|
+
# The Console's standalone-page template (tools/xtracer/console/static/access_denied.html): the brand
|
|
245
245
|
# stylesheet and its fonts from xmemory.ai, `xm-gradient-bg` on the body, a `xm-card--glass` holding a title
|
|
246
246
|
# and a body line, with the two page-local rules those pages carry. Offline the stylesheet does not load and
|
|
247
247
|
# the page is plain but readable.
|