cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -0,0 +1,502 @@
1
+ """Codex rollout containers (#968 spec 5.1, 5.4).
2
+
3
+ A dependency-light leaf: it imports only the standard library and nothing from
4
+ cache, journal, configuration or CLI modules. It owns
5
+
6
+ * logical normalization (``X.jsonl.zst`` -> ``X.jsonl``) — every persisted key is
7
+ the logical path, whatever container holds the bytes;
8
+ * container selection (plain wins, else the ``.zst`` twin, else none);
9
+ * the call-time decoder chain (stdlib ``compression.zstd`` on 3.14+, then
10
+ ``backports.zstd``, then a typed unavailable result);
11
+ * a decoded binary stream whose ``tell``/``seek`` speak DECODED offsets, so a
12
+ compressed rollout yields exactly the offsets its plain twin would;
13
+ * container fingerprints, full-stream validation and the content anchor chain.
14
+
15
+ JSONL semantics stay in ``_lib_jsonl``; its readers receive these streams.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import io
21
+ import json
22
+ import os
23
+ import pathlib
24
+ import stat as _stat
25
+ from typing import Callable, NamedTuple, Optional, Union
26
+
27
+ PLAIN = "plain"
28
+ ZSTD = "zstd"
29
+ PLAIN_SUFFIX = ".jsonl"
30
+ ZSTD_SUFFIX = ".jsonl.zst"
31
+ DECODE_CHUNK = 1 << 20
32
+ READ_CHUNK = 64 << 10
33
+ ANCHOR_VERSION = 1
34
+ _ANCHOR_SEED = hashlib.sha256(b"cctally-codex-content-anchor-v1\0").digest()
35
+
36
+ PathLike = Union[str, "os.PathLike[str]"]
37
+ StopPredicate = Optional[Callable[[], bool]]
38
+
39
+
40
+ class ZstdBackend(NamedTuple):
41
+ name: str
42
+ module: object
43
+
44
+
45
+ class ZstdUnavailable(NamedTuple):
46
+ reason: str = "decoder-unavailable"
47
+
48
+
49
+ UNAVAILABLE = ZstdUnavailable()
50
+
51
+
52
+ def resolve_zstd_backend() -> "ZstdBackend | ZstdUnavailable":
53
+ """Resolve the decoder NOW (U1). Never cached; never reads an env switch.
54
+
55
+ Tests replace this module attribute with ``lambda: UNAVAILABLE``; every
56
+ caller reaches it through the module, so the replacement is honored.
57
+ Production code has no flag that does the same.
58
+ """
59
+ try:
60
+ from compression import zstd as module # Python 3.14+
61
+ except ImportError:
62
+ pass
63
+ else:
64
+ return ZstdBackend("stdlib", module)
65
+ try:
66
+ from backports import zstd as module # type: ignore[no-redef]
67
+ except ImportError:
68
+ return UNAVAILABLE
69
+ return ZstdBackend("backports.zstd", module)
70
+
71
+
72
+ def decoder_capability() -> str:
73
+ backend = resolve_zstd_backend()
74
+ return backend.name if isinstance(backend, ZstdBackend) else "unavailable"
75
+
76
+
77
+ class ContainerError(OSError):
78
+ """A container could not be read; ``category`` is bounded and privacy-safe.
79
+
80
+ Subclassing OSError keeps every existing ``except OSError`` per-file
81
+ failure path (keep the accepted state, retry later) correct by default;
82
+ callers that must tell a missing decoder from a missing file test for this
83
+ class FIRST.
84
+ """
85
+
86
+ CATEGORIES = frozenset({
87
+ "decoder-unavailable", "decode-error", "read-error", "unstable-stat"})
88
+
89
+ def __init__(self, category: str):
90
+ if category not in self.CATEGORIES:
91
+ raise ValueError(f"unknown container error category {category!r}")
92
+ super().__init__(category)
93
+ self.category = category
94
+
95
+
96
+ class ContainerReadInterrupted(Exception):
97
+ """The caller's stop predicate fired inside a read or seek.
98
+
99
+ Nothing partial was returned: the stream position is unchanged for a seek,
100
+ and no line was handed out for a read.
101
+ """
102
+
103
+
104
+ def logical_path(p: PathLike):
105
+ """Remove ONE terminal ``.zst`` from a ``*.jsonl.zst`` name; idempotent."""
106
+ if isinstance(p, pathlib.PurePath):
107
+ name = p.name
108
+ return p.with_name(name[:-4]) if name.endswith(ZSTD_SUFFIX) else p
109
+ text = os.fspath(p)
110
+ return text[:-4] if text.endswith(ZSTD_SUFFIX) else text
111
+
112
+
113
+ def is_container_name(name: str) -> bool:
114
+ return name.endswith(PLAIN_SUFFIX) or name.endswith(ZSTD_SUFFIX)
115
+
116
+
117
+ def zstd_twin(logical: PathLike) -> pathlib.Path:
118
+ return pathlib.Path(os.fspath(logical_path(logical)) + ".zst")
119
+
120
+
121
+ class ContainerFingerprint(NamedTuple):
122
+ kind: str
123
+ path: str
124
+ size: int
125
+ mtime_ns: int
126
+ device: int
127
+ inode: int
128
+
129
+ def watch_sig(self) -> tuple:
130
+ """The live-tail signature: plain keeps today's size; zstd binds the
131
+ container's identity, device included, exactly as the zstd generation
132
+ token and every compressed container-unchanged check do (review P3-2,
133
+ round-2 N9), so a remounted container is observed as a change once."""
134
+ if self.kind == PLAIN:
135
+ return (PLAIN, int(self.size))
136
+ return (ZSTD, int(self.size), int(self.mtime_ns), int(self.device),
137
+ int(self.inode))
138
+
139
+ def to_json(self) -> str:
140
+ return json.dumps(list(self), separators=(",", ":"))
141
+
142
+ @classmethod
143
+ def from_json(cls, text) -> "ContainerFingerprint | None":
144
+ try:
145
+ kind, path, size, mtime_ns, device, inode = json.loads(text)
146
+ if kind not in (PLAIN, ZSTD):
147
+ return None
148
+ return cls(str(kind), str(path), int(size), int(mtime_ns),
149
+ int(device), int(inode))
150
+ except (TypeError, ValueError):
151
+ return None
152
+
153
+
154
+ def _regular_stat(path: PathLike):
155
+ try:
156
+ st = os.stat(path)
157
+ except OSError:
158
+ return None
159
+ return st if _stat.S_ISREG(st.st_mode) else None
160
+
161
+
162
+ def fingerprint_of(path: PathLike, kind: str) -> "ContainerFingerprint | None":
163
+ st = _regular_stat(path)
164
+ if st is None:
165
+ return None
166
+ return ContainerFingerprint(kind, os.fspath(path), int(st.st_size),
167
+ int(st.st_mtime_ns), int(st.st_dev),
168
+ int(st.st_ino))
169
+
170
+
171
+ class SelectedContainer(NamedTuple):
172
+ logical: pathlib.Path
173
+ kind: str
174
+ path: pathlib.Path
175
+ fingerprint: ContainerFingerprint
176
+
177
+
178
+ def select_container(logical: PathLike) -> "SelectedContainer | None":
179
+ """Plain when it exists, otherwise the ``.zst`` twin, otherwise None (5.2)."""
180
+ lp = pathlib.Path(logical_path(os.fspath(logical)))
181
+ fp = fingerprint_of(lp, PLAIN)
182
+ if fp is not None:
183
+ return SelectedContainer(lp, PLAIN, lp, fp)
184
+ twin = zstd_twin(lp)
185
+ fp = fingerprint_of(twin, ZSTD)
186
+ if fp is not None:
187
+ return SelectedContainer(lp, ZSTD, twin, fp)
188
+ return None
189
+
190
+
191
+ def observe_watch_sig(logical: PathLike) -> "tuple | None":
192
+ selected = select_container(logical)
193
+ return None if selected is None else selected.fingerprint.watch_sig()
194
+
195
+
196
+ class DecodedZstdStream:
197
+ """A binary reader over one zstd container that speaks DECODED offsets.
198
+
199
+ ``_buf`` holds decoded bytes ``[_buf_start, _raw_pos)``. ``readline`` keeps
200
+ every byte from the start of the line it returned, so a torn-line rewind
201
+ (``seek(line_start)``) is served from memory. A forward seek decodes and
202
+ discards bounded chunks; a backward seek beyond the window restarts the
203
+ decoder. The stop predicate is consulted between chunks inside large reads
204
+ and seeks, and a stop raises ``ContainerReadInterrupted`` without moving
205
+ the position.
206
+ """
207
+
208
+ def __init__(self, path: PathLike, backend: ZstdBackend, *,
209
+ should_stop: StopPredicate = None):
210
+ self._path = os.fspath(path)
211
+ self._backend = backend
212
+ self._should_stop = should_stop
213
+ self._raw = None
214
+ self._closed = False
215
+ self._open_raw()
216
+ self._pos = 0
217
+
218
+ # -- plumbing ---------------------------------------------------------
219
+ def _open_raw(self) -> None:
220
+ if self._raw is not None:
221
+ self._raw.close()
222
+ try:
223
+ self._raw = self._backend.module.ZstdFile(self._path, "rb")
224
+ except OSError:
225
+ raise ContainerError("read-error") from None
226
+ self._raw_pos = 0
227
+ self._buf = bytearray()
228
+ self._buf_start = 0
229
+ self._eof = False
230
+
231
+ def _check_stop(self) -> None:
232
+ if self._should_stop is not None and self._should_stop():
233
+ raise ContainerReadInterrupted()
234
+
235
+ def _fill(self, size: int) -> int:
236
+ if self._eof:
237
+ return 0
238
+ self._check_stop()
239
+ decode_errors = (EOFError,)
240
+ zstd_error = getattr(self._backend.module, "ZstdError", None)
241
+ if isinstance(zstd_error, type):
242
+ decode_errors = (EOFError, zstd_error)
243
+ try:
244
+ chunk = self._raw.read(min(size, DECODE_CHUNK))
245
+ except decode_errors:
246
+ raise ContainerError("decode-error") from None
247
+ except OSError:
248
+ raise ContainerError("read-error") from None
249
+ if not chunk:
250
+ self._eof = True
251
+ return 0
252
+ self._buf += chunk
253
+ self._raw_pos += len(chunk)
254
+ return len(chunk)
255
+
256
+ def _drop_before(self, keep_from: int) -> None:
257
+ keep_from = min(keep_from, self._raw_pos)
258
+ if keep_from > self._buf_start:
259
+ del self._buf[: keep_from - self._buf_start]
260
+ self._buf_start = keep_from
261
+
262
+ # -- file protocol ----------------------------------------------------
263
+ def readable(self) -> bool:
264
+ return True
265
+
266
+ def seekable(self) -> bool:
267
+ return True
268
+
269
+ def tell(self) -> int:
270
+ return self._pos
271
+
272
+ def seek(self, offset: int, whence: int = 0) -> int:
273
+ if whence == io.SEEK_CUR:
274
+ offset = self._pos + offset
275
+ elif whence != io.SEEK_SET:
276
+ raise io.UnsupportedOperation("decoded streams seek from the start")
277
+ if offset < 0:
278
+ raise ValueError("negative seek position")
279
+ saved = (self._pos,)
280
+ if offset < self._buf_start:
281
+ self._open_raw()
282
+ if offset > self._raw_pos:
283
+ self._buf = bytearray()
284
+ self._buf_start = self._raw_pos
285
+ while self._raw_pos < offset:
286
+ try:
287
+ got = self._fill(min(DECODE_CHUNK, offset - self._raw_pos))
288
+ except ContainerReadInterrupted:
289
+ if saved[0] < self._buf_start:
290
+ self._open_raw()
291
+ self._pos = saved[0]
292
+ raise
293
+ if got == 0:
294
+ break
295
+ self._buf = bytearray()
296
+ self._buf_start = self._raw_pos
297
+ self._pos = offset
298
+ return self._pos
299
+
300
+ def _prepare_read(self) -> None:
301
+ # An interrupted discard may have restarted the decoder while keeping
302
+ # the caller's old decoded position. Materialize it only when needed.
303
+ if self._pos > self._raw_pos and not self._eof:
304
+ self.seek(self._pos)
305
+
306
+ def read(self, size: int = -1) -> bytes:
307
+ if size == 0:
308
+ return b""
309
+ self._prepare_read()
310
+ if self._pos >= self._raw_pos and self._eof:
311
+ return b""
312
+ start = self._pos - self._buf_start
313
+ while size < 0 or len(self._buf) - start < size:
314
+ want = DECODE_CHUNK if size < 0 else max(
315
+ READ_CHUNK, size - (len(self._buf) - start))
316
+ if self._fill(want) == 0:
317
+ break
318
+ end = len(self._buf) if size < 0 else min(len(self._buf), start + size)
319
+ data = bytes(self._buf[start:end])
320
+ self._pos += len(data)
321
+ self._drop_before(self._pos)
322
+ return data
323
+
324
+ def readline(self, limit: int = -1) -> bytes:
325
+ self._prepare_read()
326
+ if self._pos >= self._raw_pos and self._eof:
327
+ return b""
328
+ start = self._pos - self._buf_start
329
+ search_from = start
330
+ bounded = limit is not None and limit >= 0
331
+ while True:
332
+ idx = self._buf.find(b"\n", search_from)
333
+ if idx >= 0:
334
+ end = idx + 1
335
+ if bounded and end - start > limit:
336
+ end = start + limit
337
+ break
338
+ if bounded and len(self._buf) - start >= limit:
339
+ end = start + limit
340
+ break
341
+ search_from = len(self._buf)
342
+ if self._fill(READ_CHUNK) == 0:
343
+ end = len(self._buf)
344
+ break
345
+ line = bytes(self._buf[start:end])
346
+ # Retain from the start of THIS line so a torn-line rewind stays local.
347
+ self._drop_before(self._pos)
348
+ self._pos += len(line)
349
+ return line
350
+
351
+ def close(self) -> None:
352
+ if not self._closed and self._raw is not None:
353
+ self._raw.close()
354
+ self._closed = True
355
+
356
+ def __enter__(self):
357
+ return self
358
+
359
+ def __exit__(self, *exc) -> None:
360
+ self.close()
361
+
362
+
363
+ def open_decoded(container: SelectedContainer, *, should_stop: StopPredicate = None):
364
+ """Open the selected container as a binary stream with decoded offsets."""
365
+ if container.kind == PLAIN:
366
+ return open(container.path, "rb")
367
+ backend = resolve_zstd_backend()
368
+ if not isinstance(backend, ZstdBackend):
369
+ raise ContainerError("decoder-unavailable")
370
+ return DecodedZstdStream(container.path, backend, should_stop=should_stop)
371
+
372
+
373
+ class StreamValidation(NamedTuple):
374
+ ok: bool
375
+ decoded_length: "int | None"
376
+ complete_line_end: "int | None"
377
+ error: "str | None"
378
+
379
+
380
+ def validate_full_stream(container: SelectedContainer, *,
381
+ should_stop: StopPredicate = None) -> StreamValidation:
382
+ """Decode the whole stream to frame completion (5.1).
383
+
384
+ Reports the decoded length, the complete-line end (offset after the last
385
+ newline, 0 when none) and any decoder error. A container whose stat moved
386
+ during the pass is ``unstable-stat``. ``ContainerReadInterrupted``
387
+ propagates to the caller, which treats it as a budget stop.
388
+ """
389
+ total = 0
390
+ last_line_end = 0
391
+ try:
392
+ with open_decoded(container, should_stop=should_stop) as stream:
393
+ while True:
394
+ if should_stop is not None and should_stop():
395
+ raise ContainerReadInterrupted()
396
+ chunk = stream.read(DECODE_CHUNK)
397
+ if not chunk:
398
+ break
399
+ idx = chunk.rfind(b"\n")
400
+ if idx >= 0:
401
+ last_line_end = total + idx + 1
402
+ total += len(chunk)
403
+ except ContainerError as exc:
404
+ return StreamValidation(False, None, None, exc.category)
405
+ except OSError:
406
+ return StreamValidation(False, None, None, "read-error")
407
+ after = fingerprint_of(container.path, container.kind)
408
+ if after != container.fingerprint:
409
+ return StreamValidation(False, None, None, "unstable-stat")
410
+ return StreamValidation(True, total, last_line_end, None)
411
+
412
+
413
+ def anchor_seed() -> bytes:
414
+ return _ANCHOR_SEED
415
+
416
+
417
+ def anchor_step(prev: bytes, line: bytes) -> bytes:
418
+ """anchor_n = SHA-256(anchor_{n-1} || SHA-256(line_n)) (5.4)."""
419
+ return hashlib.sha256(prev + hashlib.sha256(line).digest()).digest()
420
+
421
+
422
+ def anchor_over(stream, *, start_offset: int, start_anchor: bytes,
423
+ end_offset: int) -> "tuple[bytes, int]":
424
+ """Fold every complete line in ``[start_offset, end_offset)``.
425
+
426
+ Stops at a line that is not newline-terminated or would cross
427
+ ``end_offset``; returns ``(anchor, offset reached)``.
428
+ """
429
+ stream.seek(start_offset)
430
+ anchor = start_anchor
431
+ offset = start_offset
432
+ while offset < end_offset:
433
+ line = stream.readline()
434
+ if not line or not line.endswith(b"\n") or offset + len(line) > end_offset:
435
+ break
436
+ anchor = anchor_step(anchor, line)
437
+ offset += len(line)
438
+ return anchor, offset
439
+
440
+
441
+ class LineAnchorRecorder:
442
+ """Wrap an ingest stream and fold each complete, sequential line.
443
+
444
+ ``_lib_jsonl``'s fused iterator reads strictly sequentially and only ever
445
+ rewinds to the start of a line it rejected (torn tail or stop point), so
446
+ folding a complete line exactly when it starts at the folded frontier gives
447
+ the chain over every complete raw line — malformed and non-emitting lines
448
+ included — without storing per-line digests. ``start_anchor=None`` means
449
+ the cursor carries no resumable anchor (a legacy row); ``result`` is then
450
+ always None.
451
+ """
452
+
453
+ def __init__(self, stream, *, start_offset: int,
454
+ start_anchor: "bytes | None"):
455
+ self._s = stream
456
+ self._anchor = start_anchor
457
+ self._folded_to = start_offset
458
+
459
+ def tell(self) -> int:
460
+ return self._s.tell()
461
+
462
+ def seek(self, offset: int, whence: int = 0) -> int:
463
+ return self._s.seek(offset, whence)
464
+
465
+ def read(self, size: int = -1) -> bytes:
466
+ return self._s.read(size)
467
+
468
+ def readline(self, limit: int = -1) -> bytes:
469
+ start = self._s.tell()
470
+ line = self._s.readline(limit)
471
+ if (self._anchor is not None and line.endswith(b"\n")
472
+ and start == self._folded_to):
473
+ self._anchor = anchor_step(self._anchor, line)
474
+ self._folded_to = start + len(line)
475
+ return line
476
+
477
+ def result(self, final_offset: int) -> "bytes | None":
478
+ if self._anchor is None or final_offset != self._folded_to:
479
+ return None
480
+ return self._anchor
481
+
482
+
483
+ def generation_token(fingerprint: ContainerFingerprint,
484
+ decoded_length: "int | None") -> str:
485
+ """Bind a container fingerprint and its validated content evidence (5.11).
486
+
487
+ A plain file keeps one generation while it grows in place (appends change
488
+ the scan target, not the generation). A zstd token binds the whole
489
+ selected fingerprint (5.1, 5.11): device, inode, size and mtime, plus the
490
+ validated decoded length, so any change to one of them, a remount that
491
+ renumbers st_dev included, is a new generation. That is stricter than the
492
+ #769 S6 rule (inode decides, device corroborates), which 5.5 permits.
493
+
494
+ The plain token stays inode-only on purpose. An unchanged plain file is
495
+ never re-read, so its cursor's stored device is never refreshed; a device
496
+ in the plain token would make every retained plain file look changed
497
+ after a remount and strand the membership scan and the seal (5.11, U6).
498
+ """
499
+ if fingerprint.kind == PLAIN:
500
+ return f"plain:{fingerprint.inode}"
501
+ return (f"zstd:{fingerprint.device}:{fingerprint.inode}:"
502
+ f"{fingerprint.size}:{fingerprint.mtime_ns}:{decoded_length}")
@@ -779,6 +779,9 @@ def neutral_events_preflight(conn: sqlite3.Connection, ref: str) -> dict:
779
779
  return {"status": "normalization_pending",
780
780
  "conversation_key": cref.conversation_key}
781
781
  if not q.codex_conversation_exists(conn, cref.conversation_key):
782
+ if q.codex_publication_pending(conn, cref.conversation_key):
783
+ return {"status": "normalization_pending",
784
+ "conversation_key": cref.conversation_key}
782
785
  return {"status": "not_found",
783
786
  "conversation_key": cref.conversation_key}
784
787
  return {"status": "ok", "conversation_key": cref.conversation_key,