cfgit 0.1.0__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.
cfg/core/engine.py ADDED
@@ -0,0 +1,1388 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """DB-neutral cfgit engine."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, time, timezone
7
+ import fnmatch
8
+ import re
9
+ import uuid
10
+ from typing import Any
11
+
12
+ from cfg.adapters.base import AtomicityUnavailable, NoSuchConfig, StorageAdapter
13
+ from cfg.core.authz import authorize_mutation
14
+ from cfg.core.config import CollectionConfig, ProjectConfig
15
+ from cfg.core.diff import diff_values
16
+ from cfg.core.hashing import hash_doc, stored_doc, strip_for_hash
17
+ from cfg.core.identity import Identity, self_asserted_identity
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RecordRef:
22
+ collection: str
23
+ record_id: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class StatusRow:
28
+ collection: str
29
+ record_id: str
30
+ state: str
31
+ live_oid: str | None
32
+ head_oid: str | None
33
+ head_seq: int | None
34
+
35
+
36
+ class SecretBlocked(ValueError):
37
+ """A configured secret deny-list matched a document about to enter history."""
38
+
39
+
40
+ class BranchingDisabled(ValueError):
41
+ """Branch/PR commands require [branches] enabled = true."""
42
+
43
+
44
+ class Engine:
45
+ def __init__(
46
+ self,
47
+ config: ProjectConfig,
48
+ adapter: StorageAdapter,
49
+ *,
50
+ env: str,
51
+ author: str | None = None,
52
+ identity: Identity | None = None,
53
+ ):
54
+ self.config = config
55
+ self.adapter = adapter
56
+ self.env = env
57
+ env_cfg = config.envs[env]
58
+ self.identity = identity or self_asserted_identity(author or "", cfg=env_cfg.identity)
59
+ self.author = self.identity.author
60
+
61
+ def init(self) -> dict[str, Any]:
62
+ self._authorize("init")
63
+ self.adapter.ensure_schema()
64
+ return {
65
+ "atomic": self.adapter.check_atomicity_scope(),
66
+ "invariant_violations": self.adapter.check_runtime_invariant(),
67
+ "branches": {
68
+ "enabled": self.config.branches.enabled,
69
+ "refs_collection": self.config.branches.refs_collection if self.config.branches.enabled else None,
70
+ "default_branch": self.config.branches.default_branch,
71
+ },
72
+ }
73
+
74
+ def branch_list(self) -> list[dict[str, Any]]:
75
+ self._ensure_branching()
76
+ default = self.config.branches.default_branch
77
+ rows = [
78
+ {
79
+ "type": "branch",
80
+ "id": default,
81
+ "name": default,
82
+ "base_branch": None,
83
+ "head_commit_id": None,
84
+ "created_at": None,
85
+ "updated_at": None,
86
+ "author": None,
87
+ "message": "runtime branch",
88
+ "runtime_mutated": False,
89
+ }
90
+ ]
91
+ rows.extend(self.adapter.list_refs("branch"))
92
+ return sorted(rows, key=lambda row: (row["name"] != default, row["name"]))
93
+
94
+ def branch_create(self, name: str, *, from_branch: str | None = None, message: str | None = None) -> dict[str, Any]:
95
+ self._authorize("branch")
96
+ self._ensure_branching()
97
+ name = _clean_branch_name(name)
98
+ default = self.config.branches.default_branch
99
+ base = _clean_branch_name(from_branch or default)
100
+ if name == default:
101
+ raise ValueError(f"{default!r} is the runtime branch and cannot be created")
102
+ if self.adapter.get_ref("branch", name):
103
+ raise ValueError(f"branch already exists: {name}")
104
+ if base != default and not self.adapter.get_ref("branch", base):
105
+ raise NoSuchConfig(f"base branch not found: {base}")
106
+ now = self.adapter.now()
107
+ base_ref = None if base == default else self.adapter.get_ref("branch", base)
108
+ doc = {
109
+ "type": "branch",
110
+ "id": name,
111
+ "name": name,
112
+ "base_branch": base,
113
+ "base_commit_id": base_ref.get("head_commit_id") if base_ref else None,
114
+ "head_commit_id": None,
115
+ "author": self.author,
116
+ "message": str(message or f"create branch {name}"),
117
+ "created_at": now,
118
+ "updated_at": now,
119
+ "runtime_mutated": False,
120
+ }
121
+ self.adapter.put_ref(doc)
122
+ return doc
123
+
124
+ def branch_delete(self, name: str) -> dict[str, Any]:
125
+ self._authorize("branch")
126
+ self._ensure_branching()
127
+ name = _clean_branch_name(name)
128
+ default = self.config.branches.default_branch
129
+ if name == default:
130
+ raise ValueError(f"{default!r} is the runtime branch and cannot be deleted")
131
+ if not self.adapter.get_ref("branch", name):
132
+ raise NoSuchConfig(f"branch not found: {name}")
133
+ open_prs = self.adapter.list_refs("pr", head_branch=name, status="open")
134
+ if open_prs:
135
+ ids = ", ".join(str(pr["id"]) for pr in open_prs)
136
+ raise ValueError(f"branch has open PR(s): {ids}")
137
+ self.adapter.delete_ref("branch", name)
138
+ return {"state": "deleted", "branch": name, "runtime_mutated": False}
139
+
140
+ def branch_current(self, name: str | None) -> dict[str, Any]:
141
+ self._ensure_branching()
142
+ branch = _clean_branch_name(name or self.config.branches.default_branch)
143
+ self._require_branch(branch)
144
+ return {"branch": branch, "runtime_mutated": False}
145
+
146
+ def branch_commit(
147
+ self,
148
+ branch: str,
149
+ ref: RecordRef,
150
+ doc: dict[str, Any],
151
+ *,
152
+ message: str,
153
+ allow_secret: bool = False,
154
+ ) -> dict[str, Any]:
155
+ self._authorize("commit")
156
+ self._ensure_branching()
157
+ branch_ref = self._require_branch(_clean_branch_name(branch))
158
+ if branch_ref is None:
159
+ raise ValueError("committing to main mutates runtime; omit --branch or use cfg commit on main")
160
+ plan = self._branch_commit_plan(
161
+ branch_ref,
162
+ ref,
163
+ doc,
164
+ message=message,
165
+ allow_secret=allow_secret,
166
+ )
167
+ if plan["state"] != "ready":
168
+ return {key: value for key, value in plan.items() if key != "branch_ref"}
169
+ self.adapter.put_ref(plan["commit"])
170
+ updated_branch = {**branch_ref, "head_commit_id": plan["commit"]["id"], "updated_at": plan["commit"]["created_at"]}
171
+ self.adapter.put_ref(updated_branch)
172
+ return {
173
+ "state": "committed",
174
+ "branch": branch_ref["name"],
175
+ "commit_id": plan["commit"]["id"],
176
+ "collection": ref.collection,
177
+ "record_id": ref.record_id,
178
+ "oid": plan["commit"]["oid"],
179
+ "runtime_mutated": False,
180
+ }
181
+
182
+ def branch_commit_many(
183
+ self,
184
+ branch: str,
185
+ items: list[tuple[RecordRef, dict[str, Any]]],
186
+ *,
187
+ message: str,
188
+ allow_secret: bool = False,
189
+ ) -> dict[str, Any]:
190
+ self._authorize("commit")
191
+ self._ensure_branching()
192
+ branch_ref = self._require_branch(_clean_branch_name(branch))
193
+ if branch_ref is None:
194
+ raise ValueError("bulk branch commit needs a non-main branch")
195
+ message = _require_message(message)
196
+ if not items:
197
+ raise ValueError("bulk branch commit needs at least one item")
198
+
199
+ seen: set[tuple[str, str]] = set()
200
+ plans: list[dict[str, Any]] = []
201
+ blocked: list[dict[str, Any]] = []
202
+ for index, (ref, doc) in enumerate(items, start=1):
203
+ key = (ref.collection, ref.record_id)
204
+ if key in seen:
205
+ raise ValueError(f"duplicate record in bulk branch commit: {ref.collection}:{ref.record_id}")
206
+ seen.add(key)
207
+ plan = self._branch_commit_plan(
208
+ branch_ref,
209
+ ref,
210
+ doc,
211
+ message=message,
212
+ allow_secret=allow_secret,
213
+ bulk_index=index,
214
+ bulk_count=len(items),
215
+ )
216
+ if plan["state"] in {"missing", "changed_outside_cfgit"}:
217
+ blocked.append(_branch_plan_result(plan))
218
+ else:
219
+ plans.append(plan)
220
+ if blocked:
221
+ return {"state": "blocked", "branch": branch_ref["name"], "results": [], "failed": blocked, "runtime_mutated": False}
222
+
223
+ results = []
224
+ last_commit_id = branch_ref.get("head_commit_id")
225
+ updated_at = branch_ref.get("updated_at")
226
+ for plan in plans:
227
+ if plan["state"] == "noop":
228
+ results.append(_branch_plan_result(plan))
229
+ continue
230
+ self.adapter.put_ref(plan["commit"])
231
+ last_commit_id = plan["commit"]["id"]
232
+ updated_at = plan["commit"]["created_at"]
233
+ results.append(_branch_plan_result(plan, state="committed"))
234
+ if last_commit_id != branch_ref.get("head_commit_id"):
235
+ self.adapter.put_ref({**branch_ref, "head_commit_id": last_commit_id, "updated_at": updated_at})
236
+ state = "noop" if all(item["state"] == "noop" for item in results) else "committed"
237
+ return {"state": state, "branch": branch_ref["name"], "results": results, "runtime_mutated": False}
238
+
239
+ def branch_log(self, branch: str, *, limit: int | None = 20) -> list[dict[str, Any]]:
240
+ self._ensure_branching()
241
+ branch_name = _clean_branch_name(branch)
242
+ self._require_branch(branch_name)
243
+ rows = self.adapter.list_refs("branch_commit", branch=branch_name)
244
+ rows = sorted(rows, key=lambda row: row["created_at"], reverse=True)
245
+ if limit is not None:
246
+ rows = rows[:limit]
247
+ return rows
248
+
249
+ def branch_diff(self, range_expr: str) -> dict[str, Any]:
250
+ self._ensure_branching()
251
+ base, head = _parse_branch_range(range_expr, self.config.branches.default_branch)
252
+ if base != self.config.branches.default_branch:
253
+ raise ValueError("v1 branch diff supports only main..<branch>")
254
+ self._require_branch(head)
255
+ latest = self._branch_latest_by_record(head)
256
+ records = []
257
+ for ref, commit in sorted(latest.items(), key=lambda item: (item[0].collection, item[0].record_id)):
258
+ coll = self.config.collection(ref.collection)
259
+ base_doc = self._main_doc(ref)
260
+ left = strip_for_hash(base_doc or {}, coll)
261
+ right = strip_for_hash(commit["doc"], coll)
262
+ changes = diff_values(left, right)
263
+ if changes:
264
+ records.append(
265
+ {
266
+ "collection": ref.collection,
267
+ "record_id": ref.record_id,
268
+ "base_oid": hash_doc(base_doc, coll) if base_doc else None,
269
+ "branch_oid": commit["oid"],
270
+ "commit_id": commit["id"],
271
+ "changes": changes,
272
+ }
273
+ )
274
+ return {"range": f"{base}..{head}", "records": records, "runtime_mutated": False}
275
+
276
+ def pr_create(
277
+ self,
278
+ *,
279
+ base: str,
280
+ head: str,
281
+ message: str,
282
+ ) -> dict[str, Any]:
283
+ self._authorize("pr")
284
+ self._ensure_branching()
285
+ default = self.config.branches.default_branch
286
+ base = _clean_branch_name(base)
287
+ head = _clean_branch_name(head)
288
+ if base != default:
289
+ raise ValueError("v1 PRs can target only main")
290
+ branch_ref = self._require_branch(head)
291
+ if branch_ref is None:
292
+ raise ValueError("PR head must be a non-main branch")
293
+ commits = self.branch_log(head, limit=None)
294
+ if not commits:
295
+ raise ValueError(f"branch has no commits: {head}")
296
+ latest = self._branch_latest_by_record(head)
297
+ now = self.adapter.now()
298
+ pr_id = f"pr_{uuid.uuid4().hex[:12]}"
299
+ doc = {
300
+ "type": "pr",
301
+ "id": pr_id,
302
+ "status": "open",
303
+ "base_branch": base,
304
+ "head_branch": head,
305
+ "head_commit_id": branch_ref.get("head_commit_id"),
306
+ "commit_ids": [row["id"] for row in commits],
307
+ "records": [
308
+ {
309
+ "collection": ref.collection,
310
+ "record_id": ref.record_id,
311
+ "commit_id": commit["id"],
312
+ "oid": commit["oid"],
313
+ }
314
+ for ref, commit in sorted(latest.items(), key=lambda item: (item[0].collection, item[0].record_id))
315
+ ],
316
+ "message": _require_message(message),
317
+ "author": self.author,
318
+ "created_at": now,
319
+ "updated_at": now,
320
+ "runtime_mutated": False,
321
+ }
322
+ self.adapter.put_ref(doc)
323
+ return doc
324
+
325
+ def pr_list(self, *, status: str | None = None) -> list[dict[str, Any]]:
326
+ self._ensure_branching()
327
+ filters = {"status": status} if status else {}
328
+ return self.adapter.list_refs("pr", **filters)
329
+
330
+ def pr_show(self, pr_id: str) -> dict[str, Any]:
331
+ self._ensure_branching()
332
+ pr = self.adapter.get_ref("pr", pr_id)
333
+ if not pr:
334
+ raise NoSuchConfig(f"PR not found: {pr_id}")
335
+ return pr
336
+
337
+ def pr_close(self, pr_id: str) -> dict[str, Any]:
338
+ self._authorize("pr")
339
+ self._ensure_branching()
340
+ pr = self.pr_show(pr_id)
341
+ if pr["status"] != "open":
342
+ raise ValueError(f"PR is not open: {pr_id}")
343
+ now = self.adapter.now()
344
+ closed = {**pr, "status": "closed", "closed_at": now, "updated_at": now, "runtime_mutated": False}
345
+ self.adapter.put_ref(closed)
346
+ return closed
347
+
348
+ def pr_merge(self, pr_id: str, *, message: str | None = None) -> dict[str, Any]:
349
+ self._authorize("merge")
350
+ self._ensure_branching()
351
+ self._ensure_atomic("merge")
352
+ pr = self.pr_show(pr_id)
353
+ if pr["status"] != "open":
354
+ raise ValueError(f"PR is not open: {pr_id}")
355
+ if pr["base_branch"] != self.config.branches.default_branch:
356
+ raise ValueError("v1 PR merge supports only main as base")
357
+ branch_ref = self._require_branch(pr["head_branch"])
358
+ if branch_ref is None:
359
+ raise ValueError("PR head branch is main")
360
+ if branch_ref.get("head_commit_id") != pr.get("head_commit_id"):
361
+ raise ValueError("PR is stale: head branch moved after PR creation")
362
+ latest = self._branch_latest_by_record(pr["head_branch"])
363
+ if len(latest) > 1:
364
+ raise AtomicityUnavailable(
365
+ "multi-record PR merge needs adapter-level batch atomicity; split into one-record PRs for v1"
366
+ )
367
+ if not latest:
368
+ raise ValueError("PR has no records to merge")
369
+
370
+ ref, commit = next(iter(latest.items()))
371
+ coll = self.config.collection(ref.collection)
372
+ live = self.adapter.get_record(ref.collection, ref.record_id)
373
+ if live is None:
374
+ raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
375
+ head = self.adapter.get_head(ref.collection, ref.record_id)
376
+ expected_head = head.get("oid") if head else None
377
+ expected_live = hash_doc(live, coll)
378
+ base_head = (commit.get("meta") or {}).get("base_head_oid")
379
+ if expected_head != base_head:
380
+ return {
381
+ "state": "stale",
382
+ "reason": "main moved since branch commit",
383
+ "collection": ref.collection,
384
+ "record_id": ref.record_id,
385
+ "head_oid": expected_head,
386
+ "branch_base_oid": base_head,
387
+ "runtime_mutated": False,
388
+ }
389
+ if head and expected_live != expected_head:
390
+ return {
391
+ "state": "changed_outside_cfgit",
392
+ "collection": ref.collection,
393
+ "record_id": ref.record_id,
394
+ "live_oid": expected_live,
395
+ "head_oid": expected_head,
396
+ "runtime_mutated": False,
397
+ }
398
+ entry = self._entry(
399
+ ref,
400
+ commit["doc"],
401
+ coll,
402
+ message=message or pr["message"],
403
+ op="merge",
404
+ parent_oid=expected_head,
405
+ meta={
406
+ "source_pr_id": pr_id,
407
+ "source_branch": pr["head_branch"],
408
+ "source_branch_commit_id": commit["id"],
409
+ "source_branch_oid": commit["oid"],
410
+ },
411
+ )
412
+ result = self.adapter.apply(
413
+ collection=ref.collection,
414
+ record_id=ref.record_id,
415
+ new_doc=commit["doc"],
416
+ entry=entry,
417
+ expected_head_oid=expected_head,
418
+ expected_live_oid=expected_live,
419
+ make_head=True,
420
+ )
421
+ now = self.adapter.now()
422
+ merged_pr = {
423
+ **pr,
424
+ "status": "merged",
425
+ "merged_at": now,
426
+ "updated_at": now,
427
+ "merge_result": {
428
+ "collection": result.collection,
429
+ "record_id": result.record_id,
430
+ "seq": result.seq,
431
+ "oid": result.oid,
432
+ },
433
+ "runtime_mutated": True,
434
+ }
435
+ self.adapter.put_ref(merged_pr)
436
+ return {"state": "merged", "pr": pr_id, **merged_pr["merge_result"], "runtime_mutated": True}
437
+
438
+ def status(self, ref: RecordRef | None = None) -> list[StatusRow]:
439
+ refs = [ref] if ref else self._all_refs(include_history=True)
440
+ rows: list[StatusRow] = []
441
+ for item in refs:
442
+ coll = self.config.collection(item.collection)
443
+ live = self.adapter.get_record(item.collection, item.record_id)
444
+ head = self.adapter.get_head(item.collection, item.record_id)
445
+ live_oid = hash_doc(live, coll) if live else None
446
+ head_oid = head.get("oid") if head else None
447
+ head_seq = head.get("seq") if head else None
448
+ if live is None and head is None:
449
+ state = "not_found"
450
+ elif live is None:
451
+ state = "missing"
452
+ elif head is None:
453
+ state = "new"
454
+ elif live_oid != head_oid:
455
+ state = "changed_outside_cfgit"
456
+ else:
457
+ state = "clean"
458
+ rows.append(StatusRow(item.collection, item.record_id, state, live_oid, head_oid, head_seq))
459
+ return sorted(rows, key=lambda r: (r.collection, r.record_id))
460
+
461
+ def import_records(
462
+ self,
463
+ ref: RecordRef | None,
464
+ *,
465
+ message: str,
466
+ allow_secret: bool = False,
467
+ ) -> list[dict[str, Any]]:
468
+ self._authorize("import")
469
+ self._ensure_atomic("import")
470
+ refs = [ref] if ref else self._all_refs(include_history=False)
471
+ results: list[dict[str, Any]] = []
472
+ for item in refs:
473
+ if self.adapter.get_head(item.collection, item.record_id):
474
+ results.append({"collection": item.collection, "record_id": item.record_id, "state": "exists"})
475
+ continue
476
+ live = self.adapter.get_record(item.collection, item.record_id)
477
+ if live is None:
478
+ results.append({"collection": item.collection, "record_id": item.record_id, "state": "missing"})
479
+ continue
480
+ coll = self.config.collection(item.collection)
481
+ meta = self._secret_meta(live, coll, allow_secret=allow_secret, message=message)
482
+ entry = self._entry(item, live, coll, message=message, op="import", parent_oid=None, meta=meta)
483
+ result = self.adapter.apply(
484
+ collection=item.collection,
485
+ record_id=item.record_id,
486
+ new_doc=None,
487
+ entry=entry,
488
+ expected_head_oid=None,
489
+ expected_live_oid=None,
490
+ make_head=True,
491
+ )
492
+ results.append({"collection": item.collection, "record_id": item.record_id, "state": "imported", "seq": result.seq, "oid": result.oid})
493
+ return results
494
+
495
+ def doctor(self, ref: RecordRef | None = None, *, large_field_bytes: int = 20000) -> dict[str, Any]:
496
+ """Read-only preflight. Walks live records and reports what would trip an
497
+ import/commit BEFORE anything is written: secret-deny matches (grouped by
498
+ field path), oversized fields, and id values that are not unique under
499
+ live_when. Writes nothing. Returns a structured report plus paste-ready
500
+ config snippets so the user can fix .cfg.toml in one pass.
501
+ """
502
+ refs = [ref] if ref else self._all_refs(include_history=False)
503
+ # group secret hits by (collection, field path) so 200 docs with the same
504
+ # schema field collapse to one actionable line.
505
+ secret_groups: dict[tuple[str, str, str], dict[str, Any]] = {}
506
+ large_groups: dict[tuple[str, str], dict[str, Any]] = {}
507
+ invariant_violations = self.adapter.check_runtime_invariant(ref.collection if ref else None)
508
+ scanned = 0
509
+ for item in refs:
510
+ live = self.adapter.get_record(item.collection, item.record_id)
511
+ if live is None:
512
+ continue
513
+ scanned += 1
514
+ coll = self.config.collection(item.collection)
515
+ scan = stored_doc(live, coll) # same view the secret scan + storage use
516
+ for match in _secret_matches(scan, self.config.secrets.block_fields, self.config.secrets.block_values):
517
+ # normalize list indices so foo[0].bar and foo[3].bar group together
518
+ norm = re.sub(r"\[\d+\]", "[]", match["path"])
519
+ key = (item.collection, norm, match["kind"])
520
+ g = secret_groups.setdefault(key, {"collection": item.collection, "path": norm,
521
+ "kind": match["kind"], "pattern": match["pattern"],
522
+ "count": 0, "example": item.record_id})
523
+ g["count"] += 1
524
+ for path, value in _walk_doc(scan):
525
+ size = len(value) if isinstance(value, (str, bytes)) else 0
526
+ if size >= large_field_bytes:
527
+ norm = re.sub(r"\[\d+\]", "[]", path)
528
+ key = (item.collection, norm)
529
+ g = large_groups.setdefault(key, {"collection": item.collection, "path": norm,
530
+ "count": 0, "max_bytes": 0, "example": item.record_id})
531
+ g["count"] += 1
532
+ g["max_bytes"] = max(g["max_bytes"], size)
533
+ secrets = sorted(secret_groups.values(), key=lambda g: (g["collection"], g["path"]))
534
+ large = sorted(large_groups.values(), key=lambda g: (-g["max_bytes"], g["collection"]))
535
+ # build paste-ready fix snippets per collection. Roll each secret path UP to its
536
+ # secret-bearing container so 9 sub-paths under ...openai_api_key collapse to the
537
+ # one container that, stripped, removes them all. Detailed paths stay in
538
+ # secret_blocks for transparency; suggestions stay short and pasteable.
539
+ suggestions: dict[str, dict[str, list[str]]] = {}
540
+ for g in secrets:
541
+ container = _secret_container(g["path"], self.config.secrets.block_fields)
542
+ sf = suggestions.setdefault(g["collection"], {}).setdefault("secret_fields", [])
543
+ if container not in sf:
544
+ sf.append(container)
545
+ for g in large:
546
+ ig = suggestions.setdefault(g["collection"], {}).setdefault("ignore_fields", [])
547
+ if g["path"] not in ig:
548
+ ig.append(g["path"])
549
+ # drop any suggested secret_field that is a child of another suggested one
550
+ for entry in suggestions.values():
551
+ sf = entry.get("secret_fields")
552
+ if sf:
553
+ entry["secret_fields"] = [p for p in sf
554
+ if not any(p != q and p.startswith(q + ".") for q in sf)]
555
+ ok = not secrets and not large and not invariant_violations
556
+ return {
557
+ "ok": ok,
558
+ "scanned": scanned,
559
+ "secret_blocks": secrets,
560
+ "large_fields": large,
561
+ "key_issues": invariant_violations,
562
+ "suggestions": suggestions,
563
+ "large_field_bytes": large_field_bytes,
564
+ }
565
+
566
+ def adopt(self, ref: RecordRef, *, message: str, allow_secret: bool = False) -> dict[str, Any]:
567
+ self._authorize("adopt")
568
+ self._ensure_atomic("adopt")
569
+ live = self.adapter.get_record(ref.collection, ref.record_id)
570
+ if live is None:
571
+ raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
572
+ coll = self.config.collection(ref.collection)
573
+ live_oid = hash_doc(live, coll)
574
+ head = self.adapter.get_head(ref.collection, ref.record_id)
575
+ if head and head.get("oid") == live_oid:
576
+ return {"state": "clean", "oid": live_oid, "seq": head.get("seq")}
577
+ meta = {
578
+ "bypass_detected_oid": live_oid,
579
+ **self._secret_meta(live, coll, allow_secret=allow_secret, message=message),
580
+ }
581
+ entry = self._entry(
582
+ ref,
583
+ live,
584
+ coll,
585
+ message=message,
586
+ op="adopt",
587
+ parent_oid=head.get("oid") if head else None,
588
+ meta=meta,
589
+ )
590
+ result = self.adapter.apply(
591
+ collection=ref.collection,
592
+ record_id=ref.record_id,
593
+ new_doc=None,
594
+ entry=entry,
595
+ expected_head_oid=head.get("oid") if head else None,
596
+ expected_live_oid=None,
597
+ make_head=True,
598
+ )
599
+ return {"state": "adopted", "oid": result.oid, "seq": result.seq}
600
+
601
+ def commit(
602
+ self,
603
+ ref: RecordRef,
604
+ doc: dict[str, Any],
605
+ *,
606
+ message: str,
607
+ allow_secret: bool = False,
608
+ ) -> dict[str, Any]:
609
+ self._authorize("commit")
610
+ self._ensure_atomic("commit")
611
+ coll = self.config.collection(ref.collection)
612
+ live = self.adapter.get_record(ref.collection, ref.record_id)
613
+ if live is None:
614
+ raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
615
+ head = self.adapter.get_head(ref.collection, ref.record_id)
616
+ expected_head = head.get("oid") if head else None
617
+ expected_live = hash_doc(live, coll)
618
+ if head and expected_live != expected_head:
619
+ return {"state": "changed_outside_cfgit", "live_oid": expected_live, "head_oid": expected_head}
620
+ new_oid = hash_doc(doc, coll)
621
+ if head and new_oid == expected_head:
622
+ return {"state": "noop", "oid": new_oid, "seq": head.get("seq")}
623
+ meta = self._secret_meta(doc, coll, allow_secret=allow_secret, message=message)
624
+ entry = self._entry(ref, doc, coll, message=message, op="commit", parent_oid=expected_head, meta=meta)
625
+ result = self.adapter.apply(
626
+ collection=ref.collection,
627
+ record_id=ref.record_id,
628
+ new_doc=doc,
629
+ entry=entry,
630
+ expected_head_oid=expected_head,
631
+ expected_live_oid=expected_live,
632
+ make_head=True,
633
+ )
634
+ return {"state": "committed", "oid": result.oid, "seq": result.seq}
635
+
636
+ def commit_many(
637
+ self,
638
+ items: list[tuple[RecordRef, dict[str, Any]]],
639
+ *,
640
+ message: str,
641
+ allow_secret: bool = False,
642
+ ) -> dict[str, Any]:
643
+ """Commit multiple full documents as one operator intent.
644
+
645
+ The current adapter contract is per-record atomic (`apply()`), so this method does
646
+ not pretend the whole batch is one database transaction. It does, however, preflight
647
+ every target before writing anything. Existing drift, missing records, duplicate
648
+ targets, and secret-policy failures block the entire batch up front.
649
+ """
650
+ self._authorize("commit")
651
+ self._ensure_atomic("commit")
652
+ message = _require_message(message)
653
+ if not items:
654
+ raise ValueError("bulk commit needs at least one item")
655
+
656
+ seen: set[tuple[str, str]] = set()
657
+ plans: list[dict[str, Any]] = []
658
+ blocked: list[dict[str, Any]] = []
659
+ total = len(items)
660
+ for index, (ref, doc) in enumerate(items, start=1):
661
+ key = (ref.collection, ref.record_id)
662
+ if key in seen:
663
+ raise ValueError(f"duplicate record in bulk commit: {ref.collection}:{ref.record_id}")
664
+ seen.add(key)
665
+ plan = self._commit_plan(
666
+ ref,
667
+ doc,
668
+ message=message,
669
+ allow_secret=allow_secret,
670
+ bulk_index=index,
671
+ bulk_count=total,
672
+ )
673
+ if plan["state"] in {"missing", "changed_outside_cfgit"}:
674
+ blocked.append(_plan_result(plan))
675
+ else:
676
+ plans.append(plan)
677
+
678
+ if blocked:
679
+ return {"state": "blocked", "results": [], "failed": blocked}
680
+
681
+ results: list[dict[str, Any]] = []
682
+ for offset, plan in enumerate(plans):
683
+ if plan["state"] == "noop":
684
+ results.append(_plan_result(plan))
685
+ continue
686
+ try:
687
+ result = self.adapter.apply(
688
+ collection=plan["ref"].collection,
689
+ record_id=plan["ref"].record_id,
690
+ new_doc=plan["doc"],
691
+ entry=plan["entry"],
692
+ expected_head_oid=plan["expected_head"],
693
+ expected_live_oid=plan["expected_live"],
694
+ make_head=True,
695
+ )
696
+ except Exception as exc:
697
+ failed = [
698
+ {
699
+ "collection": plan["ref"].collection,
700
+ "record_id": plan["ref"].record_id,
701
+ "state": "failed",
702
+ "error": str(exc),
703
+ "error_type": exc.__class__.__name__,
704
+ }
705
+ ]
706
+ pending = [_plan_result(p) for p in plans[offset + 1:]]
707
+ return {
708
+ "state": "partial",
709
+ "results": results,
710
+ "failed": failed,
711
+ "pending": pending,
712
+ }
713
+ results.append(
714
+ {
715
+ "collection": result.collection,
716
+ "record_id": result.record_id,
717
+ "state": "committed",
718
+ "seq": result.seq,
719
+ "oid": result.oid,
720
+ }
721
+ )
722
+
723
+ state = "noop" if all(item["state"] == "noop" for item in results) else "committed"
724
+ return {"state": state, "results": results}
725
+
726
+ def _commit_plan(
727
+ self,
728
+ ref: RecordRef,
729
+ doc: dict[str, Any],
730
+ *,
731
+ message: str,
732
+ allow_secret: bool,
733
+ bulk_index: int | None = None,
734
+ bulk_count: int | None = None,
735
+ ) -> dict[str, Any]:
736
+ coll = self.config.collection(ref.collection)
737
+ live = self.adapter.get_record(ref.collection, ref.record_id)
738
+ if live is None:
739
+ return {"ref": ref, "state": "missing"}
740
+ head = self.adapter.get_head(ref.collection, ref.record_id)
741
+ expected_head = head.get("oid") if head else None
742
+ expected_live = hash_doc(live, coll)
743
+ if head and expected_live != expected_head:
744
+ return {
745
+ "ref": ref,
746
+ "state": "changed_outside_cfgit",
747
+ "live_oid": expected_live,
748
+ "head_oid": expected_head,
749
+ }
750
+ new_oid = hash_doc(doc, coll)
751
+ if head and new_oid == expected_head:
752
+ return {"ref": ref, "state": "noop", "oid": new_oid, "seq": head.get("seq")}
753
+ meta = self._secret_meta(doc, coll, allow_secret=allow_secret, message=message)
754
+ if bulk_index is not None and bulk_count is not None:
755
+ meta = {**meta, "bulk_commit": {"index": bulk_index, "count": bulk_count}}
756
+ entry = self._entry(ref, doc, coll, message=message, op="commit", parent_oid=expected_head, meta=meta)
757
+ return {
758
+ "ref": ref,
759
+ "doc": doc,
760
+ "entry": entry,
761
+ "expected_head": expected_head,
762
+ "expected_live": expected_live,
763
+ "state": "ready",
764
+ }
765
+
766
+ def _branch_commit_plan(
767
+ self,
768
+ branch_ref: dict[str, Any],
769
+ ref: RecordRef,
770
+ doc: dict[str, Any],
771
+ *,
772
+ message: str,
773
+ allow_secret: bool,
774
+ bulk_index: int | None = None,
775
+ bulk_count: int | None = None,
776
+ ) -> dict[str, Any]:
777
+ coll = self.config.collection(ref.collection)
778
+ live = self.adapter.get_record(ref.collection, ref.record_id)
779
+ if live is None:
780
+ return {"branch_ref": branch_ref, "ref": ref, "state": "missing"}
781
+ head = self.adapter.get_head(ref.collection, ref.record_id)
782
+ expected_head = head.get("oid") if head else None
783
+ expected_seq = head.get("seq") if head else None
784
+ expected_live = hash_doc(live, coll)
785
+ if head and expected_live != expected_head:
786
+ return {
787
+ "branch_ref": branch_ref,
788
+ "ref": ref,
789
+ "state": "changed_outside_cfgit",
790
+ "live_oid": expected_live,
791
+ "head_oid": expected_head,
792
+ }
793
+ latest = self._branch_latest_for_record(branch_ref["name"], ref)
794
+ parent_oid = latest.get("oid") if latest else expected_head
795
+ new_oid = hash_doc(doc, coll)
796
+ if new_oid == parent_oid:
797
+ return {
798
+ "branch_ref": branch_ref,
799
+ "ref": ref,
800
+ "state": "noop",
801
+ "oid": new_oid,
802
+ "commit_id": latest.get("id") if latest else None,
803
+ }
804
+ meta = {
805
+ "identity": self.identity.history_meta(),
806
+ "base_head_oid": expected_head,
807
+ "base_head_seq": expected_seq,
808
+ "base_live_oid": expected_live,
809
+ "runtime_mutated": False,
810
+ **self._secret_meta(doc, coll, allow_secret=allow_secret, message=message),
811
+ }
812
+ if bulk_index is not None and bulk_count is not None:
813
+ meta["bulk_commit"] = {"index": bulk_index, "count": bulk_count}
814
+ now = self.adapter.now()
815
+ commit_id = f"bc_{uuid.uuid4().hex[:16]}"
816
+ return {
817
+ "branch_ref": branch_ref,
818
+ "ref": ref,
819
+ "state": "ready",
820
+ "commit": {
821
+ "type": "branch_commit",
822
+ "id": commit_id,
823
+ "branch": branch_ref["name"],
824
+ "collection": ref.collection,
825
+ "record_id": ref.record_id,
826
+ "oid": new_oid,
827
+ "parent_oid": parent_oid,
828
+ "parent_commit_id": latest.get("id") if latest else None,
829
+ "doc": stored_doc(doc, coll),
830
+ "message": _require_message(message),
831
+ "author": self.author,
832
+ "created_at": now,
833
+ "updated_at": now,
834
+ "meta": meta,
835
+ "runtime_mutated": False,
836
+ },
837
+ }
838
+
839
+ def restore(self, ref: RecordRef, target_ref: str, *, message: str) -> dict[str, Any]:
840
+ self._authorize("restore")
841
+ self._ensure_atomic("restore")
842
+ target = self.resolve_ref(ref, target_ref)
843
+ return self._restore_one(ref, target, message=message, restored_from=target_ref)
844
+
845
+ def restore_system_as_of(
846
+ self,
847
+ when: datetime,
848
+ *,
849
+ message: str,
850
+ dry_run: bool = False,
851
+ ) -> dict[str, Any]:
852
+ self._authorize("restore_system")
853
+ if not dry_run:
854
+ self._ensure_atomic("restore_system")
855
+ targets: list[tuple[RecordRef, dict[str, Any]]] = []
856
+ missing: list[dict[str, Any]] = []
857
+ for ref in self._all_refs(include_history=True):
858
+ rows = self.adapter.query_history(
859
+ collection=ref.collection,
860
+ record_id=ref.record_id,
861
+ as_of_valid=when,
862
+ limit=1,
863
+ order="desc",
864
+ with_doc=True,
865
+ )
866
+ if rows:
867
+ targets.append((ref, rows[0]))
868
+ else:
869
+ missing.append({"collection": ref.collection, "record_id": ref.record_id, "state": "did_not_exist"})
870
+ return self._restore_many(
871
+ targets,
872
+ message=message,
873
+ dry_run=dry_run,
874
+ source=f"as-of {when.isoformat()}",
875
+ extra=missing,
876
+ )
877
+
878
+ def restore_system_tag(
879
+ self,
880
+ tag: str,
881
+ *,
882
+ message: str,
883
+ dry_run: bool = False,
884
+ ) -> dict[str, Any]:
885
+ self._authorize("restore_system")
886
+ if not dry_run:
887
+ self._ensure_atomic("restore_system")
888
+ rows = self.adapter.query_history(tag=tag, limit=None, order="desc", with_doc=True)
889
+ latest: dict[tuple[str, str], dict[str, Any]] = {}
890
+ for row in rows:
891
+ key = (row["collection"], row["record_id"])
892
+ if key not in latest or int(row["seq"]) > int(latest[key]["seq"]):
893
+ latest[key] = row
894
+ if not latest:
895
+ raise NoSuchConfig(f"tag not found: {tag}")
896
+ targets = [
897
+ (RecordRef(collection, record_id), row)
898
+ for (collection, record_id), row in sorted(latest.items())
899
+ ]
900
+ tagged_keys = set(latest)
901
+ known_keys = {(ref.collection, ref.record_id) for ref in self._all_refs(include_history=True)}
902
+ uncovered = [
903
+ {"collection": collection, "record_id": record_id, "state": "not_in_tag", "tag": tag}
904
+ for collection, record_id in sorted(known_keys - tagged_keys)
905
+ ]
906
+ return self._restore_many(
907
+ targets,
908
+ message=message,
909
+ dry_run=dry_run,
910
+ source=f"tag:{tag}",
911
+ extra=uncovered,
912
+ )
913
+
914
+ def _restore_one(
915
+ self,
916
+ ref: RecordRef,
917
+ target: dict[str, Any],
918
+ *,
919
+ message: str,
920
+ restored_from: str,
921
+ seed_missing: bool = False,
922
+ ) -> dict[str, Any]:
923
+ coll = self.config.collection(ref.collection)
924
+ live = self.adapter.get_record(ref.collection, ref.record_id)
925
+ if live is None and not seed_missing:
926
+ raise NoSuchConfig(f"{ref.collection}:{ref.record_id}")
927
+ head = self.adapter.get_head(ref.collection, ref.record_id)
928
+ expected_head = head.get("oid") if head else None
929
+ expected_live = hash_doc(live, coll) if live is not None else None
930
+ if live is not None and head and expected_live != expected_head:
931
+ return {"state": "changed_outside_cfgit", "live_oid": expected_live, "head_oid": expected_head}
932
+ doc = target["doc"]
933
+ entry = self._entry(
934
+ ref,
935
+ doc,
936
+ coll,
937
+ message=message,
938
+ op="restore",
939
+ parent_oid=expected_head,
940
+ meta={"restored_from": restored_from},
941
+ )
942
+ result = self.adapter.apply(
943
+ collection=ref.collection,
944
+ record_id=ref.record_id,
945
+ new_doc=doc,
946
+ entry=entry,
947
+ expected_head_oid=expected_head,
948
+ expected_live_oid=expected_live,
949
+ make_head=True,
950
+ seed_missing=seed_missing,
951
+ )
952
+ return {
953
+ "state": "restored_deleted" if seed_missing else "restored",
954
+ "oid": result.oid,
955
+ "seq": result.seq,
956
+ }
957
+
958
+ def _restore_many(
959
+ self,
960
+ targets: list[tuple[RecordRef, dict[str, Any]]],
961
+ *,
962
+ message: str,
963
+ dry_run: bool,
964
+ source: str,
965
+ extra: list[dict[str, Any]],
966
+ ) -> dict[str, Any]:
967
+ blocked: list[dict[str, Any]] = []
968
+ plan: list[tuple[RecordRef, dict[str, Any], str, bool]] = []
969
+ results: list[dict[str, Any]] = list(extra)
970
+
971
+ for ref, target in targets:
972
+ coll = self.config.collection(ref.collection)
973
+ live = self.adapter.get_record(ref.collection, ref.record_id)
974
+ head = self.adapter.get_head(ref.collection, ref.record_id)
975
+ expected_head = head.get("oid") if head else None
976
+ expected_live = hash_doc(live, coll) if live is not None else None
977
+ seed_missing = live is None
978
+ if live is not None and head and expected_live != expected_head:
979
+ blocked.append(
980
+ {
981
+ "collection": ref.collection,
982
+ "record_id": ref.record_id,
983
+ "state": "changed_outside_cfgit",
984
+ "live_oid": expected_live,
985
+ "head_oid": expected_head,
986
+ }
987
+ )
988
+ continue
989
+ if live is not None and expected_head == target["oid"]:
990
+ results.append(
991
+ {
992
+ "collection": ref.collection,
993
+ "record_id": ref.record_id,
994
+ "state": "unchanged",
995
+ "seq": head.get("seq") if head else None,
996
+ "oid": expected_head,
997
+ }
998
+ )
999
+ continue
1000
+ plan.append((ref, target, f"{ref.collection}:{ref.record_id}@{target['seq']}", seed_missing))
1001
+
1002
+ if blocked:
1003
+ return {"state": "blocked", "source": source, "blocked": blocked, "results": results}
1004
+
1005
+ if dry_run:
1006
+ preview = [
1007
+ {
1008
+ "collection": ref.collection,
1009
+ "record_id": ref.record_id,
1010
+ "state": "would_seed" if seed_missing else "would_restore",
1011
+ "from": restored_from,
1012
+ "oid": target["oid"],
1013
+ "seq": target["seq"],
1014
+ }
1015
+ for ref, target, restored_from, seed_missing in plan
1016
+ ]
1017
+ return {"state": "dry_run", "source": source, "results": results + preview}
1018
+
1019
+ failed: list[dict[str, Any]] = []
1020
+ for ref, target, restored_from, seed_missing in plan:
1021
+ try:
1022
+ result = self._restore_one(
1023
+ ref,
1024
+ target,
1025
+ message=message,
1026
+ restored_from=restored_from,
1027
+ seed_missing=seed_missing,
1028
+ )
1029
+ results.append({"collection": ref.collection, "record_id": ref.record_id, **result})
1030
+ except Exception as exc:
1031
+ failed.append(
1032
+ {
1033
+ "collection": ref.collection,
1034
+ "record_id": ref.record_id,
1035
+ "state": "failed",
1036
+ "error": str(exc),
1037
+ "error_type": exc.__class__.__name__,
1038
+ "from": restored_from,
1039
+ }
1040
+ )
1041
+ if failed:
1042
+ return {
1043
+ "state": "partial",
1044
+ "source": source,
1045
+ "results": results,
1046
+ "failed": failed,
1047
+ "resume_token": {
1048
+ "source": source,
1049
+ "pending": failed,
1050
+ "hint": "rerun the same restore command; records already at target are skipped",
1051
+ },
1052
+ }
1053
+ return {"state": "restored", "source": source, "results": results}
1054
+
1055
+ def resolve_ref(self, ref: RecordRef, value: str) -> dict[str, Any]:
1056
+ if value in ("=HEAD", "HEAD"):
1057
+ head = self.adapter.get_head(ref.collection, ref.record_id)
1058
+ if not head:
1059
+ raise NoSuchConfig(f"no HEAD for {ref.collection}:{ref.record_id}")
1060
+ return head
1061
+ if value in ("=live", "live"):
1062
+ live = self.adapter.get_record(ref.collection, ref.record_id)
1063
+ if not live:
1064
+ raise NoSuchConfig(f"no live record for {ref.collection}:{ref.record_id}")
1065
+ coll = self.config.collection(ref.collection)
1066
+ return {"doc": live, "oid": hash_doc(live, coll), "seq": None}
1067
+ if value.startswith("@{") and value.endswith("}"):
1068
+ when = _parse_when(value[2:-1])
1069
+ rows = self.adapter.query_history(
1070
+ collection=ref.collection,
1071
+ record_id=ref.record_id,
1072
+ as_of_valid=when,
1073
+ limit=1,
1074
+ order="desc",
1075
+ with_doc=True,
1076
+ )
1077
+ elif value.startswith("@"):
1078
+ seq = int(value[1:])
1079
+ rows = self.adapter.query_history(collection=ref.collection, record_id=ref.record_id, ref=f"@{seq}", with_doc=True)
1080
+ elif value.startswith("tag:"):
1081
+ rows = self.adapter.query_history(collection=ref.collection, record_id=ref.record_id, tag=value[4:], with_doc=True)
1082
+ else:
1083
+ rows = self.adapter.query_history(collection=ref.collection, record_id=ref.record_id, ref=value, with_doc=True)
1084
+ if not rows:
1085
+ raise NoSuchConfig(f"ref not found: {value}")
1086
+ if len(rows) > 1:
1087
+ raise ValueError(f"ambiguous ref: {value}")
1088
+ return rows[0]
1089
+
1090
+ def diff(self, ref: RecordRef, a: str, b: str) -> list[dict[str, Any]]:
1091
+ coll = self.config.collection(ref.collection)
1092
+ left = strip_for_hash(self.resolve_ref(ref, a)["doc"], coll)
1093
+ right = strip_for_hash(self.resolve_ref(ref, b)["doc"], coll)
1094
+ return diff_values(left, right)
1095
+
1096
+ def log(self, ref: RecordRef, *, limit: int | None = None) -> list[dict[str, Any]]:
1097
+ return self.adapter.query_history(
1098
+ collection=ref.collection,
1099
+ record_id=ref.record_id,
1100
+ limit=limit,
1101
+ order="desc",
1102
+ with_doc=False,
1103
+ )
1104
+
1105
+ def tag(self, name: str) -> list[dict[str, Any]]:
1106
+ self._authorize("tag")
1107
+ tagged: list[dict[str, Any]] = []
1108
+ for ref in self._all_refs(include_history=True):
1109
+ head = self.adapter.get_head(ref.collection, ref.record_id)
1110
+ if not head:
1111
+ tagged.append(
1112
+ {
1113
+ "collection": ref.collection,
1114
+ "record_id": ref.record_id,
1115
+ "state": "skipped_no_head",
1116
+ }
1117
+ )
1118
+ continue
1119
+ self.adapter.add_tag(collection=ref.collection, record_id=ref.record_id, seq=head["seq"], tag=name)
1120
+ tagged.append(
1121
+ {
1122
+ "collection": ref.collection,
1123
+ "record_id": ref.record_id,
1124
+ "state": "tagged",
1125
+ "oid": head["oid"],
1126
+ }
1127
+ )
1128
+ return tagged
1129
+
1130
+ def _entry(
1131
+ self,
1132
+ ref: RecordRef,
1133
+ doc: dict[str, Any],
1134
+ coll: CollectionConfig,
1135
+ *,
1136
+ message: str,
1137
+ op: str,
1138
+ parent_oid: str | None,
1139
+ meta: dict[str, Any] | None = None,
1140
+ ) -> dict[str, Any]:
1141
+ message = _require_message(message)
1142
+ now = self.adapter.now()
1143
+ stored = stored_doc(doc, coll)
1144
+ oid = hash_doc(stored, coll)
1145
+ return {
1146
+ "collection": ref.collection,
1147
+ "record_id": ref.record_id,
1148
+ "env": self.env,
1149
+ "seq": None,
1150
+ "oid": oid,
1151
+ "parent_oid": parent_oid,
1152
+ "doc": stored,
1153
+ "message": message,
1154
+ "author": self.author,
1155
+ "recorded_at": now,
1156
+ "valid_from": now,
1157
+ "valid_to": None,
1158
+ "valid_from_estimated": False,
1159
+ "op": op,
1160
+ "git_shas": [],
1161
+ "tags": [],
1162
+ "meta": self._history_meta(meta),
1163
+ }
1164
+
1165
+ def _all_refs(self, *, include_history: bool) -> list[RecordRef]:
1166
+ refs: set[tuple[str, str]] = set()
1167
+ for coll in self.config.collections:
1168
+ for record_id in self.adapter.list_record_ids(coll.name):
1169
+ refs.add((coll.name, record_id))
1170
+ if include_history:
1171
+ for row in self.adapter.query_history(limit=None, order="asc", with_doc=False):
1172
+ refs.add((row["collection"], row["record_id"]))
1173
+ return [RecordRef(collection, record_id) for collection, record_id in refs]
1174
+
1175
+ def _main_doc(self, ref: RecordRef) -> dict[str, Any] | None:
1176
+ head = self.adapter.get_head(ref.collection, ref.record_id)
1177
+ if head:
1178
+ return head["doc"]
1179
+ return self.adapter.get_record(ref.collection, ref.record_id)
1180
+
1181
+ def _branch_latest_by_record(self, branch: str) -> dict[RecordRef, dict[str, Any]]:
1182
+ by_record: dict[RecordRef, list[dict[str, Any]]] = {}
1183
+ rows = self.adapter.list_refs("branch_commit", branch=branch)
1184
+ for row in rows:
1185
+ by_record.setdefault(RecordRef(row["collection"], row["record_id"]), []).append(row)
1186
+ latest: dict[RecordRef, dict[str, Any]] = {}
1187
+ for ref, commits in by_record.items():
1188
+ parent_ids = {row.get("parent_commit_id") for row in commits if row.get("parent_commit_id")}
1189
+ candidates = [row for row in commits if row["id"] not in parent_ids]
1190
+ if not candidates:
1191
+ candidates = commits
1192
+ latest[ref] = sorted(candidates, key=lambda row: (row["created_at"], row["id"]))[-1]
1193
+ return latest
1194
+
1195
+ def _branch_latest_for_record(self, branch: str, ref: RecordRef) -> dict[str, Any] | None:
1196
+ return self._branch_latest_by_record(branch).get(ref)
1197
+
1198
+ def _authorize(self, action: str) -> None:
1199
+ authorize_mutation(self.config.envs[self.env], identity=self.identity, action=action)
1200
+
1201
+ def _ensure_atomic(self, action: str) -> None:
1202
+ report = self.adapter.check_atomicity_scope()
1203
+ if report.atomic:
1204
+ return
1205
+ raise AtomicityUnavailable(
1206
+ f"{self.adapter.backend_name()} cannot safely run {action}: {report.reason}. "
1207
+ "Use a transactional deployment with runtime and cfgit history co-located."
1208
+ )
1209
+
1210
+ def _ensure_branching(self) -> None:
1211
+ if not self.config.branches.enabled:
1212
+ raise BranchingDisabled(
1213
+ "branching is not enabled. Add [branches] enabled = true and run cfg init."
1214
+ )
1215
+
1216
+ def _require_branch(self, name: str) -> dict[str, Any] | None:
1217
+ default = self.config.branches.default_branch
1218
+ if name == default:
1219
+ return None
1220
+ branch = self.adapter.get_ref("branch", name)
1221
+ if not branch:
1222
+ raise NoSuchConfig(f"branch not found: {name}")
1223
+ return branch
1224
+
1225
+ def _secret_meta(
1226
+ self,
1227
+ doc: dict[str, Any],
1228
+ coll: CollectionConfig,
1229
+ *,
1230
+ allow_secret: bool,
1231
+ message: str,
1232
+ ) -> dict[str, Any]:
1233
+ scan_doc = stored_doc(doc, coll)
1234
+ matches = _secret_matches(scan_doc, self.config.secrets.block_fields, self.config.secrets.block_values)
1235
+ if not matches:
1236
+ return {}
1237
+ if self.config.secrets.on_match == "refuse" and not allow_secret:
1238
+ # group by normalized path so list-index/duplicate hits collapse, and
1239
+ # give the exact secret_fields lines to paste — one fix pass, not N.
1240
+ seen: dict[str, str] = {}
1241
+ for item in matches:
1242
+ norm = re.sub(r"\[\d+\]", "[]", item["path"])
1243
+ seen.setdefault(norm, item["kind"])
1244
+ paths = sorted(seen)
1245
+ shown = ", ".join(f"{p} ({seen[p]})" for p in paths[:8])
1246
+ more = f" and {len(paths) - 8} more" if len(paths) > 8 else ""
1247
+ snippet = ", ".join(f'"{p}"' for p in paths)
1248
+ raise SecretBlocked(
1249
+ f"secret-like content refused in {len(paths)} field(s): {shown}{more}. "
1250
+ f"These are stripped from history if you add them to this collection's "
1251
+ f"secret_fields:\n secret_fields = [{snippet}]\n"
1252
+ f"Run `cfg doctor` to see every collection at once, or rerun with "
1253
+ f"--allow-secret if this is intentional."
1254
+ )
1255
+ return {
1256
+ "allow_secret": bool(allow_secret),
1257
+ "allow_secret_author": self.author if allow_secret else None,
1258
+ "allow_secret_identity": self.identity.history_meta() if allow_secret else None,
1259
+ "allow_secret_reason": _require_message(message) if allow_secret else None,
1260
+ "secret_matches": matches,
1261
+ "secret_policy": self.config.secrets.on_match,
1262
+ }
1263
+
1264
+ def _history_meta(self, meta: dict[str, Any] | None = None) -> dict[str, Any]:
1265
+ out = {"identity": self.identity.history_meta()}
1266
+ out.update(meta or {})
1267
+ return out
1268
+
1269
+
1270
+ def _require_message(message: str) -> str:
1271
+ text = str(message or "").strip()
1272
+ if not text:
1273
+ raise ValueError("message must be non-empty")
1274
+ return text
1275
+
1276
+
1277
+ _BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
1278
+
1279
+
1280
+ def _clean_branch_name(name: str) -> str:
1281
+ value = str(name or "").strip()
1282
+ if not value:
1283
+ raise ValueError("branch name must be non-empty")
1284
+ if value in {".", ".."} or value.endswith("/") or value.endswith("."):
1285
+ raise ValueError(f"invalid branch name: {value}")
1286
+ if ".." in value or "//" in value or "@{" in value:
1287
+ raise ValueError(f"invalid branch name: {value}")
1288
+ if not _BRANCH_NAME_RE.match(value):
1289
+ raise ValueError(f"invalid branch name: {value}")
1290
+ return value
1291
+
1292
+
1293
+ def _secret_matches(
1294
+ doc: dict[str, Any],
1295
+ field_patterns: tuple[str, ...],
1296
+ value_patterns: tuple[str, ...],
1297
+ ) -> list[dict[str, str]]:
1298
+ if not field_patterns and not value_patterns:
1299
+ return []
1300
+ matches: list[dict[str, str]] = []
1301
+ compiled = [(pattern, re.compile(pattern)) for pattern in value_patterns]
1302
+ for path, value in _walk_doc(doc):
1303
+ basename = path.rsplit(".", 1)[-1].split("[", 1)[0]
1304
+ for pattern in field_patterns:
1305
+ if fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase(basename, pattern):
1306
+ matches.append({"path": path, "kind": "field", "pattern": pattern})
1307
+ break
1308
+ if isinstance(value, str):
1309
+ for pattern, regex in compiled:
1310
+ if regex.search(value):
1311
+ matches.append({"path": path, "kind": "value", "pattern": pattern})
1312
+ break
1313
+ return matches
1314
+
1315
+
1316
+ def _secret_container(path: str, field_patterns: tuple[str, ...]) -> str:
1317
+ """Roll a dotted path up to its shallowest secret-bearing segment, so a stripping
1318
+ suggestion covers the whole subtree. For
1319
+ `cached_schema...properties.openai_api_key.type` with a `*api_key*` pattern, returns
1320
+ `cached_schema...properties.openai_api_key` (stripping that removes all its children).
1321
+ If no segment matches a field pattern (e.g. a value-only match), returns path as-is."""
1322
+ segments = path.split(".")
1323
+ for i, seg in enumerate(segments):
1324
+ base = seg.split("[", 1)[0]
1325
+ if any(fnmatch.fnmatchcase(base, pat) for pat in field_patterns):
1326
+ return ".".join(segments[: i + 1])
1327
+ return path
1328
+
1329
+
1330
+ def _plan_result(plan: dict[str, Any]) -> dict[str, Any]:
1331
+ ref = plan["ref"]
1332
+ result = {"collection": ref.collection, "record_id": ref.record_id, "state": plan["state"]}
1333
+ for key in ("oid", "seq", "live_oid", "head_oid"):
1334
+ if key in plan:
1335
+ result[key] = plan[key]
1336
+ return result
1337
+
1338
+
1339
+ def _branch_plan_result(plan: dict[str, Any], *, state: str | None = None) -> dict[str, Any]:
1340
+ ref = plan["ref"]
1341
+ result = {
1342
+ "collection": ref.collection,
1343
+ "record_id": ref.record_id,
1344
+ "state": state or plan["state"],
1345
+ "runtime_mutated": False,
1346
+ }
1347
+ if plan.get("commit"):
1348
+ result["commit_id"] = plan["commit"]["id"]
1349
+ result["oid"] = plan["commit"]["oid"]
1350
+ for key in ("oid", "commit_id", "live_oid", "head_oid"):
1351
+ if key in plan and key not in result:
1352
+ result[key] = plan[key]
1353
+ return result
1354
+
1355
+
1356
+ def _parse_branch_range(value: str, default_branch: str) -> tuple[str, str]:
1357
+ raw = str(value or "").strip()
1358
+ if ".." not in raw:
1359
+ raise ValueError("branch range must look like main..<branch>")
1360
+ base, head = raw.split("..", 1)
1361
+ return _clean_branch_name(base or default_branch), _clean_branch_name(head)
1362
+
1363
+
1364
+ def _walk_doc(value: Any, prefix: str = "") -> list[tuple[str, Any]]:
1365
+ if isinstance(value, dict):
1366
+ out: list[tuple[str, Any]] = []
1367
+ for key, child in value.items():
1368
+ path = f"{prefix}.{key}" if prefix else str(key)
1369
+ out.extend(_walk_doc(child, path))
1370
+ return out
1371
+ if isinstance(value, list):
1372
+ out = []
1373
+ for index, child in enumerate(value):
1374
+ out.extend(_walk_doc(child, f"{prefix}[{index}]"))
1375
+ return out
1376
+ return [(prefix, value)]
1377
+
1378
+
1379
+ def _parse_when(raw: str) -> datetime:
1380
+ value = raw.strip()
1381
+ date_only = len(value) == 10 and value[4] == "-" and value[7] == "-"
1382
+ if date_only:
1383
+ dt = datetime.combine(datetime.fromisoformat(value).date(), time.max)
1384
+ else:
1385
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
1386
+ if dt.tzinfo is None:
1387
+ dt = dt.replace(tzinfo=timezone.utc)
1388
+ return dt.astimezone(timezone.utc)