@miller-tech/uap 1.210.7 → 1.211.0

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.
@@ -1,20 +1,37 @@
1
1
  #!/usr/bin/env python3
2
2
  """schema-diff-gate enforcer: schema/pool changes must pass uap schema-diff."""
3
3
  from __future__ import annotations
4
+ import json
5
+ import os
4
6
  import re
7
+ import shutil
5
8
  import sqlite3
9
+ import subprocess
10
+ import tempfile
6
11
  import sys
7
12
  import time
8
13
  from pathlib import Path
9
14
 
10
15
  sys.path.insert(0, str(Path(__file__).parent))
11
- from _common import emit, parse_cli, repo_root, run, worktree_root # noqa: E402
16
+ from _common import ( # noqa: E402
17
+ _clean_env,
18
+ emit,
19
+ parse_cli,
20
+ repo_root,
21
+ run,
22
+ worktree_root,
23
+ )
12
24
 
25
+ # DOTALL because `.` otherwise stops at a newline, and a newline is a legal
26
+ # character in a path. `migrations/a\nb.sql` matched nothing, so the gate did
27
+ # not consider it watched and never examined the column drop inside it. Git
28
+ # hides this by C-quoting such names in its default output; the -z enumeration
29
+ # above hands us the real bytes, which is where the gap became visible.
13
30
  WATCHED_RE = re.compile(
14
31
  r"(migrations/.*\.sql|infra/postgres-spock/|infra/helm_charts/[^/]*pgdog|"
15
32
  r"infra/helm_charts/[^/]*cnpg|infra/helm_charts/[^/]*redis|"
16
33
  r"infra/helm_charts/[^/]*envoy|infra/helm_charts/[^/]*sentinel)",
17
- re.I,
34
+ re.I | re.S,
18
35
  )
19
36
  # NOTE: bare "Bash" used to be in this set, which made EVERY shell command a
20
37
  # gate point — including the `uap schema-diff` remedy itself (self-deadlock).
@@ -23,6 +40,19 @@ COMMIT_OPS = {"git-commit", "git commit"}
23
40
  RECENT_SEC = 3600
24
41
 
25
42
 
43
+ def merge_in_progress(root: Path) -> bool:
44
+ """Whether a merge is underway — invocation-constant, so resolved ONCE.
45
+
46
+ This used to be re-derived per watched file, spawning a `git rev-parse`
47
+ for each one on the hot path of every commit and push, purely to learn
48
+ "not a merge". A merge touching 30 migrations paid 30 spawns.
49
+ """
50
+ rc, git_dir_out, _ = run(["git", "rev-parse", "--absolute-git-dir"], cwd=root)
51
+ if rc != 0:
52
+ return False
53
+ return (Path(git_dir_out.strip()) / "MERGE_HEAD").exists()
54
+
55
+
26
56
  def merge_verbatim(root: Path, path: str) -> bool:
27
57
  """During a merge, a staged watched file that is byte-identical to the
28
58
  incoming MERGE_HEAD version was not authored on this branch — it was
@@ -30,68 +60,501 @@ def merge_verbatim(root: Path, path: str) -> bool:
30
60
  forced a schema-diff re-pass for content the merge cannot change (hit on
31
61
  the 2026-08-16 pay2u #3153 conflict-resolution merge, where migrations
32
62
  from already-merged main blocked the merge commit)."""
33
- git_dir = root / ".git"
34
- # Worktrees use a .git FILE pointing at the real gitdir.
35
- if git_dir.is_file():
36
- try:
37
- ref = git_dir.read_text().strip()
38
- if ref.startswith("gitdir:"):
39
- git_dir = Path(ref.split(":", 1)[1].strip())
40
- except OSError:
41
- return False
42
- if not (git_dir / "MERGE_HEAD").exists():
43
- return False
44
63
  rc, staged_sha, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
45
64
  rc2, theirs_sha, _ = run(["git", "rev-parse", f"MERGE_HEAD:{path}"], cwd=root)
46
- return rc == 0 and rc2 == 0 and staged_sha.strip() == theirs_sha.strip()
65
+ if rc != 0 or rc2 != 0 or staged_sha.strip() != theirs_sha.strip():
66
+ return False
67
+
68
+ # The INDEX matching theirs is not enough: `git commit -a` and
69
+ # `git commit -- <path>` commit the WORKING TREE. Exempting on the index
70
+ # alone let an unstaged edit ride along ungated -- reproduced: an appended
71
+ # `DROP TABLE users;` yielded {"allowed": true, "reason": "no watched
72
+ # schema/pool paths in diff"} while `commit -am` would have committed the
73
+ # drop.
74
+ #
75
+ # `git diff --quiet` rather than hashing the file ourselves: it exits 0
76
+ # exactly when the worktree copy matches the index, using git's own
77
+ # comparison. Hashing followed symlinks (so a verbatim merged symlink never
78
+ # matched), failed outright on a dangling link or a sparse/skip-worktree
79
+ # path, and re-ran clean filters on large files against a 5s timeout --
80
+ # every one of those a FALSE BLOCK on a legitimate merge.
81
+ rc3, _, _ = run(["git", "diff", "--quiet", "--", path], cwd=root)
82
+ return rc3 == 0
47
83
 
48
84
 
49
85
  def touched_watched_paths(root: Path) -> list[str]:
50
- rc, out, _ = run(["git", "diff", "--name-only", "HEAD"], cwd=root)
86
+ """Watched paths in the pending change, as their real bytes.
87
+
88
+ `-z` rather than plain --name-only: git C-quotes any path containing a
89
+ newline, tab, quote, backslash or non-ASCII byte, emitting the literal
90
+ string `"migrations/a\\nb.sql"` -- quotes and escapes included. That string
91
+ was forwarded to the checker as a filename, no such file could be opened,
92
+ the checker reported it clean, and the gate allowed a column drop.
93
+ Reproduced with newline, tab, quote, backslash and unicode names. `-z`
94
+ emits NUL-terminated raw paths and never quotes.
95
+ """
96
+ rc, out, _ = run(["git", "diff", "--name-only", "-z", "HEAD"], cwd=root)
51
97
  if rc != 0:
52
98
  return []
53
- rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
54
- all_files = (out + "\n" + (staged if rc2 == 0 else "")).splitlines()
55
- watched = [f for f in all_files if f and WATCHED_RE.search(f)]
99
+ rc2, staged, _ = run(["git", "diff", "--name-only", "-z", "--cached"], cwd=root)
100
+ all_files = (out + "\0" + (staged if rc2 == 0 else "")).split("\0")
101
+ # dict.fromkeys dedupes while preserving order: a file that is both
102
+ # unstaged and staged appeared twice, and the gate's reason line listed it
103
+ # twice ("covers: x, x"), which reads like two files were covered.
104
+ watched = list(dict.fromkeys(f for f in all_files if f and WATCHED_RE.search(f)))
105
+ # Nothing watched is the overwhelmingly common case; probing git there
106
+ # would make the hoist a net cost rather than a saving.
107
+ if not watched or not merge_in_progress(root):
108
+ return watched
56
109
  return [f for f in watched if not merge_verbatim(root, f)]
57
110
 
58
111
 
