xmemcli 1.7.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 1.7.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,7 +175,7 @@ xmemcli version
175
175
  xmemcli auth login
176
176
  ```
177
177
 
178
- Version: **1.7.7**.
178
+ Version: **1.7.8**.
179
179
 
180
180
 
181
181
  ---
@@ -247,7 +247,7 @@ 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. 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.
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
251
 
252
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.
253
253
 
@@ -296,7 +296,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
296
296
 
297
297
  # `xmemcli` — capabilities reference
298
298
 
299
- **Version:** `1.7.7` (see `xmemcli version`)
299
+ **Version:** `1.7.8` (see `xmemcli version`)
300
300
 
301
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.
302
302
 
@@ -343,7 +343,7 @@ Permission-filtered view of what this API key can see.
343
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. |
344
344
  | **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
345
345
  | **`org key`** `[name-or-prefix]` | One key's role, its effective permission codes (the role plus a super-admin widening), and the clusters it is bound to when this credential can name every one of them. If it cannot, that field is unavailable, not a shorter list and not an empty one. A missing field is unavailable, not an empty grant. With no argument, the key this command is using, which the server marks in the list (`is_caller`); a server that predates that mark is matched by the stored prefix, and two keys that share it ask for a name. A selector is a name or a prefix of the stored prefix; a longer value is not a key, and an error keeps only the first eight characters of an `xmem_` selector. |
346
- | **`org create key`** `--name` `--role` `--cluster` `--out` | Mint a key for this user bound to exactly one cluster this key can already see. `--cluster` is a name or an id; `--role` is the role's name (`org key` shows the role of each key you hold), or its id when two roles share the name. The new role cannot grant a permission this key lacks, and the owner must hold `api_key.create_own` or `create_all`. The key is written only to `--out`, a new file created 0600 (an existing file is refused); output carries `key_prefix` and `key_file`, never the key, and nothing goes to `.xmemrc.json`. The new key keeps that one cluster (clusters created later are not added), expires no later than this key, and is deactivated when this key is, or unlinked from the cluster when this key is. To contain a leaked key, deactivate it in the Console: what it minted goes with it. A lost reply or a 5xx says a key may still exist; a 404 means `--cluster` is not linked to this key; a 422 means the server predates this command, which needs a server from the same release as this CLI; a bare 403 means the server does not let a key create keys yet (that arrives in a later server release than this CLI). |
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. |
347
347
 
348
348
  ---
349
349
 
@@ -561,7 +561,7 @@ learn; the CLI composes the Console's search clauses itself.
561
561
 
562
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`.
563
563
 
564
- > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and the Console refuses a key's `User:` search clause naming a whole address (`User:sarah@example.com`) with a `400` `user_email_filter_unsupported_for_api_key`; filter by `user_id` instead. For that reason `--user-id` refuses a value containing `@` as a usage error (exit **2**) on both `trace ls` and `trace ls --search`, rather than sending a filter no key can resolve. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects` (an array holding one `{objects, relations}` document, failed writes included; empty when no extracted objects reached the journal: a write that failed before extraction finished or extracted nothing, or one that timed out), `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries` (the rows owned by the read's instance, for every caller who may open the read), and two tiers of gated fields. **Super admin only** — the accounts API decides, for a key as for a browser session (a key counts only when its owner and its creator are both active super admins): `llm_calls[]` (each model call the operation made; fields below), `step_decisions[]` (what each step of the operation decided, per item; below), `full_error_message`, the write's and read's cause code `error_code`, raw token counts, and unsanitised xuids. **Callers with no instance restriction at all** — a super admin, or a browser session on a `KINGDOM_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
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`.
565
565
 
566
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:
567
567
 
@@ -579,7 +579,7 @@ learn; the CLI composes the Console's search clauses itself.
579
579
 
580
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.
581
581
 
582
- **Each `step_decisions[]` entry** is one record of what one step of the operation decided, oldest first (by `recorded_at`, then `sub_stage`). A step records each time it runs and decides something (a step that decided nothing records nothing), so one operation can hold several records of one step, and every record is served; none is merged into another. A write's deletion check records in its own process and again where the diff engine applies its gate, and a write retried whole under the same trace id (a stale-schema retry, a stall requeue, a stuck write claimed again) records every step again. Writes, reads and schema evolution record today. A write's extraction records too, under `extraction.` steps named like its model calls (`extraction.structured_output`, `extraction.description_merger`, `extraction.batch_field_extractor`, `extraction.field_verifier`, `extraction.relations_extractor`, `extraction.relations_verifier`, `extraction.deduplication`): what code decided about a reply (candidates folded into one, a reply set aside so the extracted values stand, connections dropped, a candidate left out of the write, a group of types split), and the verifiers' own rejections. Field verification records once per object type, when that type's field processing ends; a write that fails before then records none for that type. When the judge in front of field verification is configured but does not run on a write whose detection found something, the write records why, once, under the stage the judge's calls would journal under (`extraction.verification_judge`, or `shadow.extraction.verification_judge` in shadow mode): one item, `kind` `judge`, `decision` `not_run`, with `detail.not_run_reason` (`sampled_out`, `pinned_provider` or `config_unavailable`) and `detail.mode`. Extraction's items have the diff engine's shape: `kind` is `merge`, `object`, `relation`, `relation_type` or `type_group`; `decision` is `folded`, `skipped`, `not_held` (the verifier found the text does not state the object holds, so it is marked as one that does not), `kept_unverified`, `verifier_skipped` (field verification did not run because the judge in front of it cleared the object), `key_restored`, `rejected`, `uncastable`, `dropped`, `split` or `escalation_disabled`; `detail` holds field names, candidate numbers and counts (`candidate_count`, `candidate_numbers`, `kept_number`, `ignored`, `basis`, `filled_fields`, `conflicting_fields`, `relations_repointed`, `fields`, `nulled`, `omitted`, `connection`, `field`, `candidates_per_link`, `relations`, `types`, `halves`, `refused_max_tokens`, `error_code`), and no field's value except an object's key: in `key`, in a dropped relation's `links` (`Type(field='value')` per link), and a `description` for an object that has no key. `/extract` records its extraction the same way; it has no detail document of its own, so its records show only on a write's document when that write, on the same instance, reuses its trace id. A write's apply records what its merges did to the stored rows, under `write.merge_application`, once the write commits (a write that rolls back records none): one item per merge action, `kind` `merge`, named by the surviving row (the diff engine's `merge` items are named by the row merged away instead): its `object_type`, its `key` (absent for a type without a key, for a key with any part NULL or missing, which could name another row, and when the two rows were one) and its `xuid`, with `decision` `applied` and, in `detail`, the row merged away (`merged_away_xuid`), `relations_repointed`, `relations_dropped` by reason (`linking_the_merged_rows`: a relation between the two merged rows; `removed_by_this_write`; `duplicate`: one the survivor already held) and `filled_fields`, the survivor's empty fields filled from the merged-away row, by name; or `decision` `nothing_to_merge` with why in `reason` (the two rows were one, or the row to merge away was already gone). Schema evolution records under `operation_kind` `evolution`: review, decide and apply each journal their steps' records once, when the stage ends, under their own trace, so an apply's show on its update document (its trace is the update id), and the detached pass that resolves an apply's gaps afterwards journals its own under the same trace. Its steps are the two model calls' names, `gap analyzer` (gaps never analysed or stored with no concepts, rows a reply left pending, concepts the parser dropped) and `semantic gate` (what the gate may do with each suggestion, candidates kept without a verdict, verdicts downgraded or not acted on), and the rule steps `evolution.backlog`, `evolution.resolution_filter`, `evolution.clustering`, `evolution.consolidation`, `evolution.prior_decisions`, `evolution.prerequisites`, `evolution.evidence_retirement` and `evolution.apply`; `kind` is `gap` (named by its feedback id in `key` and its concept's object and field), `suggestion` (its fingerprint in `key`, its change's names, and the gaps it was built from in `detail.gaps`, the first 20 with `gaps_total` when there are more), `cluster`, `decision`, `backlog`, `reply_entry`, `build` or `gate`, and no item carries what a user wrote. What a transaction did (a gap resolved, stored with no concepts, deleted or retired) is recorded only once it commits, and a row only when that step's own write or delete reached it. A build retried on a stale schema records both attempts: once the build runs again, every item the stage notes until it ends, the build's and the stage's own after it, carries `detail.attempt` (`2`), so an item without `attempt` was noted before the build ran again, and a `retried` item under `evolution.consolidation` records that it did; an `omitted` item is a marker, not a decision, and carries no `attempt`. A read records what the reader's own rules decided, under the name of the model call whose reply a decision is about (`read.question_decomposer`, `read.sql_syntax_fixer`, `read.empty_result_reviewer`, `read.overfetch_reviewer`, `read.answer_formatter`) or under a rule step (`read.scope`, `read.vector_grounding`, `read.sql_rewrites`, `read.sql_guard`, `read.sql_retries`, `read.answer`, and `read.other` for a family no step claims yet); a decision about a SQL statement a model call wrote goes under `read.sql_rewrites`, with the calling step in `detail.phase` where the rule names it. A read step that decided more than 101 times keeps its first and last 50 decisions, with one item (`decision` `omitted`, `detail.count`) where the rest were left out, cut further from the middle when they do not fit one record. A read journals its records once, when it ends, so a read still running has none yet, and a read retried on a stale schema runs again under the same trace id and records every step again; its items have `kind` (the decision family, such as `sql_guard_refused` or `distinct_order_by_case_dropped`), `decision` (`applied` or `not_applied`), `reason` where the rule says why, and `detail.part`, the part of a decomposed question the decision was made for, counted from 1, where it was made for one. `sub_stage` names the step in the `llm_calls[].sub_stage` vocabulary, so a step's decisions and its model calls join on it (a re-issued call journals under its step's name with a suffix such as `.refusal_fallback`, so it joins on the name as a prefix; the reader's rule steps, `extraction.deduplication` and `write.merge_application` have no model calls of their own) (`diff_engine.keyless_binding`, `diff_engine.deletion_check`, `diff_engine.intent_rules`, `diff_engine.value_span_containment`, `diff_engine.intent_verification_deep`, `diff_engine.alias_confirmation`, `diff_engine.object_resolution`, `diff_engine.unwritten_identity_verification`, `diff_engine.plan_construction`, the intent pipeline's `diff_engine.intent_gate`, `diff_engine.intent_operations`, `diff_engine.operations_repair` and `diff_engine.operations_verification`, and the `read.` steps above). A model call made in shadow mode, beside the work it never changes and never charged, carries the prefix `shadow.` on its step's name; the intent pipeline and the field verifier's judge are the steps that run in shadow today. The field verifier's judge, which can stand in front of `extraction.field_verifier` on a DEEP write or `/extract`, makes its calls under `extraction.verification_judge`, or `shadow.extraction.verification_judge` in shadow mode, and records under that same stage, once per object type, one item per object it was asked about: `kind` `object`, `decision` `flagged` (a field at or over its threshold, or one no question covered), `cleared` or `not_judged` (no verdict, which counts as every field flagged), with `detail.field_scores` (the flagged fields the judge scored, and their scores), `detail.unjudged_fields` (flagged fields no question covered), `detail.score`, `detail.field` and `detail.question` (the top score and where it came from), `detail.threshold`, `detail.questions_asked` and `detail.verifier` (`ran`, `ran on the flagged fields and the key`, or `skipped`). In on mode the verifier is asked only about the flagged fields and the key; in shadow mode it sees every field, and the item says what on mode would have sent it. A `verifier_skipped` item under `extraction.field_verifier` is an object an `extraction.verification_judge` call of the same operation cleared; every question and answer is in that call in `llm_calls[]`. The intent pipeline names its model calls by its step names above, with the prefix `shadow.` in shadow mode (`diff_engine.operations_repair` the operations step asked once more about the entries its checks dropped); `diff_engine.intent_operations` records what the write did with the answer in on mode only, where it stands in for `diff_engine.intent_rules`, and in either mode (with the prefix in shadow mode), once the run has finished, one item when the operations call ran out of its output budget and was asked once more: `kind` `intent_operations`, `decision` `retry_answered` or `retry_failed`, and `detail.model`, `detail.error` (the first call's code) and, on `retry_failed`, `detail.failure` (the type of the error the step then failed with); a run the write's grace cancelled with the retry in flight, or one whose gate closed or failed, records none, and the retried call is the step's `llm_calls[]` entry whose `reissue_reason` is `provider_output_token_limit_exceeded`. `diff_engine.operations_repair` (with the prefix in shadow mode) records one item, once the run has finished, when the operations step asked its model once more about the entries its checks dropped: `kind` `intent_operations`, `decision` `repaired` (the second reply stood), `repair_rejected` (it was not the first reply with those entries fixed, so the first stood with its drops) or `repair_failed` (the repair met an error, so the first reply stood with its drops), and `detail.model` (the model the repair asked last: the one that gave the first reply, or its fallback after an outage) and, on `repair_failed`, `detail.failure` (the type of the error it met); a run whose gate closed records none, and so does a reply over the cap, which is never repaired: the write falls back to the engine. `diff_engine.intent_gate` (with the prefix in shadow mode) records one item when the gate's deadline, the write's grace, opened it: `kind` `intent_gate`, `decision` `opened_by_deadline`, and `detail.deadline_seconds`, `detail.unanswered_calls` (the gate calls cut off) and `detail.sentence_count`. A step whose decisions act on another step's calls names itself the same way: `diff_engine.intent_rules` acts on the replies under `diff_engine.object_and_relations_deletion_detection` and `diff_engine.batch_field_erasure_detection`. The records hold what the engine decided that the operation's `llm_calls[]` (their prompts and replies) and its `diff_plan` do not already show, so the model's own answers are read in `llm_calls[]`, not here. `decision_count` is the step's true total and `decisions_truncated` marks a record cut to fit (for a read step, also one cut to its first and last 50 decisions), so `decisions` can hold fewer items than the count. `decisions` is the items as the step recorded them, a JSON array, or `null` when the stored text is not one (the count still shows). A pane serves at most 500 records and 4 MiB of `decisions` text, the newest; when it cut older ones it adds `step_decisions_truncated: true` beside the array, and otherwise the key is absent. A trace id a client reuses across writes serves every write's records together, ordered by time with nothing between the writes. The diff engine's items share one shape: `kind` (what the item is: `intent_gate`, `intent_operations`, `intent_verification`, `object`, `identity_change`, `merge`, `filtered_deletion`, `filtered_update`, `relation`, `deletion`, `alias`, `relabel`, `scope`, `detection`, `operation`, `intent_pipeline`) and `decision` (what was decided: `deletion_dropped`, `converted`, `rejected`, `kept`, `bound`, `not_bound`, `matched`, `left_out`, …), then, where set, `object_type` and `key` (the primary-key values as `field='value'`), `index` (the object's position in the diff engine's sorted object list, not in `extracted_objects[]`), `xuid` (the stored row), `reason`, and `detail` (anything else the step decided). `diff_engine.keyless_binding` records one `object` item per record of a keyed type that states no key but another field: `bound` to the one stored row it names (its `key` and `xuid`), or `not_bound` with why in `detail.outcome` (a record one stored row matches in every field it states carries that row's `key` and `xuid` when it is left `not_identifying`, matching only in yes/no and listed-value fields and never put to the verifier, or `rejected` or `unverified`, the verifier not confirming the text refers to it); `same_key_group` (the write holds records of different types with the same key, so nothing of it is looked up), `key_stated_for_another_type` (a record of another type in the write states the key of the row it would bind to), `key_of_another_type` (a stored row of another type has that key) and `key_check_unread` (the stored rows could not be read, or the key has more fields than the check looks up) are a temporary net against extraction copying a record's identifier onto another type; a binding left unbound for one of the last three carries the row it would have bound to (its `key` and `xuid`; a record not asked about its closest rows for them lists those rows in `detail.other_type_rows` instead), and the other types in `detail.other_types`: for `key_stated_for_another_type` and `key_of_another_type`, and for a record set aside unasked as `key_check_unread` whose rows stand down for different reasons, the types its rows' keys were found under. The same-key groups and the stated-key check go once extraction no longer returns such copies; the stored-key check goes only once the copies earlier writes stored are cleaned up too, or a scan finds none. A stored copy whose key differs only in case, surrounding spaces or accents is not found. In on mode, Phase 1's `record_where` lookup may also ask the verifier under `diff_engine.keyless_binding` (on a Claude 5 model, when one stored row matches and the answer would be used once it is confirmed), about a rename's or a merge's record the text names by its fields whose extracted record carries the operation's new key, and records no item of its own: what came of it shows under `diff_engine.intent_operations`, where a bound rename is named by the stored row's key, or as `record_where_unbound` with `detail.sub` `stated_key`, or as the fallback a bound answer met later (such as `rename_old_key_unmatched`, or `rename_or_merge_rejected` when Phase 1.5 did not confirm every rename and merge of the answer); under `intent_pipeline_failures: refuse` a bound answer whose translation fails, or whose key-only lookup could not run, is refused instead (a failure of the verified relabel's own work, `relabel_failed`, still goes to the engine). In on mode `diff_engine.intent_operations` says what the write did with the intent pipeline's answer: an `operation` item per operation it applied, its `decision` the operation's kind (`delete`, `unlink`, `move`, `clear`, `rename`, `merge`, `bulk_delete`, `bulk_update`) and its `reason` the passage that asks for it, then `not_applied` for one the write could not carry out (a delete of a record by the new name a rename of the same write gives it reads "it is also the target of a rename", as the engine's own rule says it) and `dropped` for one the pipeline dropped, each with why and `detail.position` (its place in the model's reply), then an `object` item `skip` for each record of a keyed type that states no key and that the write skipped, and `erase_refused` for each record bound to a stored row by its other fields whose clears were not applied (`detail.fields`); a `clear` item therefore names what the pipeline asked for, not what was written. When the engine's own detection decides the write instead, the step records one `intent_pipeline` item, `decision` `engine_decides`, whose `detail.fallback` names why (its `reason` says it in words) and beside which the engine's own steps record as with the pipeline off: `missing` (no result reached the write), `unreadable`, `translation` (the rows the operations name could not be read), `dropped` (the pipeline dropped an operation it proposed for a reason other than its verifier's rejection, so its answer is not the whole change), `verifier_rejected` (a temporary net: the pipeline's verifier rejected a delete, a clear, an unlink or a move it proposed, which may be about how the operation names its records rather than the change; a rejected bulk delete or update does not count, and a rename it rejects, or a merge while `execution.entity_merge` is on, goes to the engine as `rename_or_merge`), `record_where_unbound` (it names a record by the field values the text states, a key stated in part among them, and once verified, binding it through the write's own extraction found no single record for it; a writer that predates the binding decided it before the pipeline's verifier was asked), `unresolved` (it saw a change whose records it could not name: a record described rather than named, records selected through a relation, every link of a kind), `rename_or_merge` (it holds a rename sharing a record with a merge, or renames that swap, cycle, fork or converge, decided before the pipeline's verifier is asked, or the verifier rejected a rename, or a merge while merges are on), `rename_old_key_unmatched` (it renames a record that no single stored record has by the name the text gives), `merge_loser_unmatched` (it merges away a record that no single stored record has by the name the text gives), `rename_onto_stored` (it renames a record onto a name another stored record already has), `rename_record_partly_stated` (the write states a record without the rest of a key a rename names, so whether it is that rename's old record, its new record or another one is unknown), `clear_in_question`, `unlink_in_question`, `move_in_question`, `repoint_in_question` (temporary nets: while the text holds a question mark, it clears a field, unlinks records of a relation type the extraction holds a relation of, or moves a relation, or, whatever it holds, the extraction holds a relation of a type keyed on some of its ends but not all, which written re-points the stored link those ends share, as with the pipeline off; Phase 1.5 cannot verify or take back any of these), `delete_with_values` (it deletes a record the write's own extraction gives values other than its fields' defaults), `unlink_replaced` (it unlinks records while the write's own extraction holds another record the link may have moved to, which no new link joins to the unlink's other ends and no other operation ends or removes), `key_rewritten` (an operation's key was read as the extraction's spelling), `unmatched_key_only` (the gate opened, while the extraction holds a record by its key alone that the answer would write and no stored row has; a delete's record, a rename's old and new records, a merge's survivor and a move's new end are not looked up), `key_only_lookup_failed` (the same, but whether those records are stored could not be read), `end_not_stored` (an unlink's end, or a move's old or kept end, binds no single stored row: none has its key, or several do, so the stored link would stay), `rename_or_merge_rejected` (the write's second verification did not confirm every rename and merge of the pipeline's answer, so the engine's own detection decided it again from the start; a temporary safety net, until the pipeline binds the records its operations name itself; it goes together with `unmatched_key_only` and the end guard, which would otherwise send most such writes to the engine), `end_not_extracted` (a move's new end is neither a stored row of its type nor a record the write's extraction holds, by its key: a key the pipeline made up, such as a name or part of an id), `empty_answer_check_found_intent` (a temporary net: the answer holds no operation while Phase 0's gate, which read the deletion check the write ran, is open), `date_field` (an operation names a field in which the write's extraction resolved a date token, or dropped one that named no day, which the pipeline does not yet state as the extraction resolves it; a temporary safety net, until the pipeline resolves those dates too), or the step a run without an answer stopped at (`gate`, `operations`, `verification`, `executor`, `late`, `not_run`). A write of an instance that pins its write text to a provider is no fallback: its instance's own mode is `off`, so the step records one `intent_pipeline` item, `decision` `instance_mode`, with `detail.mode` `off` and `detail.sub` `pinned_provider`, and the engine's own steps record beside it as with the pipeline off. Beside `fallback`, `detail.sub` gives why as one code with no value, when one is known (for `unmatched_key_only` what the records were, `relation_end`, `not_an_end`, `partial_key` or `empty_answer`; a failed stage's exception type, a not-run reason, the drop kinds and reasons, the rejected kinds, whether `rename_or_merge` was decided before or after verification), `detail.dropped_operations` lists each operation the pipeline dropped (`position` in the model's reply, `operation` kind when readable, `reason`, and `part`, a code for what within the reason failed, when known), and `detail.unresolved_changes` each change it could not name the records of (`position`, `evidence`, the passage that asks for it, `object_type` when the model gave one, and `shape`, a code, when the model gave one). `dropped_operations` carries no field values; an `unresolved_changes` entry's `evidence` is a passage of the write's own text, as an `operation` item's `reason` is. `diff_engine.intent_rules` logs what the engine's rules did to the detection model's answer, in the order they acted, so an item can be dropped and later restored (a rename whose old record is stated by part of a composite key is an `identity_change` item `completed`, bound to the one stored row its stated part matches and named by that row's `xuid`, or `not_bound`, left as stated so the write goes as it would without the step: no stored row or several match, the one row disagrees with the new record, a stored row with a NULL or blank key part matches, the database could not be asked, a value is not one its field can hold, the new record is of another type than the rename's, no index serves a lookup by the stated part, another rename or a merge lands on the same new record, or something else in the write may name the row or the key, its kind in `detail.named_by`); `diff_engine.plan_construction` says what building the plan decided after every earlier step, which the plan shows only by leaving it out (a merge the relation-budget re-check refused or re-admitted after alias binding, a deletion a rename overrides, an unlink dropped for a row a confirmed delete does not run on or a lookalike row the write does not bind, a move whose old link stays with the replacement guard's cause, a dropped merge or one already a single row, what a scoped write left out). A deletion that verification rejected reads `kind: "deletion"`, `decision: "rejected"` under `diff_engine.intent_verification_deep`, with what the object became and why; the proposal itself is the detection model's reply in `llm_calls[]`. Another part of the system may record items of its own shape here; they are served as recorded. Every detail pane serves its operation's records the same way; the write, read and evolution panes serve them for an operation still running or stale too (create and update have no such pane). A step that refuses the write still records what it decided first: the detector its rules, resolution what it found, plan construction the merges, unlinks and scope it changed. A caller who is not a super admin gets neither `step_decisions` nor `step_decisions_truncated`, exactly as for `llm_calls[]`.
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[]`.
583
583
 
584
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.
585
585
 
@@ -744,6 +744,8 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
744
744
 
745
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.
746
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
+
747
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`.
748
750
 
