Update letter: Ouroboros writes one paragraph about a pending official update

A fetching managed-update check (boot, and the Updates panel's Check for updates) now
also writes an update letter: one accounted LIGHT-slot call with the ordinary task
context (build_llm_messages routed at the light model), fed the first-parent commits
between the running base and the official target plus the README Version History rows
those commits added, recovered from the commit diffs because the table is capped and
untagged releases have no tag to look up.

The letter lives in state/update_letter.json under its exact (base, target, channel,
ref) key and is never deleted after the update lands; one projection relates it to the
live HEAD (pending / applied / superseded / other) for both the Updates payload
(`letter`) and the Runtime context, whose new `official_update` fact tells every task
and every consciousness cycle whether the body is current with its official source as
of the last check. No wake is forced, no new endpoint or WebSocket type, no model
substitution when the light slot has no credentials (typed failure instead), the last
good letter survives a failed rewrite, and cost stays in the ledger by attempt id.

CONSCIOUSNESS.md gains one guideline (6682 -> 6927 bytes): mentioning an update is the
mind's judgment, not a duty.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Anton Razzhigaev 2026-09-03 19:20:55 +00:00
parent 83a69e673a
commit d89bba3d4e
12 changed files with 971 additions and 1 deletions

View file

@ -65,6 +65,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── settings_integrity.py ← Strict settings-snapshot integrity pin; `OUROBOROS_SETTINGS_SHA256` enables the trust root
├── credential_shapes.py ← Filename shapes that commonly indicate credential material; blocks root read imports
├── update_channels.py ← Closed Stable/QA/Development channel mapping and update-network defaults
├── update_letter.py ← The update letter: first-parent range material (commits plus the README history rows they added, recovered from commit diffs because the table is capped and untagged releases have no tag), one accounted LIGHT-slot call with the ordinary task context, `state/update_letter.json`, and the one letter projection shared by the Updates panel payload and the Runtime context `official_update` fact; written synchronously inside a fetching check, never deleted after apply
├── colab_bootstrap.py ← Google Colab source-mode bootstrap: official update source, stable local `ouroboros` branch, Drive-backed settings/data, personal origin, no-UI server command, native Telegram setup
├── cli.py ← Source/headless CLI over gateway tasks, logs, settings, skills, marketplace, local-model, and MCP wrappers
├── packaged_cli.py ← Packaged desktop CLI bridge: resolves bundle roots, bootstraps the launcher-managed repo, delegates to cli.py
@ -509,6 +510,7 @@ Extension children, delegated runtimes, services, the local model, and companion
│ │ ├── post_task_evolution_counter.json ← per-drive every_n counter
│ │ ├── scheduled_tasks.json ← cron (5-field + tz) and one-shot {type:"once", run_at} schedules; consumed one-shot receipts age out past the unified GC retention
│ │ ├── claudexor_rotation_provisioning.json ← receipt of the last rotation-reconcile settings POST
│ │ ├── update_letter.json ← the last update letter (key = base/target/channel/ref, state, text, `last_good`); kept after apply and projected as pending/applied/superseded/other against the live HEAD (update_letter.py)
│ │ ├── projects.json ← Project registry: immutable id/chat identity, working folder, lifecycle/routing fence, revision; tombstones are durable and never age-pruned
│ │ ├── project_task_bindings.json ← schema v1 root↔Project bindings with REQUIRED typed origin; one-way enrichment; tombstoning never removes a binding
│ │ ├── ui_preferences.json ← owner-local layout preferences + monotonic project_seen_revision ACKs
@ -1008,7 +1010,7 @@ Prompt caching is stable-first: governance and task-stable contracts precede mut
#### Background consciousness and Evolution
Background Consciousness is the high-horizon awareness loop. It can groom memory, identity candidates, knowledge, and the improvement backlog, message the owner, and initiate an already configured reviewed Presence binding; it does not acquire the binding's tools or transport authority, and it does not run shell/code work, subagents, reviews, commits, or evolution toggles — awareness proposes work without bypassing task, budget, and review authority.
Background Consciousness is the high-horizon awareness loop. It can groom memory, identity candidates, knowledge, and the improvement backlog, message the owner, and initiate an already configured reviewed Presence binding; it does not acquire the binding's tools or transport authority, and it does not run shell/code work, subagents, reviews, commits, or evolution toggles — awareness proposes work without bypassing task, budget, and review authority. Awareness of the body's own version is ambient: the Runtime context of every task and every consciousness cycle carries `official_update` — running version versus the official target as of the last fetching check, plus the update letter (`update_letter.py`); no wake is forced by a check, and whether to mention an update to the owner is the mind's judgment (`prompts/CONSCIOUSNESS.md`).
Its observation inbox is the append-only `state/consciousness_observations.jsonl` store. Producers write a stable-ID `enqueue` row before the wake notification returns; `_ack_observations` appends ACK rows only after the thought receipt, tool receipts, budget settlement, and other durable writes for that snapshot have succeeded, so any failure leaves the snapshot pending with an actor-readable source reference. The status surface reports only pending count, oldest timestamp, source, and gap count. When the same cycle can call `update_identity`, an observation gap joins the identity-completeness envelope and blocks that destructive write until the actor resolves the named source.
@ -1285,6 +1287,7 @@ Providers name the same output-token budget differently: OpenRouter/Anthropic-co
| Skill publish PR body generation | 8,192 |
| Background consciousness loop | 65,536 |
| Project naming LIGHT one-shot (`project_naming.llm_project_name`) | 256 |
| Update letter LIGHT one-shot (`update_letter.write_letter`) | 1,024 |
| Provider Test (`llm_probe.PROVIDER_TEST_MAX_TOKENS`) | 16 |
### Default settings
@ -1328,6 +1331,7 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults stay canonical in `conf
| OUROBOROS_MODEL_SLOT_MAX_WAIT_SEC | 180 | Concurrency-slot wait bound |
| OUROBOROS_PROJECT_NAMING_TIMEOUT_SEC | 60 | Project-naming call ceiling |
| OUROBOROS_PROJECT_NAMING_ASYNC_TIMEOUT_SEC | 8 | Proactive-namer async bound |
| OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC | 120 | Update-letter LIGHT-call transport ceiling (`update_letter.py`) |
| OUROBOROS_FALLBACK_COOLDOWN_ENABLED | true | 429-aware per-process model cooldown |
| OUROBOROS_FALLBACK_COOLDOWN_SEC | 120 | Cooldown window |
| OUROBOROS_FALLBACK_ATTEMPTS_PER_MODEL | 1 | Attempts per model in the fallback walk |

View file

@ -137,6 +137,8 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
# (not project_naming.py magic numbers) per DEVELOPMENT "Timeout & Wait Control".
"OUROBOROS_PROJECT_NAMING_TIMEOUT_SEC": 60,
"OUROBOROS_PROJECT_NAMING_ASYNC_TIMEOUT_SEC": 8,
# Update-letter LIGHT-call provider transport ceiling (update_letter.py); SSOT here.
"OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC": 120,
# Skill lifecycle lane deadline (wedged-job loud-failure bound).
"OUROBOROS_SKILL_LIFECYCLE_TIMEOUT_SEC": 1800,
"OUROBOROS_CLAUDEXOR_HARNESS_INSTALL_TIMEOUT_SEC": 300,
@ -370,6 +372,15 @@ def get_light_model() -> str:
return str(os.environ.get("OUROBOROS_MODEL_LIGHT", "") or "").strip() or _main_model()
def get_update_letter_timeout_sec() -> float:
"""Transport ceiling for the update-letter LIGHT call (default IS the SSOT value)."""
default = SETTINGS_DEFAULTS["OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC"]
try:
return float(os.environ.get("OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC", default))
except (TypeError, ValueError):
return float(default)
def get_heavy_model() -> str:
"""Return the heavy (strong acting/coding) lane slot; empty falls back to
OUROBOROS_MODEL. Renamed from the legacy code slot."""

View file

@ -853,6 +853,8 @@ def build_runtime_section(env: Any, task: Dict[str, Any], *, ctx: Any = None, sc
}
except Exception:
log.debug("Failed to inject answer_protocol rule", exc_info=True)
from ouroboros.update_letter import official_update_projection # lazy: never raises
runtime_data["official_update"] = official_update_projection(git_sha)
runtime_ctx = json.dumps(runtime_data, ensure_ascii=False, indent=2)
out = "## Runtime context\n\n" + runtime_ctx
try:

View file

@ -49,6 +49,21 @@ def _managed_update_payload(*, fetch: bool, include_tags: bool) -> dict[str, Any
from supervisor.update_merge import active_update_tx
status = compute_managed_update_status(fetch=fetch)
# The update letter rides the same payload: written only after a FETCHING
# check (never on the passive read), projected against the live HEAD/target.
letter = None
try:
from ouroboros import update_letter as _letter
if fetch:
_letter.refresh_after_check(status)
letter = _letter.project_letter(
_letter.read_record(),
head_sha=str(status.get("current_sha") or ""),
latest_sha=str(status.get("latest_sha") or ""),
)
except Exception:
log.debug("update letter projection failed", exc_info=True)
# Additive minimal public projection of an active managed-update
# transaction, so a re-opened panel can say "resolution in progress"
# instead of silently reading as ordinary state (a second apply 409s).
@ -82,6 +97,7 @@ def _managed_update_payload(*, fetch: bool, include_tags: bool) -> dict[str, Any
"latest_version": latest_version,
"official_tags": official_tags,
"update_tx": update_tx,
"letter": letter,
**status,
}

529
ouroboros/update_letter.py Normal file
View file

@ -0,0 +1,529 @@
"""Update letter: Ouroboros's own short note about what a pending official update brings.
The letter is written by the LIGHT model slot with the ordinary task context (identity,
memory, recent dialogue, governance — ``context.build_llm_messages`` with a synthetic task
routed at the light model), fed the material of the update range: every first-parent commit
between the running base and the official target, plus the README "Version History" rows
those commits ADDED. Rows are recovered from the commit diffs rather than read from any
README snapshot, because the history table is capped (rows roll off inside one range) and
untagged releases have no tag to look up.
Floor (host code): exact versions/SHAs, the material, one paragraph, the truth rule ("only
what the material says"), physical-attempt accounting, no silent model substitution, and a
letter that is never deleted after the update lands. Ceiling (the mind): language,
register, what matters to this human, and whether to mention it at all.
Storage is one file, ``data/state/update_letter.json``, with one writer —
``refresh_after_check``, which runs synchronously inside a FETCHING update check (boot and
the Updates panel's "Check for updates" button) — and one reader shape, ``project_letter``,
shared by the Updates panel payload and the agent's Runtime context
(``official_update_projection``). The projection compares recorded SHAs with the live HEAD by
equality only (no git on the hot context path); a HEAD that descends from the target reads
as ``other``, a disclosed residual the panel, which has git, does not share.
"""
from __future__ import annotations
import logging
import os
import pathlib
import re
import threading
from typing import Any, Callable, Dict, List, Optional, Tuple
from ouroboros.utils import atomic_write_json, read_json_dict, truncate_within_limit, utc_now_iso
log = logging.getLogger(__name__)
RECORD_REL = pathlib.Path("state") / "update_letter.json"
SYSTEM_TASK_ID = "system:update_letter"
USAGE_CATEGORY = "update_letter"
# Output budget of the LIGHT one-shot (the letter is one short paragraph; docs/ARCHITECTURE.md §7).
UPDATE_LETTER_MAX_TOKENS = 1024
# Per-commit body bound inside the material (a disclosed cut, never a silent slice).
COMMIT_BODY_MAX_CHARS = 1200
DEFAULT_MAX_COMMITS = 200
ERROR_KINDS = (
"no_credentials", "budget_exhausted", "context_overflow", "timeout",
"provider_unavailable", "empty_response",
)
_ROW_RE = re.compile(r"^\+\|\s*(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?)\s*\|")
_UNESCAPED_PIPE = re.compile(r"(?<!\\)\|")
_REFRESH_LOCK = threading.Lock()
GitCapture = Callable[[List[str]], Tuple[int, str, str]]
# ---------------------------------------------------------------------------
# Material: the update range as evidence
# ---------------------------------------------------------------------------
def _default_git() -> GitCapture:
from supervisor.git_ops import git_capture
return git_capture
def _split_row(line: str) -> Optional[Tuple[str, str, str]]:
"""Split one added history row into (version, date, text) on UNESCAPED pipes.
Rows carry escaped pipes in prose (``incomplete\\|unknown``), so a raw split
would shred them. Returns ``None`` when the row does not have exactly three
cells — the caller counts that as a disclosed omission, never a silent skip.
"""
cells = [cell.strip() for cell in _UNESCAPED_PIPE.split(line.lstrip("+").strip())]
if cells and cells[0] == "":
cells = cells[1:]
if cells and cells[-1] == "":
cells = cells[:-1]
if len(cells) != 3:
return None
return cells[0], cells[1], cells[2].replace("\\|", "|")
def collect_range_material(
base_sha: str,
target_sha: str,
*,
git: Optional[GitCapture] = None,
max_commits: int = DEFAULT_MAX_COMMITS,
) -> Dict[str, Any]:
"""Collect the first-parent commits and the README history rows they added.
``commits`` are newest-first and capped at ``max_commits`` (``omitted_commits``
discloses the rest); ``releases`` are every row added anywhere in the range,
newest-first with first-wins per version; malformed rows are counted in
``omitted_rows``. An empty range yields empty lists.
"""
capture = git or _default_git()
spec = f"{base_sha}..{target_sha}"
material: Dict[str, Any] = {
"base_sha": base_sha, "target_sha": target_sha,
"commits": [], "omitted_commits": 0, "releases": [], "omitted_rows": 0,
"stats": {}, "tags": [],
}
rc, out, _err = capture([
"git", "log", "--first-parent", "--format=%H%x1f%aI%x1f%s%x1f%b%x1e", spec,
])
all_shas: List[str] = []
commits: List[Dict[str, Any]] = []
if rc == 0 and out.strip():
for chunk in out.split("\x1e"):
chunk = chunk.strip("\n")
if not chunk.strip():
continue
parts = chunk.split("\x1f", 3)
if len(parts) < 3:
continue
sha = parts[0].strip()
body = parts[3].strip() if len(parts) > 3 else ""
all_shas.append(sha)
commits.append({
"sha": sha, "date": parts[1].strip(), "subject": parts[2].strip(),
"body": truncate_within_limit(body, COMMIT_BODY_MAX_CHARS) if body else "",
})
material["omitted_commits"] = max(0, len(commits) - max_commits)
material["commits"] = commits[:max_commits]
rc, out, _err = capture([
"git", "log", "-m", "--first-parent", "-p", "-U0", "--format=%x01%H", spec, "--", "README.md",
])
seen_versions: set = set()
releases: List[Dict[str, Any]] = []
if rc == 0 and out:
current_sha = ""
for line in out.splitlines():
if line.startswith("\x01"):
current_sha = line[1:].strip()
continue
if not line.startswith("+|"):
continue
match = _ROW_RE.match(line)
if not match:
continue
cells = _split_row(line)
if cells is None:
material["omitted_rows"] += 1
continue
version, date, text = cells
if version in seen_versions:
continue
seen_versions.add(version)
releases.append({"version": version, "date": date, "text": text, "commit": current_sha})
material["releases"] = releases
stats: Dict[str, Any] = {"first_parent_count": len(all_shas)}
rc, out, _err = capture(["git", "rev-list", "--left-right", "--count", f"{base_sha}...{target_sha}"])
if rc == 0:
try:
ahead, behind = (int(part) for part in out.split())
stats["ahead"], stats["behind"] = ahead, behind
except ValueError:
pass
rc, _out, _err = capture(["git", "merge-base", "--is-ancestor", base_sha, target_sha])
stats["base_is_ancestor"] = rc == 0
for label, sha in (("base_version", base_sha), ("target_version", target_sha)):
rc, out, _err = capture(["git", "show", f"{sha}:VERSION"])
stats[label] = out.strip() if rc == 0 else ""
material["stats"] = stats
# for-each-ref has no %xNN escapes; tag names carry no whitespace, so split on it.
rc, out, _err = capture([
"git", "for-each-ref", "--format=%(refname:short) %(objectname) %(*objectname)",
"refs/tags/v*", "refs/ouroboros-managed/tags/v*",
])
if rc == 0 and out.strip():
in_range = set(all_shas)
for line in out.splitlines():
parts = line.split()
if len(parts) < 2:
continue
peeled = parts[2].strip() if len(parts) > 2 else parts[1].strip()
if peeled in in_range:
material["tags"].append({"tag": parts[0].strip().split("/")[-1], "sha": peeled})
return material
def material_text(material: Dict[str, Any]) -> str:
"""Render the material as compact evidence text for the prompt (releases first)."""
lines: List[str] = []
releases = material.get("releases") or []
if releases:
lines.append("Release notes added in this range (newest first):")
for row in releases:
lines.append(f"- {row.get('version')} ({row.get('date')}): {row.get('text')}")
if material.get("omitted_rows"):
lines.append(f"- [{material['omitted_rows']} malformed history row(s) omitted]")
commits = material.get("commits") or []
if commits:
lines.append("")
lines.append("First-parent commits in this range (newest first):")
for commit in commits:
head = f"- {str(commit.get('sha') or '')[:8]} {commit.get('date', '')[:10]} {commit.get('subject', '')}"
lines.append(head)
body = str(commit.get("body") or "").strip()
if body:
lines.append(" " + body.replace("\n", "\n "))
if material.get("omitted_commits"):
lines.append(f"- [{material['omitted_commits']} older commit(s) omitted]")
if not lines:
lines.append("(no commits in this range)")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Generation: one LIGHT call with the ordinary context
# ---------------------------------------------------------------------------
def _request_text(status: Dict[str, Any], material: Dict[str, Any], target_version: str) -> str:
import json
from ouroboros import get_version
facts = {
"running": {"version": get_version(), "sha": str(status.get("current_sha") or "")},
"official_target": {"version": target_version, "sha": str(status.get("latest_sha") or "")},
"update_channel": str(status.get("update_channel") or ""),
"commits_behind": status.get("behind"),
"commits_ahead": status.get("ahead"),
"checked_at": str(status.get("checked_at") or ""),
}
return (
"[UPDATE LETTER REQUEST]\n"
"An official update of my body is available. Facts (host-attested):\n"
+ json.dumps(facts, ensure_ascii=False, indent=2)
+ "\n\nMaterial — what changed between the running version and the target:\n"
+ material_text(material)
+ "\n\nWrite my human ONE short paragraph — no headings, no lists, no more than about "
"120 words — about what this update brings, as myself. Use only what the material says; "
"if the material does not say something, I do not invent it. This paragraph is shown on "
"the Updates page and becomes part of my own context; the commit history stays readable "
"for detail. Reply with the paragraph only.\n"
"[/UPDATE LETTER REQUEST]"
)
def _context_messages(env: Any, memory: Any, task: Dict[str, Any]) -> List[Dict[str, Any]]:
"""The ordinary task context, projected for the synthetic light-slot task."""
from ouroboros.context import build_llm_messages
messages, _cap = build_llm_messages(env, memory, task)
return messages
def _chat(client: Any, *, drive_root: pathlib.Path, **kwargs: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]:
from ouroboros.llm_observability import chat_observed
return chat_observed(client, drive_root=drive_root, task_id=SYSTEM_TASK_ID, call_type=USAGE_CATEGORY, **kwargs)
def _classify(exc: Exception) -> Tuple[str, str]:
from ouroboros.usage_accounting import BudgetExceeded
from ouroboros.utils import sanitize_tool_result_for_log
safe = sanitize_tool_result_for_log(f"{type(exc).__name__}: {exc}")[:300]
if isinstance(exc, BudgetExceeded):
return "budget_exhausted", safe
if isinstance(exc, TimeoutError) or "timeout" in type(exc).__name__.lower():
return "timeout", safe
try:
from ouroboros.loop_llm_call import classify_llm_exception
kind = str(classify_llm_exception(exc, safe).kind or "")
except Exception:
kind = ""
if kind == "context_overflow":
return "context_overflow", safe
return "provider_unavailable", safe
def write_letter(
status: Dict[str, Any],
material: Dict[str, Any],
*,
drive_root: Optional[pathlib.Path] = None,
repo_dir: Optional[pathlib.Path] = None,
llm_client: Any = None,
) -> Dict[str, Any]:
"""One accounted LIGHT-slot call; returns the letter record (``ready`` or ``failed``)."""
from ouroboros import get_version
from ouroboros.config import DATA_DIR, REPO_DIR, get_light_model, get_update_letter_timeout_sec
data_root = pathlib.Path(drive_root or DATA_DIR)
repo_root = pathlib.Path(repo_dir or REPO_DIR)
key = _key_from_status(status)
target_version = str(material.get("stats", {}).get("target_version") or "")
record: Dict[str, Any] = {
"schema": 1,
"key": key,
"checked_head_sha": key["base_sha"],
"state": "failed",
"text": "",
"author_version": get_version(),
"target_version": target_version,
"model": "",
"written_at": utc_now_iso(),
"attempt_id": "",
"error_kind": "",
"error_text": "",
"last_good": None,
}
model = get_light_model()
use_local = str(os.environ.get("USE_LOCAL_LIGHT", "") or "").lower() in ("true", "1")
record["model"] = model
if not use_local:
from ouroboros.provider_models import model_has_credentials
if not model_has_credentials(model):
record.update(error_kind="no_credentials",
error_text=f"no credentials for the light model slot ({model})")
return record
try:
from ouroboros import model_concurrency
from ouroboros.agent import Env
from ouroboros.llm import LLMClient
from ouroboros.memory import Memory
from ouroboros.usage_accounting import UsageScope, usage_scope
env = Env(repo_dir=repo_root, drive_root=data_root)
memory = Memory(drive_root=data_root, repo_dir=repo_root)
task = {
"id": SYSTEM_TASK_ID, "type": "update_letter", "source": USAGE_CATEGORY,
"model": model, "use_local_model": use_local, "metadata": {},
"text": _request_text(status, material, target_version),
}
messages = _context_messages(env, memory, task)
try:
global_limit = float(os.environ.get("TOTAL_BUDGET", "0") or 0)
except (TypeError, ValueError):
global_limit = 0.0
scope = UsageScope(
drive_root=data_root, task_id=SYSTEM_TASK_ID, root_task_id=SYSTEM_TASK_ID,
category=USAGE_CATEGORY, source=USAGE_CATEGORY,
global_limit_usd=global_limit if global_limit > 0 else None,
)
client = llm_client or LLMClient()
with model_concurrency.model_call_slot(model, use_local):
with usage_scope(scope):
msg, usage = _chat(
client, drive_root=data_root, messages=messages, model=model, tools=None,
reasoning_effort="low", max_tokens=UPDATE_LETTER_MAX_TOKENS,
use_local=use_local, timeout=get_update_letter_timeout_sec(),
)
attempt_ids = list((usage or {}).get("ledger_attempt_ids") or [])
record["attempt_id"] = str(attempt_ids[-1]) if attempt_ids else ""
text = str((msg or {}).get("content") or "").strip()
if not text:
record.update(error_kind="empty_response", error_text="the model returned no text")
return record
record.update(state="ready", text=text, written_at=utc_now_iso())
return record
except Exception as exc: # noqa: BLE001 — every failure becomes a typed, secret-free record
kind, safe = _classify(exc)
log.debug("update letter generation failed (%s)", kind, exc_info=True)
record.update(error_kind=kind, error_text=safe)
return record
# ---------------------------------------------------------------------------
# Storage and the one refresh seam
# ---------------------------------------------------------------------------
def record_path(drive_root: Optional[pathlib.Path] = None) -> pathlib.Path:
from ouroboros.config import DATA_DIR
return pathlib.Path(drive_root or DATA_DIR) / RECORD_REL
def read_record(drive_root: Optional[pathlib.Path] = None) -> Optional[Dict[str, Any]]:
record = read_json_dict(record_path(drive_root))
return record if record and isinstance(record.get("key"), dict) else None
def _key_from_status(status: Dict[str, Any]) -> Dict[str, str]:
return {
"base_sha": str(status.get("current_sha") or ""),
"target_sha": str(status.get("latest_sha") or ""),
"update_channel": str(status.get("update_channel") or ""),
"target_ref": str(status.get("target_ref") or ""),
}
def refresh_after_check(
status: Dict[str, Any],
*,
drive_root: Optional[pathlib.Path] = None,
repo_dir: Optional[pathlib.Path] = None,
llm_client: Any = None,
) -> Optional[Dict[str, Any]]:
"""Write the letter after a successful FETCHING check; never raise, never delete.
``check_ok`` other than True or no available update leaves the stored record as it
is (an applied update keeps its letter — that is the "what changed in this version"
text). A second concurrent refresh is a no-op that returns the current record.
"""
try:
current = read_record(drive_root)
if status.get("check_ok") is not True or not status.get("available"):
return current
key = _key_from_status(status)
if not key["base_sha"] or not key["target_sha"]:
return current
if not _REFRESH_LOCK.acquire(blocking=False):
return current
try:
material = collect_range_material(key["base_sha"], key["target_sha"])
if not material.get("commits") and not material.get("releases"):
return current
record = write_letter(status, material, drive_root=drive_root, repo_dir=repo_dir,
llm_client=llm_client)
if record.get("state") != "ready" and current and current.get("key") == key:
previous_good = current if current.get("state") == "ready" else current.get("last_good")
record["last_good"] = previous_good or None
atomic_write_json(record_path(drive_root), record)
return record
finally:
_REFRESH_LOCK.release()
except Exception:
log.debug("update letter refresh failed", exc_info=True)
return read_record(drive_root)
# ---------------------------------------------------------------------------
# The one projection shared by the panel payload and the Runtime context
# ---------------------------------------------------------------------------
def project_letter(
record: Optional[Dict[str, Any]],
*,
head_sha: str,
latest_sha: str,
) -> Optional[Dict[str, Any]]:
"""Relate the stored letter to the live HEAD and official target by SHA equality."""
if not record:
return None
key = record.get("key") if isinstance(record.get("key"), dict) else {}
base, target = str(key.get("base_sha") or ""), str(key.get("target_sha") or "")
head, latest = str(head_sha or ""), str(latest_sha or "")
if head and head == target:
relation = "applied"
elif head and head == base and latest == target:
relation = "pending"
elif head and head == base:
relation = "superseded"
else:
relation = "other"
text = str(record.get("text") or "")
last_good = record.get("last_good") if isinstance(record.get("last_good"), dict) else None
if record.get("state") != "ready" and not text and last_good:
text = str(last_good.get("text") or "")
return {
"state": "ready" if record.get("state") == "ready" else "failed",
"relation": relation,
"text": text,
"author_version": str(record.get("author_version") or ""),
"target_version": str(record.get("target_version") or ""),
"written_at": str(record.get("written_at") or ""),
"error_kind": str(record.get("error_kind") or ""),
"error_text": str(record.get("error_text") or ""),
"key": dict(key),
"has_last_good": bool(last_good and last_good.get("text")),
}
def official_update_projection(
head_sha: str,
*,
drive_root: Optional[pathlib.Path] = None,
state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""The typed fact for the Runtime context: O(1), no git, never raises.
``status`` is honest about freshness: ``up_to_date`` only when HEAD equals the last
checked official target, ``update_available`` only when the letter was written for
this exact HEAD, ``moved_since_check`` when HEAD matches neither, ``unchecked`` when
no fetching check has completed. ``status_as_of`` names the check the fact comes from.
"""
try:
from ouroboros import get_version
head = str(head_sha or "")
if state is None:
from supervisor.state import load_state
state = load_state() or {}
cache = state.get("managed_update_cache") if isinstance(state, dict) else None
cache = cache if isinstance(cache, dict) else {}
record = read_record(drive_root)
latest = str(cache.get("latest_sha") or "")
letter = project_letter(record, head_sha=head, latest_sha=latest)
checked_head = str((record or {}).get("checked_head_sha") or "")
if not cache:
status = "unchecked"
elif head and head == latest:
status = "up_to_date"
elif cache.get("available") and checked_head and checked_head == head:
status = "update_available"
else:
status = "moved_since_check"
target = None
if latest:
target = {
"version": str((record or {}).get("target_version") or ""),
"sha": latest,
}
return {
"status": status,
"status_as_of": str(cache.get("checked_at") or ""),
"running": {"version": get_version(), "sha": head},
"update_channel": str(cache.get("update_channel") or ""),
"target": target,
"behind": cache.get("behind"),
"ahead": cache.get("ahead"),
"letter": letter,
}
except Exception as exc: # noqa: BLE001 — a fact that raises would take the whole context with it
log.debug("official_update projection failed", exc_info=True)
return {"status": "unknown", "error": type(exc).__name__}

View file

@ -127,6 +127,9 @@ If you have something genuinely useful to say, use `send_user_message`.
- Default wakeup: 300 seconds (5 min). Increase if nothing is happening.
- Decrease wakeup interval if something urgent or interesting is going on.
- Do NOT message my human unless you have something genuinely worth saying.
- I know from `official_update` in my Runtime context whether I am current with
my official source and what a pending update brings. Mentioning it to my human
is my judgment, not a duty; my earlier words and their answer are in Recent chat.
- If nothing interesting is happening and maintenance is done, set a longer
wakeup (600-1800s).
- **Do ONE maintenance item per wakeup, not all of them.**

View file

@ -2648,6 +2648,12 @@ def _boot_managed_update_tasks() -> None:
_request_restart_exit()
return
update_status = compute_managed_update_status(fetch=True)
try:
from ouroboros.update_letter import refresh_after_check
refresh_after_check(update_status)
except Exception:
log.debug("boot update letter refresh failed", exc_info=True)
broadcast_ws_sync({
"type": "update_status_ready",
"available": bool(update_status.get("available")),

View file

@ -2078,3 +2078,16 @@ def test_delegation_fact_failure_never_drops_capability_digest(tmp_path, monkeyp
# The surrounding digest survives intact.
assert "allow_mutative_subagents" in capabilities
assert "write_surfaces" in capabilities
def test_runtime_section_carries_official_update_fact(tmp_path, monkeypatch):
env = _make_health_env(tmp_path)
monkeypatch.setattr(
"ouroboros.update_letter.official_update_projection",
lambda head: {"status": "unchecked", "running": {"sha": head}, "letter": None},
)
section = build_runtime_section(env, {"id": "task-1", "type": "task"})
payload = json.loads(section.split("\n\n", 1)[1])
assert payload["official_update"]["status"] == "unchecked"
assert payload["official_update"]["letter"] is None

View file

@ -276,6 +276,8 @@ PROMPT_NON_TOOL_IDENTIFIERS = frozenset({
"mermaid", "chart",
# backlog item status value in CONSCIOUSNESS.md
"done",
# runtime-context fact (build_runtime_section -> update_letter.official_update_projection)
"official_update",
})

View file

@ -834,3 +834,11 @@ def test_triad_fit_sizes_against_the_local_route(monkeypatch, tmp_path):
"a local-only install must size the triad prompt against the local "
"route's real window, not the provider inferred from the model text"
)
def test_update_letter_max_tokens_pinned():
"""The update letter is ONE short paragraph (owner decision 2026-09-03): its LIGHT
one-shot budget stays 1024 and matches the ARCHITECTURE 'LLM output token budgets' row."""
from ouroboros.update_letter import UPDATE_LETTER_MAX_TOKENS
assert UPDATE_LETTER_MAX_TOKENS == 1024

353
tests/test_update_letter.py Normal file
View file

@ -0,0 +1,353 @@
"""The update letter: range material, the accounted LIGHT call, storage, and projection."""
from __future__ import annotations
import json
import subprocess
import pytest
from ouroboros import update_letter as ul
# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------
def _git(repo, *args):
return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=True).stdout.strip()
def _capture_for(repo):
def capture(cmd):
proc = subprocess.run(cmd, cwd=repo, capture_output=True, text=True)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
return capture
_HEADER = "# Demo\n\n## Version History\n\n| Version | Date | Description |\n|---|---|---|\n"
def _write_readme(repo, rows):
(repo / "README.md").write_text(_HEADER + "".join(f"| {v} | {d} | {t} |\n" for v, d, t in rows), encoding="utf-8")
def _commit(repo, message):
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", message)
return _git(repo, "rev-parse", "HEAD")
@pytest.fixture
def history_repo(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
_write_readme(repo, [("1.0.0", "2026-01-01", "first")])
(repo / "VERSION").write_text("1.0.0\n")
base = _commit(repo, "release: 1.0.0")
_write_readme(repo, [("1.1.0", "2026-01-02", "second with an escaped \\| pipe"), ("1.0.0", "2026-01-01", "first")])
(repo / "VERSION").write_text("1.1.0\n")
c1 = _commit(repo, "release: 1.1.0\n\nBody of the second release.")
# Two rows in one commit plus one malformed row (two cells only).
(repo / "README.md").write_text(
_HEADER
+ "| 1.2.0 | 2026-01-03 | third |\n"
+ "| 1.1.1 | 2026-01-03 | third-fix |\n"
+ "| 1.3.0 | only two cells |\n"
+ "| 1.1.0 | 2026-01-02 | second with an escaped \\| pipe |\n"
+ "| 1.0.0 | 2026-01-01 | first |\n",
encoding="utf-8",
)
(repo / "VERSION").write_text("1.2.0\n")
c2 = _commit(repo, "release: 1.2.0")
# Roll-off: the 1.0.0 row leaves the table (capped), a new row arrives, a tag lands.
_write_readme(repo, [
("1.4.0", "2026-01-04", "fourth"),
("1.2.0", "2026-01-03", "third"),
("1.1.1", "2026-01-03", "third-fix"),
("1.1.0", "2026-01-02", "second with an escaped \\| pipe"),
])
(repo / "VERSION").write_text("1.4.0\n")
c3 = _commit(repo, "release: 1.4.0")
_git(repo, "tag", "-a", "v1.4.0", "-m", "v1.4.0")
(repo / "notes.txt").write_text("tail\n")
c4 = _commit(repo, "tail work\n\nDetails of the untagged tail.")
return {"repo": repo, "base": base, "c1": c1, "c2": c2, "c3": c3, "c4": c4}
# ---------------------------------------------------------------------------
# material
# ---------------------------------------------------------------------------
def test_material_recovers_rows_from_commit_diffs_and_lists_first_parent_commits(history_repo):
repo = history_repo["repo"]
material = ul.collect_range_material(history_repo["base"], history_repo["c4"], git=_capture_for(repo))
assert [row["version"] for row in material["releases"]] == ["1.4.0", "1.2.0", "1.1.1", "1.1.0"]
assert material["omitted_rows"] == 1 # the two-cell 1.3.0 row is disclosed, not silently dropped
by_version = {row["version"]: row for row in material["releases"]}
assert by_version["1.1.0"]["text"] == "second with an escaped | pipe"
assert by_version["1.4.0"]["commit"] == history_repo["c3"]
assert [c["sha"] for c in material["commits"]] == [
history_repo["c4"], history_repo["c3"], history_repo["c2"], history_repo["c1"],
]
assert material["commits"][0]["body"] == "Details of the untagged tail."
assert material["omitted_commits"] == 0
stats = material["stats"]
assert stats["first_parent_count"] == 4
assert stats["base_is_ancestor"] is True
assert (stats["ahead"], stats["behind"]) == (0, 4)
assert stats["base_version"] == "1.0.0" and stats["target_version"] == "1.4.0"
assert material["tags"] == [{"tag": "v1.4.0", "sha": history_repo["c3"]}]
def test_material_caps_commits_but_keeps_every_release_row(history_repo):
material = ul.collect_range_material(
history_repo["base"], history_repo["c4"], git=_capture_for(history_repo["repo"]), max_commits=2,
)
assert len(material["commits"]) == 2 and material["omitted_commits"] == 2
assert len(material["releases"]) == 4
rendered = ul.material_text(material)
assert "2 older commit(s) omitted" in rendered and "1 malformed history row(s) omitted" in rendered
def test_material_reworded_row_is_first_wins_newest_first(history_repo):
repo = history_repo["repo"]
_write_readme(repo, [("1.4.0", "2026-01-04", "fourth, reworded"), ("1.2.0", "2026-01-03", "third")])
c5 = _commit(repo, "reword 1.4.0 row")
material = ul.collect_range_material(history_repo["base"], c5, git=_capture_for(repo))
assert material["releases"][0]["text"] == "fourth, reworded"
assert [row["version"] for row in material["releases"]] == ["1.4.0", "1.2.0", "1.1.1", "1.1.0"]
def test_material_empty_range_and_non_ancestor_base(history_repo):
repo = history_repo["repo"]
empty = ul.collect_range_material(history_repo["c4"], history_repo["c4"], git=_capture_for(repo))
assert empty["commits"] == [] and empty["releases"] == []
reverse = ul.collect_range_material(history_repo["c4"], history_repo["base"], git=_capture_for(repo))
assert reverse["commits"] == [] and reverse["stats"]["base_is_ancestor"] is False
def test_split_row_keeps_three_cells_only():
assert ul._split_row("+| 1.2.3 | 2026-01-01 | a \\| b |") == ("1.2.3", "2026-01-01", "a | b")
assert ul._split_row("+| 1.2.3 | only two |") is None
# ---------------------------------------------------------------------------
# generation
# ---------------------------------------------------------------------------
def _status(**over):
base = {
"check_ok": True, "available": True, "current_sha": "a" * 40, "latest_sha": "b" * 40,
"update_channel": "stable", "target_ref": "managed/main", "behind": 3, "ahead": 0,
"checked_at": "2026-09-03T18:00:00+00:00",
}
base.update(over)
return base
def _material():
return {
"commits": [{"sha": "b" * 40, "date": "2026-09-03", "subject": "s", "body": ""}],
"releases": [{"version": "6.114.0", "date": "2026-09-01", "text": "row", "commit": "b" * 40}],
"omitted_commits": 0, "omitted_rows": 0,
"stats": {"target_version": "6.114.0", "base_version": "6.113.5"}, "tags": [],
}
@pytest.fixture
def letter_env(tmp_path, monkeypatch):
drive = tmp_path / "data"
repo = tmp_path / "repo"
(drive / "state").mkdir(parents=True)
(drive / "memory").mkdir()
repo.mkdir()
monkeypatch.setenv("OUROBOROS_MODEL_LIGHT", "test/light")
monkeypatch.delenv("USE_LOCAL_LIGHT", raising=False)
monkeypatch.setattr("ouroboros.provider_models.model_has_credentials", lambda model: True)
calls = []
def fake_context(env, memory, task):
calls.append(("context", task))
return [{"role": "system", "content": "identity"}, {"role": "user", "content": task["text"]}]
monkeypatch.setattr(ul, "_context_messages", fake_context)
return {"drive": drive, "repo": repo, "calls": calls}
def test_write_letter_ready_record_carries_attempt_and_versions(letter_env, monkeypatch):
seen = {}
def fake_chat(client, *, drive_root, **kwargs):
seen.update(kwargs)
return {"content": " One short paragraph. "}, {"ledger_attempt_ids": ["att-1", "att-2"]}
monkeypatch.setattr(ul, "_chat", fake_chat)
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
assert record["state"] == "ready" and record["text"] == "One short paragraph."
assert record["attempt_id"] == "att-2" and record["model"] == "test/light"
assert record["key"]["base_sha"] == "a" * 40 and record["checked_head_sha"] == "a" * 40
assert record["target_version"] == "6.114.0" and record["error_kind"] == ""
assert seen["model"] == "test/light" and seen["max_tokens"] == ul.UPDATE_LETTER_MAX_TOKENS
assert seen["reasoning_effort"] == "low" and seen["tools"] is None
task = letter_env["calls"][0][1]
assert task["id"] == ul.SYSTEM_TASK_ID and task["model"] == "test/light"
assert "[UPDATE LETTER REQUEST]" in task["text"] and "6.114.0" in task["text"]
assert "ONE short paragraph" in seen["messages"][-1]["content"]
def test_write_letter_without_light_credentials_fails_typed_and_never_calls(letter_env, monkeypatch):
monkeypatch.setattr("ouroboros.provider_models.model_has_credentials", lambda model: False)
monkeypatch.setattr(ul, "_chat", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call")))
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
assert record["state"] == "failed" and record["error_kind"] == "no_credentials"
assert "test/light" in record["error_text"] and record["text"] == ""
@pytest.mark.parametrize("exc, kind", [
(TimeoutError("slow"), "timeout"),
(RuntimeError("boom"), "provider_unavailable"),
])
def test_write_letter_failures_are_typed(letter_env, monkeypatch, exc, kind):
def fake_chat(client, *, drive_root, **kwargs):
raise exc
monkeypatch.setattr(ul, "_chat", fake_chat)
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
assert record["state"] == "failed" and record["error_kind"] == kind
assert "boom" in record["error_text"] or "slow" in record["error_text"]
def test_write_letter_budget_exhausted_is_typed(letter_env, monkeypatch):
from ouroboros.usage_accounting import BudgetExceeded
def fake_chat(client, *, drive_root, **kwargs):
raise BudgetExceeded("global budget exhausted")
monkeypatch.setattr(ul, "_chat", fake_chat)
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
assert record["error_kind"] == "budget_exhausted"
def test_write_letter_empty_response_is_typed(letter_env, monkeypatch):
monkeypatch.setattr(ul, "_chat", lambda client, *, drive_root, **kw: ({"content": " "}, {}))
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
assert record["state"] == "failed" and record["error_kind"] == "empty_response"
# ---------------------------------------------------------------------------
# refresh seam
# ---------------------------------------------------------------------------
def test_refresh_skips_without_a_successful_available_check(tmp_path, monkeypatch):
drive = tmp_path / "data"
monkeypatch.setattr(ul, "collect_range_material", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no material")))
assert ul.refresh_after_check(_status(check_ok=False), drive_root=drive) is None
assert ul.refresh_after_check(_status(check_ok=None), drive_root=drive) is None
assert ul.refresh_after_check(_status(available=False), drive_root=drive) is None
assert not ul.record_path(drive).exists()
def test_refresh_writes_record_and_keeps_last_good_on_failure(tmp_path, monkeypatch):
drive = tmp_path / "data"
monkeypatch.setattr(ul, "collect_range_material", lambda base, target, **k: _material())
ready = {"schema": 1, "key": ul._key_from_status(_status()), "checked_head_sha": "a" * 40,
"state": "ready", "text": "first letter", "author_version": "6.113.5",
"target_version": "6.114.0", "model": "m", "written_at": "t1", "attempt_id": "att-1",
"error_kind": "", "error_text": "", "last_good": None}
monkeypatch.setattr(ul, "write_letter", lambda status, material, **k: dict(ready))
record = ul.refresh_after_check(_status(), drive_root=drive)
assert record["state"] == "ready" and ul.read_record(drive)["text"] == "first letter"
failed = dict(ready, state="failed", text="", error_kind="timeout", written_at="t2")
monkeypatch.setattr(ul, "write_letter", lambda status, material, **k: dict(failed))
record = ul.refresh_after_check(_status(), drive_root=drive)
assert record["state"] == "failed" and record["last_good"]["text"] == "first letter"
stored = json.loads(ul.record_path(drive).read_text())
assert stored["last_good"]["text"] == "first letter"
# An applied update (available=False) leaves the letter untouched.
kept = ul.refresh_after_check(_status(available=False), drive_root=drive)
assert kept["last_good"]["text"] == "first letter"
def test_refresh_is_single_flight(tmp_path, monkeypatch):
drive = tmp_path / "data"
monkeypatch.setattr(ul, "collect_range_material", lambda *a, **k: (_ for _ in ()).throw(AssertionError("busy")))
assert ul._REFRESH_LOCK.acquire(blocking=False)
try:
assert ul.refresh_after_check(_status(), drive_root=drive) is None
finally:
ul._REFRESH_LOCK.release()
def test_refresh_never_raises(tmp_path, monkeypatch):
drive = tmp_path / "data"
monkeypatch.setattr(ul, "collect_range_material", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("git down")))
assert ul.refresh_after_check(_status(), drive_root=drive) is None
# ---------------------------------------------------------------------------
# projection
# ---------------------------------------------------------------------------
def _record(**over):
record = {"schema": 1, "key": {"base_sha": "a" * 40, "target_sha": "b" * 40, "update_channel": "stable",
"target_ref": "managed/main"},
"checked_head_sha": "a" * 40, "state": "ready", "text": "letter", "author_version": "6.113.5",
"target_version": "6.114.0", "model": "m", "written_at": "t", "attempt_id": "att",
"error_kind": "", "error_text": "", "last_good": None}
record.update(over)
return record
@pytest.mark.parametrize("head, latest, relation", [
("a" * 40, "b" * 40, "pending"),
("b" * 40, "b" * 40, "applied"),
("b" * 40, "c" * 40, "applied"),
("a" * 40, "c" * 40, "superseded"),
("d" * 40, "b" * 40, "other"),
])
def test_project_letter_relations(head, latest, relation):
view = ul.project_letter(_record(), head_sha=head, latest_sha=latest)
assert view["relation"] == relation and view["state"] == "ready" and view["text"] == "letter"
def test_project_letter_failed_with_last_good_shows_previous_text():
record = _record(state="failed", text="", error_kind="timeout", error_text="slow",
last_good=_record(text="older letter"))
view = ul.project_letter(record, head_sha="a" * 40, latest_sha="b" * 40)
assert view["state"] == "failed" and view["text"] == "older letter" and view["has_last_good"] is True
assert view["error_kind"] == "timeout"
assert ul.project_letter(None, head_sha="a" * 40, latest_sha="") is None
def test_official_update_projection_states(tmp_path):
drive = tmp_path / "data"
(drive / "state").mkdir(parents=True)
head = "a" * 40
assert ul.official_update_projection(head, drive_root=drive, state={})["status"] == "unchecked"
cache = {"managed_update_cache": {"latest_sha": "b" * 40, "available": True, "behind": 3, "ahead": 0,
"checked_at": "t0", "update_channel": "stable"}}
# A check happened but no letter was written for THIS head: moved/unknown, never invented.
assert ul.official_update_projection(head, drive_root=drive, state=cache)["status"] == "moved_since_check"
ul.record_path(drive).write_text(json.dumps(_record()))
fact = ul.official_update_projection(head, drive_root=drive, state=cache)
assert fact["status"] == "update_available" and fact["letter"]["relation"] == "pending"
assert fact["target"] == {"version": "6.114.0", "sha": "b" * 40} and fact["status_as_of"] == "t0"
assert fact["running"]["sha"] == head and fact["behind"] == 3
applied = ul.official_update_projection("b" * 40, drive_root=drive, state=cache)
assert applied["status"] == "up_to_date" and applied["letter"]["relation"] == "applied"
moved = ul.official_update_projection("e" * 40, drive_root=drive, state=cache)
assert moved["status"] == "moved_since_check" and moved["letter"]["relation"] == "other"
def test_official_update_projection_never_raises(tmp_path):
fact = ul.official_update_projection("a" * 40, drive_root=tmp_path / "missing", state={"managed_update_cache": "bad"})
assert fact["status"] == "unchecked"

View file

@ -250,3 +250,26 @@ def test_passive_read_hides_checked_at_when_cached_tip_is_unresolvable(monkeypat
state = git_ops.compute_managed_update_status(fetch=False)
assert "checked_at" not in state
assert not state.get("available")
def test_passive_payload_projects_letter_without_writing_it(monkeypatch):
import ouroboros.update_letter as update_letter
_wire(monkeypatch)
monkeypatch.setattr(
update_letter, "refresh_after_check",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("a passive read must not write the letter")),
)
monkeypatch.setattr(update_letter, "read_record", lambda drive_root=None: {
"key": {"base_sha": CURRENT, "target_sha": LATEST, "update_channel": "stable",
"target_ref": "refs/ouroboros-managed/tags/v6.87.5"},
"checked_head_sha": CURRENT, "state": "ready", "text": "one paragraph",
"author_version": "6.87.4", "target_version": "6.87.5", "written_at": "2026-08-03T00:00:00Z",
"attempt_id": "att", "error_kind": "", "error_text": "", "last_good": None,
})
payload = control._managed_update_payload(fetch=False, include_tags=False)
assert payload["letter"]["relation"] == "pending"
assert payload["letter"]["text"] == "one paragraph"
assert payload["letter"]["author_version"] == "6.87.4"