59
- def schema_diff_ok(root: Path) -> bool:
112
+ def _parse_marker_ts(raw) -> float | None:
113
+ """Epoch seconds for a marker timestamp, or None when it cannot be read.
114
+
115
+ Stored as UTC ISO-8601 with a trailing Z; parsed as UTC because
116
+ time.mktime would read them as local time and expire the marker hours
117
+ early or late depending on the host timezone.
118
+ """
119
+ if not isinstance(raw, str):
120
+ return None
121
+ try:
122
+ import calendar
123
+ return calendar.timegm(time.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S"))
124
+ except Exception: # noqa: BLE001
125
+ return None
126
+
127
+
128
+ _HEX_RE = re.compile(r"^[0-9a-f]{40}$|^[0-9a-f]{64}$")
129
+
130
+
131
+ def marker_files(content: str) -> dict | None:
132
+ """{path: sha7} from a content-scoped marker, or None if it is not one.
133
+
134
+ None means "cannot judge coverage": a legacy marker (written before this
135
+ change, or by an older installed CLI), or one whose file list was truncated
136
+ because the change was enormous. Callers must fall back to the time window
137
+ rather than block — an operator running the documented remedy has no way to
138
+ produce a covering marker for either case, and this gate has already
139
+ self-deadlocked three times.
140
+ """
141
+ _, sep, tail = content.partition("| files: ")
142
+ if not sep or tail.startswith("("): # "(none changed)" / "(truncated: N files)"
143
+ return None
144
+ entries = [e.strip() for e in tail.split(",") if e.strip()]
145
+ scoped = {}
146
+ identified = 0
147
+ for e in entries:
148
+ path, at, sha = e.rpartition("@")
149
+ if at and _HEX_RE.match(sha):
150
+ scoped[path] = sha
151
+ identified += 1
152
+ else:
153
+ # A bare or unparsable entry means THIS PATH has no verified
154
+ # identity -- not that the whole marker is legacy. Returning None
155
+ # here let one `git rm` (its path cannot be hashed, so it renders
156
+ # bare) disable content scoping for the entire commit, and let a
157
+ # filename containing the delimiters inject a forged entry that
158
+ # overwrote a real one. Record the path with no sha so
159
+ # uncovered_paths treats it as uncovered.
160
+ scoped.setdefault(e.rpartition("@")[0] or e, "")
161
+ # Only a marker with NO identified entry at all is a legacy marker.
162
+ return scoped if identified else None
163
+
164
+
165
+ def uncovered_paths(root: Path, watched: list, scoped: dict, use_worktree: bool = False) -> list:
166
+ """Watched paths whose CURRENT bytes the marker does not vouch for.
167
+
168
+ Only paths we can positively hash are judged. One that cannot be hashed
169
+ (deleted, unreadable, sparse) is left out: the CLI could not have recorded
170
+ it either, so blocking on it would be unclearable.
171
+
172
+ One `hash-object` per watched path. The watched set is what the commit
173
+ actually touches -- a handful of files -- and _common.run() has no stdin
174
+ channel to batch through.
175
+ """
176
+ bad = []
177
+ for path in watched:
178
+ # THE version this commit will store -- not every version that exists.
179
+ # `git commit` stores the INDEX; `git commit -a` stores the WORKTREE.
180
+ # Requiring one recorded sha to equal BOTH made ordinary partial
181
+ # staging (`git add -p`, or staging then continuing to edit) an
182
+ # unclearable block: one value cannot equal two, and re-running the
183
+ # remedy reproduced the same marker. Verified before this fix.
184
+ actual = ""
185
+ if not use_worktree:
186
+ rc, staged, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
187
+ if rc == 0:
188
+ actual = staged.strip()
189
+ if not actual and (root / path).is_file():
190
+ rc2, wt, _ = run(["git", "hash-object", "--", path], cwd=root)
191
+ if rc2 == 0:
192
+ actual = wt.strip()
193
+ if not actual:
194
+ continue # nothing hashable: unknown, never a block
195
+ if scoped.get(path) != actual:
196
+ bad.append(path)
197
+ return bad
198
+
199
+
200
+ def schema_diff_ok(root: Path) -> list | None:
201
+ """Contents of every in-window pass marker, or None if there are none.
202
+
203
+ A LIST, because concurrent worktrees share one database and any one of
204
+ their markers may be the one that covers the bytes in front of us. The
205
+ freshness window is applied here, per candidate.
206
+
207
+ Every miss must return None, not False: main() consumes this value, and a
208
+ stray bool crashed the enforcer -- which the policy hook turns into ALLOW,
209
+ so a wrong type here opens the gate rather than closing it.
210
+ """
60
211
  db = root / "agents" / "data" / "memory" / "short_term.db"
61
212
  if not db.exists():
62
- return False
213
+ return None
63
214
  try:
64
215
  con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
65
216
  # `uap memory store` writes to `memories` (type 'action'), while older
66
217
  # UAP wrote session rows to `session_memories` — accept the marker from
67
218
  # either table so the documented remedy actually clears the gate.
68
- row = None
219
+ newest = None
220
+ candidates: list = []
69
221
  for table in ("memories", "session_memories"):
70
222
  try:
223
+ # ANCHORED to the recorder's fixed prefix. The old
224
+ # '%schema-diff%pass%' matched those substrings anywhere in any
225
+ # memory -- including this gate's OWN refusal text ("...require
226
+ # `uap schema-diff` to pass"), so an agent storing the blocker
227
+ # as a lesson unblocked itself, and a note saying the diff
228
+ # FAILED cleared it just as well. Verified both.
229
+ # ALL recent markers, not just the newest. Every worktree
230
+ # writes to the same main-checkout database, so with LIMIT 1 two
231
+ # concurrent worktrees invalidated each other's markers forever:
232
+ # A records, B records, A is blocked by B's marker, A re-records,
233
+ # B is blocked. A livelock this repo's whole .worktrees workflow
234
+ # would hit, and a regression versus the old time-only rule.
71
235
  cur = con.execute(
72
- f"SELECT timestamp FROM {table} "
73
- "WHERE content LIKE '%schema-diff%pass%' "
74
- "ORDER BY id DESC LIMIT 1"
236
+ f"SELECT timestamp, content FROM {table} "
237
+ "WHERE content LIKE 'schema-diff pass: base %' "
238
+ "ORDER BY id DESC LIMIT 20"
75
239
  )
76
- r = cur.fetchone()
77
- if r and (row is None or r[0] > row[0]):
78
- row = r
240
+ _rows = cur.fetchall()
241
+ # Compare PARSED epochs, not raw strings. The two tables need
242
+ # not share a timestamp format, and a lexicographic winner that
243
+ # then fails to parse returned False without ever considering
244
+ # the runner-up: one legacy row sorting above ISO-8601 (say
245
+ # "2026/08/17", '/' > '-') would out-rank every correct marker
246
+ # forever, and re-running the remedy could not help because it
247
+ # writes to the other table. Gate shut permanently, no waiver.
248
+ # A malformed row is ignored, never authoritative.
249
+ for r in _rows:
250
+ ts_val = _parse_marker_ts(r[0])
251
+ if ts_val is None:
252
+ continue
253
+ age = time.time() - ts_val
254
+ if not (-5 <= age < RECENT_SEC):
255
+ continue
256
+ candidates.append(r[1] if len(r) > 1 else "")
257
+ if newest is None or ts_val > newest:
258
+ newest = ts_val
79
259
  except sqlite3.Error:
80
260
  continue
81
261
  con.close()
82
- if not row:
83
- return False
84
- try:
85
- # Timestamps are stored as UTC ISO-8601 (trailing 'Z'); parse them
86
- # as UTC — time.mktime would misread them as local time and expire
87
- # the marker hours early (or late) depending on the host TZ.
88
- import calendar
89
- ts = calendar.timegm(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
90
- except Exception: # noqa: BLE001
91
- return False
92
- return (time.time() - ts) < RECENT_SEC
262
+ if not candidates:
263
+ return None
264
+ return candidates
93
265
  except sqlite3.Error:
266
+ return None
267
+
268
+
269
+ INLINE_GUARD = "UAP_SCHEMA_DIFF_INLINE"
270
+ # Comfortably inside policy-tools' 30s enforcer budget. At 60s a slow checker
271
+ # meant the ENFORCER was killed first, and a killed enforcer is an ALLOW -- so
272
+ # the timeout meant to produce a safe fallback produced a bypass instead. 400
273
+ # watched files measured at 6.3s, so this leaves ample headroom.
274
+ INLINE_TIMEOUT = 15.0
275
+ # Verdict shapes this gate knows how to read (SCHEMA_DIFF_CONTRACT in the CLI).
276
+ KNOWN_CONTRACTS = (1,)
277
+ # Passed to the checker. Everything else -- API keys, tokens, proxy secrets --
278
+ # is withheld: the child is a subprocess chosen partly by filesystem contents,
279
+ # and it has no business holding the agent's credentials.
280
+ CHILD_ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "NODE_OPTIONS", "NODE_PATH")
281
+
282
+
283
+ def divergent_paths(root: Path, watched: list) -> list:
284
+ """Watched paths whose worktree copy differs from the index.
285
+
286
+ `git diff --quiet -- <path>` exits 0 exactly when the two match, using
287
+ git's own comparison rather than a hash (which followed symlinks, failed on
288
+ dangling links and sparse paths, and re-ran clean filters).
289
+ """
290
+ out = []
291
+ for path in watched:
292
+ rc, _, _ = run(["git", "diff", "--quiet", "--", path], cwd=root)
293
+ if rc != 0:
294
+ out.append(path)
295
+ return out
296
+
297
+
298
+ def sources_to_check(root: Path, watched: list) -> list:
299
+ """Which byte-sources this commit could possibly store.
300
+
301
+ Previously this was inferred from the command string, and the inference was
302
+ wrong in both directions -- each direction a demonstrated bypass:
303
+
304
+ * MISSED worktree forms. `git commit -- <path>`, `--only`, `--include`,
305
+ `-o` and `-i` all store the WORKTREE copy of the named paths. None
306
+ contains `-a` or `--all`, so the gate checked the index while git
307
+ committed the worktree. Confirmed by committing: HEAD afterwards held
308
+ the dropped column the gate had just cleared.
309
+ * FALSE worktree matches. The command is lowercased whole, so the `-A` in
310
+ `git add -A && git commit -m x` matched the short-flag pattern and the
311
+ gate read the WORKTREE for a commit that stores the INDEX. Stage the
312
+ break, restore the file on disk, commit: allowed.
313
+
314
+ Enumerating git's commit forms correctly is the trap, not the fix -- the
315
+ space is larger than it looks and the file had already litigated it twice.
316
+ There are only ever two candidate byte-strings per path. When they are
317
+ identical the question is moot; when they differ, check BOTH and refuse if
318
+ either is breaking. That removes the inference, and with it the whole class.
319
+
320
+ The cost is a second checker run only when a watched schema file is staged
321
+ and then edited again -- and in that state a breaking worktree copy is worth
322
+ surfacing regardless of which form the operator eventually types.
323
+ """
324
+ return ["index", "worktree"] if divergent_paths(root, watched) else ["index"]
325
+
326
+
327
+ def is_uap_checkout(root: Path) -> bool:
328
+ """Is `root` the UAP repo itself, rather than a project that uses UAP?"""
329
+ try:
330
+ pkg = json.loads((root / "package.json").read_text())
331
+ except Exception: # noqa: BLE001 - missing, unreadable, not JSON
332
+ return False
333
+ return isinstance(pkg, dict) and pkg.get("name") == "@miller-tech/uap"
334
+
335
+
336
+ def cli_argv(root: Path) -> list | None:
337
+ """How to invoke the checker.
338
+
339
+ The INSTALLED cli first, deliberately. Preferring `<root>/dist/bin/cli.js`
340
+ handed the verdict to a file inside the tree being gated: `dist/` is
341
+ gitignored build output, is on self-protect's "reconstructible, do not
342
+ guard" list, and is a conventional path in any Node project. Three lines
343
+ written there and the gate reports whatever they print -- an easier forgery
344
+ than the marker row this design replaced, and in a consumer repo it means
345
+ committing a migration executes that project's unrelated build output.
346
+
347
+ The local build is still preferred when `root` is the UAP checkout itself,
348
+ because there developing the checker and running the gate are the same act
349
+ and an installed release would be the stale copy. That is a deliberate,
350
+ narrow exception: in the UAP repo, `dist/` is already what `npm i -g .`
351
+ installs.
352
+ """
353
+ uap = shutil.which("uap")
354
+ node = shutil.which("node")
355
+ local = root / "dist" / "bin" / "cli.js"
356
+ if is_uap_checkout(root) and local.is_file() and node:
357
+ return [node, str(local)]
358
+ if uap:
359
+ return [uap]
360
+ return None
361
+
362
+
363
+ def blob_sha(root: Path, path: str, source: str) -> str:
364
+ """The blob the given source holds for `path`, or "" if there is none."""
365
+ if source == "index":
366
+ rc, out, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
367
+ else:
368
+ rc, out, _ = run(["git", "hash-object", "--", path], cwd=root)
369
+ out = (out or "").strip()
370
+ return out if rc == 0 and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", out) else ""
371
+
372
+
373
+ def inline_schema_diff(root: Path, watched: list, source: str) -> list | None:
374
+ """Run the checker over `watched` and return the breaking findings.
375
+
376
+ Returns a list (empty means "checked, nothing breaking"), or None meaning
377
+ "no answer" -- and the caller must then fall back rather than read silence
378
+ as a pass.
379
+
380
+ None covers every kind of not-answered: no CLI, a crash, a timeout,
381
+ unparseable output, an unknown contract, ran=false, a missing entry, a
382
+ file the checker could not read, or one no analyser understood. That last
383
+ pair is what the first version got wrong. It asked only whether the paths
384
+ it sent came back -- a comparison between two copies of its own argument,
385
+ which could fail on a whitespace artefact and nothing else. Meanwhile a
386
+ helm chart (no analyser exists), a file git C-quoted (could not be opened)
387
+ and a brand-new migration all returned an empty change list, and the gate
388
+ published that as "no breaking changes".
389
+ """
390
+ argv = cli_argv(root)
391
+ if argv is None:
392
+ return None
393
+
394
+ env = {k: v for k, v in _clean_env().items() if k in CHILD_ENV_KEEP}
395
+ # Tells a nested gate to skip its own inline run. NOT an allow: forging
396
+ # this must cost an attacker nothing more than the fallback they could
397
+ # have had anyway.
398
+ env[INLINE_GUARD] = "1"
399
+
400
+ tmp = None
401
+ try:
402
+ # NUL-separated via a file, so no filename can corrupt the list --
403
+ # a comma split one path into two, and git's C-quoting of newlines and
404
+ # unicode produced paths that do not exist.
405
+ fd, tmp = tempfile.mkstemp(prefix="uap-schema-paths-")
406
+ with os.fdopen(fd, "w") as fh:
407
+ fh.write("\0".join(watched))
408
+ proc = subprocess.run(
409
+ argv
410
+ + [
411
+ "schema-diff",
412
+ "--json",
413
+ "--base",
414
+ "HEAD",
415
+ "--paths-from",
416
+ tmp,
417
+ "--source",
418
+ source,
419
+ ],
420
+ cwd=str(root),
421
+ env=env,
422
+ capture_output=True,
423
+ text=True,
424
+ timeout=INLINE_TIMEOUT,
425
+ )
426
+ except Exception: # noqa: BLE001 - timeout, ENOENT, anything
427
+ return None
428
+ finally:
429
+ if tmp:
430
+ try:
431
+ os.unlink(tmp)
432
+ except OSError:
433
+ pass
434
+
435
+ lines = [ln for ln in (proc.stdout or "").splitlines() if ln.strip()]
436
+ if not lines:
437
+ return None
438
+ try:
439
+ verdict = json.loads(lines[-1])
440
+ except Exception: # noqa: BLE001
441
+ return None
442
+ if not isinstance(verdict, dict):
443
+ return None
444
+ if verdict.get("contract") not in KNOWN_CONTRACTS:
445
+ return None
446
+ if verdict.get("ran") is not True:
447
+ return None
448
+
449
+ entries = verdict.get("files")
450
+ if not isinstance(entries, list):
451
+ return None
452
+ by_path = {}
453
+ for entry in entries:
454
+ if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
455
+ return None
456
+ by_path[entry["path"]] = entry
457
+
458
+ findings = []
459
+ for path in watched:
460
+ entry = by_path.get(path)
461
+ if entry is None:
462
+ return None
463
+ # It must have read the same bytes this commit will store. The sha is
464
+ # re-derived here rather than taken on trust.
465
+ expected = blob_sha(root, path, source)
466
+ if entry.get("sha") != expected:
467
+ return None
468
+ # "Nothing breaking" is only meaningful from an analyser that
469
+ # understood the file.
470
+ if entry.get("analysed") is not True:
471
+ return None
472
+ if not expected:
473
+ # This source holds no blob for the path -- the commit REMOVES it.
474
+ # An empty sha is therefore not "could not read"; it is the
475
+ # deletion itself, and the checker must have reported it as
476
+ # breaking. If it did not, it was not looking at a deletion and we
477
+ # have no idea what it was looking at.
478
+ if not entry.get("breaking"):
479
+ return None
480
+ breaking = entry.get("breaking")
481
+ if not isinstance(breaking, list):
482
+ return None
483
+ if breaking:
484
+ findings.append(f"{path}: " + "; ".join(str(c) for c in breaking[:3]))
485
+ return findings
486
+
487
+
488
+ def breaking_waived(root: Path, paths: list) -> bool:
489
+ """A committed waiver that NAMES the paths whose break is intended.
490
+
491
+ Three properties, each of which the first version lacked and each of which
492
+ was demonstrated to matter:
493
+
494
+ committed -- `git cat-file -e HEAD:<path>`. The previous check was
495
+ `Path.is_file()` on the working tree, and policies/waivers/ is on
496
+ self-protect's PROTECTED_EXEMPT list precisely so agents CAN write
497
+ there (the carve-out was justified when a waiver only satisfied
498
+ expert-review). `touch policies/waivers/x-schema-diff.md` -- an empty
499
+ file, never committed -- cleared every breaking change. Verified.
500
+ scoped -- the waiver must name each path it excuses, so one file cannot
501
+ silently disarm the gate repo-wide and permanently.
502
+ resolved against the WORKTREE -- the refusal tells the operator to write
503
+ this file, this project mandates that all edits happen in a worktree,
504
+ and the previous version read the main checkout, where the file they
505
+ just wrote does not exist. That is the repo_root()/worktree_root()
506
+ substitution already fixed twice, in expert_review_required and
507
+ local_build_before_push.
508
+ """
509
+ waivers = root / "policies" / "waivers"
510
+ if not waivers.is_dir() or not paths:
511
+ return False
512
+ for waiver in sorted(waivers.glob("*schema-diff*.md")):
513
+ rel = waiver.relative_to(root).as_posix()
514
+ rc, _, _ = run(["git", "cat-file", "-e", f"HEAD:{rel}"], cwd=root)
515
+ if rc != 0:
516
+ continue # uncommitted: not reviewable, not a waiver
517
+ try:
518
+ body = waiver.read_text(errors="replace")
519
+ except OSError:
520
+ continue
521
+ if all(p in body for p in paths):
522
+ return True
523
+ return False
524
+
525
+
526
+ def legacy_marker_covers(marker: str, named: str, watched: list) -> bool:
527
+ """Does a marker with no per-file SHAs excuse these paths?
528
+
529
+ Three shapes, three answers -- the previous version treated the first two
530
+ as one and got both wrong in turn:
531
+
532
+ `(none changed)` The run examined ZERO files. It is evidence of the
533
+ opposite of coverage, and accepting it made
534
+ `git stash && uap schema-diff && git stash pop` into an hour-long
535
+ skeleton key needing no database tampering at all.
536
+ `(truncated: N files)` The run examined N files but the list was too
537
+ long to record. Coverage is unknowable, not absent; refusing it would
538
+ block a large legitimate change with no way to make the list shorter.
539
+ no `| files: ` section The previous CLI release's format. Refusing it
540
+ deadlocks every operator still on that release: the remedy the gate
541
+ prints runs their CLI, which writes exactly this format again. Where
542
+ that format states a count, honour it -- "0 schema file(s) checked" is
543
+ `(none changed)` wearing an older coat.
544
+ """
545
+ if named.startswith("(none"):
94
546
  return False
547
+ if named.startswith("("):
548
+ return True # (truncated: N files)
549
+ if named:
550
+ listed = {e.strip() for e in named.split(",") if e.strip()}
551
+ # Exact membership, not substring: `migrations/1.sql` was "covered" by
552
+ # a marker naming `migrations/1.sql.bak`.
553
+ return set(watched) <= listed
554
+ count = re.search(r"(\d+)\s+schema file\(s\) checked", marker)
555
+ if count:
556
+ return int(count.group(1)) > 0
557
+ return True # genuinely unknown legacy shape: PRESERVE over deadlock
95
558
 
96
559
 
97
560
  def main() -> None:
@@ -106,8 +569,117 @@ def main() -> None:
106
569
  if not watched:
107
570
  emit(True, "no watched schema/pool paths in diff")
108
571
 
109
- if schema_diff_ok(repo_root()):
110
- emit(True, f"recent schema-diff pass covers: {', '.join(watched[:5])}")
572
+ # Run the checker rather than look for evidence that someone ran it.
573
+ #
574
+ # The stored-marker design could never close the gap between what a past
575
+ # run examined and what this commit contains: three review rounds went into
576
+ # binding a marker to content, commit form, and freshness, and each fix
577
+ # exposed the next seam. Running the check here removes the gap instead of
578
+ # narrowing it -- there is no interval in which the recorded bytes and the
579
+ # committed bytes can diverge, because they are the same read.
580
+ # Inside the gate's own checker run, skip the inline layer -- otherwise a
581
+ # git hook that re-entered the policy layer would call the gate, which
582
+ # would call the checker again. Deliberately NOT an allow: this env var
583
+ # travels with the shell, and the first version emitted allowed:true here
584
+ # before looking at anything, which made `UAP_SCHEMA_DIFF_INLINE=1` a
585
+ # complete off-switch for a security control. Skipping to the fallback
586
+ # costs a forger exactly what they already had.
587
+ inline_ok = os.environ.get(INLINE_GUARD) != "1"
588
+
589
+ findings = []
590
+ answered = False
591
+ checked = []
592
+ if inline_ok:
593
+ for source in sources_to_check(worktree_root(), watched):
594
+ result = inline_schema_diff(worktree_root(), watched, source)
595
+ if result is None:
596
+ answered = False
597
+ break
598
+ answered = True
599
+ checked.append(source)
600
+ # Attribute the finding. When the index is clean and an unstaged
601
+ # edit is the breaking one, the operator needs to be told that --
602
+ # otherwise the refusal looks like it is about the change they
603
+ # just staged, and the obvious remedies (stash it, finish it,
604
+ # stage it) are not obvious at all.
605
+ findings.extend(f"[{source}] {f}" for f in result)
606
+
607
+ if answered:
608
+ where = " and ".join(checked)
609
+ if not findings:
610
+ emit(
611
+ True,
612
+ f"schema-diff ran on the {where} content of "
613
+ + ", ".join(watched[:5])
614
+ + ": no breaking changes",
615
+ )
616
+ broken = sorted({f.split("] ", 1)[1].split(":", 1)[0] for f in findings})
617
+ if breaking_waived(worktree_root(), broken):
618
+ emit(
619
+ True,
620
+ "breaking schema change waived by a committed "
621
+ "policies/waivers/*schema-diff*.md naming "
622
+ + ", ".join(broken[:5])
623
+ + ": "
624
+ + " | ".join(findings[:5]),
625
+ )
626
+ emit(
627
+ False,
628
+ f"schema-diff-gate: BREAKING schema change in the {where} content -- "
629
+ + " | ".join(findings[:5])
630
+ + ". Make it additive (nullable, or NOT NULL with a DEFAULT), or commit a"
631
+ + " policies/waivers/<name>-schema-diff.md naming "
632
+ + ", ".join(broken[:3])
633
+ + ".",
634
+ )
635
+
636
+ # The checker could not answer -- no CLI, a crash, a timeout, or an
637
+ # examined set that did not cover everything watched. Fall through to the
638
+ # behaviour that shipped: a recorded pass, or a refusal naming the remedy.
639
+ # The inline check only ever ADDS precision on top of this; it never
640
+ # subtracts a refusal, because a checker that cannot run is not evidence
641
+ # that the change is safe.
642
+ markers = schema_diff_ok(repo_root())
643
+ if markers:
644
+ stale = None
645
+ for marker in markers:
646
+ scoped = marker_files(marker)
647
+ if scoped is None:
648
+ # LEGACY or truncated marker (older CLI, or a change too large
649
+ # to enumerate): time-window only, as before this change
650
+ # (PRESERVE). It excuses only what it NAMES -- a blanket allow
651
+ # meant one un-upgraded CLI anywhere in the fleet switched
652
+ # content scoping off repo-wide for an hour.
653
+ named = marker.partition("| files: ")[2]
654
+ if legacy_marker_covers(marker, named, watched):
655
+ emit(
656
+ True,
657
+ "recent schema-diff pass on record; watched paths: "
658
+ + ", ".join(watched[:5]),
659
+ )
660
+ continue
661
+ # Same reasoning as sources_to_check: when the index and the
662
+ # worktree disagree, a marker must vouch for BOTH, because the
663
+ # command form that decides between them cannot be read reliably
664
+ # off the command string.
665
+ missed = []
666
+ for src in sources_to_check(worktree_root(), watched):
667
+ missed.extend(
668
+ uncovered_paths(worktree_root(), watched, scoped, src == "worktree")
669
+ )
670
+ if not missed:
671
+ emit(
672
+ True,
673
+ f"schema-diff pass covers the committed content of: {', '.join(watched[:5])}",
674
+ )
675
+ if stale is None:
676
+ stale = missed
677
+ emit(
678
+ False,
679
+ "schema-diff-gate: no recent pass covers the CURRENT content of "
680
+ + ", ".join((stale or watched)[:5])
681
+ + " (changed since it ran). Re-run `uap schema-diff` and re-commit.",
682
+ )
111
683
 
112
684
  emit(
113
685
  False,
@@ -118,4 +690,20 @@ def main() -> None:
118
690
 
119
691
 
120
692
  if __name__ == "__main__":
121
- main()
693
+ # A crash must not read as consent. The policy hook turns a non-zero exit
694
+ # or unparseable output into ALLOW for every enforcer except self-protect
695
+ # (.claude/hooks/uap-policy-gate.sh), so an unhandled exception anywhere
696
+ # above is a silent bypass -- and the review found two reachable ones, both
697
+ # TypeErrors on a malformed verdict. The guards are still in place; this is
698
+ # the backstop for the ones nobody thought of.
699
+ try:
700
+ main()
701
+ except SystemExit:
702
+ raise # emit() exits through here
703
+ except Exception as exc: # noqa: BLE001
704
+ emit(
705
+ False,
706
+ "schema-diff-gate: the gate itself failed "
707
+ f"({type(exc).__name__}: {exc}) -- refusing rather than allowing "
708
+ "unverified. Re-run, and report this if it persists.",
709
+ )