cctally 1.115.0 → 1.116.0

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -479,6 +479,11 @@ class DoctorState:
479
479
  # descriptor discovery plus one bounded loopback probe per live
480
480
  # instance elsewhere. {"mode", "instances": [...]}; None = not gathered.
481
481
  dashboard_disk_writes: Optional[dict] = None
482
+ # #997 D10: per detached rebuild worker ("epoch", "heal"), the read-only
483
+ # snapshot `_cctally_store.rebuild_worker_snapshot` gathered (run state,
484
+ # launch ledger, process identities probed before a re-read, pending
485
+ # marker). None = not gathered; the check then reads both as never run.
486
+ rebuild_workers: Optional[dict] = None
482
487
 
483
488
 
484
489
  @dataclasses.dataclass(frozen=True)
@@ -1049,7 +1054,7 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
1049
1054
  # current index as a mismatch; keep this in lockstep with core
1050
1055
  # (#496 S5b §6.1). It stays a literal because this kernel is pure and
1051
1056
  # must not import `_cctally_core`.
1052
- epoch = 1018
1057
+ epoch = 1019
1053
1058
  mismatch = uv > legacy_head and uv != epoch
1054
1059
  return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
1055
1060
  "mismatch": mismatch}
@@ -1105,6 +1110,267 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
1105
1110
  )
1106
1111
 
1107
1112
 