749
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.
@@ -167,7 +167,7 @@ xmemcli version
167
167
  xmemcli auth login
168
168
  ```
169
169
 
170
- Version: **1.7.7**.
170
+ Version: **1.7.8**.
171
171
 
172
172
 
173
173
  ---
@@ -239,7 +239,7 @@ 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. 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.
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
243
 
244
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.
245
245
 
@@ -288,7 +288,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
288
288
 
289
289
  # `xmemcli` — capabilities reference
290
290
 
291
- **Version:** `1.7.7` (see `xmemcli version`)
291
+ **Version:** `1.7.8` (see `xmemcli version`)
292
292
 
293
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.
294
294
 
@@ -335,7 +335,7 @@ Permission-filtered view of what this API key can see.
335
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. |
336
336
  | **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
337
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; a 404 means `--cluster` is not linked to this key; a 422 means the server predates this command, which needs a server from the same release as this CLI; a bare 403 means the server does not let a key create keys yet (that arrives in a later server release than this CLI). |
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. |
339
339
 
340
340
  ---
341
341
 
@@ -553,7 +553,7 @@ learn; the CLI composes the Console's search clauses itself.
553
553
 
554
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`.
555
555
 
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 `KINGDOM_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
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`.
557
557
 
558
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:
559
559
 
@@ -571,7 +571,7 @@ learn; the CLI composes the Console's search clauses itself.
571
571
 
572
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.
573
573
 
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`, `move`, `clear`, `rename`, `merge`, `bulk_delete`, `bulk_update`) and its `reason` the passage that asks for it, then `not_applied` for one the write could not carry out (a delete of a record by the new name a rename of the same write gives it reads "it is also the target of a rename", as the engine's own rule says it) and `dropped` for one the pipeline dropped, each with why and `detail.position` (its place in the model's reply), then an `object` item `skip` for each record of a keyed type that states no key and that the write skipped, and `erase_refused` for each record bound to a stored row by its other fields whose clears were not applied (`detail.fields`); a `clear` item therefore names what the pipeline asked for, not what was written. When the engine's own detection decides the write instead, the step records one `intent_pipeline` item, `decision` `engine_decides`, whose `detail.fallback` names why (its `reason` says it in words) and beside which the engine's own steps record as with the pipeline off: `missing` (no result reached the write), `unreadable`, `translation` (the rows the operations name could not be read), `dropped` (the pipeline dropped an operation it proposed for a reason other than its verifier's rejection, so its answer is not the whole change), `verifier_rejected` (a temporary net: the pipeline's verifier rejected a delete, a clear, an unlink or a move it proposed, which may be about how the operation names its records rather than the change; a rejected bulk delete or update does not count, and a rename it rejects, or a merge while `execution.entity_merge` is on, goes to the engine as `rename_or_merge`), `record_where_unbound` (it names a record by the field values the text states, a key stated in part among them, and once verified, binding it through the write's own extraction found no single record for it; a writer that predates the binding decided it before the pipeline's verifier was asked), `unresolved` (it saw a change whose records it could not name: a record described rather than named, records selected through a relation, every link of a kind), `rename_or_merge` (it holds a rename sharing a record with a merge, or renames that swap, cycle, fork or converge, decided before the pipeline's verifier is asked, or the verifier rejected a rename, or a merge while merges are on), `rename_old_key_unmatched` (it renames a record that no single stored record has by the name the text gives), `merge_loser_unmatched` (it merges away a record that no single stored record has by the name the text gives), `rename_onto_stored` (it renames a record onto a name another stored record already has), `rename_record_partly_stated` (the write states a record without the rest of a key a rename names, so whether it is that rename's old record, its new record or another one is unknown), `clear_in_question`, `unlink_in_question`, `move_in_question`, `repoint_in_question` (temporary nets: while the text holds a question mark, it clears a field, unlinks records of a relation type the extraction holds a relation of, or moves a relation, or, whatever it holds, the extraction holds a relation of a type keyed on some of its ends but not all, which written re-points the stored link those ends share, as with the pipeline off; Phase 1.5 cannot verify or take back any of these), `delete_with_values` (it deletes a record the write's own extraction gives values other than its fields' defaults), `unlink_replaced` (it unlinks records while the write's own extraction holds another record the link may have moved to, which no new link joins to the unlink's other ends and no other operation ends or removes), `key_rewritten` (an operation's key was read as the extraction's spelling), `unmatched_key_only` (the gate opened, while the extraction holds a record by its key alone that the answer would write and no stored row has; a delete's record, a rename's old and new records, a merge's survivor and a move's new end are not looked up), `key_only_lookup_failed` (the same, but whether those records are stored could not be read), `end_not_stored` (an unlink's end, or a move's old or kept end, binds no single stored row: none has its key, or several do, so the stored link would stay), `rename_or_merge_rejected` (the write's second verification did not confirm every rename and merge of the pipeline's answer, so the engine's own detection decided it again from the start; a temporary safety net, until the pipeline binds the records its operations name itself; it goes together with `unmatched_key_only` and the end guard, which would otherwise send most such writes to the engine), `end_not_extracted` (a move's new end is neither a stored row of its type nor a record the write's extraction holds, by its key: a key the pipeline made up, such as a name or part of an id), `empty_answer_check_found_intent` (a temporary net: the answer holds no operation while Phase 0's gate, which read the deletion check the write ran, is open), `date_field` (an operation names a field in which the write's extraction resolved a date token, or dropped one that named no day, which the pipeline does not yet state as the extraction resolves it; a temporary safety net, until the pipeline resolves those dates too), or the step a run without an answer stopped at (`gate`, `operations`, `verification`, `executor`, `late`, `not_run`). A write of an instance that pins its write text to a provider is no fallback: its instance's own mode is `off`, so the step records one `intent_pipeline` item, `decision` `instance_mode`, with `detail.mode` `off` and `detail.sub` `pinned_provider`, and the engine's own steps record beside it as with the pipeline off. Beside `fallback`, `detail.sub` gives why as one code with no value, when one is known (for `unmatched_key_only` what the records were, `relation_end`, `not_an_end`, `partial_key` or `empty_answer`; a failed stage's exception type, a not-run reason, the drop kinds and reasons, the rejected kinds, whether `rename_or_merge` was decided before or after verification), `detail.dropped_operations` lists each operation the pipeline dropped (`position` in the model's reply, `operation` kind when readable, `reason`, and `part`, a code for what within the reason failed, when known), and `detail.unresolved_changes` each change it could not name the records of (`position`, `evidence`, the passage that asks for it, `object_type` when the model gave one, and `shape`, a code, when the model gave one). `dropped_operations` carries no field values; an `unresolved_changes` entry's `evidence` is a passage of the write's own text, as an `operation` item's `reason` is. `diff_engine.intent_rules` logs what the engine's rules did to the detection model's answer, in the order they acted, so an item can be dropped and later restored (a rename whose old record is stated by part of a composite key is an `identity_change` item `completed`, bound to the one stored row its stated part matches and named by that row's `xuid`, or `not_bound`, left as stated so the write goes as it would without the step: no stored row or several match, the one row disagrees with the new record, a stored row with a NULL or blank key part matches, the database could not be asked, a value is not one its field can hold, the new record is of another type than the rename's, no index serves a lookup by the stated part, another rename or a merge lands on the same new record, or something else in the write may name the row or the key, its kind in `detail.named_by`); `diff_engine.plan_construction` says what building the plan decided after every earlier step, which the plan shows only by leaving it out (a merge the relation-budget re-check refused or re-admitted after alias binding, a deletion a rename overrides, an unlink dropped for a row a confirmed delete does not run on or a lookalike row the write does not bind, a move whose old link stays with the replacement guard's cause, a dropped merge or one already a single row, what a scoped write left out). A deletion that verification rejected reads `kind: "deletion"`, `decision: "rejected"` under `diff_engine.intent_verification_deep`, with what the object became and why; the proposal itself is the detection model's reply in `llm_calls[]`. Another part of the system may record items of its own shape here; they are served as recorded. Every detail pane serves its operation's records the same way; the write, read and evolution panes serve them for an operation still running or stale too (create and update have no such pane). A step that refuses the write still records what it decided first: the detector its rules, resolution what it found, plan construction the merges, unlinks and scope it changed. A caller who is not a super admin gets neither `step_decisions` nor `step_decisions_truncated`, exactly as for `llm_calls[]`.
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[]`.
575
575
 
576
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.
577
577
 
@@ -736,6 +736,8 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
736
736
 
737
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.
738
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
+
739
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`.
740
742
 
741
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "1.7.7"
3
+ version = "1.7.8"
4
4
  description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4"
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "1.7.7"
3
+ __version__ = "1.7.8"
@@ -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"
@@ -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/kingdom/static/access_denied.html): the brand
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.
@@ -323,17 +323,38 @@ def _discard_key_file(fd: int, path: Path) -> None:
323
323
  pass
324
324
 
325
325
 
326
- def _create_failure_hint(status: int, body: str, name: str, cluster: str) -> str | None:
327
- """What a failed ``POST /api-keys`` most likely means, beyond its status line."""
326
+ # What a server that predates key-minted keys answers an API key on ``POST /api-keys``: the organisation resolver's
327
+ # generic refusal, before the endpoint runs. That text is fixed in every server it can come from, so it is what tells an
328
+ # old server apart; any other 403 is the endpoint's own refusal, whatever its wording.
329
+ _GENERIC_FORBIDDEN = "Insufficient permissions"
330
+
331
+
332
+ def _create_failure_hint(m: Any, e: Any, name: str, cluster: str) -> str | None:
333
+ """What a failed ``POST /api-keys`` most likely means, beyond the server's own message on the status line."""
334
+ status: int = e.status
335
+ first = m._first_structured_error(e)
336
+ code = first.get("code") if first is not None else None
337
+ if status == 503 and code == "SERVICE_UNAVAILABLE":
338
+ # The server's own load-shedding answer: the mint's transaction rolled back. A bare 503 from a proxy is not
339
+ # this, since a proxy can answer 503 after the request reached the server, so it keeps the hint below.
340
+ return "the server was busy and created no key; it is safe to run this again in a few seconds"
328
341
  if status == 0 or status >= 500:
329
342
  # The server may have committed before the reply was lost.
330
343
  return f'a key named "{name}" may still exist; check with `xmemcli org list keys` before running this again'
344
+ if status == 429:
345
+ # Every 429 refuses before the mint; the server's message says which limit.
346
+ return "no key was created; it is safe to run this again after a short wait"
347
+ if status == 400:
348
+ return "no key was created"
331
349
  if status == 404:
332
350
  # An API-key mint answers 404 only for its cluster: role failures are one opaque 403.
333
351
  return f"cluster {cluster} is not one this key is linked to"
334
- if status == 422:
352
+ if status == 422 and "api_role_name" in e.body:
335
353
  return "this API server predates `org create key`; it needs a server from the same release as this CLI"
336
- if status == 403 and "this key cannot create that key" in body:
354
+ if status == 403 and (m._structured_error_message(e) or _GENERIC_FORBIDDEN) == _GENERIC_FORBIDDEN:
355
+ # The route refuses every API key before it runs until a server release lets keys mint keys.
356
+ return "this API server does not let a key create keys yet; that comes in a later server release"
357
+ if status == 403:
337
358
  # One answer for every refusal after the cluster check, so the server names no role or permission; the likely
338
359
  # causes are the ones it folds together.
339
360
  return (
@@ -341,9 +362,6 @@ def _create_failure_hint(status: int, body: str, name: str, cluster: str) -> str
341
362
  "if two roles share the name), your account may no longer hold api_key.create_own, or this key or the "
342
363
  "cluster may have changed; `xmemcli org key` shows this key's role and clusters"
343
364
  )
344
- if status == 403:
345
- # The route refuses every API key before it runs until a server release lets keys mint keys.
346
- return "this API server does not let a key create keys yet; that comes in a later server release"
347
365
  return None
348
366
 
349
367
 
@@ -427,7 +445,7 @@ def _mint_into(
427
445
  api_key,
428
446
  )
429
447
  except m.ApiError as e:
430
- hint = _create_failure_hint(e.status, e.body, name, cluster_name)
448
+ hint = _create_failure_hint(m, e, name, cluster_name)
431
449
  output, code = http_error_output(e, context="key creation failed")
432
450
  if hint is not None and isinstance(output.payload, dict) and isinstance(output.payload.get("error"), str):
433
451
  output.payload["error"] = f"{output.payload['error']} ({hint})"
@@ -274,7 +274,7 @@ LIST_ONLY_FLAGS: tuple[tuple[str, str], ...] = (
274
274
 
275
275
 
276
276
  #: How long a Console read may stay silent before the CLI gives up, in seconds. The Console lets one query wait up to
277
- #: 20 s for a read permit (`DEFAULT_READ_QUEUE_TIMEOUT_SECS` in Kingdom's retry.rs) and then run up to 45 s
277
+ #: 20 s for a read permit (`DEFAULT_READ_QUEUE_TIMEOUT_SECS` in Console's retry.rs) and then run up to 45 s
278
278
  #: (`DEFAULT_READ_REQUEST_TIMEOUT_SECS`), so a shorter wait here gives up on an answer the Console is still producing.
279
279
  CONSOLE_READ_TIMEOUT_S = 70.0
280
280
 
@@ -4,7 +4,7 @@ import platform
4
4
  import re
5
5
 
6
6
  CLI_NAME = "xmemcli"
7
- __version__ = "1.7.7"
7
+ __version__ = "1.7.8"
8
8
 
9
9
  # The header this CLI sends its identity in, restated rather than imported: the published package is
10
10
  # standalone and must not import anything outside this directory. The server's own constant is the other
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes