oc_chat
Loading...
Searching...
No Matches
envelope.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/regenerate/envelope.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/regenerate/test_envelope.py"
6# OCT-LINT: disable=no_hardcoded_secrets reason="SRC_*/REGEN_*/SCHEMA_* code strings are the spec enum"
7"""
8The durable error-code enum + JSON envelope for ``regenerate`` (§10).
9
10Purpose
11-------
12Define the complete, granular ``SRC_*``/``REGEN_*``/``SCHEMA_*`` code enum
13(Registry Source & Storage Spec V0.22 §10 — two conditions never share a code,
14AC §8.53), the ``RegenerateError``/``SchemaError`` types, and the single JSON
15envelope ``{ok, code, message, records, raw_source_tree_hash, source_hash,
16logical_state_hash, hint, scan_error?}`` every non-zero exit emits, plus the
17exit-code mapping (§5.3).
18
19Responsibilities
20----------------
21- Re-export the P1.3 ``SRC_*`` codes + add the cross-record / rebuild / schema
22 codes; expose ``ALL_CODES`` (the enum-completeness anchor).
23- ``build_envelope`` (success or error) + ``exit_code_for``.
24
25Diagnostics
26-----------
27Domain: OC_CHAT.MAIN
28Levels:
29 L2 - envelope assembly
30 L3 - code/exit mapping
31 L4 - hash-field nullability
32
33Contracts
34---------
35- ``code`` is the durable machine token, NOT the exception class name.
36- The three hash fields follow the §10 stage nullability (via P1.3 stage_hashes).
37
38Dependencies
39------------
40- oc_diagnostics (structured debug logging via _dbg)
41- oc_chat.registry.source.errors (the P1.3 SourceError hierarchy + SRC_* codes)
42"""
43from __future__ import annotations
44
45from typing import Any, Optional, Sequence
46
47from oc_diagnostics import _dbg
48
50 SRC_BAD_PHASE_PATH,
51 SRC_BAD_QUERY_PARAMS,
52 SRC_BAD_YAML,
53 SRC_BAD_YAML_FEATURE,
54 SRC_DUP_OR_STEM_MISMATCH,
55 SRC_NOT_CANONICAL,
56 SRC_SCAN_FAILED,
57 SRC_STRICT_TYPE,
58 SRC_UNKNOWN_FILE,
59 SourceError,
60 SourceScanError,
61 SourceValidationError,
62)
63
64_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
65
66# --- Cross-record SRC_* codes added by the P1.4 step-6 validation -----------
67SRC_BAD_TRACK_CODES = "SRC_BAD_TRACK_CODES"
68SRC_DANGLING_DEPENDS_ON = "SRC_DANGLING_DEPENDS_ON"
69SRC_BAD_DEPENDENCY_KIND = "SRC_BAD_DEPENDENCY_KIND"
70SRC_DEPENDENCY_CYCLE = "SRC_DEPENDENCY_CYCLE"
71SRC_SUCCESSOR_CYCLE = "SRC_SUCCESSOR_CYCLE"
72SRC_DONE_PARENT_LIVE_CHILD = "SRC_DONE_PARENT_LIVE_CHILD"
73SRC_DONE_MISSING_CLOSED_VERSION = "SRC_DONE_MISSING_CLOSED_VERSION"
74SRC_NON_PHASE_HAS_PHASE = "SRC_NON_PHASE_HAS_PHASE"
75SRC_DUPLICATE_ORDER = "SRC_DUPLICATE_ORDER"
76SRC_DUPLICATE_CODE = "SRC_DUPLICATE_CODE"
77SRC_DUPLICATE_RENDER_SECTION = "SRC_DUPLICATE_RENDER_SECTION"
78SRC_LIFECYCLE_CROSSFIELD = "SRC_LIFECYCLE_CROSSFIELD"
79SRC_EMPTY_PURPOSE = "SRC_EMPTY_PURPOSE"
80SRC_VERSION_METADATA_CONFLICT = "SRC_VERSION_METADATA_CONFLICT"
81SRC_ITEM_ID_COLLISION = "SRC_ITEM_ID_COLLISION"
82# Plan 142 P6: every kind=roadmap changelog record must carry a unique positive-int
83# extensions.changelog_seq (fail-closed; prevents the silent-drop render bug).
84SRC_ROADMAP_CHANGELOG_SEQ = "SRC_ROADMAP_CHANGELOG_SEQ"
85# Plan 142 P2: kind=product changelog seq is ALL-OR-NONE (no seq -> structured
86# fallback; any seq -> every selected product record needs a unique positive-int
87# seq). Shared validator with the render path; fail-closed (silent-drop protection).
88SRC_PRODUCT_CHANGELOG_SEQ = "SRC_PRODUCT_CHANGELOG_SEQ"
89# [NEW·V24] Plan 144: the framing-entity anchor validation (§3.3). A dangling
90# anchor target (unresolved era/version/position) -> DANGLING; a same-(target,
91# placement, order) collision, including both-order-unset -> DUPLICATE.
92SRC_FRAMING_DANGLING_ANCHOR = "SRC_FRAMING_DANGLING_ANCHOR"
93SRC_FRAMING_DUPLICATE_ANCHOR = "SRC_FRAMING_DUPLICATE_ANCHOR"
94# Plan 142j pre-dispatch mint (2026-07-08): the write-mode clean-tree
95# precondition. A dirty *resolved source root* (uncommitted tracked or untracked
96# files under the dir the write-mode command will rewrite) has no rollback point
97# for a destructive rewrite -> fail closed. SHARED: 142j `format` (write-mode) +
98# 142m `migrate`; minted here at 142j dispatch (first of the two). Exits 2
99# (refusal class). Raise-site is wired by the 142j worker (`cli/source.py`).
100SRC_TREE_DIRTY = "SRC_TREE_DIRTY"
101# Plan 142m (minted at dispatch prep, 2026-07-09): the ten migrate-specific
102# refusal codes -- raise-sites TARGET, wired by the 142m worker; all exit 2 via
103# the migrate refusal class (spec SS8). SRC_TREE_DIRTY above is SHARED with
104# migrate (142j wired the format raise-site; migrate reuses the code).
105SRC_TREE_NOT_VERSIONED = "SRC_TREE_NOT_VERSIONED"
106SRC_NO_MIGRATION = "SRC_NO_MIGRATION"
107SRC_SOURCE_MUTATED = "SRC_SOURCE_MUTATED"
108SRC_UNSAFE_PATH = "SRC_UNSAFE_PATH"
109SRC_MIGRATION_INVALID = "SRC_MIGRATION_INVALID"
110SRC_MIGRATION_OUTPUT_INVALID = "SRC_MIGRATION_OUTPUT_INVALID"
111SRC_JOURNAL_UNRESOLVED = "SRC_JOURNAL_UNRESOLVED"
112SRC_JOURNAL_WRITE_FAILED = "SRC_JOURNAL_WRITE_FAILED"
113SRC_MIGRATION_WRITE_FAILED = "SRC_MIGRATION_WRITE_FAILED"
114SRC_MIGRATION_TARGET_INVALID = "SRC_MIGRATION_TARGET_INVALID"
115# Reserved 142/P2 cutover-ledger codes (dormant in v1).
116SRC_LEDGER_ENTRY_INVALID = "SRC_LEDGER_ENTRY_INVALID"
117SRC_LEDGER_BASELINE_DRIFT = "SRC_LEDGER_BASELINE_DRIFT"
118SRC_ACTIVE_EXEMPTION_NOT_TERMINAL = "SRC_ACTIVE_EXEMPTION_NOT_TERMINAL"
119SRC_LEDGER_HISTORY_UNAVAILABLE = "SRC_LEDGER_HISTORY_UNAVAILABLE"
120SRC_LEDGER_REOPEN_DETECTED_NULL_CLOSE = "SRC_LEDGER_REOPEN_DETECTED_NULL_CLOSE"
121SRC_LEDGER_LIFECYCLE_LOG_DIVERGENCE = "SRC_LEDGER_LIFECYCLE_LOG_DIVERGENCE"
122
123# --- Rebuild / schema codes -------------------------------------------------
124REGEN_BUILD_FAILED = "REGEN_BUILD_FAILED"
125REGEN_NOT_BYTE_STABLE = "REGEN_NOT_BYTE_STABLE"
126REGEN_SCOPE_CONFLICT = "REGEN_SCOPE_CONFLICT"
127REGEN_LOCK_HELD = "REGEN_LOCK_HELD"
128# Plan 142i R5: the §5.3 pre-swap TOCTOU abort — source/ mutated between the
129# step-1 scan and the pre-swap re-check (distinct from REGEN_NOT_BYTE_STABLE, an
130# external retryable race, not an internal determinism bug). The one code newly
131# added to ALL_CODES by this plan.
132REGEN_SOURCE_MUTATED = "REGEN_SOURCE_MUTATED"
133REGEN_UNVERIFIED_PROJECTION = "REGEN_UNVERIFIED_PROJECTION"
134REGEN_UNSUPPORTED_BACKEND = "REGEN_UNSUPPORTED_BACKEND"
135SCHEMA_TOO_OLD = "SCHEMA_TOO_OLD"
136SCHEMA_TOO_NEW = "SCHEMA_TOO_NEW"
137
138#: The durable enum (§10 / AC §8.53) — every emitted code is a member; distinct.
139ALL_CODES: frozenset[str] = frozenset(
140 {
141 SRC_SCAN_FAILED, SRC_UNKNOWN_FILE, SRC_BAD_PHASE_PATH, SRC_BAD_YAML,
142 SRC_BAD_YAML_FEATURE, SRC_STRICT_TYPE, SRC_NOT_CANONICAL,
143 SRC_DUP_OR_STEM_MISMATCH, SRC_BAD_TRACK_CODES, SRC_DANGLING_DEPENDS_ON,
144 SRC_BAD_DEPENDENCY_KIND, SRC_DEPENDENCY_CYCLE, SRC_SUCCESSOR_CYCLE,
145 SRC_DONE_PARENT_LIVE_CHILD, SRC_DONE_MISSING_CLOSED_VERSION,
146 SRC_LEDGER_ENTRY_INVALID, SRC_LEDGER_BASELINE_DRIFT,
147 SRC_ACTIVE_EXEMPTION_NOT_TERMINAL, SRC_LEDGER_HISTORY_UNAVAILABLE,
148 SRC_LEDGER_REOPEN_DETECTED_NULL_CLOSE, SRC_LEDGER_LIFECYCLE_LOG_DIVERGENCE,
149 SRC_NON_PHASE_HAS_PHASE, SRC_DUPLICATE_ORDER, SRC_DUPLICATE_CODE,
150 SRC_DUPLICATE_RENDER_SECTION, SRC_LIFECYCLE_CROSSFIELD, SRC_EMPTY_PURPOSE,
151 SRC_VERSION_METADATA_CONFLICT, SRC_BAD_QUERY_PARAMS, SRC_ITEM_ID_COLLISION,
152 SRC_ROADMAP_CHANGELOG_SEQ, SRC_PRODUCT_CHANGELOG_SEQ,
153 SRC_FRAMING_DANGLING_ANCHOR, SRC_FRAMING_DUPLICATE_ANCHOR, SRC_TREE_DIRTY,
154 SRC_TREE_NOT_VERSIONED, SRC_NO_MIGRATION, SRC_SOURCE_MUTATED,
155 SRC_UNSAFE_PATH, SRC_MIGRATION_INVALID, SRC_MIGRATION_OUTPUT_INVALID,
156 SRC_JOURNAL_UNRESOLVED, SRC_JOURNAL_WRITE_FAILED,
157 SRC_MIGRATION_WRITE_FAILED, SRC_MIGRATION_TARGET_INVALID,
158 REGEN_BUILD_FAILED, REGEN_NOT_BYTE_STABLE, REGEN_SCOPE_CONFLICT,
159 REGEN_LOCK_HELD, REGEN_SOURCE_MUTATED, REGEN_UNVERIFIED_PROJECTION,
160 REGEN_UNSUPPORTED_BACKEND, SCHEMA_TOO_OLD, SCHEMA_TOO_NEW,
161 }
162)
163
164
165class RegenerateError(Exception):
166 """A rebuild-stage (``REGEN_*``) failure carrying the §10 envelope fields."""
167
169 self,
170 message: str,
171 *,
172 code: str,
173 records: Optional[Sequence[str]] = None,
174 hint: Optional[str] = None,
175 ) -> None:
176 super().__init__(message)
177 self.code = code
178 self.records: list[str] = list(records) if records else []
179 self.hint = hint
180
181
182class SchemaError(Exception):
183 """A ``schema_version`` (``SCHEMA_TOO_OLD``/``SCHEMA_TOO_NEW``) failure."""
184
186 self,
187 message: str,
188 *,
189 code: str,
190 records: Optional[Sequence[str]] = None,
191 hint: Optional[str] = None,
192 ) -> None:
193 super().__init__(message)
194 self.code = code
195 self.records: list[str] = list(records) if records else []
196 self.hint = hint
197
198
199# Exit codes (§5.3): 1 source-validation · 2 build/unsupported-backend/
200# scope-refusal · 3 round-trip not byte-stable · 4 reserved (vacated 2026-07-06,
201# Plan 142i R4 — was "authored/runtime conflict", whose only code REGEN_SCOPE_CONFLICT
202# now exits 2 per the §5.3 fail-loud contract) · 5 stale schema.
203_EXIT_BY_CODE: dict[str, int] = {
204 REGEN_NOT_BYTE_STABLE: 3,
205 REGEN_SCOPE_CONFLICT: 2,
206 SRC_TREE_DIRTY: 2, # Plan 142j: write-mode clean-tree refusal (refusal class)
207 # Plan 142m (dispatch-prep mint): the migrate refusal class -- all exit 2.
208 SRC_TREE_NOT_VERSIONED: 2,
209 SRC_NO_MIGRATION: 2,
210 SRC_SOURCE_MUTATED: 2,
211 SRC_UNSAFE_PATH: 2,
212 SRC_MIGRATION_INVALID: 2,
213 SRC_MIGRATION_OUTPUT_INVALID: 2,
214 SRC_JOURNAL_UNRESOLVED: 2,
215 SRC_JOURNAL_WRITE_FAILED: 2,
216 SRC_MIGRATION_WRITE_FAILED: 2,
217 SRC_MIGRATION_TARGET_INVALID: 2,
218 SCHEMA_TOO_OLD: 5,
219 SCHEMA_TOO_NEW: 5,
220}
221
222
223def exit_code_for(error: BaseException) -> int:
224 """Map an exception to the §5.3 exit code."""
225 if isinstance(error, (SourceScanError, SourceValidationError)):
226 return 1
227 code = getattr(error, "code", None)
228 if code in _EXIT_BY_CODE:
229 return _EXIT_BY_CODE[code]
230 if isinstance(error, (RegenerateError, SchemaError)):
231 return 5 if isinstance(error, SchemaError) else 2
232 return 2
233
234
236 error: Optional[BaseException],
237 *,
238 raw_source_tree_hash: Optional[str] = None,
239 source_hash: Optional[str] = None,
240 logical_state_hash: Optional[str] = None,
241 message: Optional[str] = None,
242 rollback: Optional[str] = None,
243 journal_path: Optional[str] = None,
244 remaining_paths: Optional[Sequence[str]] = None,
245 journal_unresolved: Optional[bool] = None,
246) -> dict[str, Any]:
247 """Assemble the §10 JSON envelope for a success (``error=None``) or failure.
248
249 The migrate (Plan 142m) recovery fields ``rollback`` / ``journal_path`` /
250 ``remaining_paths`` / ``journal_unresolved`` are **additive keyword
251 arguments** (Sonnet5-R02-P1 / DeepSeek-R03-NP1-3 Option B): each is emitted
252 into the envelope **only when non-``None``** (``journal_unresolved`` only when
253 ``True``), so a non-migrate caller — which never passes them — sees a
254 byte-identical envelope to before. They describe the recovery *attempt* and
255 are passed explicitly by the migrate runner (NOT ``getattr``-read off the
256 exception, which carries only the error condition).
257 """
258 if error is None:
259 return {
260 "ok": True,
261 "code": None,
262 "message": message or "regenerate ok",
263 "records": [],
264 "raw_source_tree_hash": raw_source_tree_hash,
265 "source_hash": source_hash,
266 "logical_state_hash": logical_state_hash,
267 "hint": None,
268 }
269 envelope: dict[str, Any] = {
270 "ok": False,
271 "code": getattr(error, "code", None),
272 "message": str(error),
273 "records": list(getattr(error, "records", []) or []),
274 "raw_source_tree_hash": raw_source_tree_hash,
275 "source_hash": source_hash,
276 "logical_state_hash": logical_state_hash,
277 "hint": getattr(error, "hint", None),
278 }
279 scan_error = getattr(error, "scan_error", None)
280 if scan_error is not None:
281 envelope["scan_error"] = scan_error
282 # Migrate recovery fields — emit-only-when-non-None (the byte-identical
283 # invariant for every non-migrate caller).
284 if rollback is not None:
285 envelope["rollback"] = rollback
286 if journal_path is not None:
287 envelope["journal_path"] = journal_path
288 if remaining_paths is not None:
289 envelope["remaining_paths"] = list(remaining_paths)
290 if journal_unresolved:
291 envelope["journal_unresolved"] = True
292 return envelope
293
294
295# Re-export the P1.3 hierarchy so callers import errors from one place.
296__all__ = [
297 "SourceError",
298 "SourceScanError",
299 "SourceValidationError",
300 "RegenerateError",
301 "SchemaError",
302 "ALL_CODES",
303 "build_envelope",
304 "exit_code_for",
305]
None __init__(self, str message, *, str code, Optional[Sequence[str]] records=None, Optional[str] hint=None)
Definition envelope.py:175
None __init__(self, str message, *, str code, Optional[Sequence[str]] records=None, Optional[str] hint=None)
Definition envelope.py:192
int exit_code_for(BaseException error)
Definition envelope.py:223
dict[str, Any] build_envelope(Optional[BaseException] error, *, Optional[str] raw_source_tree_hash=None, Optional[str] source_hash=None, Optional[str] logical_state_hash=None, Optional[str] message=None, Optional[str] rollback=None, Optional[str] journal_path=None, Optional[Sequence[str]] remaining_paths=None, Optional[bool] journal_unresolved=None)
Definition envelope.py:246