codereview-loop 0.13.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.
@@ -0,0 +1,904 @@
1
+ """Verdict record schema v1.0.0 — the one versioned record contract.
2
+
3
+ Every surface (CLI, MCP server, GitHub adapter, agent seats,
4
+ workflow/loop driver) calls the same core (`core.run_review` /
5
+ `core.run_verification`) and therefore emits the same record.
6
+ This module is that record's formal contract:
7
+
8
+ - `RECORD_SCHEMA` — a JSON-Schema-style definition (a plain
9
+ dict; the product is stdlib-only, so no external schema
10
+ library is involved) describing what the core ACTUALLY emits.
11
+ - `validate_record(record)` — the in-tree validator. The core
12
+ validates every record at emission (`finalize_record` raises
13
+ on a violation), and `core.write_report` re-validates a v1
14
+ record before it is written to disk, so a record that reaches
15
+ disk — the copy the MCP `get_verdict` tool reads back — is a
16
+ record that validated.
17
+ - `finalize_record(report, ...)` — the core's last step before
18
+ a report is returned: it stamps the v1 sections below onto
19
+ the legacy record shape and validates the result. It never
20
+ touches the gate, the findings, or any verdict logic — this
21
+ is record-shape work only.
22
+
23
+ Relationship to the legacy tag: records have always carried
24
+ `schema: "codereview.report/1"`. That tag is KEPT (consumers
25
+ compare it literally) and v1.0.0 is carried alongside it as
26
+ `schema_version`. A record with the tag but no
27
+ `schema_version` is a legacy (pre-v1) record: readers must not
28
+ treat it as corrupt, but it makes no v1 promises — in
29
+ particular its verify-mode records may lack `coverage` and
30
+ `degraded_causes`, and no record of that era carries `checks`,
31
+ `panel`, `cost`, or `record_identity` sections.
32
+
33
+ Semantics locked by this schema (the GTM engineering contract):
34
+
35
+ - Verdicts are PASS / BLOCKED / INCONCLUSIVE. The record's
36
+ legacy `gate` field spells them lowercase ("pass" /
37
+ "blocked" / "inconclusive"); the v1 `verdict` field carries
38
+ the uppercase spelling, and the two must agree. CLI exit
39
+ codes are 0 (PASS), 1 (BLOCKED), 2 (INCONCLUSIVE) — see
40
+ `EXIT_CODES`.
41
+ - A PASS means: no actionable issue was found under the
42
+ checks that ran. It is NOT a correctness guarantee. The
43
+ record states this in its `verdict_semantics` section.
44
+ - Checks that did not run must be visible: the `checks`
45
+ section lists `run`, `not_run`, and `unavailable` (attempted
46
+ but produced no result — e.g. every reviewer seat errored)
47
+ separately. A PASS over a partial check set is visible as
48
+ such, never dressed up as a full one.
49
+ - Degraded-provider behaviour is explicit: `degraded` +
50
+ `degraded_causes` on the record, per-seat status/family/tier
51
+ in `panel.seats`, and a degraded panel can never be read as
52
+ a healthy one (see core's gate rules, unchanged by this
53
+ schema).
54
+ - Cost: the core has NO cost data — providers return review
55
+ text only and report no usage to the core. The v1 record
56
+ says so explicitly (`cost.status == "not_available"`)
57
+ instead of omitting the field or inventing a number. (The
58
+ bench wrapper estimates cost per attempt from its own seat
59
+ price config, outside this record, under the separate
60
+ `codereview.benchresult/1` schema.)
61
+ - Record identity/path: `record_identity.id` is a content
62
+ hash stamped at emission. The record's PATH is assigned by
63
+ the writer, not the core, so at emission `record_identity
64
+ .path` is null; `core.write_report` stamps the file's base
65
+ name into the written copy, and the MCP payload carries the
66
+ full path as `record_path`, outside the record.
67
+ - Record content integrity (P6-03): the writer also stamps
68
+ `record_identity.content_sha256` — a hash over the WHOLE
69
+ record except the `record_identity` envelope itself (the
70
+ id is a hash of a subset; the path is location metadata,
71
+ so a renamed file still verifies). record-verify
72
+ recomputes it in single and chain mode: ANY later edit —
73
+ a finding's status, the gate, forged test results — fails
74
+ verification. Threat model, stated plainly: this detects
75
+ edits and corruption by anything that does not recompute
76
+ the hash (hand edits, torn copies, accidental mutation).
77
+ It is NOT a signature: an editor who recomputes the hash
78
+ over their edited record passes. Records written before
79
+ the hash existed carry none and verify under the legacy
80
+ rule — the check reports not-applicable, exactly like the
81
+ coverage/tier/sidecar checks treat their pre-machinery
82
+ records — because registered and pilot records are
83
+ evidence and must not become unverifiable retroactively.
84
+ The legacy rule is bounded by provenance (P6-12): it
85
+ applies only when the record's own declarations
86
+ establish that it predates the stamp (see
87
+ `content_hash_required`). The first bound trusted
88
+ whatever a single edited field declared, and read
89
+ missing or unparseable fields as pre-stamp — deleting
90
+ or garbling two fields reopened the downgrade (Phase 6
91
+ rerun 2). The rule now fails closed: EVERY declaration
92
+ (the top-level version, `provenance.generator_version`,
93
+ `created_at`) is read; a stamping-era or unparseable
94
+ declaration defeats the exemption, and no usable
95
+ declarations at all defeats it — legacy requires
96
+ positive, consistent, pre-stamp evidence. What remains
97
+ is the stated limit above: recompute the hash, or
98
+ rewrite every declaration into one consistent pre-stamp
99
+ claim — the hash was never a signature. One documented
100
+ cost: unstamped records written by 0.13.0 dev builds in
101
+ the hours before the stamp landed (stamping-era version
102
+ line, pre-stamp `created_at`) no longer read as legacy;
103
+ that window's artefacts are dev records, not registered
104
+ evidence — the B2-era records predate the 0.13.0 version
105
+ line entirely and keep the exemption. Records with no
106
+ trace of the v1 machinery at all (no `schema_version`,
107
+ `provenance` or `record_identity`) are read by the same
108
+ rule on their surviving declarations (P6-12b, rerun 3):
109
+ the three-key deletion from a current record no longer
110
+ reaches a categorical exemption — a surviving
111
+ stamping-era `version` defeats it, and no declarations
112
+ at all establishes nothing. Genuine pre-schema records
113
+ carry a pre-stamp `version` and `created_at` and keep
114
+ the exemption on that evidence.
115
+ """
116
+
117
+ import datetime
118
+ import hashlib
119
+ import json
120
+ import re
121
+
122
+ SCHEMA_VERSION = "1.0.0"
123
+ SCHEMA_ID = "codereview.report/1"
124
+
125
+ # The verdict vocabulary. `gate` keeps the historical lowercase
126
+ # spelling; `verdict` is the same fact in the contract's
127
+ # uppercase spelling.
128
+ GATES = ("pass", "blocked", "inconclusive")
129
+ VERDICTS = ("PASS", "BLOCKED", "INCONCLUSIVE")
130
+ VERDICT_FOR_GATE = dict(zip(GATES, VERDICTS))
131
+ EXIT_CODES = {"pass": 0, "blocked": 1, "inconclusive": 2}
132
+
133
+ MODES = ("review", "verify")
134
+ SEVERITIES = ("blocker", "major", "minor", "nit")
135
+ EVIDENCE_CLASSES = ("E0", "E1", "E2", "E3")
136
+
137
+ PASS_MEANS = ("no actionable issue found under the checks "
138
+ "that ran (see the checks section)")
139
+ PASS_IS_NOT = ("not a correctness guarantee: checks that did "
140
+ "not run are listed in checks.not_run / "
141
+ "checks.unavailable, and a degraded panel is "
142
+ "labelled in panel / degraded_causes")
143
+
144
+ # JSON-Schema-style definition of the v1 record. It describes
145
+ # the emitted shape: `required` is the v1 core contract, and
146
+ # `additionalProperties` is deliberately true — the record has
147
+ # always grown optional, mode- and feature-specific sections
148
+ # (policy, tiering, evidence, disputes, hooks, ...) and a v1
149
+ # reader must tolerate sections it does not know.
150
+ RECORD_SCHEMA = {
151
+ "$id": "codereview.report/1",
152
+ "version": SCHEMA_VERSION,
153
+ "title": "CodeReview verdict record",
154
+ "type": "object",
155
+ "additionalProperties": True,
156
+ "required": [
157
+ "schema", "schema_version", "version", "mode",
158
+ "created_at", "gate", "verdict", "gate_reasons",
159
+ "findings", "reviewer_reports", "degraded",
160
+ "degraded_causes", "truncated", "scope", "checks",
161
+ "panel", "cost", "duration_ms", "waivers",
162
+ "provenance", "record_identity", "verdict_semantics",
163
+ ],
164
+ "properties": {
165
+ "schema": {"const": SCHEMA_ID},
166
+ "schema_version": {"const": SCHEMA_VERSION},
167
+ "version": {"type": "string",
168
+ "description": "product version that "
169
+ "produced the record"},
170
+ "mode": {"enum": list(MODES)},
171
+ "created_at": {"type": "string",
172
+ "description": "ISO-8601 creation time"},
173
+ "gate": {"enum": list(GATES),
174
+ "description": "legacy lowercase verdict"},
175
+ "verdict": {"enum": list(VERDICTS),
176
+ "description": "uppercase verdict; must "
177
+ "agree with gate"},
178
+ "gate_reasons": {"type": "array",
179
+ "items": {"type": "string"}},
180
+ "findings": {
181
+ "type": "array",
182
+ "description": "merged findings standing at the "
183
+ "gate (review mode); in verify mode, "
184
+ "NEW findings raised by the round — "
185
+ "prior blockers live in cleared / "
186
+ "remaining / unjudged",
187
+ "items": {
188
+ "type": "object",
189
+ "required": ["id", "severity", "title"],
190
+ "properties": {
191
+ "id": {"type": "string"},
192
+ "severity": {"enum": list(SEVERITIES)},
193
+ "category": {"type": ["string", "null"]},
194
+ "file": {"type": ["string", "null"]},
195
+ "line": {"type": ["integer", "null"]},
196
+ "title": {"type": "string"},
197
+ "evidence": {"type": ["string", "null"]},
198
+ "evidence_quote": {
199
+ "type": ["string", "null"]},
200
+ "evidence_class": {
201
+ "enum": list(EVIDENCE_CLASSES),
202
+ "description": "E0 deterministic, E2 "
203
+ "probe-confirmed, E1 "
204
+ "corroborated, E3 "
205
+ "single-source / "
206
+ "uncorroborated. Absent "
207
+ "means unstamped (legacy "
208
+ "or deterministic-era "
209
+ "finding), never E0."},
210
+ "recommendation": {
211
+ "type": ["string", "null"]},
212
+ "confidence": {
213
+ "type": ["number", "null"]},
214
+ },
215
+ },
216
+ },
217
+ "reviewer_reports": {
218
+ "type": "array",
219
+ "description": "one entry per reviewer job that "
220
+ "ran (per seat/lens/batch in review "
221
+ "mode; per seat in verify mode)",
222
+ "items": {
223
+ "type": "object",
224
+ "required": ["provider", "status"],
225
+ "properties": {
226
+ "provider": {"type": ["string", "null"]},
227
+ "lens": {"type": ["string", "null"]},
228
+ "status": {"enum": ["ok", "error"]},
229
+ "family": {"type": ["string", "null"]},
230
+ "tier": {"type": ["string", "null"]},
231
+ "duration_ms": {
232
+ "type": ["integer", "null"]},
233
+ },
234
+ },
235
+ },
236
+ "degraded": {"type": "boolean"},
237
+ "degraded_causes": {"type": "array",
238
+ "items": {"type": "string"}},
239
+ "truncated": {"type": "boolean"},
240
+ "patch_sha256": {"type": "string",
241
+ "description": "sha256 of the patch "
242
+ "text reviewed"},
243
+ "scope": {
244
+ "type": "object",
245
+ "required": ["files", "patch_sha256"],
246
+ "properties": {
247
+ "files": {"type": "array",
248
+ "items": {"type": "string"}},
249
+ "patch_sha256": {"type": "string"},
250
+ "patch_chars": {"type": ["integer", "null"]},
251
+ "range": {"type": ["string", "null"],
252
+ "description": "git range, when the "
253
+ "caller recorded one "
254
+ "(CLI runs); null for "
255
+ "direct core callers"},
256
+ "head": {"type": ["string", "null"],
257
+ "description": "commit the range was "
258
+ "taken at, same "
259
+ "provenance as range"},
260
+ "previous_gate": {"type": ["string", "null"],
261
+ "description": "verify mode: "
262
+ "the prior "
263
+ "record's gate"},
264
+ },
265
+ },
266
+ "checks": {
267
+ "type": "object",
268
+ "required": ["run", "not_run", "unavailable"],
269
+ "description": "check availability, explicit: a "
270
+ "check is in exactly one list. "
271
+ "Names: deterministic_scan, "
272
+ "reviewer_panel, tests, evidence, "
273
+ "policy, coverage (review mode "
274
+ "only; verify mode lists coverage "
275
+ "as not_run — a known, documented "
276
+ "gap: the verification round does "
277
+ "not recompute per-file coverage)",
278
+ "properties": {
279
+ "run": {"type": "array",
280
+ "items": {"type": "string"}},
281
+ "not_run": {"type": "array",
282
+ "items": {"type": "string"}},
283
+ "unavailable": {"type": "array",
284
+ "items": {"type": "string"}},
285
+ },
286
+ },
287
+ "panel": {
288
+ "type": "object",
289
+ "required": ["seats", "degraded", "degraded_causes"],
290
+ "properties": {
291
+ "seats": {
292
+ "type": "array",
293
+ "description": "distinct seats by "
294
+ "(provider, lens), first "
295
+ "report wins for status",
296
+ "items": {
297
+ "type": "object",
298
+ "properties": {
299
+ "provider": {
300
+ "type": ["string", "null"]},
301
+ "lens": {
302
+ "type": ["string", "null"]},
303
+ "family": {
304
+ "type": ["string", "null"]},
305
+ "tier": {
306
+ "type": ["string", "null"]},
307
+ "status": {
308
+ "type": ["string", "null"]},
309
+ },
310
+ },
311
+ },
312
+ "families": {"type": "array",
313
+ "items": {"type": "string"}},
314
+ "seat_count": {"type": "integer"},
315
+ "ok_count": {"type": "integer"},
316
+ "degraded": {"type": "boolean"},
317
+ "degraded_causes": {"type": "array",
318
+ "items": {"type": "string"}},
319
+ },
320
+ },
321
+ "cost": {
322
+ "type": "object",
323
+ "required": ["status"],
324
+ "properties": {
325
+ "status": {"const": "not_available"},
326
+ "amount_usd": {"type": "null"},
327
+ "reason": {"type": "string"},
328
+ },
329
+ },
330
+ "duration_ms": {"type": ["integer", "null"],
331
+ "description": "core wall-clock for "
332
+ "the round; null only "
333
+ "when timing was not "
334
+ "recorded"},
335
+ "waivers": {
336
+ "type": "array",
337
+ "description": "waivers the caller recorded for "
338
+ "this round (review mode: the "
339
+ "policy-evaluation waivers, verbatim;"
340
+ " verify mode: always empty — "
341
+ "verification waivers live in the "
342
+ "loop/workflow state, documented "
343
+ "gap, not hidden)",
344
+ "items": {"type": "object"},
345
+ },
346
+ "provenance": {
347
+ "type": "object",
348
+ "required": ["generator", "generator_version",
349
+ "patch_sha256"],
350
+ "properties": {
351
+ "generator": {"const": "codereview"},
352
+ "generator_version": {"type": "string"},
353
+ "patch_sha256": {"type": "string"},
354
+ "range": {"type": ["string", "null"]},
355
+ "head": {"type": ["string", "null"]},
356
+ },
357
+ },
358
+ "record_identity": {
359
+ "type": "object",
360
+ "required": ["id", "path"],
361
+ "properties": {
362
+ "id": {"type": "string",
363
+ "description": "sha256 over mode + "
364
+ "created_at + patch hash "
365
+ "+ gate, computed at "
366
+ "emission"},
367
+ "path": {"type": ["string", "null"],
368
+ "description": "null at emission; "
369
+ "write_report stamps "
370
+ "the written file's "
371
+ "base name"},
372
+ "content_sha256": {
373
+ "type": "string",
374
+ "description": "P6-03: sha256 over the "
375
+ "whole record except the "
376
+ "record_identity envelope, "
377
+ "stamped by write_report; "
378
+ "record-verify recomputes "
379
+ "it. Detects edits, is not "
380
+ "a signature. Absent on "
381
+ "records written before it "
382
+ "existed (legacy)"},
383
+ },
384
+ },
385
+ "verdict_semantics": {
386
+ "type": "object",
387
+ "required": ["pass_means", "pass_is_not"],
388
+ "properties": {
389
+ "pass_means": {"const": PASS_MEANS},
390
+ "pass_is_not": {"const": PASS_IS_NOT},
391
+ },
392
+ },
393
+ },
394
+ }
395
+
396
+
397
+ def _check_names(report):
398
+ """Derive the checks section from the finished report.
399
+
400
+ Every name lands in exactly one list. A reviewer panel that
401
+ was configured but whose every job errored is `unavailable`
402
+ (attempted, no result) — not `run`, and not silently
403
+ absent. No panel at all is `not_run`."""
404
+ mode = report.get("mode")
405
+ run, not_run, unavailable = [], [], []
406
+
407
+ def place(name, bucket):
408
+ bucket.append(name)
409
+
410
+ # Deterministic scan: run_review always scans; the
411
+ # verification round re-checks prior deterministic findings
412
+ # against the new scan by construction.
413
+ place("deterministic_scan", run)
414
+ reports = report.get("reviewer_reports") or []
415
+ if not reports:
416
+ place("reviewer_panel", not_run)
417
+ elif any(r.get("status") == "ok" for r in reports
418
+ if isinstance(r, dict)):
419
+ place("reviewer_panel", run)
420
+ else:
421
+ place("reviewer_panel", unavailable)
422
+ place("tests", run if report.get("test_results") else not_run)
423
+ if mode == "review":
424
+ place("evidence", run if report.get("evidence") is not None
425
+ else not_run)
426
+ place("policy", run if report.get("policy") is not None
427
+ else not_run)
428
+ place("coverage", run if report.get("coverage") is not None
429
+ else not_run)
430
+ else: # verify
431
+ # The verify round collects no evidence record, evaluates
432
+ # no policy, and recomputes no coverage — visible here,
433
+ # not papered over (see RECORD_SCHEMA.checks).
434
+ for name in ("evidence", "policy", "coverage"):
435
+ place(name, not_run)
436
+ return {"run": run, "not_run": not_run,
437
+ "unavailable": unavailable}
438
+
439
+
440
+ def _panel_section(report):
441
+ seats, seen = [], set()
442
+ for rep in report.get("reviewer_reports") or []:
443
+ if not isinstance(rep, dict):
444
+ continue
445
+ key = (rep.get("provider"), rep.get("lens"))
446
+ if key in seen:
447
+ continue
448
+ seen.add(key)
449
+ seats.append({
450
+ "provider": rep.get("provider"),
451
+ "lens": rep.get("lens"),
452
+ "family": rep.get("family"),
453
+ "tier": rep.get("tier"),
454
+ "status": rep.get("status"),
455
+ })
456
+ # Canonical order: reviewer_reports arrive in completion
457
+ # order (a thread artifact); the panel section is a summary
458
+ # of composition, so it sorts by (provider, lens) and two
459
+ # records of equivalent runs carry identical panel sections.
460
+ seats.sort(key=lambda s: (str(s["provider"]),
461
+ str(s["lens"])))
462
+ families = sorted({s["family"] for s in seats
463
+ if s["family"] not in (None, "", "unknown")})
464
+ return {
465
+ "seats": seats,
466
+ "families": families,
467
+ "seat_count": len(seats),
468
+ "ok_count": sum(1 for s in seats if s["status"] == "ok"),
469
+ "degraded": bool(report.get("degraded")),
470
+ "degraded_causes": list(report.get("degraded_causes") or []),
471
+ }
472
+
473
+
474
+ def record_content_sha256(record):
475
+ """The P6-03 content hash: sha256 of the record's
476
+ canonical JSON (sorted keys, compact separators) with the
477
+ `record_identity` envelope excluded. Excluding the whole
478
+ envelope is what lets the writer stamp the hash INTO the
479
+ envelope without self-reference, and keeps the hash a
480
+ statement about the record's content rather than its
481
+ file name."""
482
+ body = {k: v for k, v in record.items()
483
+ if k != "record_identity"}
484
+ canon = json.dumps(body, ensure_ascii=False,
485
+ sort_keys=True, separators=(",", ":"))
486
+ return hashlib.sha256(canon.encode("utf-8")).hexdigest()
487
+
488
+
489
+ # The stamping era (P6-12): the first generator line whose
490
+ # writer stamps content_sha256 on every record it writes,
491
+ # and the moment that stamp landed (the P6-03 commit,
492
+ # 2026-11-07T02:07:56Z). The two bounds classify each of a
493
+ # record's provenance declarations as pre-stamp or
494
+ # stamping-era; `content_hash_required` reads them all
495
+ # and fails closed (see its docstring for the rule and
496
+ # the rerun-2 history).
497
+ CONTENT_HASH_ERA_VERSION = (0, 13, 0)
498
+ CONTENT_HASH_ERA_FROM = datetime.datetime(
499
+ 2026, 11, 7, 2, 7, 56, tzinfo=datetime.timezone.utc)
500
+
501
+ _VERSION_PREFIX_RE = re.compile(r"^(\d+(?:\.\d+)*)")
502
+
503
+
504
+ def _version_tuple(text):
505
+ """The leading numeric dotted prefix of a version
506
+ string as a tuple ("0.13.0.dev0" → (0, 13, 0)), or
507
+ None when the string carries no numeric prefix."""
508
+ if not isinstance(text, str):
509
+ return None
510
+ match = _VERSION_PREFIX_RE.match(text)
511
+ if not match:
512
+ return None
513
+ return tuple(int(p) for p in match.group(1).split("."))
514
+
515
+
516
+ def _created_instant(text):
517
+ """A record's created_at as an aware datetime, or None
518
+ when it does not parse (a naive value is read as UTC —
519
+ that is the only form the writer emits)."""
520
+ if not isinstance(text, str) or not text:
521
+ return None
522
+ try:
523
+ parsed = datetime.datetime.fromisoformat(
524
+ text.replace("Z", "+00:00"))
525
+ except ValueError:
526
+ return None
527
+ if parsed.tzinfo is None:
528
+ parsed = parsed.replace(tzinfo=datetime.timezone.utc)
529
+ return parsed
530
+
531
+
532
+ def _version_state(value):
533
+ """A version declaration classified: "absent" (None),
534
+ "garbled" (present but carries no numeric prefix),
535
+ "pre" (predates the stamping-era version line) or
536
+ "era" (the stamping-era line or later)."""
537
+ if value is None:
538
+ return "absent"
539
+ parsed = _version_tuple(value)
540
+ if parsed is None:
541
+ return "garbled"
542
+ return ("era" if parsed >= CONTENT_HASH_ERA_VERSION
543
+ else "pre")
544
+
545
+
546
+ def _created_state(value):
547
+ """A created_at declaration classified like
548
+ `_version_state`, against the stamp instant."""
549
+ if value is None:
550
+ return "absent"
551
+ parsed = _created_instant(value)
552
+ if parsed is None:
553
+ return "garbled"
554
+ return ("era" if parsed >= CONTENT_HASH_ERA_FROM
555
+ else "pre")
556
+
557
+
558
+ def content_hash_required(record):
559
+ """True when a record carrying no
560
+ `record_identity.content_sha256` must NOT take the
561
+ legacy exemption — the hash was removed, or the claim
562
+ to have never been stamped does not hold up (P6-12).
563
+
564
+ The record's THREE provenance declarations are read:
565
+ the top-level `version`, `provenance.generator_version`
566
+ and `created_at`. Legacy is a positive-evidence rule
567
+ and fails closed:
568
+
569
+ - any declaration in the stamping era defeats it (a
570
+ single edited field cannot outvote the others);
571
+ - any declaration present but unparseable defeats it
572
+ (garbled provenance is not pre-stamp provenance);
573
+ - no usable declarations on a record of the
574
+ declarations era defeats it (absent provenance is
575
+ not pre-stamp provenance);
576
+ - legacy requires at least one parseable pre-stamp
577
+ declaration and no defeating one.
578
+
579
+ The rule applies inside the pre-machinery shape too
580
+ (P6-12b, rerun 3): a record carrying no trace of the
581
+ v1 machinery (no `schema_version`, no `provenance`,
582
+ no `record_identity`) is NOT categorically exempt —
583
+ its surviving declarations (`version`, `created_at`)
584
+ are read by the same rule. That closes the three-key
585
+ deletion: dropping the machinery sections from a
586
+ current record while keeping `version: 0.13.0.dev0`
587
+ (her G6) declares the stamping era and fails, and
588
+ deleting every declaration (her G1) establishes
589
+ nothing and fails. Genuine pre-schema records still
590
+ pass: every writer of that era stamped `version` and
591
+ `created_at` (the 0.9.4 / 0.9.6 historicals carry
592
+ both), so one parseable pre-stamp declaration is
593
+ positive evidence, not a new burden. A record with
594
+ no declarations at all was never written by any
595
+ version of the writer; the consistent-forgery
596
+ residual (forge every declaration pre-stamp, her
597
+ G2/G3/G4) remains the documented not-a-signature
598
+ limit, unchanged.
599
+
600
+ The first P6-12 bound read missing/unparseable fields
601
+ as pre-stamp and let any one edited declaration carry
602
+ the claim, so stripping the hash plus deleting or
603
+ garbling two fields reopened the downgrade (Phase 6
604
+ rerun 2, p6_downgrade2.py). Documented cost of failing
605
+ closed: unstamped records from 0.13.0 dev builds in
606
+ the hours before the stamp landed (stamping-era
607
+ version line, pre-stamp `created_at`) no longer read
608
+ as legacy — a closed set of dev artefacts; registered
609
+ and B2-era records predate the 0.13.0 line entirely.
610
+ """
611
+ provenance = record.get("provenance")
612
+ generator_version = (provenance.get("generator_version")
613
+ if isinstance(provenance, dict)
614
+ else None)
615
+ states = [
616
+ _version_state(record.get("version")),
617
+ _version_state(generator_version),
618
+ _created_state(record.get("created_at")),
619
+ ]
620
+ if "era" in states or "garbled" in states:
621
+ return True
622
+ return "pre" not in states
623
+
624
+
625
+ def _record_id(report):
626
+ material = json.dumps({
627
+ "mode": report.get("mode"),
628
+ "created_at": report.get("created_at"),
629
+ "patch_sha256": report.get("patch_sha256"),
630
+ "gate": report.get("gate"),
631
+ }, sort_keys=True, ensure_ascii=False)
632
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
633
+
634
+
635
+ def finalize_record(report, duration_ms=None, waivers=None):
636
+ """Stamp the v1.0.0 sections onto a finished core report
637
+ and validate it. Called by core.run_review /
638
+ core.run_verification as their last step (before verdict
639
+ notifications, which read only gate/findings). Raises
640
+ ValueError listing every problem when the finished record
641
+ does not meet the schema — an emission bug must fail loud,
642
+ never write a record that claims v1 and is not.
643
+
644
+ Idempotent in identity: a record that already carries a
645
+ record_identity.id keeps it (the CLI's hooks rewrite goes
646
+ through write_report, not back through here, but a caller
647
+ re-finalising a record must not mint a second identity)."""
648
+ gate = report.get("gate")
649
+ files = report.get("files")
650
+ if files is None:
651
+ # Verify mode carries no file list; the scope is the
652
+ # files of the patch it judged, which the caller of
653
+ # finalize cannot see — core passes them via the
654
+ # report's `_scope_files` scratch key when it has them.
655
+ files = report.pop("_scope_files", None) or []
656
+ else:
657
+ report.pop("_scope_files", None)
658
+ report["schema_version"] = SCHEMA_VERSION
659
+ report["verdict"] = VERDICT_FOR_GATE.get(gate)
660
+ report["scope"] = {
661
+ "files": list(files),
662
+ "patch_sha256": report.get("patch_sha256"),
663
+ "patch_chars": report.get("patch_chars"),
664
+ "range": report.get("range"),
665
+ "head": report.get("head"),
666
+ "previous_gate": report.get("previous_gate"),
667
+ }
668
+ report["checks"] = _check_names(report)
669
+ report["panel"] = _panel_section(report)
670
+ report["cost"] = {
671
+ "status": "not_available",
672
+ "amount_usd": None,
673
+ "currency": "USD",
674
+ "reason": ("core providers return review text only and "
675
+ "report no usage/cost to the core; bench "
676
+ "wrappers may estimate cost outside this "
677
+ "record (codereview.benchresult/1 cost_usd)"),
678
+ }
679
+ report["duration_ms"] = duration_ms
680
+ normalised = []
681
+ for waiver in waivers or []:
682
+ normalised.append(waiver if isinstance(waiver, dict)
683
+ else {"finding_id": str(waiver)})
684
+ report["waivers"] = normalised
685
+ report["provenance"] = {
686
+ "generator": "codereview",
687
+ "generator_version": report.get("version"),
688
+ "patch_sha256": report.get("patch_sha256"),
689
+ "range": report.get("range"),
690
+ "head": report.get("head"),
691
+ }
692
+ existing = report.get("record_identity") or {}
693
+ report["record_identity"] = {
694
+ "id": existing.get("id") or _record_id(report),
695
+ "path": existing.get("path"),
696
+ }
697
+ report["verdict_semantics"] = {
698
+ "pass_means": PASS_MEANS,
699
+ "pass_is_not": PASS_IS_NOT,
700
+ }
701
+ problems = validate_record(report)
702
+ if problems:
703
+ raise ValueError(
704
+ "verdict record failed schema v" + SCHEMA_VERSION
705
+ + " validation at emission: " + "; ".join(problems))
706
+ return report
707
+
708
+
709
+ def _is_int(value):
710
+ return isinstance(value, int) and not isinstance(value, bool)
711
+
712
+
713
+ def validate_record(record):
714
+ """Validate a record against RECORD_SCHEMA (v1.0.0).
715
+
716
+ Returns a list of problem strings — empty means valid.
717
+ This is a hand-rolled validator for the subset of
718
+ JSON-Schema the definition uses (types, required, enum,
719
+ const), stdlib only. It validates the v1 contract, not
720
+ every optional section the record may carry: unknown
721
+ sections are permitted (additionalProperties) and their
722
+ own sub-schemas (evidence, tiering, precedents, ...) keep
723
+ their own validators."""
724
+ problems = []
725
+
726
+ def bad(msg):
727
+ problems.append(msg)
728
+
729
+ if not isinstance(record, dict):
730
+ return ["record is not an object"]
731
+ if record.get("schema") != SCHEMA_ID:
732
+ bad(f"schema is {record.get('schema')!r}, "
733
+ f"expected {SCHEMA_ID!r}")
734
+ if record.get("schema_version") != SCHEMA_VERSION:
735
+ bad(f"schema_version is {record.get('schema_version')!r}, "
736
+ f"expected {SCHEMA_VERSION!r} (a record without it is "
737
+ f"a legacy pre-v1 record, not a v1 record)")
738
+ if not isinstance(record.get("version"), str) \
739
+ or not record.get("version"):
740
+ bad("version must be a non-empty string")
741
+ mode = record.get("mode")
742
+ if mode not in MODES:
743
+ bad(f"mode is {mode!r}, expected one of {MODES} "
744
+ f"(the loop summary record is a container of verdict "
745
+ f"records, not itself a v1 verdict record)")
746
+ if not isinstance(record.get("created_at"), str) \
747
+ or not record.get("created_at"):
748
+ bad("created_at must be a non-empty string")
749
+ gate = record.get("gate")
750
+ if gate not in GATES:
751
+ bad(f"gate is {gate!r}, expected one of {GATES}")
752
+ elif record.get("verdict") != VERDICT_FOR_GATE[gate]:
753
+ bad(f"verdict is {record.get('verdict')!r} but gate "
754
+ f"{gate!r} requires {VERDICT_FOR_GATE[gate]!r}")
755
+ reasons = record.get("gate_reasons")
756
+ if not isinstance(reasons, list) \
757
+ or not all(isinstance(r, str) for r in reasons or []):
758
+ bad("gate_reasons must be a list of strings")
759
+ if not isinstance(record.get("degraded"), bool):
760
+ bad("degraded must be a boolean")
761
+ causes = record.get("degraded_causes")
762
+ if not isinstance(causes, list) \
763
+ or not all(isinstance(c, str) for c in causes or []):
764
+ bad("degraded_causes must be a list of strings "
765
+ "(verify-mode records before v1 omitted it; v1 "
766
+ "requires it, empty when not degraded)")
767
+ if record.get("degraded") and isinstance(causes, list) \
768
+ and not causes:
769
+ bad("degraded is true but degraded_causes is empty: "
770
+ "degraded behaviour must name its cause(s)")
771
+ if not isinstance(record.get("truncated"), bool):
772
+ bad("truncated must be a boolean")
773
+ findings = record.get("findings")
774
+ if not isinstance(findings, list):
775
+ bad("findings must be a list")
776
+ else:
777
+ for i, finding in enumerate(findings):
778
+ if not isinstance(finding, dict):
779
+ bad(f"findings[{i}] is not an object")
780
+ continue
781
+ if not finding.get("id"):
782
+ bad(f"findings[{i}] has no id")
783
+ if finding.get("severity") not in SEVERITIES:
784
+ bad(f"findings[{i}] severity is "
785
+ f"{finding.get('severity')!r}, expected one "
786
+ f"of {SEVERITIES}")
787
+ if not isinstance(finding.get("title"), str):
788
+ bad(f"findings[{i}] title must be a string")
789
+ eclass = finding.get("evidence_class")
790
+ if eclass is not None and eclass not in EVIDENCE_CLASSES:
791
+ bad(f"findings[{i}] evidence_class is "
792
+ f"{eclass!r}, expected one of "
793
+ f"{EVIDENCE_CLASSES} or absent (unstamped)")
794
+ reports = record.get("reviewer_reports")
795
+ if not isinstance(reports, list):
796
+ bad("reviewer_reports must be a list")
797
+ else:
798
+ for i, rep in enumerate(reports):
799
+ if not isinstance(rep, dict):
800
+ bad(f"reviewer_reports[{i}] is not an object")
801
+ continue
802
+ if "provider" not in rep:
803
+ bad(f"reviewer_reports[{i}] has no provider")
804
+ if rep.get("status") not in ("ok", "error"):
805
+ bad(f"reviewer_reports[{i}] status is "
806
+ f"{rep.get('status')!r}, expected 'ok' or "
807
+ f"'error'")
808
+ scope = record.get("scope")
809
+ if not isinstance(scope, dict):
810
+ bad("scope must be an object")
811
+ else:
812
+ if not isinstance(scope.get("files"), list):
813
+ bad("scope.files must be a list")
814
+ if scope.get("patch_sha256") != record.get("patch_sha256"):
815
+ bad("scope.patch_sha256 does not match the record's "
816
+ "patch_sha256")
817
+ if not (isinstance(record.get("patch_sha256"), str)
818
+ and len(record.get("patch_sha256") or "") == 64):
819
+ bad("patch_sha256 must be a 64-char sha256 hex string")
820
+ checks = record.get("checks")
821
+ if not isinstance(checks, dict):
822
+ bad("checks must be an object")
823
+ else:
824
+ lists = {}
825
+ for key in ("run", "not_run", "unavailable"):
826
+ value = checks.get(key)
827
+ if not isinstance(value, list) \
828
+ or not all(isinstance(c, str) for c in value or []):
829
+ bad(f"checks.{key} must be a list of check names")
830
+ # A non-list value is reported above and must not
831
+ # crash the validator (set(5) is a TypeError).
832
+ lists[key] = set(value) if isinstance(value, list) \
833
+ else set()
834
+ seen = lists["run"] | lists["not_run"] | lists["unavailable"]
835
+ total = sum(len(lists[k]) for k in lists)
836
+ if len(seen) != total:
837
+ bad("checks lists overlap: every check must be in "
838
+ "exactly one of run / not_run / unavailable")
839
+ if mode == "review" and gate == "pass" \
840
+ and "reviewer_panel" in lists.get("unavailable", set()):
841
+ bad("gate is pass but checks.unavailable contains "
842
+ "reviewer_panel: a PASS may not rest on a panel "
843
+ "that produced no result")
844
+ panel = record.get("panel")
845
+ if not isinstance(panel, dict):
846
+ bad("panel must be an object")
847
+ else:
848
+ if not isinstance(panel.get("seats"), list):
849
+ bad("panel.seats must be a list")
850
+ if panel.get("degraded") != record.get("degraded"):
851
+ bad("panel.degraded does not match the record's "
852
+ "degraded flag")
853
+ cost = record.get("cost")
854
+ if not isinstance(cost, dict) \
855
+ or cost.get("status") != "not_available":
856
+ bad("cost must be an object with status "
857
+ "'not_available' (the core has no cost data; a "
858
+ "record claiming a core cost figure is inventing "
859
+ "one)")
860
+ elif cost.get("amount_usd") is not None:
861
+ bad("cost.amount_usd must be null when cost.status is "
862
+ "not_available")
863
+ duration = record.get("duration_ms")
864
+ if duration is not None and not _is_int(duration):
865
+ bad("duration_ms must be an integer or null")
866
+ waivers = record.get("waivers")
867
+ if not isinstance(waivers, list) \
868
+ or not all(isinstance(w, dict) for w in waivers or []):
869
+ bad("waivers must be a list of waiver records (objects)")
870
+ provenance = record.get("provenance")
871
+ if not isinstance(provenance, dict):
872
+ bad("provenance must be an object")
873
+ else:
874
+ if provenance.get("generator") != "codereview":
875
+ bad("provenance.generator must be 'codereview'")
876
+ if provenance.get("patch_sha256") \
877
+ != record.get("patch_sha256"):
878
+ bad("provenance.patch_sha256 does not match the "
879
+ "record's patch_sha256")
880
+ identity = record.get("record_identity")
881
+ if not isinstance(identity, dict):
882
+ bad("record_identity must be an object")
883
+ else:
884
+ if not (isinstance(identity.get("id"), str)
885
+ and len(identity.get("id") or "") == 64):
886
+ bad("record_identity.id must be a 64-char sha256 "
887
+ "hex string")
888
+ if identity.get("path") is not None \
889
+ and not isinstance(identity.get("path"), str):
890
+ bad("record_identity.path must be a string or null")
891
+ chash = identity.get("content_sha256")
892
+ if chash is not None and not (
893
+ isinstance(chash, str) and len(chash) == 64
894
+ and all(c in "0123456789abcdef"
895
+ for c in chash)):
896
+ bad("record_identity.content_sha256 must be a "
897
+ "64-char sha256 hex string when present")
898
+ semantics = record.get("verdict_semantics")
899
+ if not isinstance(semantics, dict) \
900
+ or semantics.get("pass_means") != PASS_MEANS \
901
+ or semantics.get("pass_is_not") != PASS_IS_NOT:
902
+ bad("verdict_semantics must carry the locked "
903
+ "pass_means / pass_is_not wording")
904
+ return problems