agent-postmortem 0.3.4__py3-none-any.whl

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 (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,931 @@
1
+ """Disclosure levels and stable aliases (docs/DESIGN.md 4.13.5, 4.13.11's
2
+ "N3, disclosure" items, 7.5). N3a only: pure logic plus the orchestration
3
+ that drives agent_postmortem.state's disclosure I/O primitives. Nothing
4
+ here is wired into a renderer, a CLI flag, a hook, or `watch` yet -- that
5
+ is N3b. No command's output changes because this module exists.
6
+
7
+ LEVELS names the three disclosure levels (share/local/raw); the alias
8
+ kinds are agent, session, project. An AliasBook, returned by register(),
9
+ is the one object a caller holds for the lifetime of one command: it was
10
+ handed every identity the command already knows it will render, assigned
11
+ each of them a stable alias under one lock hold (docs/DESIGN.md 7.5/P9),
12
+ and from then on answers alias(kind, real_id) from memory. A registration
13
+ that cannot complete for any reason (secret or registry unavailable, lock
14
+ contention, a failed write, a bound that would be exceeded) returns a
15
+ fallback book instead: positional agent-N/session-N/project-N per kind, in
16
+ first-use order, available=False. A pre-commit failure leaves the real
17
+ secret and registry exactly as they were, byte-for-byte and mtime
18
+ untouched; a post-commit failure (docs/DESIGN.md 7.5a) is the one
19
+ exception -- the new file may already be installed, and this fallback is
20
+ what register() returns only once re-probing it and requiring a fresh
21
+ directory-durability confirmation have both still failed to confirm it,
22
+ never by silently claiming nothing changed. A fallback book never
23
+ contains a real id, by construction -- it is never handed one.
24
+
25
+ Every actual file read or write happens in agent_postmortem.state, never
26
+ here (docs/DESIGN.md 5.4, tests/test_no_write_surface.py): this module
27
+ calls state.lock_state_dir(), state.probe_secret(), state.create_secret(),
28
+ state.probe_registry_bytes(), and state.write_registry_bytes(), and
29
+ decides, in memory, what those calls should do and what they returned.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import hmac
36
+ import json
37
+ import os
38
+ import re
39
+ import uuid as uuid_module
40
+ from collections.abc import Callable
41
+ from dataclasses import dataclass, field
42
+ from pathlib import Path
43
+
44
+ from agent_postmortem import state
45
+
46
+ LEVELS: tuple[str, ...] = ("share", "local", "raw")
47
+
48
+ # N3b P15: the very first line of text output at local and raw. Share
49
+ # prints no such line, and JSON never does.
50
+ FIRST_LINE: dict[str, str] = {
51
+ "local": "disclosure: local (real names and paths; do not paste)",
52
+ "raw": "disclosure: raw (real names, absolute paths; do not paste)",
53
+ }
54
+
55
+ KINDS: tuple[str, ...] = ("agent", "session", "project")
56
+
57
+ # docs/DESIGN.md 7.5/P4: both bounds are module constants a test can
58
+ # override (monkeypatch.setattr(disclosure, "MAX_ENTRIES", ...)) to exercise
59
+ # the fallback without actually building a 4 MiB or 50,000-entry file.
60
+ MAX_ENTRIES = 50_000
61
+ MAX_BYTES = 4 * 1024 * 1024 # 4 MiB
62
+
63
+ MIN_ALIAS_LEN = 12
64
+ MAX_ALIAS_LEN = 64
65
+
66
+ # N3b prints this; N3a only defines it, so there is exactly one spelling of
67
+ # the diagnostic for every future caller to share (docs/DESIGN.md 7.5/P8).
68
+ UNAVAILABLE_DIAGNOSTIC = "stable aliases unavailable"
69
+
70
+ _REGISTRY_VERSION = 1
71
+ _REGISTRY_TOP_KEYS = frozenset({"version", "agent", "session", "project"})
72
+
73
+ # docs/DESIGN.md 5.1: an agent id is 17 lowercase hex characters (the
74
+ # existing reader._AGENT_ID_RE additionally requires the first to be "a";
75
+ # this plan's P2 only requires "17 lowercase hex", the broader of the two,
76
+ # so every id reader.py accepts is also canonical here).
77
+ _AGENT_ID_RE = re.compile(r"[0-9a-f]{17}")
78
+ _HEX64_RE = re.compile(r"[0-9a-f]{64}")
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class DisclosureContext:
83
+ """N3b D-a: the one object a command builds once from its parsed level
84
+ and hands to its Redactor. `level` is one of LEVELS. `book` is the
85
+ AliasBook the command registered its identities with, or None for a
86
+ command that renders no alias (every command in Part 1a, and a versioned
87
+ JSON command always, D-k). `home` is the home directory structural
88
+ redaction replaces with "~". `repo_root` is the run's repository root
89
+ when an observation the command already made resolved one (D-n), else
90
+ None. `identities` is what the command collected for its one
91
+ registration. Nothing here reads the filesystem except Path.home() for
92
+ the default home."""
93
+
94
+ level: str
95
+ book: "AliasBook | None" = None
96
+ home: Path = field(default_factory=Path.home)
97
+ repo_root: str | None = None
98
+ # N3b P9/P11: every (kind, real_id) the command collected before it
99
+ # rendered (the same list it registered with the book). A free-text
100
+ # sink at share substitutes these by their alias, the run's project slug
101
+ # before structural redaction.
102
+ identities: tuple[tuple[str, str], ...] = ()
103
+
104
+ def __post_init__(self) -> None:
105
+ if not isinstance(self.level, str) or self.level not in LEVELS:
106
+ raise ValueError(f"unknown disclosure level: {self.level!r}")
107
+
108
+ @property
109
+ def enabled(self) -> bool:
110
+ """Structural redaction is on at every level but raw."""
111
+ return self.level != "raw"
112
+
113
+
114
+ # --- rendered forms (P10) -----------------------------------------------------
115
+
116
+ _POSITIONAL_ALIAS_RE = re.compile(r"(?:agent|session|project)-[0-9]+")
117
+ _FORM_PREFIX = {"agent": "a:", "session": "s:", "project": "project:"}
118
+ _FILE_STEM_PREFIX = {"agent": "agent-", "session": "session-"}
119
+
120
+
121
+ def is_positional_alias(alias: str) -> bool:
122
+ """A fallback book's name (agent-3, session-1, project-2). A stable
123
+ alias is lowercase hex, so the two never collide."""
124
+ return _POSITIONAL_ALIAS_RE.fullmatch(alias) is not None
125
+
126
+
127
+ def rendered_form(kind: str, alias: str) -> str:
128
+ """P10: the text form of an identity: a:<alias>, s:<alias>,
129
+ project:<alias>. A positional name is already its own form."""
130
+ if is_positional_alias(alias):
131
+ return alias
132
+ return _FORM_PREFIX[kind] + alias
133
+
134
+
135
+ def file_stem(kind: str, alias: str) -> str:
136
+ """P10: the file-name stem of an agent or session identity in an
137
+ evidence pointer: agent-<alias>, session-<alias> (bare hex, no "a:"). A
138
+ positional name is its own stem (agent-3, session-1)."""
139
+ if is_positional_alias(alias):
140
+ return alias
141
+ return _FILE_STEM_PREFIX[kind] + alias
142
+
143
+
144
+ def _under(root: str, path: str) -> bool:
145
+ """Whether `path` is `root` or lies below it, by spelling: both must be
146
+ absolute and already normalized. A ValueError from commonpath (mixed
147
+ drives, or one relative path) means outside."""
148
+ try:
149
+ return os.path.commonpath([root, path]) == root
150
+ except ValueError:
151
+ return False
152
+
153
+
154
+ _SCHEME_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:")
155
+
156
+
157
+ def local_path(
158
+ value: object,
159
+ *,
160
+ repo_root: str | None,
161
+ home: Path | str | None,
162
+ base: str | None,
163
+ outside: Callable[[str], str],
164
+ ) -> str:
165
+ """N3b P19 (D-m): the one function every sink uses to show a path at
166
+ `local`; never returns an absolute path.
167
+
168
+ The path is made absolute against `base` (the documented base, for
169
+ example the run's cwd for a relative transcript path) when it is
170
+ relative, then os.path.normpath-ed; symlinks are never resolved, so a
171
+ path counts by how it is spelled. Inside the repository
172
+ (os.path.commonpath([repo_root, path]) == repo_root) it is
173
+ repo-relative ("." for the root itself); inside home but outside the
174
+ repository it is the structural "~/.../<basename>"; outside both it is
175
+ `outside(value)`, the caller's path-N pseudonym. `repo_root` comes only
176
+ from an observation the command already made (D-n); None, or a root
177
+ that is not absolute, means every path counts as outside the repository.
178
+ A relative path with no absolute base cannot be placed and counts as
179
+ outside. A value that is not a non-empty string without NUL is passed
180
+ to `outside` as is.
181
+ """
182
+ if not isinstance(value, str) or not value or "\x00" in value:
183
+ return outside(value) if isinstance(value, str) else outside(str(value))
184
+ if _SCHEME_RE.match(value):
185
+ # S11: a URL or other `scheme:` spelling is never a place on disk.
186
+ return outside(value)
187
+ if value == "~" or value.startswith("~/"):
188
+ # S11: `~/` is expanded against the context's home, then the rule.
189
+ home_text = os.path.normpath(str(home)) if home is not None else ""
190
+ if not (home_text and os.path.isabs(home_text)):
191
+ return outside(value)
192
+ value = home_text + value[1:]
193
+ elif value.startswith("//"):
194
+ # S11: POSIX normpath keeps exactly two leading slashes; they name the
195
+ # same place as one, so collapse them before the containment check.
196
+ value = "/" + value.lstrip("/")
197
+ if os.path.isabs(value):
198
+ candidate = value
199
+ elif base and os.path.isabs(base):
200
+ candidate = os.path.join(base, value)
201
+ else:
202
+ return outside(value)
203
+ normalized = os.path.normpath(candidate)
204
+
205
+ if repo_root and os.path.isabs(repo_root):
206
+ root = os.path.normpath(repo_root)
207
+ if _under(root, normalized):
208
+ relative = normalized[len(root):].lstrip(os.sep)
209
+ return relative or "."
210
+
211
+ home_str = os.path.normpath(str(home)) if home is not None else ""
212
+ if home_str and os.path.isabs(home_str) and home_str != os.sep and _under(home_str, normalized):
213
+ if normalized == home_str:
214
+ return "~"
215
+ basename = os.path.basename(normalized)
216
+ return f"~/.../{basename}" if basename else "~"
217
+
218
+ return outside(value)
219
+
220
+
221
+ class DisclosureError(Exception):
222
+ """Base class for disclosure.py's own errors (not state.py's -- a
223
+ StateError/StateLocked/OSError from the I/O layer is caught by
224
+ register()/AliasBook.alias() and turned into a fallback, never raised
225
+ to the caller)."""
226
+
227
+
228
+ # --- canonical id checks (P2) ------------------------------------------------
229
+
230
+
231
+ def is_canonical_id(kind: str, value: object) -> bool:
232
+ """P2's exact shape for each kind. agent: 17 lowercase hex. session: a
233
+ UUID, canonical lowercase-hyphenated form only (str(uuid.UUID(value))
234
+ must round-trip to the same string, so "AAAA...", a braced form, or a
235
+ 32-hex-no-dashes spelling are all rejected even though uuid.UUID() would
236
+ parse them). project: a non-empty string with no NUL and no newline
237
+ (docs/DESIGN.md: "the same string `local` prints")."""
238
+ if kind == "agent":
239
+ return isinstance(value, str) and bool(_AGENT_ID_RE.fullmatch(value))
240
+ if kind == "session":
241
+ if not isinstance(value, str):
242
+ return False
243
+ try:
244
+ parsed = uuid_module.UUID(value)
245
+ except (ValueError, AttributeError, TypeError):
246
+ return False
247
+ return str(parsed) == value
248
+ if kind == "project":
249
+ return isinstance(value, str) and value != "" and "\x00" not in value and "\n" not in value
250
+ raise ValueError(f"unknown alias kind: {kind!r}")
251
+
252
+
253
+ def require_canonical_id(kind: str, value: object) -> str:
254
+ """is_canonical_id's gate, raising ValueError on failure: "non-canonical
255
+ ids raise ValueError at registration" (the renderer decides positional
256
+ names for them in N3b; N3a never silently accepts one)."""
257
+ if not is_canonical_id(kind, value):
258
+ raise ValueError(f"not a canonical {kind} id: {value!r}")
259
+ return value # type: ignore[return-value]
260
+
261
+
262
+ # --- HMAC (P1) ----------------------------------------------------------------
263
+
264
+
265
+ def hmac_hex(secret: bytes, real_id: str) -> str:
266
+ """HMAC-SHA256 of real_id's UTF-8 bytes under secret, lowercase 64 hex
267
+ (hexdigest() is already lowercase). A module-level function, called by
268
+ bare name from _assign_all below, so a test can monkeypatch
269
+ disclosure.hmac_hex to force a chosen collision without needing to
270
+ brute-force one against a real secret."""
271
+ return hmac.new(secret, real_id.encode("utf-8"), hashlib.sha256).hexdigest()
272
+
273
+
274
+ # --- registry schema (P4) ------------------------------------------------------
275
+
276
+
277
+ def empty_registry() -> dict:
278
+ return {"version": _REGISTRY_VERSION, "agent": {}, "session": {}, "project": {}}
279
+
280
+
281
+ def is_empty_registry(registry: dict) -> bool:
282
+ return all(not registry[kind] for kind in KINDS)
283
+
284
+
285
+ def count_entries(registry: dict) -> int:
286
+ return sum(len(registry[kind]) for kind in KINDS)
287
+
288
+
289
+ def validate_registry(data: object) -> dict | None:
290
+ """P4's exact schema, validated with nothing coerced:
291
+ {"version": 1, "agent": {...}, "session": {...}, "project": {...}},
292
+ no other top-level key; each table a dict mapping a lowercase 64-hex
293
+ key to an int -- never a bool, which is a subclass of int in Python, so
294
+ the bool check must run before the int check -- in 12..64; per kind,
295
+ the assigned aliases (key[:length]) are unique. Returns a validated
296
+ dict (plain dicts, independent of whatever container type the input
297
+ used) on success, or None for any violation at all: a malformed
298
+ top-level shape, a malformed key, a malformed length, or a duplicate
299
+ alias. MAX_ENTRIES/MAX_BYTES are checked by the caller against the
300
+ registry as a whole (they depend on what is about to be *added*, which
301
+ this function does not know about), not here.
302
+ """
303
+ if not isinstance(data, dict):
304
+ return None
305
+ # N3a fix round 2, Finding 6: a plain `!= 1` comparison accepts both
306
+ # `True` (bool is an int subclass in Python, and True == 1) and `1.0`
307
+ # (float equality) as version 1. isinstance(x, bool) must be checked
308
+ # before the int check, exactly as for an alias length below.
309
+ version = data.get("version")
310
+ if isinstance(version, bool) or not isinstance(version, int) or version != _REGISTRY_VERSION:
311
+ return None
312
+ if set(data.keys()) != _REGISTRY_TOP_KEYS:
313
+ return None
314
+
315
+ result: dict = {"version": _REGISTRY_VERSION}
316
+ for kind in KINDS:
317
+ table = data[kind]
318
+ if not isinstance(table, dict):
319
+ return None
320
+ seen_aliases: set[str] = set()
321
+ validated_table: dict[str, int] = {}
322
+ for key, length in table.items():
323
+ if not isinstance(key, str) or not _HEX64_RE.fullmatch(key):
324
+ return None
325
+ if isinstance(length, bool) or not isinstance(length, int):
326
+ return None
327
+ if not (MIN_ALIAS_LEN <= length <= MAX_ALIAS_LEN):
328
+ return None
329
+ alias = key[:length]
330
+ if alias in seen_aliases:
331
+ return None
332
+ seen_aliases.add(alias)
333
+ validated_table[key] = length
334
+ result[kind] = validated_table
335
+ return result
336
+
337
+
338
+ def _dumps(registry: dict) -> bytes:
339
+ """Canonical encoding of a registry dict: stable key order (sort_keys)
340
+ so two writers who agree on content produce byte-identical files, and a
341
+ trailing newline, matching state.py's own state.json convention."""
342
+ return json.dumps(registry, indent=2, sort_keys=True).encode("utf-8") + b"\n"
343
+
344
+
345
+ # --- assignment (P3) -----------------------------------------------------------
346
+
347
+
348
+ def assign_alias(table: dict[str, int], hashed: str) -> int:
349
+ """The smallest n in MIN_ALIAS_LEN..MAX_ALIAS_LEN such that hashed[:n]
350
+ is not already the assigned alias (key[:length]) of another entry in
351
+ table. Does not mutate table; the caller decides whether and when to
352
+ record the assignment, so a sequence of calls across several identities
353
+ of a batch can see each other's just-assigned aliases by mutating the
354
+ table in between (P3: "deterministic given registration order")."""
355
+ existing_aliases = {key[:length] for key, length in table.items()}
356
+ for n in range(MIN_ALIAS_LEN, MAX_ALIAS_LEN + 1):
357
+ if hashed[:n] not in existing_aliases:
358
+ return n
359
+ raise DisclosureError(
360
+ f"exhausted alias length space ({MIN_ALIAS_LEN}..{MAX_ALIAS_LEN}) for one hash"
361
+ )
362
+
363
+
364
+ def _assign_all(
365
+ registry: dict, secret: bytes, identities: list[tuple[str, str]]
366
+ ) -> tuple[dict, dict[tuple[str, str], str], bool]:
367
+ """Apply P3 to every (kind, real_id) in identities, in order, against a
368
+ deep-enough copy of registry (new dicts per kind table, so the caller's
369
+ own registry argument is never mutated in place). Returns (the possibly
370
+ updated registry, {(kind, real_id): alias} for every identity in
371
+ identities, whether anything actually changed). A duplicate identity in
372
+ the same batch is only assigned once; both look-ups return the same
373
+ alias."""
374
+ updated = {"version": registry["version"]}
375
+ for kind in KINDS:
376
+ updated[kind] = dict(registry[kind])
377
+
378
+ aliases: dict[tuple[str, str], str] = {}
379
+ changed = False
380
+ for kind, real_id in identities:
381
+ key = (kind, real_id)
382
+ if key in aliases:
383
+ continue
384
+ hashed = hmac_hex(secret, real_id)
385
+ table = updated[kind]
386
+ length = table.get(hashed)
387
+ if length is None:
388
+ length = assign_alias(table, hashed)
389
+ table[hashed] = length
390
+ changed = True
391
+ aliases[key] = hashed[:length]
392
+ return updated, aliases, changed
393
+
394
+
395
+ # --- initialization state machine (P6) -----------------------------------------
396
+
397
+
398
+ # N3a fix round 2 (continued), new Finding 1 (BLOCKER): how many times the
399
+ # state directory's own strict fsync is attempted before a registration
400
+ # gives up. A transient failure (fails once, succeeds on the next attempt)
401
+ # still succeeds; a persistent failure (fails on every attempt) does not
402
+ # retry forever -- it is treated exactly like any other unavailable outcome.
403
+ _DIRECTORY_FSYNC_ATTEMPTS = 2
404
+
405
+
406
+ def _directory_is_durable(directory: Path, *, dir_fd: int | None = None) -> bool:
407
+ """N3a fix round 2 (continued), new Finding 1 (BLOCKER): a successful
408
+ read-back of the secret or the registry only proves the namespace
409
+ change is visible right now -- it says nothing about whether the
410
+ directory entry that makes it visible would survive a crash, because
411
+ the strict directory fsync (state.fsync_directory) can itself fail
412
+ strictly after the content it is confirming was already committed
413
+ (create_secret()'s own post-return fsync in _initialize_or_probe;
414
+ write_registry_bytes's own PostCommitVerificationError path, recovered
415
+ in _finalize_registration). Before accepting ANY valid secret-and-
416
+ registry pair as grounds to hand out stable aliases, this makes a
417
+ fresh attempt at that same strict fsync, retried up to
418
+ _DIRECTORY_FSYNC_ATTEMPTS times total. Returns True the moment one
419
+ attempt succeeds (a transient failure still returns True); returns
420
+ False only once every attempt has failed (a persistent failure), which
421
+ every caller here treats exactly like any other unavailable outcome:
422
+ positional fallback, the real secret and registry left exactly as they
423
+ are. dir_fd: see state.lock_state_dir/LockedStateDir (N3a fix round 3,
424
+ new Finding 1) -- when given, the fsync runs on that verified directory
425
+ descriptor directly, never a path-based reopen."""
426
+ for _ in range(_DIRECTORY_FSYNC_ATTEMPTS):
427
+ try:
428
+ state.fsync_directory(directory, dir_fd=dir_fd)
429
+ return True
430
+ except OSError:
431
+ continue
432
+ return False
433
+
434
+
435
+ def _probe_registry(
436
+ directory: Path, *, dir_fd: int | None = None
437
+ ) -> tuple[state.Probe, dict | None]:
438
+ """probe_registry_bytes's file-level outcome, upgraded to a content-level
439
+ one: a file that reads back as Probe.VALID bytes but fails
440
+ validate_registry (bad JSON, wrong schema, ...) is reported as
441
+ Probe.INVALID here, exactly as P4 requires -- the byte-level and
442
+ schema-level checks are both "the registry is unavailable" to anyone
443
+ outside this module.
444
+
445
+ N3a fix round 2, Finding 3: a decode failure also includes
446
+ RecursionError -- pathologically nested JSON (for example 10,000 nested
447
+ arrays) is well under MAX_BYTES but exhausts the decoder's recursion
448
+ budget. That must classify the registry as invalid, the same as any
449
+ other decode failure, not propagate out of this function.
450
+
451
+ N3a fix round 2, Finding 5: MAX_ENTRIES is also enforced here, on read,
452
+ not only by the write path (_write_if_within_bounds): an existing
453
+ registry already over the entry bound -- however small its byte size --
454
+ is unavailable the moment it is read.
455
+
456
+ dir_fd: see state.lock_state_dir/LockedStateDir (N3a fix round 3, new
457
+ Finding 1).
458
+ """
459
+ probe, raw = state.probe_registry_bytes(
460
+ state.registry_path(directory), max_bytes=MAX_BYTES, dir_fd=dir_fd
461
+ )
462
+ if probe is not state.Probe.VALID:
463
+ return probe, None
464
+ try:
465
+ parsed = json.loads(raw.decode("utf-8"))
466
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError):
467
+ return state.Probe.INVALID, None
468
+ validated = validate_registry(parsed)
469
+ if validated is None:
470
+ return state.Probe.INVALID, None
471
+ if count_entries(validated) > MAX_ENTRIES:
472
+ return state.Probe.INVALID, None
473
+ return state.Probe.VALID, validated
474
+
475
+
476
+ def _initialize_or_probe(
477
+ directory: Path, *, dir_fd: int | None = None, _retried: bool = False
478
+ ) -> tuple[bytes | None, dict | None]:
479
+ """Run P6's table once, assuming the caller already holds
480
+ state.lock_state_dir(directory). dir_fd: see state.lock_state_dir/
481
+ LockedStateDir (N3a fix round 3, new Finding 1) -- when given, every
482
+ secret/registry operation below runs relative to that verified
483
+ directory descriptor instead of by path.
484
+
485
+ Returns (secret, registry) once both
486
+ are available -- either because they already were ("normal") or because
487
+ this call just created what P6 says is safe to create -- or (None,
488
+ None) for every row P6 calls "unavailable". Never creates the secret
489
+ when the registry is valid and non-empty (that would silently re-key
490
+ every existing alias) and never creates the registry when the secret
491
+ already exists (a fresh registry next to an old secret could reassign
492
+ an alias that secret already vouches for). A crash between writing the
493
+ registry and creating the secret leaves exactly "registry valid and
494
+ empty, secret absent", the row this same function also handles by
495
+ completing the secret -- so a retried call after a mid-flight failure
496
+ converges correctly without any special-cased recovery path.
497
+
498
+ N3a fix round 2, Finding 1 (BLOCKER): creating the registry can fail
499
+ strictly after its own commit point (the registry's os.replace) with a
500
+ state.PostCommitVerificationError. That means the file already has its
501
+ new, correct content -- so instead of propagating (which would make the
502
+ caller report "nothing happened" while something did), this re-runs the
503
+ whole probe exactly once (_retried guards against looping on a
504
+ persistent failure): by then the just-committed file reads back through
505
+ the normal P6 rows above, so no separate recovery path is needed.
506
+
507
+ N3a fix round 2 (continued), new Finding 1 (BLOCKER): a read-back alone
508
+ is not enough, for the registry or the secret -- it only proves the
509
+ content is visible right now, not that the directory entry making it
510
+ visible would survive a crash. Every path through this function that is
511
+ about to return a freshly created or already-valid pair first requires
512
+ _directory_is_durable(directory) to succeed (its own bounded retry,
513
+ distinct from this function's _retried guard): a persistent fsync
514
+ failure there is unavailable, exactly like any other row P6 calls
515
+ unavailable, never a recovered result.
516
+ """
517
+ secret_probe, secret_bytes = state.probe_secret(state.secret_path(directory), dir_fd=dir_fd)
518
+ registry_probe, registry = _probe_registry(directory, dir_fd=dir_fd)
519
+
520
+ if secret_probe is state.Probe.INVALID or registry_probe is state.Probe.INVALID:
521
+ return None, None
522
+
523
+ def _recover_once() -> tuple[bytes | None, dict | None]:
524
+ if _retried:
525
+ return None, None
526
+ return _initialize_or_probe(directory, dir_fd=dir_fd, _retried=True)
527
+
528
+ if registry_probe is state.Probe.ABSENT and secret_probe is state.Probe.ABSENT:
529
+ fresh = empty_registry()
530
+ try:
531
+ state.write_registry_bytes(
532
+ state.registry_path(directory), _dumps(fresh), dir_fd=dir_fd
533
+ )
534
+ except state.PostCommitVerificationError:
535
+ return _recover_once()
536
+ # create_secret() itself is NOT wrapped here: a failure inside it is
537
+ # always pre-commit (it cleans up the file it just created and
538
+ # raises), so it must propagate normally, exactly as before this
539
+ # fix -- only the separate, strictly post-commit directory
540
+ # durability confirmation below can fail without undoing anything.
541
+ new_secret = state.create_secret(state.secret_path(directory), dir_fd=dir_fd)
542
+ if not _directory_is_durable(directory, dir_fd=dir_fd):
543
+ return None, None
544
+ return new_secret, fresh
545
+
546
+ if registry_probe is state.Probe.VALID and is_empty_registry(registry) and (
547
+ secret_probe is state.Probe.ABSENT
548
+ ):
549
+ new_secret = state.create_secret(state.secret_path(directory), dir_fd=dir_fd)
550
+ if not _directory_is_durable(directory, dir_fd=dir_fd):
551
+ return None, None
552
+ return new_secret, registry
553
+
554
+ if registry_probe is state.Probe.VALID and not is_empty_registry(registry) and (
555
+ secret_probe is state.Probe.ABSENT
556
+ ):
557
+ return None, None # a new secret would re-key every existing alias
558
+
559
+ if registry_probe is state.Probe.ABSENT and secret_probe is state.Probe.VALID:
560
+ return None, None # a new registry could reassign an alias the secret vouches for
561
+
562
+ # registry_probe is VALID and secret_probe is VALID: normal, but a prior
563
+ # process's successful read-back here cannot distinguish a genuinely
564
+ # durable commit from one whose own directory fsync failed right after
565
+ # the file became visible (N3a fix round 2 continued, new Finding 1).
566
+ # Require a fresh, successful directory fsync before trusting this pair
567
+ # enough to hand out stable aliases; a persistent failure here is
568
+ # unavailable, exactly like any invalid file.
569
+ if not _directory_is_durable(directory, dir_fd=dir_fd):
570
+ return None, None
571
+ return secret_bytes, registry
572
+
573
+
574
+ def _write_if_within_bounds(directory: Path, registry: dict, *, dir_fd: int | None = None) -> bool:
575
+ """Encode registry and write it, unless doing so would take the file
576
+ over MAX_BYTES or MAX_ENTRIES, in which case nothing is written at all
577
+ and this returns False (the caller treats that exactly like any other
578
+ registration failure: fall back, leave the file untouched). May raise
579
+ state.PostCommitVerificationError (N3a fix round 2, Finding 1): the
580
+ write's own commit point already passed; the caller decides whether to
581
+ recover rather than assume nothing changed. dir_fd: see state.
582
+ lock_state_dir/LockedStateDir (N3a fix round 3, new Finding 1)."""
583
+ if count_entries(registry) > MAX_ENTRIES:
584
+ return False
585
+ encoded = _dumps(registry)
586
+ if len(encoded) > MAX_BYTES:
587
+ return False
588
+ state.write_registry_bytes(state.registry_path(directory), encoded, dir_fd=dir_fd)
589
+ return True
590
+
591
+
592
+ def _finalize_registration(
593
+ directory: Path,
594
+ registry: dict,
595
+ secret: bytes,
596
+ canonical: list[tuple[str, str]],
597
+ *,
598
+ dir_fd: int | None = None,
599
+ ) -> dict[tuple[str, str], str] | None:
600
+ """Compute aliases for every (kind, real_id) in canonical against
601
+ registry/secret (P3), persist the result if anything changed, and
602
+ return the {(kind, real_id): alias} mapping -- or None if this
603
+ registration cannot complete (a bound would be exceeded, or a
604
+ post-commit recovery attempt itself does not confirm the write).
605
+ dir_fd: see state.lock_state_dir/LockedStateDir (N3a fix round 3, new
606
+ Finding 1) -- threaded into every call below.
607
+
608
+ N3a fix round 2, Finding 1 (BLOCKER): a state.PostCommitVerificationError
609
+ from _write_if_within_bounds means the write's own commit point
610
+ (os.replace) already passed -- aliases.json already holds `updated`.
611
+ Re-probe the registry fresh, under the same lock: if it reads back
612
+ valid and byte-for-byte equal (as parsed dicts) to what was just
613
+ written, aliases is still correct and this call succeeded; otherwise
614
+ report failure rather than silently claim nothing changed.
615
+
616
+ N3a fix round 2 (continued), new Finding 1 (BLOCKER): content equality
617
+ alone is not enough either -- it only proves the registry is visible
618
+ with the right bytes right now, not that its directory entry would
619
+ survive a crash. Require a fresh, successful directory fsync too before
620
+ accepting this recovery; a persistent failure there reports failure the
621
+ same as a content mismatch.
622
+ """
623
+ updated, aliases, changed = _assign_all(registry, secret, canonical)
624
+ if not changed:
625
+ return aliases
626
+ try:
627
+ if not _write_if_within_bounds(directory, updated, dir_fd=dir_fd):
628
+ return None
629
+ except state.PostCommitVerificationError:
630
+ reprobed_probe, reprobed = _probe_registry(directory, dir_fd=dir_fd)
631
+ if reprobed_probe is not state.Probe.VALID or reprobed != updated:
632
+ return None
633
+ if not _directory_is_durable(directory, dir_fd=dir_fd):
634
+ return None
635
+ return aliases
636
+
637
+
638
+ # --- AliasBook and registration (P7, P8, P9) -----------------------------------
639
+
640
+
641
+ @dataclass
642
+ class AliasBook:
643
+ """One batch's worth of stable aliases, answered from memory after one
644
+ locked registration (P9). available is False for a fallback book:
645
+ every alias it hands out is positional, and it never holds a real id
646
+ anywhere in its own state (it is simply never given one -- a fallback
647
+ book is built from the same canonical (kind, real_id) pairs as a real
648
+ one, but only ever stores the positional name assigned to each).
649
+ """
650
+
651
+ available: bool
652
+ slow_path_count: int = 0
653
+ _directory: Path | None = field(default=None, repr=False)
654
+ _aliases: dict[tuple[str, str], str] = field(default_factory=dict, repr=False)
655
+ _positional: dict[tuple[str, str], str] = field(default_factory=dict, repr=False)
656
+ _positional_counts: dict[str, int] = field(default_factory=dict, repr=False)
657
+ # N3b D-i: an in-memory book (the demo) keeps its registry and secret
658
+ # here and never touches the state directory, not even on the slow path.
659
+ _memory: dict | None = field(default=None, repr=False)
660
+ _secret: bytes | None = field(default=None, repr=False)
661
+
662
+ @property
663
+ def in_memory(self) -> bool:
664
+ return self._memory is not None
665
+
666
+ def alias(self, kind: str, real_id: str) -> str:
667
+ """The alias for (kind, real_id). An identity this book was already
668
+ registered with answers from memory, instantly. An identity it was
669
+ not given at construction takes the slow path (docs/DESIGN.md 7.5/
670
+ P9): one more locked registration attempt for just this id,
671
+ counted in slow_path_count regardless of whether that attempt
672
+ itself succeeds or falls back; a fallback book has no lock to take
673
+ and simply extends its own positional numbering instead, without
674
+ touching slow_path_count at all.
675
+ """
676
+ key = (kind, real_id)
677
+ if key in self._aliases:
678
+ return self._aliases[key]
679
+ if key in self._positional:
680
+ return self._positional[key]
681
+
682
+ if not self.available:
683
+ return self._assign_positional(kind, real_id)
684
+
685
+ # N3a fix round 2, Finding 7: the slow path must apply register()'s
686
+ # own P2 validation before doing anything else. A kind alias() does
687
+ # not recognize, or an id that is not canonical for its kind, is
688
+ # never eligible for a stable alias at all -- sending it on to
689
+ # _finalize_registration would HMAC and persist a non-canonical id,
690
+ # or index a per-kind table with a kind that was never one of them
691
+ # (a raw KeyError). This is not a registration failure, just an
692
+ # identity that was never going to get a stable alias, so it does
693
+ # not take the lock and does not count against slow_path_count or
694
+ # degrade the rest of the book (contrast Finding 8 below).
695
+ if kind not in KINDS or not is_canonical_id(kind, real_id):
696
+ return self._assign_positional(kind, real_id)
697
+
698
+ if self._memory is not None:
699
+ return self._alias_in_memory(kind, real_id)
700
+
701
+ self.slow_path_count += 1
702
+ try:
703
+ with state.lock_state_dir(self._directory) as locked:
704
+ secret, registry = _initialize_or_probe(locked.path, dir_fd=locked.dir_fd)
705
+ if secret is None or registry is None:
706
+ raise DisclosureError("disclosure state became unavailable")
707
+ aliases = _finalize_registration(
708
+ locked.path, registry, secret, [(kind, real_id)], dir_fd=locked.dir_fd
709
+ )
710
+ if aliases is None:
711
+ raise DisclosureError("registry bound exceeded on the slow path")
712
+ alias = aliases[key]
713
+ self._aliases[key] = alias
714
+ return alias
715
+ except Exception:
716
+ # N3a fix round 2, Finding 3: an ordinary bug anywhere in
717
+ # probing, assignment, or writing (not just the state/disclosure
718
+ # errors this module itself defines) must still fall back,
719
+ # never raise out of alias(). KeyboardInterrupt/SystemExit are
720
+ # not ordinary Exceptions and still propagate.
721
+ #
722
+ # N3a fix round 2, Finding 8: a genuine slow-path failure means
723
+ # this book can no longer vouch for any alias it already handed
724
+ # out from memory either -- a mix of stable and positional
725
+ # aliases within one render is exactly what P8 forbids. Degrade
726
+ # the whole book atomically rather than falling back for only
727
+ # the one identity that triggered the failure.
728
+ return self._degrade_and_assign_positional(kind, real_id)
729
+
730
+ def _alias_in_memory(self, kind: str, real_id: str) -> str:
731
+ """The slow path of an in-memory book: one more assignment against
732
+ the in-memory registry, counted like the locked one, never a file."""
733
+ key = (kind, real_id)
734
+ self.slow_path_count += 1
735
+ try:
736
+ assert self._memory is not None and self._secret is not None
737
+ updated, aliases, _changed = _assign_all(self._memory, self._secret, [key])
738
+ self._memory = updated
739
+ alias = aliases[key]
740
+ self._aliases[key] = alias
741
+ return alias
742
+ except Exception:
743
+ return self._degrade_and_assign_positional(kind, real_id)
744
+
745
+ def _assign_positional(self, kind: str, real_id: str) -> str:
746
+ key = (kind, real_id)
747
+ if key in self._positional:
748
+ return self._positional[key]
749
+ n = self._positional_counts.get(kind, 0) + 1
750
+ self._positional_counts[kind] = n
751
+ name = f"{kind}-{n}"
752
+ self._positional[key] = name
753
+ return name
754
+
755
+ def _degrade_and_assign_positional(self, kind: str, real_id: str) -> str:
756
+ """N3a fix round 2, Finding 8: flip available to False and replace
757
+ every alias this book had already handed out from memory with a
758
+ positional one, in the order those identities were first
759
+ registered (self._aliases already holds them in that order, since
760
+ register() builds it from one ordered pass over its identities
761
+ list), before assigning the new, also positional, name. A caller
762
+ that already printed an earlier stable alias cannot have that
763
+ un-said -- P8's one-shot re-render rule (N3b) is what makes a
764
+ one-shot command notice this mid-render and start over before
765
+ anything reaches real output; `watch`'s own rule (C-1), that
766
+ already-emitted lines stand and only the current and later events
767
+ go positional, falls out of the same "available flips to False from
768
+ here on" behavior for free.
769
+ """
770
+ self.available = False
771
+ previously_stable = list(self._aliases.keys())
772
+ self._aliases.clear()
773
+ for prior_kind, prior_id in previously_stable:
774
+ self._assign_positional(prior_kind, prior_id)
775
+ return self._assign_positional(kind, real_id)
776
+
777
+
778
+ def _fallback_book(identities: list[tuple[str, str]]) -> AliasBook:
779
+ book = AliasBook(available=False)
780
+ for kind, real_id in identities:
781
+ book._assign_positional(kind, real_id)
782
+ return book
783
+
784
+
785
+ def fallback_book(identities: list[tuple[str, str]] | None = None) -> AliasBook:
786
+ """A positional-only book (available=False) numbered in the order of
787
+ `identities`, then in first-use order. N3b D-b uses it to re-render one
788
+ output once, from the same collected identities, when a stable book
789
+ degraded mid-render. Touches no file."""
790
+ return _fallback_book(list(identities or []))
791
+
792
+
793
+ def lookup_agent_alias(
794
+ alias_hex: str, ids_by_ref, *, state_dir: Path | None = None
795
+ ) -> dict | None:
796
+ """docs/DESIGN.md 4.13.6 (N6): which of the agent ids in `ids_by_ref` (a mapping from
797
+ an opaque ref to the agent ids to test) hold exactly the registered alias
798
+ `alias_hex`. Returns {ref: frozenset of matching ids}, only refs with a match, or
799
+ None when stable aliases are unavailable on this machine.
800
+
801
+ Read-only: it opens the state directory with state._open_verified_state_dir (a
802
+ directory, mode 0700, owned by the current user, no symlink) and reads the secret
803
+ and the registry relative to that one descriptor, closing it once before returning
804
+ (a close OSError is unavailable too). It takes no lock, creates nothing, repairs
805
+ nothing, writes nothing. An absent directory, a StateError, an OSError, or a probe
806
+ result other than VALID all mean unavailable; no
807
+ exception escapes. An id never registered has no alias and never matches; an alias
808
+ matches only the identity whose registered alias is exactly `alias_hex` (a longer
809
+ registered alias that merely starts with it belongs to another identity)."""
810
+ directory = state_dir if state_dir is not None else state.state_dir()
811
+ try:
812
+ dir_fd = state._open_verified_state_dir(directory)
813
+ except Exception:
814
+ return None
815
+ try:
816
+ result = _lookup_with(directory, dir_fd, alias_hex, ids_by_ref)
817
+ except Exception:
818
+ result = None
819
+ try:
820
+ os.close(dir_fd)
821
+ except OSError:
822
+ return None
823
+ return result
824
+
825
+
826
+ def _lookup_with(directory: Path, dir_fd: int, alias_hex: str, ids_by_ref) -> dict | None:
827
+ """The probes and the match of lookup_agent_alias, over the one verified descriptor."""
828
+ secret_probe, secret = state.probe_secret(state.secret_path(directory), dir_fd=dir_fd)
829
+ if secret_probe is not state.Probe.VALID or secret is None:
830
+ return None
831
+ registry_probe, registry = _probe_registry(directory, dir_fd=dir_fd)
832
+ if registry_probe is not state.Probe.VALID or registry is None:
833
+ return None
834
+ table = registry["agent"]
835
+ matched: dict = {}
836
+ for ref, agent_ids in ids_by_ref.items():
837
+ hits = set()
838
+ for agent_id in agent_ids:
839
+ if not is_canonical_id("agent", agent_id):
840
+ continue
841
+ hashed = hmac_hex(secret, agent_id)
842
+ length = table.get(hashed)
843
+ if length is not None and hashed[:length] == alias_hex:
844
+ hits.add(agent_id)
845
+ if hits:
846
+ matched[ref] = frozenset(hits)
847
+ return matched
848
+
849
+
850
+ def lazy_book(*, state_dir: Path | None = None) -> AliasBook:
851
+ """N3b C-1: an available book that has registered nothing yet. `watch`
852
+ uses it when no agent is known at start: the first canonical identity it
853
+ is asked for takes the slow path (one locked registration), and a
854
+ non-canonical one is named positionally without touching any file."""
855
+ directory = state_dir if state_dir is not None else state.state_dir()
856
+ return AliasBook(available=True, _directory=directory)
857
+
858
+
859
+ # N3b D-i: the secret `demo` renders with. It is PUBLIC on purpose (it is in
860
+ # the source of every copy of the tool): the demo shows one synthetic run, so
861
+ # its aliases must be the same on every machine, and it must never read or
862
+ # write a real installation secret. 32 bytes, derived from a fixed phrase.
863
+ DEMO_SECRET: bytes = hashlib.sha256(b"agent-postmortem public demo secret").digest()
864
+
865
+
866
+ def register_in_memory(
867
+ identities: list[tuple[str, str]], secret: bytes
868
+ ) -> AliasBook:
869
+ """N3b D-i: the demo's registration. P3 assignment against an empty,
870
+ in-memory registry, the identities in the order given (one fixed order,
871
+ which is the collision tiebreak), under `secret`. Touches no file: no
872
+ state directory, no lock, no secret, no registry. The book's own slow
873
+ path also stays in memory. Non-canonical ids raise ValueError, as in
874
+ register()."""
875
+ canonical = [(kind, require_canonical_id(kind, real_id)) for kind, real_id in identities]
876
+ registry, aliases, _changed = _assign_all(empty_registry(), secret, canonical)
877
+ book = AliasBook(available=True)
878
+ book._memory = registry
879
+ book._secret = secret
880
+ book._aliases.update(aliases)
881
+ return book
882
+
883
+
884
+ def register(
885
+ identities: list[tuple[str, str]], *, state_dir: Path | None = None
886
+ ) -> AliasBook:
887
+ """One locked registration for every identity a command already knows
888
+ it may render (docs/DESIGN.md 7.5/P9). identities is a list of
889
+ (kind, real_id) pairs, kind one of KINDS; every one is validated as
890
+ canonical (require_canonical_id) before the lock is even taken, so a
891
+ caller's bug (a non-canonical id slipping through) raises ValueError
892
+ immediately rather than silently becoming a positional name here (N3b's
893
+ renderer is what chooses a positional name for a non-canonical id).
894
+
895
+ Returns an AliasBook. On any failure in P4/P6/P7/P8 terms --
896
+ unavailable secret or registry, lock contention, a failed write, a
897
+ bound that would be exceeded -- returns a fallback book instead
898
+ (available=False, positional names only). A pre-commit failure leaves
899
+ the secret and the registry byte-for-byte and mtime untouched: nothing
900
+ between taking the lock and returning ever partially writes one of the
901
+ two files and then gives up. A post-commit failure (docs/DESIGN.md
902
+ 7.5a) is the one exception: the new file may already be installed, and
903
+ this fallback is what register() returns only once re-probing it and
904
+ requiring a fresh directory-durability confirmation
905
+ have both still failed to confirm it, never by silently pretending
906
+ nothing changed.
907
+ """
908
+ directory = state_dir if state_dir is not None else state.state_dir()
909
+ canonical = [(kind, require_canonical_id(kind, real_id)) for kind, real_id in identities]
910
+
911
+ try:
912
+ with state.lock_state_dir(directory) as locked:
913
+ secret, registry = _initialize_or_probe(locked.path, dir_fd=locked.dir_fd)
914
+ if secret is None or registry is None:
915
+ return _fallback_book(canonical)
916
+ aliases = _finalize_registration(
917
+ locked.path, registry, secret, canonical, dir_fd=locked.dir_fd
918
+ )
919
+ if aliases is None:
920
+ return _fallback_book(canonical)
921
+ except Exception:
922
+ # N3a fix round 2, Finding 3: fall back on any ordinary exception
923
+ # from probing, assignment, or writing -- not just the specific
924
+ # state/disclosure error types this module already knew about.
925
+ # require_canonical_id above still raises ValueError before the
926
+ # lock is ever taken, for a caller's own bug.
927
+ return _fallback_book(canonical)
928
+
929
+ book = AliasBook(available=True, _directory=directory)
930
+ book._aliases.update(aliases)
931
+ return book