Skip to content

Commit 8e79f46

Browse files
authored
Merge pull request #174 from AgriciDaniel/codex/public-fix-legacy-batch-migration
fix(migration): preserve unresolved legacy batch labels
2 parents 1c1bc49 + 7649af7 commit 8e79f46

8 files changed

Lines changed: 780 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ implementation record for older releases.
2020
- The `UNSUPPORTED_PLATFORM` refusal message now points to
2121
`docs/windows-wsl.md` for users whose WSL setup is itself misbehaving.
2222

23+
### Fixed
24+
25+
- Legacy migration and adoption no longer fail when a manifest source key is a
26+
valid batch label rather than a file. Unresolved labels are preserved as
27+
unreviewed manual sources without inventing payload mappings or hashes.
28+
Apply now rejects a reviewed migration if a legacy locator's file state
29+
changes or becomes unsafe before the transaction writes.
30+
2331
## [2.1.0] - 2026-07-31
2432

2533
Native Windows compatibility.

claude_obsidian/ledgers.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,7 +1236,14 @@ def migrate_legacy_manifest(
12361236
}
12371237
)
12381238
continue
1239-
source_id = stable_source_id("file", locator, digest)
1239+
# Some legacy generators used a canonical path-shaped batch label
1240+
# rather than a file locator. _safe_hash returns None only when that
1241+
# safely inspected path is absent; unsafe and non-regular nodes raise.
1242+
# Preserve an absent legacy identity without inventing a payload or
1243+
# treating its short legacy hash as SHA-256.
1244+
origin_kind = "file" if digest is not None else "manual"
1245+
content_kind = "document" if digest is not None else "other"
1246+
source_id = stable_source_id(origin_kind, locator, digest)
12401247
if source_id in source_ledger["sources"]:
12411248
migration_errors.append(
12421249
{
@@ -1274,8 +1281,8 @@ def migrate_legacy_manifest(
12741281
)
12751282
continue
12761283
source_ledger["sources"][source_id] = {
1277-
"origin": {"kind": "file", "locator": locator},
1278-
"content_kind": "document",
1284+
"origin": {"kind": origin_kind, "locator": locator},
1285+
"content_kind": content_kind,
12791286
"title": Path(locator).stem,
12801287
"authority": "unknown",
12811288
"content_sha256": digest,
@@ -1306,6 +1313,10 @@ def migration_bundle(
13061313
) -> dict[str, Any]:
13071314
root = canonical(vault_root)
13081315
sources, claims = migrate_legacy_manifest(root, generated_at=generated_at)
1316+
read_preconditions = {
1317+
record["origin"]["locator"]: record["content_sha256"]
1318+
for record in sources["sources"].values()
1319+
}
13091320
validate_existing_canonical_state(root, fallback_source_ledger=sources)
13101321
writes: list[dict[str, Any]] = []
13111322
expected: dict[str, str | None] = {}
@@ -1342,10 +1353,13 @@ def migration_bundle(
13421353
+ "\n",
13431354
}
13441355
)
1345-
return {
1356+
bundle = {
13461357
"schema": BUNDLE_SCHEMA,
13471358
"operation_id": operation_id,
13481359
"operation_type": "migration",
13491360
"expected_hashes": expected,
13501361
"writes": writes,
13511362
}
1363+
if SOURCE_PATH in expected:
1364+
bundle["read_preconditions"] = read_preconditions
1365+
return bundle

claude_obsidian/transaction.py

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2311,11 +2311,21 @@ def _remove_pinned_runtime_tree_at(
23112311
)
23122312
if remaining is None:
23132313
remaining = [MAX_TRANSACTION_RUNTIME_TREE_ENTRIES]
2314-
child_names = _bounded_runtime_names(
2315-
directory_fd,
2316-
limit=remaining[0],
2317-
label=f"transaction runtime {component}",
2318-
)
2314+
scan_fd = _open_runtime_directory_at(parent_fd, component, create=False)
2315+
try:
2316+
pinned = os.fstat(directory_fd)
2317+
scanned = os.fstat(scan_fd)
2318+
if (pinned.st_dev, pinned.st_ino) != (scanned.st_dev, scanned.st_ino):
2319+
raise _LockIdentityChanged(
2320+
f"runtime directory changed before enumeration: {component}"
2321+
)
2322+
child_names = _bounded_runtime_names(
2323+
scan_fd,
2324+
limit=remaining[0],
2325+
label=f"transaction runtime {component}",
2326+
)
2327+
finally:
2328+
os.close(scan_fd)
23192329
remaining[0] -= len(child_names)
23202330
for child_name in child_names:
23212331
metadata = os.stat(child_name, dir_fd=directory_fd, follow_symlinks=False)
@@ -3329,6 +3339,12 @@ def _prepare_writes(
33293339
f"expected hash for {normalized_path} must be SHA-256 or null",
33303340
)
33313341
normalized_expected[normalized_path] = digest
3342+
_assert_read_preconditions(
3343+
vault_root,
3344+
bundle,
3345+
root_fd=root_fd,
3346+
meta_fd=meta_fd,
3347+
)
33323348
seen: set[str] = set()
33333349
seen_casefold: dict[str, str] = {}
33343350
prepared: list[PreparedWrite] = []
@@ -3503,6 +3519,75 @@ def _prepare_writes(
35033519
return prepared
35043520

35053521

3522+
def _assert_read_preconditions(
3523+
vault_root: Path,
3524+
bundle: Mapping[str, Any],
3525+
*,
3526+
root_fd: int | None = None,
3527+
meta_fd: int | None = None,
3528+
) -> None:
3529+
"""Require non-write inputs to retain their reviewed file state."""
3530+
3531+
raw = bundle.get("read_preconditions", {})
3532+
if not isinstance(raw, dict):
3533+
raise TransactionValidationError(
3534+
"INVALID_READ_PRECONDITIONS",
3535+
"read_preconditions must be an object",
3536+
)
3537+
if len(raw) > MAX_TRANSACTION_WRITES:
3538+
raise TransactionValidationError(
3539+
"TRANSACTION_WRITE_LIMIT",
3540+
f"read preconditions exceed the {MAX_TRANSACTION_WRITES}-path limit",
3541+
)
3542+
normalized: dict[str, str | None] = {}
3543+
casefolded: dict[str, str] = {}
3544+
for raw_path, digest in raw.items():
3545+
path = (
3546+
_normalize_vault_path(raw_path)
3547+
if root_fd is not None
3548+
else _safe_vault_path(vault_root, raw_path)[0]
3549+
)
3550+
folded = _portable_name_key(path)
3551+
prior = casefolded.get(folded)
3552+
if prior is not None and prior != path:
3553+
raise TransactionValidationError(
3554+
"CASEFOLD_PATH_COLLISION",
3555+
f"read preconditions contain case-colliding paths: {prior}, {path}",
3556+
)
3557+
casefolded[folded] = path
3558+
if digest is not None and (
3559+
not isinstance(digest, str)
3560+
or len(digest) != 64
3561+
or digest != digest.lower()
3562+
or any(character not in "0123456789abcdef" for character in digest)
3563+
):
3564+
raise TransactionValidationError(
3565+
"INVALID_READ_PRECONDITION",
3566+
f"read precondition for {path} must be SHA-256 or null",
3567+
)
3568+
normalized[path] = digest
3569+
for path, expected in normalized.items():
3570+
try:
3571+
observed = _safe_hash(
3572+
vault_root,
3573+
path,
3574+
root_fd=root_fd,
3575+
meta_fd=meta_fd,
3576+
)
3577+
except TransactionValidationError:
3578+
raise
3579+
except OSError as exc:
3580+
raise TransactionValidationError(
3581+
"UNSAFE_READ_PRECONDITION",
3582+
f"cannot inspect read precondition {path}: {exc}",
3583+
) from exc
3584+
if observed != expected:
3585+
raise TransactionConflict(
3586+
"READ_PRECONDITION_MISMATCH",
3587+
f"{path} changed since the operation was drafted",
3588+
)
3589+
3590+
35063591
def _validate_provenance_writes(
35073592
vault_root: Path,
35083593
prepared: Iterable[PreparedWrite],
@@ -4591,6 +4676,12 @@ def apply_bundle(
45914676
_assert_transaction_namespaces(mutation_lock, runtime, operation)
45924677

45934678
try:
4679+
_assert_read_preconditions(
4680+
vault,
4681+
bundle,
4682+
root_fd=runtime.root_fd,
4683+
meta_fd=runtime.meta_fd,
4684+
)
45944685
for index, write in enumerate(prepared, start=1):
45954686
_assert_transaction_namespaces(
45964687
mutation_lock, runtime, operation

claude_obsidian/vault_ops.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,15 @@ def build_vault_bundle(
105105
source_ledger, claim_ledger = migrate_legacy_manifest(
106106
vault, generated_at=generated_at
107107
)
108+
read_preconditions = {
109+
record["origin"]["locator"]: record["content_sha256"]
110+
for record in source_ledger["sources"].values()
111+
}
108112
validate_existing_canonical_state(vault, fallback_source_ledger=source_ledger)
109113
else:
110114
source_ledger = empty_source_ledger(generated_at=generated_at)
111115
claim_ledger = empty_claim_ledger(generated_at=generated_at)
116+
read_preconditions = {}
112117
planned[SOURCE_PATH] = (
113118
json.dumps(
114119
source_ledger,
@@ -150,10 +155,13 @@ def build_vault_bundle(
150155
"sha256": new_hash,
151156
}
152157
)
153-
return {
158+
bundle = {
154159
"schema": BUNDLE_SCHEMA,
155160
"operation_id": operation_id,
156161
"operation_type": operation_type,
157162
"expected_hashes": expected,
158163
"writes": writes,
159164
}
165+
if adopt and SOURCE_PATH in expected:
166+
bundle["read_preconditions"] = read_preconditions
167+
return bundle

skills/wiki/references/provenance.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,13 @@ python3 "$CORE" migrate --vault VAULT \
6262

6363
Migration leaves `.raw/.manifest.json` byte-for-byte unchanged, creates missing
6464
ledgers, defaults unknown evidence fields honestly, and never extracts claims
65-
from legacy prose automatically.
65+
from legacy prose automatically. A legacy source key that resolves to a regular
66+
file remains a file source with its computed SHA-256. A valid key with no file
67+
is preserved as an unreviewed manual source with unknown authority and no
68+
verified payload hash. This unresolved record preserves the legacy identity,
69+
date, and page links; it does not prove a batch-to-file relationship. Migration
70+
never enumerates raw payloads to invent that relationship or promotes the
71+
legacy short hash to SHA-256.
72+
The reviewed migration bundle also pins each legacy locator's observed file
73+
state. Apply fails if an unresolved label appears, becomes unsafe, cannot be
74+
inspected, or if a file source changes after review.

0 commit comments

Comments
 (0)