1113
+ # --- #997 D10: the detached rebuild workers -------------------------------
1114
+ #
1115
+ # `rebuild_workers` is gathered read-only by `doctor_gather_state` through
1116
+ # `_cctally_store.rebuild_worker_snapshot` (no worker or admission flock is
1117
+ # ever taken): per worker the run state, the launch ledger, the process
1118
+ # identities probed BEFORE a re-read of both files, and the pending marker.
1119
+ # The kernel classifies it with `_lib_worker_launches` (read-only) — a death is
1120
+ # never inferred from elapsed time.
1121
+
1122
+ #: No progress write for this long reads as "no progress" (spec D10).
1123
+ REBUILD_STALL_SECONDS = 600
1124
+ #: A marker pending this long with no worker running warns.
1125
+ REBUILD_PENDING_SECONDS = 600
1126
+ #: Recovered deaths this recent are listed in `details`.
1127
+ REBUILD_RECENT_DEATH_SECONDS = 7 * 86400
1128
+ #: §5.2's resident-memory bound.
1129
+ REBUILD_MEMORY_BOUND_BYTES = 2 * 1024 ** 3
1130
+
1131
+ _REBUILD_NOUN = {"epoch": "stats.db upgrade rebuild",
1132
+ "heal": "stats.db auto-heal"}
1133
+ _REBUILD_CLEAN = {
1134
+ "epoch": frozenset({"success", "current"}),
1135
+ "heal": frozenset({"success", "no-request", "declined-readable"}),
1136
+ }
1137
+ _REBUILD_RETRYABLE = {
1138
+ "epoch": frozenset(),
1139
+ "heal": frozenset({"maintenance-busy", "ingest-busy"}),
1140
+ }
1141
+
1142
+
1143
+ def rebuild_outcome_class(worker: str, outcome, *, decline_reason=None,
1144
+ finalization=None) -> str:
1145
+ """``clean`` | ``retryable`` | ``failing`` (spec D10 outcome classes).
1146
+
1147
+ ``declined-unconfirmed`` is clean only when the store re-probed healthy
1148
+ (``integrity_check_ok``). A pending finalization never lowers the primary
1149
+ class: it turns a clean outcome retryable and leaves a failing one
1150
+ failing. Any outcome not listed is failing until it is added.
1151
+ """
1152
+ if worker == "heal" and outcome == "declined-unconfirmed":
1153
+ primary = ("clean" if decline_reason == "integrity_check_ok"
1154
+ else "failing")
1155
+ elif outcome in _REBUILD_CLEAN.get(worker, ()):
1156
+ primary = "clean"
1157
+ elif outcome in _REBUILD_RETRYABLE.get(worker, ()):
1158
+ primary = "retryable"
1159
+ else:
1160
+ primary = "failing"
1161
+ if primary == "clean" and finalization == "pending":
1162
+ return "retryable"
1163
+ return primary
1164
+
1165
+
1166
+ def _rebuild_parse_iso(value) -> "dt.datetime | None":
1167
+ if not isinstance(value, str) or not value:
1168
+ return None
1169
+ try:
1170
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
1171
+ except ValueError:
1172
+ return None
1173
+ if parsed.tzinfo is None:
1174
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
1175
+ return parsed
1176
+
1177
+
1178
+ def _rebuild_age_seconds(now: dt.datetime, value) -> "float | None":
1179
+ parsed = _rebuild_parse_iso(value)
1180
+ if parsed is None:
1181
+ return None
1182
+ if now.tzinfo is None:
1183
+ now = now.replace(tzinfo=dt.timezone.utc)
1184
+ return max(0.0, (now - parsed).total_seconds())
1185
+
1186
+
1187
+ def _rebuild_minutes(seconds) -> int:
1188
+ return int((seconds or 0) // 60)
1189
+
1190
+
1191
+ def _rebuild_ledger_summary(ledger: dict) -> dict:
1192
+ import _lib_worker_launches as wl
1193
+ return {
1194
+ "unresolved": [dict(e) for e in wl.unresolved(ledger)],
1195
+ "counts": dict(ledger.get("counts") or {}),
1196
+ "lastDeathAt": ledger.get("lastDeathAt"),
1197
+ "newest": dict(wl.newest(ledger) or {}) or None,
1198
+ }
1199
+
1200
+
1201
+ def assess_rebuild_worker(worker: str, snapshot, now_utc) -> dict:
1202
+ """One worker's D10 classification from its read-only snapshot.
1203
+
1204
+ Returns ``{"severity", "warnings": [sentence, ...], "summary", "row",
1205
+ "details"}``: ``warnings`` holds every warn sentence in table order and
1206
+ ``summary`` the single sentence that describes the worker.
1207
+ """
1208
+ import _lib_worker_launches as wl
1209
+
1210
+ noun = _REBUILD_NOUN[worker]
1211
+ snapshot = snapshot or {}
1212
+ state = snapshot.get("state")
1213
+ run = state.get("run") if isinstance(state, dict) else None
1214
+ run = run if isinstance(run, dict) else None
1215
+ identities = snapshot.get("identities") or {}
1216
+ ledger = snapshot.get("ledger") or wl.empty_ledger()
1217
+ # Read-only: settlements the evidence supports, never written back.
1218
+ classified = wl.classify(ledger, state, identities)["ledger"]
1219
+ pending = snapshot.get("pending") or {}
1220
+ warnings: list = []
1221
+ rows: list = []
1222
+ details: dict = {
1223
+ "state": state,
1224
+ "stateUnreadable": bool(snapshot.get("stateUnreadable")),
1225
+ "ledgerUnreadable": bool(snapshot.get("ledgerUnreadable")),
1226
+ "ledger": _rebuild_ledger_summary(classified),
1227
+ "liveness": None,
1228
+ "pending": pending,
1229
+ "recoveredDeaths": [],
1230
+ }
1231
+ if snapshot.get("gatherError"):
1232
+ details["gatherError"] = snapshot["gatherError"]
1233
+
1234
+ if snapshot.get("stateUnreadable"):
1235
+ rows.append("unreadable")
1236
+ warnings.append(f"the {noun} state is unreadable")
1237
+
1238
+ running = run is not None and run.get("status") == "running"
1239
+ running_alive = False
1240
+ if running:
1241
+ live = wl.liveness(run, identities)
1242
+ details["liveness"] = live
1243
+ if live != "gone":
1244
+ running_alive = True
1245
+ n = _rebuild_minutes(_rebuild_age_seconds(now_utc,
1246
+ run.get("startedAt")))
1247
+ idle = _rebuild_age_seconds(
1248
+ now_utc, run.get("progressAt") or run.get("startedAt"))
1249
+ if idle is not None and idle >= REBUILD_STALL_SECONDS:
1250
+ rows.append("stalled")
1251
+ warnings.append(
1252
+ f"a {noun} has been running for {n} minutes with no "
1253
+ f"progress for {_rebuild_minutes(idle)} minutes")
1254
+ else:
1255
+ rows.append("running")
1256
+ warnings.append(f"a {noun} has been running for {n} minutes")
1257
+
1258
+ # The last decisive launch, in ledger (launch) order: a launch not yet
1259
+ # started (unacknowledged, unresolved) and a `busy` loser decide nothing.
1260
+ decisive = None
1261
+ for entry in reversed(classified.get("entries") or []):
1262
+ outcome = entry.get("outcome")
1263
+ if outcome == "busy":
1264
+ continue
1265
+ if not outcome and not (isinstance(entry.get("ack"), dict)
1266
+ and entry["ack"].get("kind") == "started"):
1267
+ continue
1268
+ decisive = entry
1269
+ break
1270
+ finished_run = run if run is not None and run.get("status") == "finished" \
1271
+ else None
1272
+ ok_summary = None
1273
+ if not running_alive:
1274
+ # A state still `running` whose process is confirmed gone is a death:
1275
+ # any newer run would have replaced the slot when it started.
1276
+ died = bool(decisive and decisive.get("outcome") in wl.DEATHS) \
1277
+ or running
1278
+ if died:
1279
+ rows.append("died")
1280
+ warnings.append(f"the last {noun} died without finishing")
1281
+ details["preStart"] = bool(
1282
+ decisive and decisive.get("outcome") == "died-pre-start")
1283
+ details["launchId"] = (decisive or run or {}).get("launchId")
1284
+ elif decisive is not None and decisive.get("outcome") == "spawn-failed":
1285
+ rows.append("spawn-failed")
1286
+ warnings.append(f"the last {noun} failed (spawn-failed)")
1287
+ else:
1288
+ outcome = decline = finalization = peak = None
1289
+ if finished_run is not None and (
1290
+ decisive is None
1291
+ or decisive.get("launchId") == finished_run.get("launchId")):
1292
+ outcome = finished_run.get("outcome")
1293
+ decline = finished_run.get("declineReason")
1294
+ finalization = finished_run.get("finalization")
1295
+ peak = finished_run.get("peakResidentBytes")
1296
+ elif decisive is not None and str(
1297
+ decisive.get("outcome") or "").startswith("finished:"):
1298
+ outcome = decisive["outcome"][len("finished:"):]
1299
+ if outcome is not None:
1300
+ cls = rebuild_outcome_class(
1301
+ worker, outcome, decline_reason=decline,
1302
+ finalization=finalization)
1303
+ details["outcomeClass"] = cls
1304
+ if cls == "failing":
1305
+ rows.append("failed")
1306
+ warnings.append(f"the last {noun} failed ({outcome})")
1307
+ if isinstance(peak, (int, float)) and \
1308
+ peak > REBUILD_MEMORY_BOUND_BYTES:
1309
+ rows.append("memory")
1310
+ warnings.append(
1311
+ f"the last {noun} used {peak / 1024 ** 3:.1f} GiB of "
1312
+ f"memory, above its 2 GiB bound")
1313
+ ok_summary = f"the last {noun} finished ({outcome})"
1314
+ if (pending.get("present") and pending.get("indexPending") is not False):
1315
+ age = _rebuild_age_seconds(now_utc, pending.get("since"))
1316
+ if age is not None and age >= REBUILD_PENDING_SECONDS:
1317
+ rows.append("pending")
1318
+ warnings.append(
1319
+ f"a {noun} has been pending for {_rebuild_minutes(age)} "
1320
+ f"minutes with no worker running")
1321
+ if not wl.has_launch_capacity(classified):
1322
+ # The spawner withholds launches at capacity; the
1323
+ # pending row says why, with no new sentence.
1324
+ details["admission"] = "launch-capacity"
1325
+ details["unresolvedCount"] = len(
1326
+ wl.unresolved(classified))
1327
+ for death in (state or {}).get("deaths") or []:
1328
+ age = _rebuild_age_seconds(now_utc, death.get("detectedAt"))
1329
+ if age is not None and age <= REBUILD_RECENT_DEATH_SECONDS:
1330
+ details["recoveredDeaths"].append(dict(death))
1331
+ if ok_summary is None:
1332
+ ok_summary = f"no {noun} has run"
1333
+ severity = "warn" if warnings else "ok"
1334
+ details["rows"] = rows
1335
+ return {
1336
+ "severity": severity,
1337
+ "warnings": warnings,
1338
+ "summary": warnings[0] if warnings else ok_summary,
1339
+ "row": rows[0] if rows else ("idle" if state is None else "ok"),
1340
+ "details": details,
1341
+ }
1342
+
1343
+
1344
+ def _check_db_rebuild_worker(s: DoctorState) -> CheckResult:
1345
+ """`db.rebuild_worker` (#997 D10): running, stalled, dead, failed or
1346
+ pending stats.db upgrade rebuilds and auto-heals."""
1347
+ return rebuild_worker_check(s.rebuild_workers, s.now_utc)
1348
+
1349
+
1350
+ def rebuild_worker_check(rebuild_workers, now_utc) -> CheckResult:
1351
+ """The `db.rebuild_worker` result from the gathered worker snapshots
1352
+ alone. The doctor check and the dashboard's deferred frames
1353
+ (`rebuild_worker_envelope_value`) both build it here, so their wording
1354
+ and precedence cannot drift apart."""
1355
+ snapshots = rebuild_workers or {}
1356
+ assessed = {
1357
+ worker: assess_rebuild_worker(worker, snapshots.get(worker),
1358
+ now_utc)
1359
+ for worker in ("epoch", "heal")
1360
+ }
1361
+ warnings = [w for worker in ("epoch", "heal")
1362
+ for w in assessed[worker]["warnings"]]
1363
+ severity = _max_severity([a["severity"] for a in assessed.values()])
1364
+ summary = "; ".join(warnings) if warnings else "; ".join(
1365
+ assessed[worker]["summary"] for worker in ("epoch", "heal"))
1366
+ return CheckResult(
1367
+ id="db.rebuild_worker", title="Rebuild worker", severity=severity,
1368
+ summary=summary, remediation=None,
1369
+ details={worker: assessed[worker]["details"]
1370
+ for worker in ("epoch", "heal")},
1371
+ )
1372
+
1373
+
1108
1374
  def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
1109
1375
  if s.latest_snapshot_at is None:
1110
1376
  return CheckResult(
@@ -4547,6 +4813,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
4547
4813
  ("db.cache.file", "_check_db_cache_file"),
4548
4814
  ("db.integrity", "_check_db_integrity"),
4549
4815
  ("db.version_ahead", "_check_db_version_ahead"),
4816
+ ("db.rebuild_worker", "_check_db_rebuild_worker"),
4550
4817
  ("db.migrations.applied", "_check_db_migrations_applied"),
4551
4818
  ("db.migrations.pending", "_check_db_migrations_pending"),
4552
4819
  ("db.lock_state", "_check_db_lock_state"),
@@ -5111,6 +5378,40 @@ def fingerprint(report: DoctorReport) -> str:
5111
5378
  return f"sha1:{h}"
5112
5379
 
5113
5380
 
5381
+ def rebuild_worker_aggregate(checks) -> "dict | None":
5382
+ """#997 D10: the dashboard aggregate's ``rebuild_worker`` value —
5383
+ ``{severity, summary}`` from the ``db.rebuild_worker`` result when it is
5384
+ not ok, else ``None`` (also when the check is absent)."""
5385
+ for c in checks:
5386
+ if c.id == "db.rebuild_worker":
5387
+ if c.severity == "ok":
5388
+ return None
5389
+ return {"severity": c.severity, "summary": c.summary}
5390
+ return None
5391
+
5392
+
5393
+ def rebuild_worker_envelope_value(rebuild_workers, now_utc) -> "dict | None":
5394
+ """#997 D10: exactly the ``rebuild_worker`` value `envelope_block` would
5395
+ carry for these worker snapshots, without the rest of doctor. The
5396
+ dashboard's deferred frames use it while every stats open defers."""
5397
+ return rebuild_worker_aggregate(
5398
+ (rebuild_worker_check(rebuild_workers, now_utc),))
5399
+
5400
+
5401
+ def envelope_block(report: DoctorReport) -> dict:
5402
+ """The dashboard envelope's small doctor block for ``report``; the TUI
5403
+ precompute and the envelope's inline path both build it here, so the
5404
+ two producers stay byte-identical."""
5405
+ return {
5406
+ "severity": report.overall_severity,
5407
+ "counts": dict(report.counts),
5408
+ "generated_at": _iso_z(report.generated_at),
5409
+ "fingerprint": fingerprint(report),
5410
+ "rebuild_worker": rebuild_worker_aggregate(
5411
+ c for cat in report.categories for c in cat.checks),
5412
+ }
5413
+
5414
+
5114
5415
  _GLYPH = {"ok": "✓", "warn": "⚠", "fail": "✗"}
5115
5416
 
5116
5417
 
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
  import datetime as dt
31
31
  import hashlib
32
32
  import json
33
+ import sys
33
34
  from dataclasses import dataclass
34
35
 
35
36
  import _lib_account_protocol
@@ -222,6 +223,47 @@ def decode_line(raw: bytes) -> dict | None:
222
223
  return obj
223
224
 
224
225
 
226
+ #: Strings up to this many characters are interned by `compact_retained`.
227
+ #: Keys, kinds, sources, account keys, ids and timestamps fall under it; a
228
+ #: long free-text value is rare enough that sharing it would buy nothing.
229
+ COMPACT_INTERN_MAX_CHARS = 64
230
+
231
+
232
+ def compact_retained(value):
233
+ """An equal copy of a decoded record whose keys and string values of at
234
+ most `COMPACT_INTERN_MAX_CHARS` characters are ``sys.intern``ed (#997 D15).
235
+
236
+ CPython's JSON decoder shares keys only within one ``loads`` call, so
237
+ every record a pass retains carries its own copy of every key and every
238
+ repeated short value: 381 k retained records took 0.83 GB decoded and
239
+ 0.375 GB compacted on the maintainer's copy. Recursive through ``dict``
240
+ and ``list``; every other value (numbers, booleans, ``None``, long
241
+ strings) is shared unchanged. The result is a NEW record with new
242
+ containers, so equality, hashing, canonical encoding and mutation behave
243
+ exactly as on the plain record; only the object identity of equal strings
244
+ changes, and no consumer compares strings by identity.
245
+
246
+ Apply it immediately after decoding, before any consumer holds a
247
+ reference, so an identity check such as the rederive reader's
248
+ ``records[index] is record`` only ever sees the compacted object.
249
+ """
250
+ return _compact(value)
251
+
252
+
253
+ def _compact(value, _intern=sys.intern):
254
+ kind = type(value)
255
+ if kind is dict:
256
+ return {
257
+ (_intern(key) if type(key) is str else key): _compact(item)
258
+ for key, item in value.items()
259
+ }
260
+ if kind is list:
261
+ return [_compact(item) for item in value]
262
+ if kind is str and len(value) <= COMPACT_INTERN_MAX_CHARS:
263
+ return _intern(value)
264
+ return value
265
+
266
+
225
267
  # --------------------------------------------------------------------------
226
268
  # identity
227
269
  # --------------------------------------------------------------------------
@@ -383,11 +425,29 @@ def effects_payload_digest(payload: dict, refs: dict | None = None) -> str:
383
425
 
384
426
  _WRITER_GENERATION = _lib_account_protocol.PROTOCOL_GENERATION
385
427
  _WPG = _lib_account_protocol.WRITER_GENERATION_KEY
428
+ #: #797/#823/#840 (spec 4.6 B1): the protocol revision stamp, transport
429
+ #: metadata exactly like ``wpg``.
430
+ _WRITER_REVISION = _lib_account_protocol.PROTOCOL_REVISION
431
+ _WPR = _lib_account_protocol.REVISION_KEY
432
+ #: ``writer_revision`` default: the current revision when the record carries
433
+ #: the current writer generation, none for a previous release's shape.
434
+ _REVISION_DEFAULT = object()
435
+
436
+
437
+ def _writer_core(core: dict, writer_generation, writer_revision) -> None:
438
+ if writer_generation is not None:
439
+ core[_WPG] = writer_generation
440
+ if writer_revision is _REVISION_DEFAULT:
441
+ writer_revision = (None if writer_generation is None
442
+ else _WRITER_REVISION)
443
+ if writer_revision is not None:
444
+ core[_WPR] = writer_revision
386
445
 
387
446
 
388
447
  def make_obs(at: str, src: str, provider: str, payload: dict,
389
448
  account: str | None = None, *,
390
- writer_generation: str | None = _WRITER_GENERATION) -> dict:
449
+ writer_generation: str | None = _WRITER_GENERATION,
450
+ writer_revision=_REVISION_DEFAULT) -> dict:
391
451
  """Build a complete ``obs`` line record (raw capture; canonicalization is
392
452
  derivation-time, never baked into the stored line — spec §4.2).
393
453
 
@@ -401,35 +461,44 @@ def make_obs(at: str, src: str, provider: str, payload: dict,
401
461
  writing protocol. It is part of the content-id core, so a current record and
402
462
  a previous-release record of the same capture are distinct lines. ``None``
403
463
  builds the previous release's exact shape (a validator re-deriving a
404
- retained pre-stamp record, or a test simulating an old writer)."""
464
+ retained pre-stamp record, or a test simulating an old writer).
465
+
466
+ ``writer_revision`` (#797/#823/#840 spec 4.6 B1) is the top-level ``wpr``
467
+ protocol revision, also part of the content-id core. It defaults to the
468
+ current revision for a current-generation record and to none for a
469
+ previous release's shape; ``None`` with the current generation builds
470
+ the revision-1 (#825) shape."""
405
471
  core = {"t": "obs", "at": at, "src": src, "provider": provider, "payload": payload}
406
472
  if account is not None:
407
473
  core["account"] = account
408
- if writer_generation is not None:
409
- core[_WPG] = writer_generation
474
+ _writer_core(core, writer_generation, writer_revision)
410
475
  return {"v": LINE_VERSION, **core, "id": content_id(core)}
411
476
 
412
477
 
413
478
  def make_op(at: str, src: str, payload: dict, *,
414
- writer_generation: str | None = _WRITER_GENERATION) -> dict:
479
+ writer_generation: str | None = _WRITER_GENERATION,
480
+ writer_revision=_REVISION_DEFAULT) -> dict:
415
481
  """Build a complete ``op`` (operator record) line — no ``provider``.
416
- ``writer_generation`` as in ``make_obs``."""
482
+ ``writer_generation`` and ``writer_revision`` as in ``make_obs``."""
417
483
  core = {"t": "op", "at": at, "src": src, "payload": payload}
418
- if writer_generation is not None:
419
- core[_WPG] = writer_generation
484
+ _writer_core(core, writer_generation, writer_revision)
420
485
  return {"v": LINE_VERSION, **core, "id": content_id(core)}
421
486
 
422
487
 
423
488
  def prepare_record(record: dict) -> dict:
424
489
  """The single shared preparation step both append APIs apply (#825/#832
425
- spec 5.1): return ``record`` carrying the current writer generation.
426
-
427
- A content-id record (``id`` starting ``o:``) that is not yet stamped and
428
- whose id verifies over its unstamped core is re-identified over the stamped
429
- core, exactly as the builders compute it. Every other id — semantic event
430
- ids, bootstrap ids, correction batch ids, a content id that does not verify
431
- — is preserved. Idempotent: a stamped record is returned unchanged."""
432
- if _lib_account_protocol.is_stamped(record):
490
+ spec 5.1): return ``record`` carrying the current writer generation and,
491
+ since #797/#823/#840 (spec 4.6 B1), the current protocol revision.
492
+
493
+ A content-id record (``id`` starting ``o:``) that is not yet fully
494
+ stamped and whose id verifies over its current core is re-identified over
495
+ the stamped core, exactly as the builders compute it. Every other id —
496
+ semantic event ids, bootstrap ids, correction batch ids, a content id
497
+ that does not verify — is preserved. Idempotent: a record carrying both
498
+ stamps is returned unchanged. Only records about to be appended pass
499
+ through here, so a retained record is never restamped."""
500
+ if (_lib_account_protocol.is_stamped(record)
501
+ and _lib_account_protocol.has_current_revision(record)):
433
502
  return record
434
503
  stamped = _lib_account_protocol.stamp(record)
435
504
  rid = record.get("id")
@@ -448,10 +517,30 @@ def unstamped_record(record: dict) -> dict:
448
517
  ``prepare_record``. Drops the writer-generation stamp and, for a content-id
449
518
  record whose id verifies over its stamped core, re-identifies it over the
450
519
  unstamped core. Any other id is preserved. For fixtures and tests that
451
- simulate a pre-stamp writer; never used on a production write path."""
452
- if not isinstance(record, dict) or _WPG not in record:
520
+ simulate a pre-stamp writer; never used on a production write path.
521
+ The protocol revision (``wpr``) goes with the stamp."""
522
+ if not isinstance(record, dict) or (
523
+ _WPG not in record and _WPR not in record):
524
+ return record
525
+ out = {k: v for k, v in record.items() if k not in (_WPG, _WPR)}
526
+ rid = record.get("id")
527
+ if (record.get("t") in ("obs", "op") and isinstance(rid, str)
528
+ and rid.startswith("o:")):
529
+ core = {k: v for k, v in record.items() if k not in ("v", "id")}
530
+ if content_id(core) == rid:
531
+ out["id"] = content_id(
532
+ {k: v for k, v in out.items() if k not in ("v", "id")})
533
+ return out
534
+
535
+
536
+ def prerevision_record(record: dict) -> dict:
537
+ """The revision-1 (#825 release) shape of ``record``: the current writer
538
+ generation without the protocol revision, re-identified like
539
+ ``unstamped_record``. For fixtures and tests that simulate the previous
540
+ release; never used on a production write path."""
541
+ if not isinstance(record, dict) or _WPR not in record:
453
542
  return record
454
- out = {k: v for k, v in record.items() if k != _WPG}
543
+ out = {k: v for k, v in record.items() if k != _WPR}
455
544
  rid = record.get("id")
456
545
  if (record.get("t") in ("obs", "op") and isinstance(rid, str)
457
546
  and rid.startswith("o:")):
@@ -510,11 +599,15 @@ def event_content_hash(record: dict) -> str:
510
599
  #: The previous release's exact-key repair acknowledgement. The current binary
511
600
  #: never emits it: its validator would reject the writer-generation stamp, so
512
601
  #: current acknowledgements use the versioned kind the previous release ignores
513
- #: as an unknown op (#825/#832 spec 5.1). Current readers accept both.
602
+ #: as an unknown op (#825/#832 spec 5.1). Current readers accept every kind.
514
603
  _PROTOCOL_RESOLUTION_KIND = "journal_protocol_resolution"
515
604
  _PROTOCOL_RESOLUTION_V2_KIND = _lib_account_protocol.PROTOCOL_RESOLUTION_V2_KIND
605
+ #: #797/#823/#840 (spec 4.6 B5): the previous release's v2 validator rejects
606
+ #: the protocol revision stamp, so the current binary emits this successor.
607
+ PROTOCOL_RESOLUTION_V3_KIND = _lib_account_protocol.PROTOCOL_RESOLUTION_V3_KIND
516
608
  PROTOCOL_RESOLUTION_KINDS = frozenset((
517
- _PROTOCOL_RESOLUTION_KIND, _PROTOCOL_RESOLUTION_V2_KIND))
609
+ _PROTOCOL_RESOLUTION_KIND, _PROTOCOL_RESOLUTION_V2_KIND,
610
+ PROTOCOL_RESOLUTION_V3_KIND))
518
611
  _PROTOCOL_RESOLUTION_SRC = "journal-repair"
