Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3158,6 +3158,19 @@ freshness, a background drainer, all L5 consumers or D2/D3. See the
naming passed, failed and missing evidence. NoKV and PostgreSQL retain their
own qualification; a pass on SQLite cannot waive another affected provider.

**D3 recovery artifact checkpoint (2026-09-22).** The
[canonical authority archive](../../reference/authority-archive.md) now defines
an explicit CLI export/verify/isolated-restore journey. One TS retained-journal
codec preserves operation/receipt identity, complete historical state and source
provenance across File/SQLite and service-owned PostgreSQL. Verified recovery
copies do not select an authority, revive executors or roll back writer fences.
This closes the portable recovery-artifact gap only; final source draining,
fenced target adoption, later-write accounting and cohort cutover remain L8.
The conditional **5–8 package** estimate below is unchanged until the remaining
caller, projection, D2, migration and default exits are qualified. Pending
reviewed-promotion and command-recovery PRs must be integrated at their accepted
heads rather than counted as merged prerequisites.

**D3 — integrate and request a whole-Goal cutover.**

- Requires T1–T3, D1/D2 and qualified capture; production cutover additionally
Expand Down
10 changes: 10 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,16 @@ debit. This closes the demonstrated T3 consumer gap, not D1–D3, provider
promotion, or the remaining Python transaction adapters. See the
[operating contract](../../quota-allocation.md#receipt-backed-settlement-progress).

**Recovery boundary (2026-09-22).** The
[authority archive command](../../reference/authority-archive.md) places retained
history validation, delta reconstruction and resumable restore in the existing
TS coordination owner. Python only resolves CLI paths and transports compact
requests/results; it does not reconstruct domain state or duplicate a provider
migration rule. The state-log codec is reused rather than creating independent
File/SQLite/PostgreSQL export formats. This enables an independently verified
recovery copy for D3/L8, while authority adoption, executor fencing and actual
Python writer retirement remain separate exits.

**T4 — collect full-writer retirement after durability cutover.**

- The 2026-09-19 command audit retires two already-typed but unconsumed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,12 @@ T3/D1 reader,未完成全部 Todo writer、retention/compaction 或 promotion

配额准入与结算消费者现在从统一 Todo reader 读取完整来源,在显示压缩前解析显式 Todo 选择。它删除直接追加 Markdown 候选的路径,保留 promote 前的事件适配;promote 后权威为空或不可读都不能复活展示行。结算进度由现有 TS 回执链归约,Python 负责完整身份命令及 JSON/Markdown 展示。现有幂等 writer 可补齐缺失的 spend 回执而不再次扣款。这关闭已复现的 T3 消费者缺口,不代表 D1–D3、provider promotion 或剩余 Python 事务适配已完成。操作语义见[结算进度契约](../../quota-allocation.md#receipt-backed-settlement-progress)。

**恢复边界(2026-09-22)。** [authority archive 命令](../../reference/authority-archive.md)
由现有 TS coordination owner 负责历史校验、状态 delta 重建和可重入恢复;Python
只解析 CLI 路径、传递请求并展示紧凑结果。复用 state-log codec,避免各 provider
分别实现导出格式或在 Python 再写一份状态规则。交付的是 D3/L8 的隔离恢复副本;
正式接管 authority、执行器围栏和旧 Python writer 退役仍有独立验收条件。

**T4 — durable cutover 后兑现完整 writer 删除。**

- 2026-09-19 命令审计退役两条已经 typed、但没有实际消费者的执行面:
Expand Down
111 changes: 111 additions & 0 deletions docs/reference/authority-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Canonical authority archive and isolated recovery

`loopx authority-archive` exports a pinned prefix of the selected coordination
AuthorityStore and restores that history into a separate File or SQLite store.
It preserves the full committed projections, events, operation ids and receipts,
including archived Todos and retained leases. It does not export the registry,
quota ledger, external artifacts, host sessions or the rest of a Goal's runtime.

This is the recovery-artifact part of shared-authority D3/L8. It does not promote
a Goal, switch providers, release a writer fence, register a restored runtime,
or qualify the default local profile. The recovered lease records are historical
state, not a new execution grant. Canonical writes made after the captured cursor
are outside that archive and must be accounted for by a separate fenced cutover.
Never reactivate an older Markdown state as a rollback after canonical writes.

## Export and verify

Use the registry and runtime of the source Goal. Export is explicit and reads
the selected provider; a missing or invalid selector does not fall back to a
legacy display. The archive's parent directory must exist. An existing output
file is never replaced.

```bash
loopx --registry ./registry.json --format json authority-archive export \
--goal-id example-goal --archive ./authority.ndjson
loopx --format json authority-archive verify --archive ./authority.ndjson
```

Save `archive.archive_sha256` from the compact response for recovery review.
It identifies the canonical record hash chain and terminal seal, not the raw
file bytes: use `verify`, rather than `sha256sum` of the NDJSON file, for this value.
The archive is private state, written with mode `0600`; do not publish it as a
fixture or attach it to a public PR. Checksums detect corruption and bind the
reviewed content; they do not authenticate its author. Protect the digest and
archive together under the existing local filesystem trust boundary.

The export pins the initial head cursor, provider revision and store identity.
It reads contiguous pages only through that cursor, independently reconstructs
the archive, checks the terminal head and identity, then publishes the completed
file without overwriting another output. Concurrent appends do not force a
restart. Missing history, source replacement and a rewritten captured head fail.
A process killed before publication may leave a private `.partial` sibling;
verify any completed output before deciding whether an interrupted export needs
to be repeated.

## Restore an isolated copy

The destination is a **new directory**, not a runtime root or a provider selector.
Preview validates the complete archive and reviewed goal/digest without creating
the destination. Execution creates a private binding manifest and a `store/`
subdirectory, then writes and reads back every retained transaction.

```bash
loopx --format json authority-archive restore \
--goal-id example-goal --archive ./authority.ndjson \
--archive-sha256 <verified-digest> --provider sqlite \
--destination ./recovered-authority
# Repeat the same command with --execute to restore.
```

Use `--provider file` for an isolated File copy. The CLI deliberately does not
accept database credentials. The same TS archive/restore contract supports
PostgreSQL through a service-owned `AuthorityStore`; authentication, tenant
scope and a separate database incarnation remain the service's responsibility.
This is portable recovery, not a deployed PostgreSQL provider-switch feature.

An interrupted restore can be repeated with the same archive, digest, goal,
provider and destination. Its existing prefix must match every operation,
event, receipt and historical projection. Extra or conflicting target commits
reject; recovery never overwrites them. A lost commit response is resolved by
exact journal and receipt readback. If directory creation was interrupted before
its binding manifest was durable, use a fresh destination; an unbound occupied
directory is intentionally not adopted.

`verified-restore.json` records the archive digest, target store identity and
final target provider revision only after full readback. It is a historical
verification receipt, not a permanent claim that nobody changed the copy later.
The source physical provider revisions remain in the archive for provenance;
restored transactions receive the destination provider's own revision tokens.
Business operation identities and receipt payloads remain unchanged.

The full archive is validated before restore writes begin and again during
replay. Modifying the input during recovery fails verification; any partial
result stays isolated and must not be adopted. A separate authority transition
must own executor fencing, target adoption and the accounting for later source
writes. There is no automatic selection or Markdown rollback here.

## Format and validation scope

The versioned NDJSON stream contains a header, ordered transaction records and
a terminal seal. A transaction stores an exact state delta using the same
`authority_state_log` codec as SQLite, plus events and receipts. The initial
delta reconstructs from the empty object; later records avoid repeating the
whole graph. Each record is hash-linked to its predecessor. Verification checks
strict fields, goal identity, positive contiguous cursors, unique operation ids,
state digests, source head revision and a seal followed by EOF. A valid checksum
alone cannot make a missing transaction or reordered history valid.

Processing retains one reconstructed projection and the operation-id inventory,
not every full historical projection. Individual encoded records are limited to
64 MiB; this is an explicit archive format bound, not provider capacity evidence.
Supported-runtime, retention, crash/restore and elapsed-soak qualification for a
production profile remain separate.

Public tests cover real File/SQLite round trips, PostgreSQL in both directions,
complex native/imported graphs, checkpoint-window history, corrupted/re-signed
invalid archives, concurrent source appends and interrupted recovery. CLI tests
also prove preview, repeated recovery, independent-process readback and rejection
of an occupied active-runtime destination. Frontend and Lark settings are
unchanged: this administrative command never changes their authority selection,
configuration owner or state projection.
10 changes: 10 additions & 0 deletions loopx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
handle_bootstrap_connect_command,
handle_canary_command,
handle_coordination_shadow_command,
handle_authority_archive_command,
handle_capability_command,
handle_doctor_command,
handle_dreaming_command,
Expand Down Expand Up @@ -120,6 +121,7 @@
register_bootstrap_connect_command,
register_canary_commands,
register_coordination_shadow_command,
register_authority_archive_command,
register_capability_commands,
register_doctor_command,
register_dreaming_commands,
Expand Down Expand Up @@ -357,6 +359,7 @@ def build_parser() -> LoopXArgumentParser:
register_explore_commands(sub, add_subcommand_format)
register_todo_command(sub, add_subcommand_format)
register_coordination_shadow_command(sub, add_subcommand_format)
register_authority_archive_command(sub, add_subcommand_format)
register_task_lease_command(sub, add_subcommand_format)
register_authority_shadow_command(sub, add_subcommand_format)
register_todo_continuation(sub, add_subcommand_format)
Expand Down Expand Up @@ -886,6 +889,13 @@ def main(argv: list[str] | None = None) -> int:
if explore_result is not None:
return explore_result

authority_archive_result = handle_authority_archive_command(
args, registry_path=registry_path, runtime_root_arg=args.runtime_root,
output_format=output_format, print_payload=print_payload,
)
if authority_archive_result is not None:
return authority_archive_result

coordination_shadow_result = handle_coordination_shadow_command(
args,
registry_path=registry_path,
Expand Down
3 changes: 3 additions & 0 deletions loopx/cli_commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def _load_exports() -> None:
register_bootstrap_connect_command,
)
from .canary import handle_canary_command, register_canary_commands
from .authority_archive import handle_authority_archive_command, register_authority_archive_command
from .coordination_shadow import (
handle_coordination_shadow_command,
register_coordination_shadow_command,
Expand Down Expand Up @@ -169,6 +170,7 @@ def _load_exports() -> None:
"handle_bootstrap_connect_command",
"handle_canary_command",
"handle_coordination_shadow_command",
"handle_authority_archive_command",
"handle_capability_command",
"handle_extension_command",
"handle_check_command",
Expand Down Expand Up @@ -240,6 +242,7 @@ def _load_exports() -> None:
"register_bootstrap_connect_command",
"register_canary_commands",
"register_coordination_shadow_command",
"register_authority_archive_command",
"register_capability_commands",
"register_extension_commands",
"register_doctor_command",
Expand Down
63 changes: 63 additions & 0 deletions loopx/cli_commands/authority_archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Compact CLI transport for the TS-owned retained-authority archive contract."""
from __future__ import annotations

import argparse
from collections.abc import Callable
from pathlib import Path

from ..control_plane.effect_runtime import effect_runtime_result
from ..paths import resolve_runtime_root
from ..history import load_registry


def register_authority_archive_command(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
add_subcommand_format: Callable[[argparse.ArgumentParser], None],
) -> None:
parser = subparsers.add_parser(
"authority-archive", help="Export, verify or restore an isolated canonical authority copy."
)
add_subcommand_format(parser)
actions = parser.add_subparsers(dest="authority_archive_action", required=True)
for name in ("export", "verify", "restore"):
action = actions.add_parser(name)
action.add_argument("--archive", type=Path, required=True)
if name != "verify":
action.add_argument("--goal-id", required=True)
if name == "restore":
action.add_argument("--destination", type=Path, required=True)
action.add_argument("--provider", choices=("file", "sqlite"), required=True)
action.add_argument("--archive-sha256", required=True)
action.add_argument("--execute", action="store_true",
help="Restore into a new isolated directory; otherwise preview.")


def handle_authority_archive_command(
args: argparse.Namespace, *, registry_path: Path, runtime_root_arg: str | None,
print_payload: Callable[[dict[str, object], str, Callable[[dict[str, object]], str]], None],
output_format: Callable[..., str],
) -> int | None:
if args.command != "authority-archive":
return None
request: dict[str, object] = {
"schema_version": "loopx_authority_archive_admin_request_v0",
"action": args.authority_archive_action,
"archive": str(args.archive.expanduser().resolve()),
}
if args.authority_archive_action == "export":
request.update(goal_id=args.goal_id, runtime_root=str(resolve_runtime_root(
load_registry(registry_path), runtime_root_arg, registry_path=registry_path)))
elif args.authority_archive_action == "restore":
request.update(goal_id=args.goal_id, destination=str(args.destination.expanduser().resolve()),
provider=args.provider, archive_sha256=args.archive_sha256, execute=args.execute)
try:
result = effect_runtime_result(
"coordination.authority_archive.manage", request, timeout=300.0, retry_safe=False
)
except (RuntimeError, ValueError) as error:
result = {"status": "failed", "reason": str(error), "authority_changed": False}
print_payload(result, output_format(args), lambda value: (
f"Authority archive: {value.get('status')}\n"
f"{value.get('reason', 'Active authority selection is unchanged.')}"
))
return 1 if result.get("status") == "failed" else 0
Loading
Loading