cortexdb-cli 0.7.1__py3-none-any.whl → 0.8.0__py3-none-any.whl

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.
cortexdb_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """CortexDB CLI - command-line interface for the v1 memory API."""
2
2
 
3
- __version__ = "0.7.1"
3
+ __version__ = "0.8.0"
@@ -95,7 +95,7 @@ def admin_layers(ctx: click.Context) -> None:
95
95
 
96
96
  # -- retention (unified WAL) -------------------------------------------------
97
97
  #
98
- # The operator surface over the D1 retention plane. Every write here is
98
+ # The operator surface over the retention plane. Every write here is
99
99
  # irreversible in effect - an applied sweep removes expired captured events,
100
100
  # everything about them under an `erase` rule, their raw data under a
101
101
  # `keep_learned` one - so reporting is the default on both mutating commands
@@ -5,12 +5,20 @@ from __future__ import annotations
5
5
  import click
6
6
 
7
7
  from cortexdb_cli.client import get_client
8
- from cortexdb_cli.common import api_options, at_least_option
8
+ from cortexdb_cli.common import (
9
+ api_options,
10
+ at_least_option,
11
+ derived_by_option,
12
+ include_option,
13
+ include_param,
14
+ )
9
15
  from cortexdb_cli.errors import handle_errors
10
16
  from cortexdb_cli.output import (
11
17
  console,
18
+ derivation_summary,
12
19
  escape,
13
20
  is_json_mode,
21
+ note_unreported_derivation,
14
22
  print_json,
15
23
  spinner,
16
24
  )
@@ -40,15 +48,30 @@ def _scope_or_die(cfg: dict, scope: str | None) -> str:
40
48
  @beliefs_group.command("list")
41
49
  @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
42
50
  @click.option("--limit", "-l", default=50, show_default=True)
51
+ @include_option
43
52
  @at_least_option
44
53
  @api_options
45
54
  @click.pass_context
46
55
  @handle_errors
47
- def beliefs_list(ctx: click.Context, scope: str | None, limit: int, at_least: str | None) -> None:
48
- """List active beliefs in a scope."""
56
+ def beliefs_list(
57
+ ctx: click.Context,
58
+ scope: str | None,
59
+ limit: int,
60
+ include: tuple[str, ...],
61
+ at_least: str | None,
62
+ ) -> None:
63
+ """List active beliefs in a scope.
64
+
65
+ With --include derivation each belief also says how it was made,
66
+ computed from its supporting facts (the latest one's; unsynced when any
67
+ of them is). A belief with no fact support, a declared one, has no stamp.
68
+ """
49
69
  cfg = ctx.obj
50
70
  s = _scope_or_die(cfg, scope)
51
71
  params: dict[str, str | int] = {"scope": s, "limit": limit}
72
+ included = include_param(include)
73
+ if included:
74
+ params["include"] = included
52
75
  if at_least:
53
76
  params["at_least"] = at_least
54
77
  with get_client(cfg) as client:
@@ -79,19 +102,26 @@ def beliefs_list(ctx: click.Context, scope: str | None, limit: int, at_least: st
79
102
  table.add_column("Object", style="bold")
80
103
  table.add_column("Conf", justify="right", width=6)
81
104
  table.add_column("Support", justify="right", width=8)
105
+ if included:
106
+ table.add_column("Why", style="dim", max_width=48)
82
107
  for b in items:
83
108
  # v1 beliefs carry their triple under `claim` ({subject, predicate,
84
109
  # object}, each term {type, value|id}); the columns used to read
85
110
  # top-level `predicate`/`object`, which no belief has.
86
111
  claim = b.get("claim") if isinstance(b.get("claim"), dict) else b
87
- table.add_row(
112
+ row = [
88
113
  escape(str(b.get("belief_id") or b.get("id", ""))[:16]),
89
114
  escape(_term(claim.get("predicate"))),
90
115
  escape(_term(claim.get("object"))),
91
116
  f"{(b.get('confidence') or 0):.0%}",
92
117
  str(b.get("support_count") or len(b.get("supports") or [])),
93
- )
118
+ ]
119
+ if included:
120
+ row.append(escape(derivation_summary(b.get("derivation"))))
121
+ table.add_row(*row)
94
122
  console.print(table)
123
+ if included:
124
+ note_unreported_derivation(items)
95
125
  if isinstance(data, dict) and data.get("rollups"):
96
126
  console.print(
97
127
  f"[dim]{len(data['rollups'])} rollup(s) on this page; "
@@ -108,19 +138,30 @@ def beliefs_list(ctx: click.Context, scope: str | None, limit: int, at_least: st
108
138
  help="Return the belief only if it lies at or under this scope (?scope=; else 404). It "
109
139
  "routes the read on a cluster, and a break-glass read of a private region must name it.",
110
140
  )
141
+ @include_option
111
142
  @api_options
112
143
  @click.pass_context
113
144
  @handle_errors
114
- def beliefs_why(ctx: click.Context, belief_id: str, scope: str | None) -> None:
145
+ def beliefs_why(
146
+ ctx: click.Context, belief_id: str, scope: str | None, include: tuple[str, ...]
147
+ ) -> None:
115
148
  """Show the facts + events that ground a belief.
116
149
 
150
+ \b
117
151
  cortexdb beliefs why belief_01HX...
118
152
  cortexdb beliefs why belief_01HX... --scope org:acme/team:eng
153
+ cortexdb beliefs why belief_01HX... --include derivation
154
+
155
+ With --include derivation the belief also carries its derivation, and
156
+ the terminal says it on one line under the answer.
119
157
  """
120
158
  cfg = ctx.obj
121
159
  params = {"belief_id": belief_id}
122
160
  if scope:
123
161
  params["scope"] = scope
162
+ included = include_param(include)
163
+ if included:
164
+ params["include"] = included
124
165
  with get_client(cfg) as client:
125
166
  if not is_json_mode(cfg):
126
167
  with spinner("Tracing provenance..."):
@@ -128,7 +169,13 @@ def beliefs_why(ctx: click.Context, belief_id: str, scope: str | None) -> None:
128
169
  else:
129
170
  resp = client.get("/v1/beliefs/why", params=params)
130
171
  resp.raise_for_status()
131
- print_json(resp.json())
172
+ data = resp.json()
173
+ print_json(data)
174
+ if included and not is_json_mode(cfg):
175
+ belief = data.get("belief") if isinstance(data, dict) else None
176
+ if isinstance(belief, dict):
177
+ console.print(f"[dim]why: {escape(derivation_summary(belief.get('derivation')))}[/dim]")
178
+ note_unreported_derivation([belief])
132
179
 
133
180
 
134
181
  @beliefs_group.command("create")
@@ -165,6 +212,10 @@ def beliefs_why(ctx: click.Context, belief_id: str, scope: str | None) -> None:
165
212
  "key is 409 IDEMPOTENCY_CONFLICT).",
166
213
  )
167
214
  @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
215
+ @derived_by_option(
216
+ "A declared belief carries it in its record (below apply format 10 the server answers 503 "
217
+ "FORMAT_UPGRADE_PENDING), and include=derivation reads it back."
218
+ )
168
219
  @api_options
169
220
  @click.pass_context
170
221
  @handle_errors
@@ -179,16 +230,20 @@ def beliefs_create(
179
230
  provenance: tuple[str, ...],
180
231
  idempotency_key: str | None,
181
232
  scope: str | None,
233
+ derived_by: dict[str, str] | None,
182
234
  ) -> None:
183
235
  """Declare a belief directly (POST /v1/beliefs).
184
236
 
185
237
  \b
186
238
  cortexdb beliefs create "Acme is on the enterprise plan" \\
187
239
  --confidence 0.9 --support evt_01HX... --subject Acme --predicate plan
240
+ cortexdb beliefs create "Bob reads at level B2" --confidence 0.8 \\
241
+ --subject Bob --predicate level --derived-by acme-level@1.1.0
188
242
 
189
243
  Declaring a newer belief over the same (subject, predicate) closes the
190
244
  previous one. Pass --idempotency-key to retry safely after an outcome
191
- you did not learn (WAL_OUTCOME_UNKNOWN, a lost connection).
245
+ you did not learn (WAL_OUTCOME_UNKNOWN, a lost connection); the same key
246
+ with another --derived-by stamp, or none, is 409 IDEMPOTENCY_CONFLICT.
192
247
  """
193
248
  cfg = ctx.obj
194
249
  s = _scope_or_die(cfg, scope)
@@ -210,6 +265,8 @@ def beliefs_create(
210
265
  if not idempotency_key.strip() or len(idempotency_key.encode("utf-8")) > 256:
211
266
  raise click.UsageError("--idempotency-key is 1 to 256 bytes, not blank")
212
267
  body["idempotency_key"] = idempotency_key
268
+ if derived_by:
269
+ body["derived_by"] = derived_by
213
270
  with get_client(cfg) as client:
214
271
  resp = client.post("/v1/beliefs", json=body)
215
272
  resp.raise_for_status()
@@ -19,12 +19,21 @@ applies a structure other than the one previewed (409 PREVIEW_CHANGED).
19
19
  - a list of operations `{"path", "op": "put" | "delete", "record"?}`;
20
20
  - `{"records": [...]}` (one part), or `{"parts": [{"part", "records"}]}`.
21
21
 
22
+ A derivation policy (`derivation_policy_v1`, CortexDB 0.15.0) is a record of
23
+ kind `derive` at `<scope>/attrs:derive`. An entry `{"scope": SCOPE,
24
+ "derive": {...}}` is the put of SCOPE's policy, `{"path":
25
+ "<scope>/attrs:derive", "op": "put", "record": {"tree": {"kind": "derive"},
26
+ "derive": {...}}}`; a record that carries `derive` and no `tree` is given
27
+ `{"tree": {"kind": "derive"}}`. A change set that writes a derive record
28
+ writes nothing else (`cortexdb derivation stage` stages one for you).
29
+
22
30
  `--delete PATH` adds a delete of that path to the part. `--scope` is the
23
31
  tenant root and defaults to the root of your default scope.
24
32
  """
25
33
 
26
34
  from __future__ import annotations
27
35
 
36
+ import json
28
37
  import time
29
38
  from typing import Any
30
39
 
@@ -33,7 +42,7 @@ import click
33
42
  from cortexdb_cli.client import get_client, path_segment
34
43
  from cortexdb_cli.common import api_options, body_of, emit, read_json_file, root_or_die, send
35
44
  from cortexdb_cli.errors import handle_errors
36
- from cortexdb_cli.output import console, err_console, escape, is_json_mode, print_json
45
+ from cortexdb_cli.output import console, err_console, escape, is_json_mode, print_json, warning_text
37
46
 
38
47
  #: The exit status of a change-set command whose --wait ran out while the
39
48
  #: server still answered 202 (EX_TEMPFAIL): nothing failed, the evaluation
@@ -49,7 +58,9 @@ def changes_group() -> None:
49
58
  """Access change sets - stage, preview, activate, discard."""
50
59
 
51
60
 
52
- def _wait_option(fn):
61
+ def wait_option(fn):
62
+ """`--wait SECONDS`: how long a command sends a request again while the
63
+ server answers an evaluation's 202."""
53
64
  return click.option(
54
65
  "--wait", "wait_s", type=click.FloatRange(min=0), default=DEFAULT_WAIT_S, show_default=True,
55
66
  help="Seconds to keep sending the request while the server answers 202 (its evaluation runs "
@@ -130,15 +141,51 @@ def _records_options(fn):
130
141
  return fn
131
142
 
132
143
 
144
+ #: The leaf of a derivation policy's record path (`<scope>/attrs:derive`).
145
+ DERIVE_LEAF = "attrs:derive"
146
+
147
+
148
+ def derive_path(scope: str) -> str:
149
+ """The path of SCOPE's derivation policy record."""
150
+ return f"{scope}/{DERIVE_LEAF}"
151
+
152
+
153
+ def derive_record(body: Any) -> dict[str, Any]:
154
+ """The registry record that carries a derive body."""
155
+ return {"tree": {"kind": "derive"}, "derive": body}
156
+
157
+
158
+ def with_derive_kind(record: Any) -> Any:
159
+ """`record`, with `{"tree": {"kind": "derive"}}` when it carries a derive
160
+ body and no `tree` (a derive record is of kind derive, and the server
161
+ refuses one without it)."""
162
+ if isinstance(record, dict) and "derive" in record and "tree" not in record:
163
+ return {"tree": {"kind": "derive"}, **record}
164
+ return record
165
+
166
+
133
167
  def _operation(entry: Any) -> dict[str, Any]:
168
+ if isinstance(entry, dict) and "derive" in entry and "path" not in entry and "op" not in entry:
169
+ # A derivation policy entry: {"scope": the governed scope, "derive": body}.
170
+ scope = entry.get("scope")
171
+ if not isinstance(scope, str) or not scope:
172
+ raise click.UsageError(
173
+ "a derivation policy entry in --file names the scope it governs: "
174
+ '{"scope": SCOPE, "derive": {...}}'
175
+ )
176
+ record = {key: value for key, value in entry.items() if key != "scope"}
177
+ return {"path": derive_path(scope), "op": "put", "record": with_derive_kind(record)}
134
178
  if not isinstance(entry, dict) or not entry.get("path"):
135
- raise click.UsageError("each record or operation in --file needs a `path`")
179
+ raise click.UsageError(
180
+ "each record or operation in --file needs a `path` (a derivation policy entry: "
181
+ "`scope` and `derive`)"
182
+ )
136
183
  if "op" in entry:
137
184
  op = {"path": entry["path"], "op": entry["op"]}
138
185
  if "record" in entry:
139
- op["record"] = entry["record"]
186
+ op["record"] = with_derive_kind(entry["record"])
140
187
  return op
141
- return {"path": entry["path"], "op": "put", "record": entry}
188
+ return {"path": entry["path"], "op": "put", "record": with_derive_kind(entry)}
142
189
 
143
190
 
144
191
  def build_parts(doc: Any, part: int | None, deletes: tuple[str, ...], default_part: int | None) -> list[dict[str, Any]]:
@@ -170,7 +217,8 @@ def build_parts(doc: Any, part: int | None, deletes: tuple[str, ...], default_pa
170
217
  return [{"part": number, "records": records}]
171
218
 
172
219
 
173
- def _render_state(data: dict[str, Any]) -> None:
220
+ def render_state(data: dict[str, Any]) -> None:
221
+ """A change set's status (a staging's, an activation's or `get`'s answer)."""
174
222
  state = str(data.get("state", ""))
175
223
  style = {"activated": "success", "discarded": "dim", "scheduled": "info"}.get(state, "warning")
176
224
  parts = data.get("parts")
@@ -187,11 +235,27 @@ def _render_state(data: dict[str, Any]) -> None:
187
235
  if isinstance(refusal, dict) and refusal:
188
236
  console.print(f"[error] refused: {escape(str(refusal.get('code')))}[/error] "
189
237
  f"[dim]{escape(', '.join(refusal.get('paths') or []))}[/dim]")
238
+ _render_warnings(data)
190
239
  if data.get("consistency_token"):
191
240
  console.print(f"[dim] consistency token: {escape(str(data['consistency_token']))}[/dim]")
192
241
 
193
242
 
194
- def _render_preview(data: dict[str, Any]) -> None:
243
+ def _render_warnings(data: dict[str, Any]) -> None:
244
+ """A staging answer's or a preview page's `warnings` (since 0.15.0):
245
+ each companion put that keeps a stored `enrich` of on or off,
246
+ `{reason: "enrich_kept", path, kept}`, the first 256; absent when none.
247
+ Never a refusal: the change set stages and activates as it is."""
248
+ warnings = data.get("warnings") or []
249
+ for warning in warnings:
250
+ console.print(f"[warning] {escape(warning_text(warning))}[/warning]")
251
+ if any(isinstance(w, dict) and w.get("reason") == "enrich_kept" for w in warnings):
252
+ console.print("[dim] (a companion put that does not name enrich keeps the stored value; "
253
+ "only enrich: inherit clears it)[/dim]")
254
+
255
+
256
+ def render_preview(data: dict[str, Any]) -> None:
257
+ """A preview page: its access diff, its hash and, for a derivation change
258
+ set, its `derivation` section."""
195
259
  summary = data.get("summary") or {}
196
260
  kind = "widens" if data.get("widens") else ("narrows" if data.get("narrows") else "changes no access")
197
261
  console.print(f"[header]Preview[/header] {escape(str(data.get('id', '')))}: {kind}")
@@ -240,6 +304,8 @@ def _render_preview(data: dict[str, Any]) -> None:
240
304
  if data.get("stale_paths"):
241
305
  console.print(f"[warning] changed since staging: {escape(', '.join(data['stale_paths']))}[/warning]")
242
306
  _render_in_band(data)
307
+ _render_derivation(data)
308
+ _render_warnings(data)
243
309
  if data.get("has_more"):
244
310
  console.print(f"[dim] more on the next page - rerun with --cursor {escape(str(data.get('next_cursor')))}"
245
311
  "[/dim]")
@@ -279,6 +345,90 @@ def _render_in_band(data: dict[str, Any]) -> None:
279
345
  console.print("[dim] (the first 256 in-band records)[/dim]")
280
346
 
281
347
 
348
+ def value_text(value: Any) -> str:
349
+ """A rule's value as text: a list comma-joined, `unset` for null."""
350
+ if value is None:
351
+ return "unset"
352
+ if isinstance(value, bool):
353
+ return "true" if value else "false"
354
+ if isinstance(value, list):
355
+ return ", ".join(value_text(item) for item in value) or "none"
356
+ if isinstance(value, dict):
357
+ if set(value) == {"label"}:
358
+ return f"label {value['label']}"
359
+ return json.dumps(value, sort_keys=True, default=str)
360
+ return str(value)
361
+
362
+
363
+ def rule_change_text(change: dict[str, Any]) -> str:
364
+ """One rule a derivation change set moves: a list rule's `added` and
365
+ `removed` values, a scalar rule's `from` and `to`."""
366
+ rule = str(change.get("rule", ""))
367
+ if "added" in change or "removed" in change:
368
+ moved = []
369
+ if change.get("added"):
370
+ moved.append("+ " + value_text(change["added"]))
371
+ if change.get("removed"):
372
+ moved.append("- " + value_text(change["removed"]))
373
+ return f"{rule}: {'; '.join(moved) or 'unchanged'}"
374
+ return f"{rule}: {value_text(change.get('from'))} -> {value_text(change.get('to'))}"
375
+
376
+
377
+ def text_summary(text: dict[str, Any]) -> str:
378
+ """A policy text a derivation change set names, as its preview lists it
379
+ (`sources` is a count there) or as `derivation texts` lists it (a list
380
+ of event ids)."""
381
+ if text.get("missing"):
382
+ return f"{text.get('id')}: missing (erased since it was named; resolution skips it)"
383
+ bits = [str(text.get("role") or "text")]
384
+ sources = text.get("sources")
385
+ count = len(sources) if isinstance(sources, list) else sources
386
+ if text.get("synthetic") is True:
387
+ bits.append("synthetic")
388
+ elif isinstance(count, int) and count:
389
+ bits.append(f"cites {count} event(s)")
390
+ if text.get("bytes") is not None:
391
+ bits.append(f"{text['bytes']} bytes")
392
+ return f"{text.get('id')}: {', '.join(bits)}"
393
+
394
+
395
+ def _render_derivation(data: dict[str, Any]) -> None:
396
+ """A derivation change set's `derivation` section (`derivation_policy_v1`,
397
+ CortexDB 0.15.0): each derive record's own hash before and after, each
398
+ governed scope's effective policy hash before and after with the rules
399
+ that move, whether every record only tightens, the texts the records name
400
+ and the section's warnings. The approval binds it beside the structural
401
+ hash. An access change set's page has none."""
402
+ section = data.get("derivation")
403
+ if not isinstance(section, dict):
404
+ return
405
+ narrowing = ("narrowing: every record only tightens" if section.get("narrowing")
406
+ else "not narrowing: a record loosens or steers")
407
+ console.print(f"[header]Derivation[/header] {narrowing}")
408
+ for record in section.get("records") or []:
409
+ before = record.get("before") or "no record"
410
+ after = record.get("after_hash") or "deleted"
411
+ console.print(f" record {escape(str(record.get('path', '')))}: {escape(str(before))} -> "
412
+ f"{escape(str(after))}")
413
+ for effective in section.get("effective") or []:
414
+ console.print(f" effective at {escape(str(effective.get('scope', '')))}: "
415
+ f"{escape(str(effective.get('before')))} -> {escape(str(effective.get('after')))}")
416
+ changes = effective.get("changes") or []
417
+ for change in changes:
418
+ console.print(f" {escape(rule_change_text(change))}")
419
+ if not changes:
420
+ console.print("[dim] no rule moves[/dim]")
421
+ for text in section.get("texts") or []:
422
+ style = "warning" if text.get("missing") else "dim"
423
+ console.print(f"[{style}] text {escape(text_summary(text))}[/{style}]")
424
+ for warning in section.get("warnings") or []:
425
+ if isinstance(warning, dict) and warning.get("reason") == "text_missing":
426
+ console.print(f"[warning] warning: text {escape(str(warning.get('id')))} is gone: the policy "
427
+ "keeps its id and resolution skips it[/warning]")
428
+ else:
429
+ console.print(f"[warning] warning: {escape(warning_text(warning))}[/warning]")
430
+
431
+
282
432
  def _id_path(change_id: str, suffix: str = "") -> str:
283
433
  return f"/v1/access/changes/{path_segment(change_id)}{suffix}"
284
434
 
@@ -292,6 +442,40 @@ def _params(proposer: str | None, scope: str | None = None) -> dict[str, str] |
292
442
  return params or None
293
443
 
294
444
 
445
+ #: What a command whose staging's --wait ran out says to do next.
446
+ STAGE_AGAIN = "Send the same `cortexdb changes stage` again for its status, or `cortexdb changes get` it."
447
+ #: ... and one whose activation's --wait ran out.
448
+ ACTIVATE_AGAIN = ("Its activation goes on in the background: `cortexdb changes get` shows its state, and "
449
+ "the same `cortexdb changes activate --preview-hash` again answers its status.")
450
+
451
+
452
+ def stage_change_set(cfg: dict[str, Any], client: Any, body: dict[str, Any],
453
+ wait_s: float = DEFAULT_WAIT_S, again: str = STAGE_AGAIN) -> dict[str, Any]:
454
+ """POST /v1/access/changes with `body`, sent again while the server
455
+ answers an evaluation's 202; exits 75 when --wait runs out first."""
456
+ resp, waited = send_until_evaluated(cfg, "Staging...",
457
+ lambda: client.post("/v1/access/changes", json=body), wait_s)
458
+ if waited is not None:
459
+ still_evaluating(cfg, str(body.get("id", "")), "staging", waited, again)
460
+ return body_of(resp)
461
+
462
+
463
+ def activate_change_set(cfg: dict[str, Any], client: Any, change_id: str, root: str, preview_hash: str,
464
+ proposer: str | None = None, wait_s: float = DEFAULT_WAIT_S,
465
+ again: str = ACTIVATE_AGAIN) -> dict[str, Any]:
466
+ """POST .../activate with the approved preview's hash, sent again while
467
+ the server answers an evaluation's 202 with no body (a dated change
468
+ set's 202 carries its status, and is its answer); exits 75 when --wait
469
+ runs out first."""
470
+ body = {"scope": root, "preview_hash": preview_hash}
471
+ resp, waited = send_until_evaluated(
472
+ cfg, "Activating...",
473
+ lambda: client.post(_id_path(change_id, "/activate"), json=body, params=_params(proposer)), wait_s)
474
+ if waited is not None:
475
+ still_evaluating(cfg, change_id, "activation", waited, again)
476
+ return body_of(resp)
477
+
478
+
295
479
  @changes_group.command("stage")
296
480
  @click.argument("change_id", metavar="ID")
297
481
  @_records_options
@@ -304,7 +488,7 @@ def _params(proposer: str | None, scope: str | None = None) -> dict[str, str] |
304
488
  @click.option("--convert", is_flag=True, help="A version-1 tree conversion: its preview must change no access.")
305
489
  @click.option("--resync", is_flag=True, help="A source's resync change set (served during a restore).")
306
490
  @click.option("--resync-last", is_flag=True, help="The last resync change set of the restore.")
307
- @_wait_option
491
+ @wait_option
308
492
  @api_options
309
493
  @click.pass_context
310
494
  @handle_errors
@@ -345,12 +529,8 @@ def changes_stage(ctx, change_id, file, part, deletes, scope, parts_total, activ
345
529
  if resync or resync_last:
346
530
  body["resync"] = {"last": bool(resync_last)}
347
531
  with get_client(cfg) as client:
348
- resp, waited = send_until_evaluated(cfg, "Staging...",
349
- lambda: client.post("/v1/access/changes", json=body), wait_s)
350
- if waited is not None:
351
- still_evaluating(cfg, change_id, "staging", waited,
352
- "Send the same `cortexdb changes stage` again for its status, or `cortexdb changes get` it.")
353
- emit(cfg, body_of(resp), _render_state)
532
+ data = stage_change_set(cfg, client, body, wait_s)
533
+ emit(cfg, data, render_state)
354
534
 
355
535
 
356
536
  @changes_group.command("parts")
@@ -358,7 +538,7 @@ def changes_stage(ctx, change_id, file, part, deletes, scope, parts_total, activ
358
538
  @_records_options
359
539
  @_scope_option
360
540
  @_proposer_option
361
- @_wait_option
541
+ @wait_option
362
542
  @api_options
363
543
  @click.pass_context
364
544
  @handle_errors
@@ -378,11 +558,12 @@ def changes_parts(ctx, change_id, file, part, deletes, scope, proposer, wait_s):
378
558
  if waited is not None:
379
559
  still_evaluating(cfg, change_id, "staging of its parts", waited,
380
560
  "Send the same `cortexdb changes parts` again for its status, or `cortexdb changes get` it.")
381
- emit(cfg, body_of(resp), _render_state)
561
+ emit(cfg, body_of(resp), render_state)
382
562
 
383
563
 
384
564
  def preview_change_set(cfg, client, change_id, root, proposer=None, people=False, limit=None,
385
- cursor=None, wait_s=DEFAULT_WAIT_S):
565
+ cursor=None, wait_s=DEFAULT_WAIT_S,
566
+ again="Run the same `cortexdb changes preview` again to collect it."):
386
567
  """POST .../preview, sent again while the server answers 202
387
568
  `{state: "previewing"}`; exits 75 when --wait runs out first."""
388
569
  body: dict[str, Any] = {"scope": root}
@@ -396,8 +577,7 @@ def preview_change_set(cfg, client, change_id, root, proposer=None, people=False
396
577
  cfg, "Previewing...",
397
578
  lambda: client.post(_id_path(change_id, "/preview"), json=body, params=_params(proposer)), wait_s)
398
579
  if waited is not None:
399
- still_evaluating(cfg, change_id, "preview", waited,
400
- "Run the same `cortexdb changes preview` again to collect it.")
580
+ still_evaluating(cfg, change_id, "preview", waited, again)
401
581
  return body_of(resp)
402
582
 
403
583
 
@@ -408,7 +588,7 @@ def preview_change_set(cfg, client, change_id, root, proposer=None, people=False
408
588
  @click.option("--people", is_flag=True, help="List each person who gains or loses access, per unit.")
409
589
  @click.option("--limit", type=click.IntRange(min=1), default=None, help="Units per page.")
410
590
  @click.option("--cursor", default=None, help="The next_cursor of a previous page.")
411
- @_wait_option
591
+ @wait_option
412
592
  @api_options
413
593
  @click.pass_context
414
594
  @handle_errors
@@ -418,13 +598,17 @@ def changes_preview(ctx, change_id, scope, proposer, people, limit, cursor, wait
418
598
  A page holds at most 1 MiB: a unit that does not fit continues on the
419
599
  next page (--cursor), and its counts on this page are marked +. While
420
600
  the evaluation runs past the server's wait it answers 202
421
- {state: previewing}; the CLI asks again until it answers (--wait).
601
+ {state: previewing}; the CLI asks again until it answers (--wait). A
602
+ derivation change set's page also has its derivation section: each
603
+ derive record's hash before and after, each governed scope's effective
604
+ policy hash before and after with the rules that move, and the texts
605
+ the records name.
422
606
  """
423
607
  cfg = ctx.obj
424
608
  with get_client(cfg) as client:
425
609
  data = preview_change_set(cfg, client, change_id, root_or_die(cfg, scope), proposer,
426
610
  people, limit, cursor, wait_s)
427
- emit(cfg, data, _render_preview)
611
+ emit(cfg, data, render_preview)
428
612
 
429
613
 
430
614
  @changes_group.command("activate")
@@ -434,7 +618,7 @@ def changes_preview(ctx, change_id, scope, proposer, people, limit, cursor, wait
434
618
  @click.option("--preview-hash", metavar="HASH",
435
619
  help="The approved preview's hash (sha256:...). Without it the CLI previews first.")
436
620
  @click.option("--yes", "-y", is_flag=True, help="Skip the confirmation after the preview.")
437
- @_wait_option
621
+ @wait_option
438
622
  @api_options
439
623
  @click.pass_context
440
624
  @handle_errors
@@ -465,21 +649,14 @@ def changes_activate(ctx, change_id, scope, proposer, preview_hash, yes, wait_s)
465
649
  if not preview_hash:
466
650
  raise click.UsageError("the preview returned no preview_hash; pass --preview-hash")
467
651
  if not is_json_mode(cfg):
468
- _render_preview(preview)
652
+ render_preview(preview)
469
653
  if preview.get("in_band") and not cfg.get("break_glass"):
470
654
  console.print("[warning]No --break-glass is set: under deny this activation will be "
471
655
  "refused (403 BREAK_GLASS_REQUIRED) and nothing activated.[/warning]")
472
656
  if not yes:
473
657
  click.confirm(f"Activate change set {change_id}?", abort=True)
474
- body = {"scope": root, "preview_hash": preview_hash}
475
- resp, waited = send_until_evaluated(
476
- cfg, "Activating...",
477
- lambda: client.post(_id_path(change_id, "/activate"), json=body, params=_params(proposer)), wait_s)
478
- if waited is not None:
479
- still_evaluating(cfg, change_id, "activation", waited,
480
- "Its activation goes on in the background: `cortexdb changes get` shows its state, and "
481
- "the same `cortexdb changes activate --preview-hash` again answers its status.")
482
- emit(cfg, body_of(resp), _render_state)
658
+ data = activate_change_set(cfg, client, change_id, root, preview_hash, proposer, wait_s)
659
+ emit(cfg, data, render_state)
483
660
 
484
661
 
485
662
  @changes_group.command("get")
@@ -496,7 +673,7 @@ def changes_get(ctx, change_id, scope, proposer):
496
673
  data = body_of(send(cfg, "Loading...",
497
674
  lambda: client.get(_id_path(change_id),
498
675
  params=_params(proposer, root_or_die(cfg, scope)))))
499
- emit(cfg, data, _render_state)
676
+ emit(cfg, data, render_state)
500
677
 
501
678
 
502
679
  @changes_group.command("discard")
@@ -516,4 +693,4 @@ def changes_discard(ctx, change_id, scope, proposer, yes):
516
693
  with get_client(cfg) as client:
517
694
  data = body_of(send(cfg, "Discarding...",
518
695
  lambda: client.delete(_id_path(change_id), params=_params(proposer, root))))
519
- emit(cfg, data, _render_state)
696
+ emit(cfg, data, render_state)
@@ -319,3 +319,9 @@ def conflicts_resolve(
319
319
  records = data.get("records") or []
320
320
  if records:
321
321
  console.print(_records_table(records))
322
+ # corrections_v1 (CortexDB 0.15.0): on a shard armed for format 11 a
323
+ # resolution is proposed as corrections, replicated to every node.
324
+ correction_ids = data.get("correction_ids") or []
325
+ if correction_ids:
326
+ console.print(f"[dim]Proposed as correction(s) {escape(', '.join(map(str, correction_ids)))}: "
327
+ "`cortexdb corrections show SCOPE ID` shows one.[/dim]")