519
612
 
520
613
 
@@ -554,7 +647,7 @@ def make_protocol_resolution(
554
647
  at=at,
555
648
  src=_PROTOCOL_RESOLUTION_SRC,
556
649
  payload={
557
- "kind": _PROTOCOL_RESOLUTION_V2_KIND,
650
+ "kind": PROTOCOL_RESOLUTION_V3_KIND,
558
651
  "violations": refs,
559
652
  "journal_high_water": {
560
653
  "segment": segment,
@@ -588,7 +681,7 @@ def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
588
681
  body["kind"] = kind
589
682
  return {"v": LINE_VERSION, "t": "evt", "id": id, "rev": rev,
590
683
  "at": at, "src": "ingest", "payload": body,
591
- _WPG: _WRITER_GENERATION}
684
+ _WPG: _WRITER_GENERATION, _WPR: _WRITER_REVISION}
592
685
 
593
686
 
594
687
  # --------------------------------------------------------------------------
@@ -627,8 +720,10 @@ def _protocol_violation(
627
720
  def _validate_protocol_resolution(record: dict) -> dict:
628
721
  required = {"v", "t", "id", "at", "src", "payload"}
629
722
  # #825/#832: a current record also carries the writer-generation stamp,
630
- # which is part of its content-id core.
631
- if set(record) not in (required, required | {_WPG}):
723
+ # which is part of its content-id core; #797/#823/#840 adds the protocol
724
+ # revision (spec 4.6 B5).
725
+ if set(record) not in (required, required | {_WPG},
726
+ required | {_WPG, _WPR}):
632
727
  raise JournalProtocolError(
633
728
  "journal protocol resolution record shape is invalid"
634
729
  )
@@ -650,6 +745,8 @@ def _validate_protocol_resolution(record: dict) -> dict:
650
745
  }
651
746
  if _WPG in record:
652
747
  core[_WPG] = record[_WPG]
748
+ if _WPR in record:
749
+ core[_WPR] = record[_WPR]
653
750
  if record.get("id") != content_id(core):
654
751
  raise JournalProtocolError(
655
752
  "journal protocol resolution record id does not match its content"
@@ -802,6 +899,7 @@ def make_correction_batch(
802
899
  "action_count": len(cores),
803
900
  "actions_hash": actions_hash,
804
901
  _WPG: _WRITER_GENERATION,
902
+ _WPR: _WRITER_REVISION,
805
903
  }
806
904
  records = [{**marker, "phase": "begin"}]
807
905
  for seq, core in enumerate(cores):
@@ -813,12 +911,34 @@ def make_correction_batch(
813
911
  "batch": batch_id,
814
912
  "seq": seq,
815
913
  _WPG: _WRITER_GENERATION,
914
+ _WPR: _WRITER_REVISION,
816
915
  }
817
916
  )
818
917
  records.append({**marker, "phase": "commit"})
819
918
  return records
820
919
 
821
920
 
921
+ def revision_batch_id(batch_id: str, begins) -> str:
922
+ """The batch id a retry of ``batch_id`` appends under (#797/#823/#840
923
+ spec 4.6 B1).
924
+
925
+ ``begins`` are the retained begin markers already journalled under
926
+ ``batch_id``. A retry reuses the id only when no begin exists or one
927
+ carries the current revision stamp. An interrupted batch an earlier
928
+ writer began without that stamp is never restamped: a revision-2 copy of
929
+ its lines would be a marker conflict under the original id (the fold
930
+ hashes markers byte-exactly), and a copy without the stamp would land
931
+ after the revision boundary, where admission excludes it. Its plan is
932
+ therefore authored afresh under a derived id, and the original begin
933
+ stays the inert, incomplete batch it was."""
934
+ begins = list(begins)
935
+ if not begins or any(
936
+ _lib_account_protocol.has_current_revision(begin)
937
+ for begin in begins):
938
+ return batch_id
939
+ return f"{batch_id}:wpr{_lib_account_protocol.PROTOCOL_REVISION}"
940
+
941
+
822
942
  def _validate_batch_marker(record: dict) -> dict:
823
943
  batch_id = record.get("id")
824
944
  if not isinstance(batch_id, str) or not batch_id: