agent-bios 0.12.0 → 0.13.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.
@@ -937,6 +937,19 @@ SEVERITY_NAME_RULE = (
937
937
  # forbidding the labels that trigger it, which is why `<`, `-` and `=` stay registrable.
938
938
  SEVERITY_ARROW = " => "
939
939
  SEVERITY_CLAUSE_MARKER = "; severities "
940
+ # The criterion discipline clause. A CONSTANT, not a slot: the criterion's content is
941
+ # per-review and lives in the packet (which packet_sha256 binds), while the contract is
942
+ # per-launch and golden-pinned — so what renders here is only the discipline, identical
943
+ # for every method and every criterion. Placed BEFORE the severity clause in the row,
944
+ # because the severity clause is parsed as everything after its marker.
945
+ CRITERION_CLAUSE_MARKER = "; criterion "
946
+ CRITERION_CLAUSE = (
947
+ f"{CRITERION_CLAUSE_MARKER}declared in the packet: compile it with "
948
+ f"--compile-criterion, prefer a dispatch whose host schema flag probed present "
949
+ f"(--check-schema-flag), emit receipts under REVIEW_CRITERION_SCHEMA so emission "
950
+ f"refuses unclassified findings, and fold returned rows against the declared enum "
951
+ f"without guessing a class"
952
+ )
940
953
  # The panel that carries corpus/registration text, and the scrolling body that holds it.
941
954
  # Named once so the screen, its CSS, its key bindings and the check that drives them cannot
942
955
  # drift apart.
@@ -1127,6 +1140,17 @@ class ReviewMechanism:
1127
1140
  def parse_review_method(method_id: str, raw: Any, context: str) -> ReviewMethod:
1128
1141
  if not isinstance(raw, dict):
1129
1142
  raise LaunchError(f"{context} must be a table")
1143
+ # The method ID is the one authored value core PREFIXES to the rendered row, so the
1144
+ # formatted-body marker checks never see it — a registry key carrying a clause
1145
+ # marker forged a second decodable clause beside core's (criterion round, #1; the
1146
+ # same door round 20 #5 opened for the plan marker, closed there on the whole row).
1147
+ for marker in (SEVERITY_CLAUSE_MARKER, CONTROLS_CLAUSE_MARKER, CRITERION_CLAUSE_MARKER):
1148
+ if marker in method_id:
1149
+ raise LaunchError(
1150
+ f"{context}: the method id contains {marker!r}, which core owns for a "
1151
+ f"rendered clause; an id carrying it would forge a second clause in "
1152
+ f"every row it prefixes"
1153
+ )
1130
1154
  unknown = sorted(set(raw) - METHOD_KEYS)
1131
1155
  if unknown:
1132
1156
  raise LaunchError(f"{context}: unknown method key(s): {', '.join(unknown)}")
@@ -1307,6 +1331,12 @@ def validate_instruction_slots(template: str, context: str) -> set:
1307
1331
  f"{context}: contains {CONTROLS_CLAUSE_MARKER!r}, which core appends from the "
1308
1332
  f"method's declared controls; a second clause would contradict it"
1309
1333
  )
1334
+ if CRITERION_CLAUSE_MARKER in template:
1335
+ raise LaunchError(
1336
+ f"{context}: contains {CRITERION_CLAUSE_MARKER!r}, which core appends when the "
1337
+ f"plan declares a criterion-disciplined review; a second clause would "
1338
+ f"contradict the plan's toggle"
1339
+ )
1310
1340
  refuse_plan_marker(template, context)
1311
1341
  return used
1312
1342
 
@@ -1513,9 +1543,16 @@ def derive_review_mechanism(
1513
1543
  )
1514
1544
 
1515
1545
 
1516
- def render_review_method(method: ReviewMethod, mechanism: ReviewMechanism) -> str:
1546
+ def render_review_method(
1547
+ method: ReviewMethod, mechanism: ReviewMechanism, criterion: bool = False
1548
+ ) -> str:
1517
1549
  """The generic renderer. Every method — shipped or third-party — reaches the
1518
- contract through this one function, with the exact seat already resolved."""
1550
+ contract through this one function, with the exact seat already resolved.
1551
+
1552
+ `criterion` is the plan's toggle, threaded rather than read from anywhere global:
1553
+ when the preset declares a criterion-disciplined review, core appends the one
1554
+ discipline clause to every row, method-blind, the way the severity translation is
1555
+ appended — an author never writes it and a slot never carries it."""
1519
1556
  binding = mechanism.binding
1520
1557
  slots = {
1521
1558
  "command": mechanism.command or "",
@@ -1561,7 +1598,8 @@ def render_review_method(method: ReviewMethod, mechanism: ReviewMechanism) -> st
1561
1598
  # them as literals, and a perspective label carrying `; controls trials=1` rendered a
1562
1599
  # second controls clause beside core's (round 19, #9). Every substitution passes here.
1563
1600
  for marker, owner in ((CONTROLS_CLAUSE_MARKER, "the method's declared controls"),
1564
- (REVIEW_PLAN_MARKER, "the contract's canonical review record")):
1601
+ (REVIEW_PLAN_MARKER, "the contract's canonical review record"),
1602
+ (CRITERION_CLAUSE_MARKER, "the plan's criterion discipline")):
1565
1603
  if marker in body:
1566
1604
  raise LaunchError(
1567
1605
  f"review method {method.method_id!r} renders {marker!r} in its body, which "
@@ -1570,7 +1608,7 @@ def render_review_method(method: ReviewMethod, mechanism: ReviewMechanism) -> st
1570
1608
  rendered = (
1571
1609
  f"{method.method_id}: {body} "
1572
1610
  f"[{mechanism.shape}; {binding.model}/{binding.effort}"
1573
- f"{controls_clause(method)}{severity_translation(method)}]"
1611
+ f"{controls_clause(method)}{criterion_clause(criterion)}{severity_translation(method)}]"
1574
1612
  )
1575
1613
  # The assembled ROW, after the body. The body check above catches a SLOT value and says
1576
1614
  # so; what it cannot see is the part core itself prefixes — the METHOD ID, a user-chosen
@@ -1623,6 +1661,16 @@ def severity_vocabulary(method: ReviewMethod) -> str:
1623
1661
  return ", ".join(method.severity_emits)
1624
1662
 
1625
1663
 
1664
+ def criterion_clause(active: bool) -> str:
1665
+ """The discipline clause, or "" when the plan declares no criterion.
1666
+
1667
+ Core-owned and constant for the reason severity_translation is core-appended: a
1668
+ third-party method carries it without its author remembering to, and the gate can
1669
+ subtract the exact substring. Inside the seat bracket, BEFORE the severity clause,
1670
+ which is parsed as everything after its own marker."""
1671
+ return CRITERION_CLAUSE if active else ""
1672
+
1673
+
1626
1674
  def severity_translation(method: ReviewMethod) -> str:
1627
1675
  """The clause telling the reader how to move this reviewer's severities onto the
1628
1676
  canonical ladder, or "" when it reports on the ladder already.
@@ -1743,7 +1791,8 @@ def independence_grade(reviewer: ReviewBinding, main: ReviewBinding) -> str:
1743
1791
 
1744
1792
 
1745
1793
  def _resolve_one(
1746
- method: ReviewMethod, binding: ReviewBinding, main: ReviewBinding, config: dict[str, Any]
1794
+ method: ReviewMethod, binding: ReviewBinding, main: ReviewBinding,
1795
+ config: dict[str, Any], criterion: bool = False,
1747
1796
  ) -> ReviewMethodReport:
1748
1797
  """One method against the main seat. An optional method is NEVER silently rebound
1749
1798
  to a different seat: if its mechanism cannot be derived it is dropped, and the
@@ -1823,7 +1872,7 @@ def _resolve_one(
1823
1872
  return ReviewMethodReport(
1824
1873
  method.method_id, status, grade, "; ".join(notes),
1825
1874
  binding.model, binding.effort, binding.provider, mechanism.adapter,
1826
- render_review_method(method, mechanism), method_controls(method),
1875
+ render_review_method(method, mechanism, criterion), method_controls(method),
1827
1876
  tuple(mechanism.evidence),
1828
1877
  )
1829
1878
 
@@ -1844,7 +1893,7 @@ def best_review_grade(rows) -> str:
1844
1893
 
1845
1894
  def resolve_composable_review(
1846
1895
  review: ReviewPlan, main: ReviewBinding, config: dict[str, Any],
1847
- methods: dict[str, ReviewMethod],
1896
+ methods: dict[str, ReviewMethod], criterion: bool = False,
1848
1897
  ) -> ReviewReport:
1849
1898
  """Base panel plus the method map, each graded against the main seat.
1850
1899
 
@@ -1858,7 +1907,7 @@ def resolve_composable_review(
1858
1907
  if panel is None:
1859
1908
  raise LaunchError(f"the {PANEL_METHOD} method is required and is not registered")
1860
1909
  base_binding = review.base_binding or main
1861
- base = _resolve_one(panel, base_binding, main, config)
1910
+ base = _resolve_one(panel, base_binding, main, config, criterion)
1862
1911
  # Authored-but-unsatisfiable is a fallback too, not just an undeliverable mechanism.
1863
1912
  # Without this the §1 rule below was unreachable for the commonest case — a preset
1864
1913
  # naming a provider the profile has no host for never got here at all, because the
@@ -1866,7 +1915,7 @@ def resolve_composable_review(
1866
1915
  fell_back = review.base_unseated is not None
1867
1916
  if base.status == STATUS_DROPPED and review.base_binding is not None:
1868
1917
  # Try the same floor on the main's own seat before giving up on review.
1869
- base = _resolve_one(panel, main, main, config)
1918
+ base = _resolve_one(panel, main, main, config, criterion)
1870
1919
  fell_back = True
1871
1920
  if fell_back and base.status != STATUS_DROPPED:
1872
1921
  detail = (
@@ -1905,7 +1954,7 @@ def resolve_composable_review(
1905
1954
  if unseated
1906
1955
  else "no binding: the method names no verifier seat"))
1907
1956
  continue
1908
- rows.append(_resolve_one(method, binding, main, config))
1957
+ rows.append(_resolve_one(method, binding, main, config, criterion))
1909
1958
  return ReviewReport(base, tuple(rows), best_review_grade((base, *rows)))
1910
1959
 
1911
1960
 
@@ -2375,6 +2424,12 @@ RECEIPT_KEYS = frozenset({
2375
2424
  "provider", "model", "effort", "exit_status", "passes", "ordering_seed", "swap_group",
2376
2425
  # What the tool reported back, keyed by the field names its offer declared.
2377
2426
  "evidence",
2427
+ # The digest of the compiled criterion schema the emission validated this result
2428
+ # against — present ONLY on a schema-route dispatch (REVIEW_CRITERION_SCHEMA was
2429
+ # set), read by --verify-receipts, which recompiles the packet's criterion and
2430
+ # refuses a mismatch. Absent on a prose-route dispatch, which is disclosed, never
2431
+ # refused: host schema capability is per-machine and not retroactively provable.
2432
+ "criterion_schema_sha256",
2378
2433
  })
2379
2434
  # The BUNDLE's grammar, closed for the reason the receipt's is. The reader took its four
2380
2435
  # anchors and ignored everything else, so a key it does not know rode along unjudged and
@@ -2460,6 +2515,16 @@ def _receipt_structure_reason(receipt: Any) -> str:
2460
2515
  f"records exit_status as {type(status).__name__}, not an integer — a boolean is "
2461
2516
  f"not an exit status, and JSON false is not exit 0"
2462
2517
  )
2518
+ # Form here, in the door the adjudicator AND the fold share, so a folded pair of
2519
+ # matching non-hashes cannot agree its way past the per-receipt check.
2520
+ if "criterion_schema_sha256" in receipt and not is_sha256_hex(
2521
+ receipt["criterion_schema_sha256"]
2522
+ ):
2523
+ return (
2524
+ f"records criterion_schema_sha256={receipt['criterion_schema_sha256']!r}, "
2525
+ f"which is not a SHA-256 hash — nothing was hashed, so no schema validated "
2526
+ f"this result"
2527
+ )
2463
2528
  return ""
2464
2529
 
2465
2530
 
@@ -2496,7 +2561,7 @@ def _raw_receipt_reason(receipt: Any) -> str:
2496
2561
 
2497
2562
  def _receipt_reason(
2498
2563
  receipt: Any, row: "ReviewMethodReport | None", bundle: dict, seen: set,
2499
- controls: dict, required_evidence: tuple = (),
2564
+ controls: dict, required_evidence: tuple = (), criterion_digest: "str | None" = None,
2500
2565
  ) -> str:
2501
2566
  """Why this receipt cannot be credited, or "" when it can.
2502
2567
 
@@ -2545,6 +2610,22 @@ def _receipt_reason(
2545
2610
  )
2546
2611
  if packet != bundle.get("packet_sha256"):
2547
2612
  return "hashes a different packet than the one the plan declared"
2613
+ # Only when the packet's criterion is in hand: the digest is recompiled from the
2614
+ # packet's own record, so a mismatch means the schema the host was handed was
2615
+ # compiled from some OTHER criterion — a stricter or looser enum than the one this
2616
+ # packet declares. With no packet supplied there is nothing to recompile, and the
2617
+ # rendering says the claim is unbound rather than treating it as proven.
2618
+ if (
2619
+ criterion_digest is not None
2620
+ and "criterion_schema_sha256" in receipt
2621
+ and receipt["criterion_schema_sha256"] != criterion_digest
2622
+ ):
2623
+ return (
2624
+ f"was validated against a schema hashing "
2625
+ f"{receipt['criterion_schema_sha256']}, and recompiling this packet's "
2626
+ f"declared criterion yields {criterion_digest} — the schema enforced was "
2627
+ f"not compiled from the criterion this packet declares"
2628
+ )
2548
2629
  for field in ("provider", "model", "effort"):
2549
2630
  if receipt.get(field) != getattr(row, field):
2550
2631
  return (
@@ -2639,7 +2720,7 @@ def _receipt_reason(
2639
2720
 
2640
2721
  def verify_review_receipts(
2641
2722
  report: ReviewReport, bundle: Any, required_controls: dict[str, dict],
2642
- required_evidence: dict[str, tuple],
2723
+ required_evidence: dict[str, tuple], criterion_digest: "str | None" = None,
2643
2724
  ) -> tuple[ReviewReport, tuple[ReceiptVerdict, ...]]:
2644
2725
  """Adjudicate a review against its receipts.
2645
2726
 
@@ -2860,6 +2941,7 @@ def verify_review_receipts(
2860
2941
  # The row's own snapshot, never the caller's recomputation — see the drift
2861
2942
  # refusal above.
2862
2943
  row.evidence if row else (),
2944
+ criterion_digest,
2863
2945
  )
2864
2946
  if not reason and isinstance(receipt.get("dispatch_id"), str):
2865
2947
  seen.add(receipt["dispatch_id"])
@@ -2908,7 +2990,8 @@ def verify_review_receipts(
2908
2990
 
2909
2991
 
2910
2992
  def render_receipt_verdicts(
2911
- report: ReviewReport, verdicts: tuple[ReceiptVerdict, ...], packet_bound: bool = False
2993
+ report: ReviewReport, verdicts: tuple[ReceiptVerdict, ...],
2994
+ packet_bound: bool = False, criterion_note: str = "",
2912
2995
  ) -> str:
2913
2996
  # Coverage is printed as a count, not only as a word: `partial` tells a reader the set
2914
2997
  # was incomplete and `1/3` tells them how incomplete, which is the difference between
@@ -2952,6 +3035,10 @@ def render_receipt_verdicts(
2952
3035
  " grade_derivation=claimed — the plan does not serialize the main seat, so a row's "
2953
3036
  "independence grade is the launch's own claim and is not recomputed here"
2954
3037
  )
3038
+ if criterion_note:
3039
+ # Empty on every non-criterion adjudication, so the rendering is byte-identical
3040
+ # to today's whenever the plan declared no discipline.
3041
+ lines.append(criterion_note)
2955
3042
  for verdict in verdicts:
2956
3043
  state = "ACHIEVED" if verdict.accepted else "PROPOSED"
2957
3044
  lines.append(
@@ -2962,6 +3049,426 @@ def render_receipt_verdicts(
2962
3049
  return "\n".join(lines)
2963
3050
 
2964
3051
 
3052
+ # ── criterion discipline (defect-criterion stage 6) ─────────────────────────
3053
+ # The criterion is PER-REVIEW: its content rides the packet, never the contract or the
3054
+ # config. What core owns is the deterministic subset — the document grammar, the
3055
+ # compiled findings schema, the packet record line, and the findings check that receipt
3056
+ # emission runs. Whether a golden is persuasive or a stop condition genuinely reachable
3057
+ # stays semantic and is never adjudicated here.
3058
+ CRITERION_RECORD_SCHEMA = "ReviewCriterion/v1"
3059
+ CRITERION_RECORD_MARKER = f"{CRITERION_RECORD_SCHEMA}: "
3060
+ # The guide's eight fields plus the two the machine subset needs named apart: the enum
3061
+ # and its one stop-relevant member. Closed, and every key required — the guide's own
3062
+ # rule is that an empty cell makes a hunch, not a criterion.
3063
+ CRITERION_KEYS = frozenset({
3064
+ "name", "observer", "defect", "classes", "stop_class", "evidence",
3065
+ "non_defects", "stop_condition", "misclassification_cost", "goldens",
3066
+ })
3067
+ CRITERION_GOLDEN_KEYS = frozenset({"kind", "case", "why", "date", "provenance"})
3068
+ CRITERION_GOLDEN_KINDS = ("positive", "negative", "boundary")
3069
+ # The guide's admission bar: ≥2 positive, ≥2 negative, ≥1 boundary.
3070
+ CRITERION_GOLDEN_MINIMUMS = {"positive": 2, "negative": 2, "boundary": 1}
3071
+ CRITERION_PROVENANCE = frozenset({"measured", "constructed"})
3072
+
3073
+
3074
+ def _utf8_encodable(value: str) -> bool:
3075
+ """False for a string no UTF-8 artifact can carry — a JSON escape can smuggle a
3076
+ lone surrogate through json.loads, and every encode after it crashes."""
3077
+ try:
3078
+ value.encode("utf-8")
3079
+ except UnicodeEncodeError:
3080
+ return False
3081
+ return True
3082
+
3083
+
3084
+ def validate_criterion(document: Any, context: str) -> None:
3085
+ """Refuse a criterion document that fails the DECIDABLE subset of the guide schema,
3086
+ by field name. Nothing downstream receives a half-criterion: there is no fallback
3087
+ to "no criterion" and no default for any cell."""
3088
+ if not isinstance(document, dict):
3089
+ raise LaunchError(f"{context} must be a JSON object")
3090
+ # The whole document must be UTF-8-encodable BEFORE any field check: JSON escapes
3091
+ # admit lone surrogates that json.loads accepts and every later encode —
3092
+ # the canonical record line, the compiled schema bytes — crashes on with a
3093
+ # traceback instead of a named refusal (criterion round 2, #0).
3094
+ try:
3095
+ json.dumps(document, ensure_ascii=False).encode("utf-8")
3096
+ except UnicodeEncodeError as exc:
3097
+ raise LaunchError(
3098
+ f"{context} is not UTF-8-encodable ({exc}); a lone surrogate cannot ride "
3099
+ f"the record line or the compiled schema"
3100
+ ) from exc
3101
+ missing = sorted(CRITERION_KEYS - set(document))
3102
+ if missing:
3103
+ raise LaunchError(
3104
+ f"{context} is missing {', '.join(missing)}; an empty cell makes a hunch, "
3105
+ f"not a criterion"
3106
+ )
3107
+ unknown = sorted(set(document) - CRITERION_KEYS)
3108
+ if unknown:
3109
+ raise LaunchError(f"{context} carries unknown key(s): {', '.join(unknown)}")
3110
+ for field in ("name", "observer", "defect", "evidence", "stop_condition",
3111
+ "misclassification_cost"):
3112
+ value = document[field]
3113
+ if not isinstance(value, str) or not value.strip():
3114
+ raise LaunchError(f"{context}.{field} must be a non-empty string")
3115
+ classes = document["classes"]
3116
+ if (
3117
+ not isinstance(classes, list) or len(classes) < 2
3118
+ or not all(isinstance(value, str) and value for value in classes)
3119
+ ):
3120
+ raise LaunchError(
3121
+ f"{context}.classes must list at least two non-empty class names — the "
3122
+ f"stop-relevant class and the relief valves that keep it honest"
3123
+ )
3124
+ if len(set(classes)) != len(classes):
3125
+ raise LaunchError(f"{context}.classes repeats a value")
3126
+ for value in classes:
3127
+ if value != value.strip():
3128
+ raise LaunchError(
3129
+ f"{context}.classes[{value!r}] may not carry leading or trailing "
3130
+ f"whitespace, which no reader can see"
3131
+ )
3132
+ stop = document["stop_class"]
3133
+ if not isinstance(stop, str) or stop not in classes:
3134
+ raise LaunchError(
3135
+ f"{context}.stop_class must name exactly one member of classes; "
3136
+ f"got {stop!r}"
3137
+ )
3138
+ non_defects = document["non_defects"]
3139
+ if (
3140
+ not isinstance(non_defects, list) or not non_defects
3141
+ or not all(isinstance(value, str) and value.strip() for value in non_defects)
3142
+ ):
3143
+ raise LaunchError(
3144
+ f"{context}.non_defects must be a non-empty list of the defect-lookalikes "
3145
+ f"this criterion excludes"
3146
+ )
3147
+ goldens = document["goldens"]
3148
+ if not isinstance(goldens, list):
3149
+ raise LaunchError(f"{context}.goldens must be a list")
3150
+ counts = {kind: 0 for kind in CRITERION_GOLDEN_KINDS}
3151
+ for index, golden in enumerate(goldens):
3152
+ slot = f"{context}.goldens[{index}]"
3153
+ if not isinstance(golden, dict):
3154
+ raise LaunchError(f"{slot} must be an object")
3155
+ missing = sorted(CRITERION_GOLDEN_KEYS - set(golden))
3156
+ if missing:
3157
+ raise LaunchError(f"{slot} is missing {', '.join(missing)}")
3158
+ unknown = sorted(set(golden) - CRITERION_GOLDEN_KEYS)
3159
+ if unknown:
3160
+ raise LaunchError(f"{slot} carries unknown key(s): {', '.join(unknown)}")
3161
+ kind = golden["kind"]
3162
+ if kind not in CRITERION_GOLDEN_KINDS:
3163
+ raise LaunchError(
3164
+ f"{slot}.kind must be one of {', '.join(CRITERION_GOLDEN_KINDS)}; "
3165
+ f"got {kind!r}"
3166
+ )
3167
+ for field in ("case", "why"):
3168
+ if not isinstance(golden[field], str) or not golden[field].strip():
3169
+ raise LaunchError(f"{slot}.{field} must be a non-empty string")
3170
+ date = golden["date"]
3171
+ if not isinstance(date, str):
3172
+ raise LaunchError(f"{slot}.date must be a string")
3173
+ try:
3174
+ datetime.date.fromisoformat(date)
3175
+ except ValueError as exc:
3176
+ raise LaunchError(f"{slot}.date must be an ISO date: {exc}") from exc
3177
+ if golden["provenance"] not in CRITERION_PROVENANCE:
3178
+ raise LaunchError(
3179
+ f"{slot}.provenance must be measured or constructed; "
3180
+ f"got {golden['provenance']!r}"
3181
+ )
3182
+ counts[kind] += 1
3183
+ short = [
3184
+ f"{kind} {counts[kind]}/{minimum}"
3185
+ for kind, minimum in CRITERION_GOLDEN_MINIMUMS.items()
3186
+ if counts[kind] < minimum
3187
+ ]
3188
+ if short:
3189
+ raise LaunchError(
3190
+ f"{context}.goldens falls short of the admission bar ({', '.join(short)}); "
3191
+ f"the bar is never weakened to admit an entry"
3192
+ )
3193
+
3194
+
3195
+ def criterion_schema(classes: list) -> dict:
3196
+ """The compiled findings schema — a pure function of the enum, which is what makes
3197
+ `--verify-receipts` able to recompile it from the packet's record and compare
3198
+ digests. Both object levels reject unknown fields; `findings` may be empty."""
3199
+ return {
3200
+ "type": "object",
3201
+ "additionalProperties": False,
3202
+ "required": ["findings"],
3203
+ "properties": {
3204
+ "findings": {
3205
+ "type": "array",
3206
+ "items": {
3207
+ "type": "object",
3208
+ "additionalProperties": False,
3209
+ "required": ["class", "finding"],
3210
+ "properties": {
3211
+ "class": {"enum": list(classes)},
3212
+ "finding": {"type": "string", "minLength": 1},
3213
+ },
3214
+ },
3215
+ },
3216
+ },
3217
+ }
3218
+
3219
+
3220
+ def criterion_schema_bytes(document: dict) -> bytes:
3221
+ """Canonical bytes for the compiled schema: sorted keys, no whitespace, one
3222
+ trailing newline. Byte-identical on every compile of the same document — proven by
3223
+ the double-compile control — because the receipt digest is compared against a
3224
+ recompilation."""
3225
+ schema = criterion_schema(document["classes"])
3226
+ return (
3227
+ json.dumps(schema, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
3228
+ + "\n"
3229
+ ).encode("utf-8")
3230
+
3231
+
3232
+ def criterion_record_line(document: dict) -> str:
3233
+ """The one line the packet carries: the canonical serialization of the document
3234
+ behind the record marker, the same shape the ReviewPlan/v1 record rides in."""
3235
+ return CRITERION_RECORD_MARKER + json.dumps(
3236
+ document, ensure_ascii=False, sort_keys=True, separators=(",", ":")
3237
+ )
3238
+
3239
+
3240
+ def extract_criterion(text: str, context: str) -> dict:
3241
+ """The criterion the packet declares, from its record line — exactly one.
3242
+
3243
+ Zero records is a refusal rather than None on purpose: this is only called when
3244
+ the plan's rows carry the discipline clause, and a launch that declared the
3245
+ discipline cannot verify without the criterion."""
3246
+ # LINE-anchored, because the packet is free text: an unanchored find took a prose
3247
+ # mention — "for example, ReviewCriterion/v1: {...}" mid-sentence — as the declared
3248
+ # record (criterion round, #5). The record is a line the compiler printed: marker at
3249
+ # line start, one JSON value, nothing else before the newline.
3250
+ starts = []
3251
+ index = text.find(CRITERION_RECORD_MARKER)
3252
+ while index >= 0:
3253
+ if index == 0 or text[index - 1] == "\n":
3254
+ starts.append(index)
3255
+ index = text.find(CRITERION_RECORD_MARKER, index + 1)
3256
+ if not starts:
3257
+ raise LaunchError(
3258
+ f"{context} carries no {CRITERION_RECORD_SCHEMA} record line, so the "
3259
+ f"criterion the plan's discipline clause declares is nowhere in the bytes "
3260
+ f"the reviewers consumed"
3261
+ )
3262
+ if len(starts) > 1:
3263
+ raise LaunchError(
3264
+ f"{context} carries more than one {CRITERION_RECORD_SCHEMA} record; a mixed "
3265
+ f"packet is split into one packet per criterion, so exactly one is the claim "
3266
+ f"this can adjudicate"
3267
+ )
3268
+ start = starts[0] + len(CRITERION_RECORD_MARKER)
3269
+ try:
3270
+ document, consumed = json.JSONDecoder().raw_decode(text[start:])
3271
+ except json.JSONDecodeError as exc:
3272
+ raise LaunchError(
3273
+ f"{context} carries a {CRITERION_RECORD_SCHEMA} record that does not parse: "
3274
+ f"{exc}"
3275
+ ) from exc
3276
+ trailing = text[start + consumed:].split("\n", 1)[0]
3277
+ if trailing.strip():
3278
+ raise LaunchError(
3279
+ f"{context} carries a {CRITERION_RECORD_SCHEMA} record line with trailing "
3280
+ f"content ({trailing.strip()[:40]!r}); the record is the whole line, so "
3281
+ f"extra text on it is a different claim than the compiler printed"
3282
+ )
3283
+ validate_criterion(document, f"{context}'s {CRITERION_RECORD_SCHEMA} record")
3284
+ return document
3285
+
3286
+
3287
+ def criterion_classes_of_schema(schema: Any, context: str) -> list:
3288
+ """The enum a compiled schema file carries — accepted only when the whole schema
3289
+ equals what compiling that enum produces, so a hand-edited or foreign schema is
3290
+ refused rather than partially read."""
3291
+ try:
3292
+ classes = schema["properties"]["findings"]["items"]["properties"]["class"]["enum"]
3293
+ except (TypeError, KeyError):
3294
+ raise LaunchError(
3295
+ f"{context} is not a compiled criterion schema; compile one with "
3296
+ f"--compile-criterion"
3297
+ ) from None
3298
+ # Self-consistency alone lets the schema supply its own authority: an exact
3299
+ # criterion_schema(['anything']) equals recompiling its own enum although the
3300
+ # compiler can never produce it — validate_criterion refuses every one-class
3301
+ # document (criterion round, #2). The enum must also be one the compiler admits.
3302
+ if (
3303
+ not isinstance(classes, list) or len(classes) < 2
3304
+ or len(set(classes)) != len(classes)
3305
+ or not all(
3306
+ isinstance(value, str) and value and value == value.strip()
3307
+ and _utf8_encodable(value)
3308
+ for value in classes
3309
+ )
3310
+ ):
3311
+ raise LaunchError(
3312
+ f"{context} carries an enum --compile-criterion can never produce (fewer "
3313
+ f"than two classes, or a duplicate, empty, whitespace-padded, or "
3314
+ f"non-UTF-8-encodable name); it was not written by the compiler — "
3315
+ f"recompile rather than editing the artifact"
3316
+ )
3317
+ if schema != criterion_schema(classes):
3318
+ raise LaunchError(
3319
+ f"{context} differs from what compiling its own enum produces, so it was "
3320
+ f"not written by --compile-criterion; recompile rather than editing the "
3321
+ f"artifact"
3322
+ )
3323
+ return list(classes)
3324
+
3325
+
3326
+ def findings_violations(result: Any, classes: list) -> list:
3327
+ """Why this result is not a findings record under the declared enum — every
3328
+ violation named, empty when it conforms. Deterministic and total: no salvage, no
3329
+ guessed class, no partial admission."""
3330
+ violations = []
3331
+ if not isinstance(result, dict):
3332
+ return [f"the result is {type(result).__name__}, not an object"]
3333
+ unknown = sorted(set(result) - {"findings"})
3334
+ if unknown:
3335
+ violations.append(f"unknown result key(s): {', '.join(unknown)}")
3336
+ findings = result.get("findings")
3337
+ if not isinstance(findings, list):
3338
+ violations.append("the result carries no findings array")
3339
+ return violations
3340
+ for index, finding in enumerate(findings):
3341
+ slot = f"findings[{index}]"
3342
+ if not isinstance(finding, dict):
3343
+ violations.append(f"{slot} is {type(finding).__name__}, not an object")
3344
+ continue
3345
+ unknown = sorted(set(finding) - {"class", "finding"})
3346
+ if unknown:
3347
+ violations.append(f"{slot} carries unknown key(s): {', '.join(unknown)}")
3348
+ if "class" not in finding:
3349
+ violations.append(f"{slot} carries no class; a row is never admitted unclassified")
3350
+ elif finding["class"] not in classes:
3351
+ violations.append(
3352
+ f"{slot} carries class {finding['class']!r}, which is not in the "
3353
+ f"declared enum ({', '.join(classes)})"
3354
+ )
3355
+ prose = finding.get("finding")
3356
+ if not isinstance(prose, str) or not prose.strip():
3357
+ violations.append(f"{slot}.finding must be a non-empty string")
3358
+ return violations
3359
+
3360
+
3361
+ def compile_criterion_command(criterion_file: str, schema_out: str) -> int:
3362
+ """Validate a criterion document and publish its compiled findings schema.
3363
+
3364
+ Prints the packet record line the dispatching agent pastes verbatim, and the
3365
+ digest receipt emission will stamp — so the three artifacts (packet record,
3366
+ schema file, receipt digest) cannot drift from one authoring."""
3367
+ path = pathlib.Path(criterion_file).expanduser()
3368
+ try:
3369
+ document = json.loads(path.read_text(encoding="utf-8"))
3370
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
3371
+ raise LaunchError(f"cannot read criterion document {path}: {exc}") from exc
3372
+ validate_criterion(document, str(path))
3373
+ payload = criterion_schema_bytes(document)
3374
+ target = pathlib.Path(schema_out).expanduser()
3375
+ try:
3376
+ target.parent.mkdir(parents=True, exist_ok=True)
3377
+ publish_atomically(target, payload)
3378
+ except OSError as exc:
3379
+ raise LaunchError(f"cannot write compiled schema into {target}: {exc}") from exc
3380
+ print(criterion_record_line(document))
3381
+ print(f"criterion_schema_sha256={hashlib.sha256(payload).hexdigest()} {target}")
3382
+ return 0
3383
+
3384
+
3385
+ def check_findings_command(result_file: str, schema_file: str) -> int:
3386
+ """The structural accepting channel, standalone: refuse a findings result that
3387
+ fails the compiled schema, each violation by name. The same check runs inside
3388
+ --emit-receipt when REVIEW_CRITERION_SCHEMA is set, so bypassing this one only
3389
+ forfeits the receipt."""
3390
+ schema_path = pathlib.Path(schema_file).expanduser()
3391
+ try:
3392
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
3393
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
3394
+ raise LaunchError(f"cannot read compiled schema {schema_path}: {exc}") from exc
3395
+ classes = criterion_classes_of_schema(schema, str(schema_path))
3396
+ result_path = pathlib.Path(result_file).expanduser()
3397
+ try:
3398
+ result = json.loads(result_path.read_text(encoding="utf-8"))
3399
+ except (OSError, UnicodeDecodeError) as exc:
3400
+ raise LaunchError(f"cannot read result {result_path}: {exc}") from exc
3401
+ except json.JSONDecodeError as exc:
3402
+ print(f"REFUSED: {result_path} is not JSON, so no finding in it carries a class: {exc}")
3403
+ return 1
3404
+ violations = findings_violations(result, classes)
3405
+ if violations:
3406
+ for violation in violations:
3407
+ print(f"REFUSED: {violation}")
3408
+ return 1
3409
+ count = len(result["findings"])
3410
+ print(f"OK: {count} finding{'s' if count != 1 else ''} conform to the declared enum")
3411
+ return 0
3412
+
3413
+
3414
+ # The host CLIs' structured-output flags, and how each registers them. A TABLE plus a
3415
+ # PROBE rather than an offer key: a declared capability is documentation, and the rule
3416
+ # is probe-over-docs — the flag either appears in the installed binary's registered
3417
+ # options or the route renders prose discipline.
3418
+ HOST_SCHEMA_FLAGS = {
3419
+ "codex": (("exec", "--help"), "--output-schema"),
3420
+ "claude": (("--help",), "--json-schema"),
3421
+ }
3422
+ SCHEMA_FLAG_ABSENT_EXIT = 4
3423
+
3424
+
3425
+ def check_schema_flag_command(host: str, config_path: pathlib.Path) -> int:
3426
+ """Probe the resolved backend binary for its structured-output flag.
3427
+
3428
+ Exit 0 when the flag is registered, SCHEMA_FLAG_ABSENT_EXIT when the binary runs
3429
+ and does not register it — absence is a finding, not an error — and a LaunchError
3430
+ when nothing could be probed at all."""
3431
+ if host not in HOST_SCHEMA_FLAGS:
3432
+ raise LaunchError(
3433
+ f"no schema flag is known for host {host!r}; known hosts: "
3434
+ f"{', '.join(sorted(HOST_SCHEMA_FLAGS))}"
3435
+ )
3436
+ config = load_config(config_path)
3437
+ command = config.get("backends", {}).get(host, {}).get("command")
3438
+ if not isinstance(command, str) or not command:
3439
+ raise LaunchError(f"config names no backend command for host {host!r}")
3440
+ help_args, flag = HOST_SCHEMA_FLAGS[host]
3441
+ try:
3442
+ probe = subprocess.run(
3443
+ [command, *help_args], capture_output=True, text=True, timeout=60,
3444
+ )
3445
+ except (OSError, subprocess.TimeoutExpired) as exc:
3446
+ raise LaunchError(f"cannot probe {command!r} for {flag}: {exc}") from exc
3447
+ # A failed help run proves nothing about the flag — and its ERROR text can carry
3448
+ # the very token being searched for ("error: unknown option --output-schema"), so
3449
+ # scanning it reported present on the known-opposite input (criterion round, #7).
3450
+ if probe.returncode != 0:
3451
+ detail = (probe.stderr or probe.stdout).strip()[:160]
3452
+ raise LaunchError(
3453
+ f"cannot probe {command!r} for {flag}: help exited {probe.returncode}"
3454
+ + (f" ({detail})" if detail else "")
3455
+ + " — presence cannot be read from a failed probe"
3456
+ )
3457
+ text = (probe.stdout + probe.stderr).replace(",", " ")
3458
+ registered = any(
3459
+ token == flag or token.startswith(f"{flag}=")
3460
+ for token in text.split()
3461
+ )
3462
+ if registered:
3463
+ print(f"schema flag {flag} for host {host}: present ({command})")
3464
+ return 0
3465
+ print(
3466
+ f"schema flag {flag} for host {host}: absent ({command} registers no such "
3467
+ f"option; dispatch this route as prose discipline)"
3468
+ )
3469
+ return SCHEMA_FLAG_ABSENT_EXIT
3470
+
3471
+
2965
3472
  # ── receipt production (the adapter side of the same contract) ──────────────
2966
3473
  # Everything above adjudicates receipts; nothing produced one. That gap is the whole
2967
3474
  # defect: the only party able to write a receipt was whoever ran the review, so the
@@ -2983,6 +3490,12 @@ RECEIPT_METHOD_ENV = "REVIEW_METHOD_ID"
2983
3490
  # multi-pass method, and a third-party one gets them right by not participating.
2984
3491
  RECEIPT_SEED_ENV = "REVIEW_ORDERING_SEED"
2985
3492
  RECEIPT_SWAP_ENV = "REVIEW_SWAP_GROUP"
3493
+ # The compiled criterion schema for THIS dispatch, set by the dispatch wrapper only
3494
+ # when it passed the schema to the host. Set, emission validates the result against it
3495
+ # and refuses to write a receipt for a class-less or out-of-enum finding — so the only
3496
+ # path to a receipt IS the structural accepting channel, and a bypassed check is an
3497
+ # unproven dispatch. Unset — every prose route — emission behaves exactly as today.
3498
+ RECEIPT_CRITERION_ENV = "REVIEW_CRITERION_SCHEMA"
2986
3499
  # The probe adjudicates a one-row plan, and every plan's one required row is the base
2987
3500
  # panel wearing its reserved id (`_review_identity_reason`). It had a name of its own —
2988
3501
  # `adapter-conformance-probe` — which made the probe the only plan in the system whose
@@ -3021,9 +3534,10 @@ def _sha256_file(path: str) -> str:
3021
3534
  return digest.hexdigest()
3022
3535
 
3023
3536
 
3024
- def publish_atomically(target: pathlib.Path, text: str) -> None:
3025
- """Write `text` at `target` so a reader sees the old file or the complete new one, and
3026
- a failure leaves neither a partial file nor a temporary.
3537
+ def publish_atomically(target: pathlib.Path, payload: str | bytes,
3538
+ mode: int | None = None) -> None:
3539
+ """Write `payload` at `target` so a reader sees the old file or the complete new one,
3540
+ and a failure leaves neither a partial file nor a temporary.
3027
3541
 
3028
3542
  `write_text` creates the FINAL name and then fills it, so a concurrent reader — and
3029
3543
  the receipt fold globs exactly its directory — could observe a truncated record, and a
@@ -3036,10 +3550,17 @@ def publish_atomically(target: pathlib.Path, text: str) -> None:
3036
3550
  the preset save wrote its temporary and published it with no `try`, so an `os.replace`
3037
3551
  that failed left a complete temporary file beside the untouched preset file, for the
3038
3552
  next save's glob or the next reader to find (spec round 2, #6). The removal never
3039
- masks the error that caused it."""
3553
+ masks the error that caused it. The registration wizard was the third copy, with
3554
+ neither the boundary nor the cleanup (writable-scratch round, #6) — it publishes
3555
+ BYTES it snapshotted and re-applies the file's prior mode, hence the two optionals."""
3040
3556
  temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
3041
3557
  try:
3042
- temporary.write_text(text, encoding="utf-8")
3558
+ if isinstance(payload, bytes):
3559
+ temporary.write_bytes(payload)
3560
+ else:
3561
+ temporary.write_text(payload, encoding="utf-8")
3562
+ if mode is not None:
3563
+ temporary.chmod(mode)
3043
3564
  os.replace(temporary, target)
3044
3565
  except BaseException:
3045
3566
  try:
@@ -3075,18 +3596,79 @@ def emit_receipt_command(
3075
3596
  if not key or not separator:
3076
3597
  raise LaunchError(f"--evidence takes key=value, got {item!r}")
3077
3598
  fields[key] = value
3599
+ # Present-but-empty is a configuration error, never a silent prose downgrade: an
3600
+ # exported empty value made the whole validation vacuous while the caller believed
3601
+ # it armed (criterion round, #3 — the empty-variable class the corpus already
3602
+ # names for shell checks).
3603
+ criterion_env = os.environ.get(RECEIPT_CRITERION_ENV)
3604
+ if criterion_env is not None and not criterion_env.strip():
3605
+ raise LaunchError(
3606
+ f"{RECEIPT_CRITERION_ENV} is set but empty; unset it for a prose-route "
3607
+ f"dispatch or point it at a compiled schema"
3608
+ )
3609
+ # Only a SUCCESSFUL dispatch is validated and stamped: a failed dispatch's receipt
3610
+ # records the failure and is already refused credit for exit != 0, and refusing to
3611
+ # write it would erase the failure record — while stamping it would claim a
3612
+ # validation that never ran.
3613
+ criterion_schema_path = criterion_env if status == 0 else None
3614
+ # ONE byte snapshot each for the schema and the result: validating one read and
3615
+ # hashing a second let the digest bind bytes the validation never saw — the same
3616
+ # false PASS whether the swap is a race or an adversary (criterion round, #4).
3617
+ criterion_digest = None
3618
+ result_bytes = None
3619
+ if criterion_schema_path:
3620
+ schema_file = pathlib.Path(criterion_schema_path).expanduser()
3621
+ try:
3622
+ schema_bytes = schema_file.read_bytes()
3623
+ except OSError as exc:
3624
+ raise LaunchError(
3625
+ f"{RECEIPT_CRITERION_ENV} names {schema_file}, which is not a readable "
3626
+ f"compiled schema: {exc}"
3627
+ ) from exc
3628
+ try:
3629
+ schema = json.loads(schema_bytes)
3630
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
3631
+ raise LaunchError(
3632
+ f"{RECEIPT_CRITERION_ENV} names {schema_file}, which is not a readable "
3633
+ f"compiled schema: {exc}"
3634
+ ) from exc
3635
+ classes = criterion_classes_of_schema(schema, str(schema_file))
3636
+ try:
3637
+ result_bytes = pathlib.Path(result_file).expanduser().read_bytes()
3638
+ except OSError as exc:
3639
+ raise LaunchError(f"cannot read result {result_file}: {exc}") from exc
3640
+ try:
3641
+ result = json.loads(result_bytes)
3642
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
3643
+ raise LaunchError(
3644
+ f"no receipt: the result is not JSON, so no finding in it carries a "
3645
+ f"class from the declared enum ({exc})"
3646
+ ) from exc
3647
+ violations = findings_violations(result, classes)
3648
+ if violations:
3649
+ raise LaunchError(
3650
+ "no receipt: " + "; ".join(violations) + " — a result the schema "
3651
+ "refuses is not published, and runtime never guesses a class"
3652
+ )
3653
+ criterion_digest = hashlib.sha256(schema_bytes).hexdigest()
3078
3654
  dispatch_id = uuid.uuid4().hex
3079
3655
  receipt = {
3080
3656
  "schema": RECEIPT_SCHEMA,
3081
3657
  "method_id": method_id,
3082
3658
  "dispatch_id": dispatch_id,
3083
3659
  "packet_sha256": _sha256_file(packet_file),
3084
- "result_sha256": _sha256_file(result_file),
3660
+ # The bytes the validation saw, when it ran; the file, when it did not.
3661
+ "result_sha256": (
3662
+ hashlib.sha256(result_bytes).hexdigest()
3663
+ if result_bytes is not None else _sha256_file(result_file)
3664
+ ),
3085
3665
  "provider": provider,
3086
3666
  "model": model,
3087
3667
  "effort": effort,
3088
3668
  "exit_status": status,
3089
3669
  }
3670
+ if criterion_digest:
3671
+ receipt["criterion_schema_sha256"] = criterion_digest
3090
3672
  if fields:
3091
3673
  receipt["evidence"] = fields
3092
3674
  for key, name in (("ordering_seed", RECEIPT_SEED_ENV), ("swap_group", RECEIPT_SWAP_ENV)):
@@ -3214,7 +3796,12 @@ def _merge_method_passes(method_id: str, group: list[dict], main_dispatch_id: st
3214
3796
  # `passes`, and for a lone receipt it is naturally the one-element set. Round 4 made
3215
3797
  # the multi-pass record faithful and this is the same sentence applied to the twin
3216
3798
  # F1 already names: asked of EACH receipt, singleton included.
3217
- for field_name in ("provider", "model", "effort", "packet_sha256"):
3799
+ for field_name in (
3800
+ "provider", "model", "effort", "packet_sha256", "criterion_schema_sha256",
3801
+ ):
3802
+ # criterion_schema_sha256 included with PRESENCE counted as a value: passes
3803
+ # validated against different schemas — or one validated and one not — ran
3804
+ # under different criteria and cannot be one method's passes.
3218
3805
  values = {json.dumps(receipt.get(field_name), sort_keys=True) for receipt in group}
3219
3806
  if len(values) > 1:
3220
3807
  raise LaunchError(
@@ -3578,7 +4165,8 @@ def routed_preset_names(presets: dict[str, Any]) -> set[str]:
3578
4165
  """
3579
4166
  return {
3580
4167
  name for name, preset in presets.items()
3581
- if isinstance(preset, dict) and ("mission" in preset or "trigger" in preset)
4168
+ if isinstance(preset, dict)
4169
+ and ("mission" in preset or "trigger" in preset or "criterion" in preset)
3582
4170
  }
3583
4171
 
3584
4172
 
@@ -4519,7 +5107,7 @@ def host_models(config: dict[str, Any], host: str, tiers: dict[str, Any]) -> lis
4519
5107
 
4520
5108
  def resolve_review_for_plan(
4521
5109
  review: ReviewPlan, config: dict[str, Any], host: str, main_model: str,
4522
- main_effort: str, context: str,
5110
+ main_effort: str, context: str, criterion: bool = False,
4523
5111
  ) -> tuple[dict, "ReviewReport | None"]:
4524
5112
  """(method registry, resolved report) for a review against the main seat.
4525
5113
 
@@ -4527,6 +5115,16 @@ def resolve_review_for_plan(
4527
5115
  what a fresh launch would; two copies of this would let the Custom hub show a report
4528
5116
  the next launch disagrees with."""
4529
5117
  if review.source != "composable":
5118
+ if criterion:
5119
+ # The clause renders only through composable review rows, so on a legacy
5120
+ # plan the toggle is accepted and then does nothing — a false success the
5121
+ # operator reads as discipline in force (criterion round, #0).
5122
+ raise LaunchError(
5123
+ f"{context} declares criterion = true, but its review uses the legacy "
5124
+ f"schema; the discipline clause renders only through composable review "
5125
+ f"rows, so the toggle would be silently inert — author a composable "
5126
+ f"[review] block or drop the toggle"
5127
+ )
4530
5128
  return {}, None
4531
5129
  provider = config["hosts"][host].get("provider")
4532
5130
  if not isinstance(provider, str) or not provider:
@@ -4536,7 +5134,7 @@ def resolve_review_for_plan(
4536
5134
  )
4537
5135
  methods = load_review_methods(config)
4538
5136
  main_seat = ReviewBinding(provider, host, main_model, main_effort)
4539
- return methods, resolve_composable_review(review, main_seat, config, methods)
5137
+ return methods, resolve_composable_review(review, main_seat, config, methods, criterion)
4540
5138
 
4541
5139
 
4542
5140
  def effective_review_family(
@@ -4578,6 +5176,7 @@ def apply_review_plan(plan: dict[str, Any], config: dict[str, Any], review: Revi
4578
5176
  methods, report = resolve_review_for_plan(
4579
5177
  review, config, plan["host"], plan["tiers"][plan["main_tier"]]["model"],
4580
5178
  tier_effort(plan, plan["main_tier"]), f"custom.{plan['preset']}",
5179
+ plan.get("criterion", False),
4581
5180
  )
4582
5181
  plan["review_plan"] = review
4583
5182
  # Editing on one host replaces that host's arm and leaves the others exactly as
@@ -4792,8 +5391,18 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
4792
5391
  # isolated mechanism is an invalid launch, and that has to fail while the plan is
4793
5392
  # being built rather than halfway through printing a contract.
4794
5393
  main_effort = frontier_effort if main_tier == "frontier" else tiers[main_tier]["effort"]
5394
+ # The criterion-discipline toggle. A BOOLEAN, deliberately: the criterion itself is
5395
+ # per-review and rides the packet, which packet_sha256 binds — a preset carrying its
5396
+ # content would put per-review text into the per-launch contract the golden pins.
5397
+ criterion = preset.get("criterion", False)
5398
+ if not isinstance(criterion, bool):
5399
+ raise LaunchError(
5400
+ f"presets.{preset_name}.criterion must be a boolean toggle; the criterion "
5401
+ f"itself is declared in the review packet, never in the preset"
5402
+ )
4795
5403
  review_methods, review_report = resolve_review_for_plan(
4796
- review, config, host, tiers[main_tier]["model"], main_effort, f"presets.{preset_name}"
5404
+ review, config, host, tiers[main_tier]["model"], main_effort,
5405
+ f"presets.{preset_name}", criterion,
4797
5406
  )
4798
5407
  codex_policy = preset.get("codex_execution_policy")
4799
5408
  claude_policy = preset.get("claude_permission_mode")
@@ -4886,6 +5495,7 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
4886
5495
  },
4887
5496
  "mission": mission,
4888
5497
  "trigger": trigger,
5498
+ "criterion": criterion,
4889
5499
  }
4890
5500
 
4891
5501
 
@@ -5303,32 +5913,39 @@ def _trial_registration(
5303
5913
  through the genuine load_config — same merge order, same validation, same
5304
5914
  collision refusal. Returns None on acceptance, the reader's message on refusal."""
5305
5915
  trial = pathlib.Path(tempfile.mkdtemp(prefix="agent-launch-register-"))
5306
- shutil.copy(config_path, trial / config_path.name)
5307
- for sibling in (USER_PRESETS_NAME, USER_LAUNCHER_NAME):
5308
- source = config_path.with_name(sibling)
5309
- if source.is_file():
5310
- shutil.copy(source, trial / sibling)
5311
- methods_source = user_methods_path(config_path)
5312
- existing = methods_source.read_text(encoding="utf-8") if methods_source.is_file() else USER_METHODS_HEADER
5313
- candidate = trial / USER_METHODS_NAME
5314
- try:
5315
- candidate.write_text(existing + block, encoding="utf-8")
5316
- except OSError as exc:
5317
- # A read-only config dir or a full disk lost every answer the
5318
- # user had just typed. Reported like any other refusal instead.
5319
- raise LaunchError(f"cannot write {candidate}: {exc}") from exc
5320
- candidate.chmod(0o600)
5916
+ # One finally over EVERYTHING after the mkdtemp — the copies, the candidate
5917
+ # write, both readers: the trial is a scratch workspace, and every exit used
5918
+ # to leave it behind, one directory per attempt, for the life of the temp
5919
+ # area (writable-scratch round, #7).
5321
5920
  try:
5322
- trial_config = load_config(trial / config_path.name)
5323
- # The REAL pipeline is both readers, not the first one. load_config accepts a
5324
- # method whose instructions name a slot core does not provide; load_review_methods
5325
- # — the reader every launch actually goes through — refuses it. Trialling only the
5326
- # first wrote the block, told the user it was live, and left the refusal for the
5327
- # next launch. A trial that does not run the reader that decides is not a trial.
5328
- load_review_methods(trial_config)
5329
- except LaunchError as exc:
5330
- return str(exc)
5331
- return None
5921
+ shutil.copy(config_path, trial / config_path.name)
5922
+ for sibling in (USER_PRESETS_NAME, USER_LAUNCHER_NAME):
5923
+ source = config_path.with_name(sibling)
5924
+ if source.is_file():
5925
+ shutil.copy(source, trial / sibling)
5926
+ methods_source = user_methods_path(config_path)
5927
+ existing = methods_source.read_text(encoding="utf-8") if methods_source.is_file() else USER_METHODS_HEADER
5928
+ candidate = trial / USER_METHODS_NAME
5929
+ try:
5930
+ candidate.write_text(existing + block, encoding="utf-8")
5931
+ except OSError as exc:
5932
+ # A read-only config dir or a full disk lost every answer the
5933
+ # user had just typed. Reported like any other refusal instead.
5934
+ raise LaunchError(f"cannot write {candidate}: {exc}") from exc
5935
+ candidate.chmod(0o600)
5936
+ try:
5937
+ trial_config = load_config(trial / config_path.name)
5938
+ # The REAL pipeline is both readers, not the first one. load_config accepts a
5939
+ # method whose instructions name a slot core does not provide; load_review_methods
5940
+ # — the reader every launch actually goes through — refuses it. Trialling only the
5941
+ # first wrote the block, told the user it was live, and left the refusal for the
5942
+ # next launch. A trial that does not run the reader that decides is not a trial.
5943
+ load_review_methods(trial_config)
5944
+ except LaunchError as exc:
5945
+ return str(exc)
5946
+ return None
5947
+ finally:
5948
+ shutil.rmtree(trial, ignore_errors=True)
5332
5949
 
5333
5950
 
5334
5951
  def register_reviewer_wizard(
@@ -5550,60 +6167,67 @@ def register_reviewer_wizard(
5550
6167
  back_hint=t("corpus.back.hint"))
5551
6168
  return False
5552
6169
  mode = (write_target.stat().st_mode & 0o777) if existed else 0o600
5553
- temporary = write_target.with_name(
5554
- f".{write_target.name}.{os.getpid()}.tmp"
5555
- )
5556
- temporary.write_bytes(before + block.encode())
5557
- temporary.chmod(mode)
5558
- os.replace(temporary, write_target)
5559
- # Unreachable while the trial is the real pipeline — and
5560
- # load-bearing exactly when it is not: a post-write failure must
5561
- # restore the pre-write state (bytes, mode, or ABSENCE) and say
5562
- # so, never leave a corrupt registry behind a green screen.
5563
6170
  try:
5564
- fresh = load_config(config_path)
5565
- except LaunchError:
5566
- fresh = None
5567
- if fresh is None or answers["id"] not in load_review_methods(fresh):
5568
- # Restore ONLY when the file still holds exactly our write:
5569
- # unconditional rollback would destroy an edit that landed in
5570
- # the meantime; if the bytes moved, the human owns the merge.
5571
- current = (
5572
- write_target.read_bytes() if write_target.is_file() else b""
5573
- )
5574
- if current != before + block.encode():
5575
- # Its own sentence, because this branch deliberately does NOT
5576
- # roll back someone else's edit landed and the human owns the
5577
- # merge. Sharing the restore branch's message told the user the
5578
- # file had been restored in the one case where it was left
5579
- # exactly as the other writer wrote it.
5580
- _corpus_info(
5581
- ui, t("wizard.title"), [t("wizard.postwrite.raced.line")],
5582
- back_hint=t("corpus.back.hint"),
5583
- )
5584
- return False
5585
- if existed:
5586
- restore = write_target.with_name(
5587
- f".{write_target.name}.{os.getpid()}.restore"
6171
+ # The shared primitive owns the temporary's lifecycle: on any
6172
+ # failure it removes its own temp and re-raises. A directory at
6173
+ # the target, a permission flip, a full disk — each used to
6174
+ # escape here as a raw OSError, PAST the retained-answer loop,
6175
+ # with the completed temporary left beside the untouched file.
6176
+ # Publication failure is a refusal like any other: the reader's
6177
+ # loop keeps the answers, and the screen shows the OS's words.
6178
+ publish_atomically(write_target, before + block.encode(), mode)
6179
+ except OSError as exc:
6180
+ verdict = f"cannot write {write_target}: {exc}"
6181
+ if verdict is None:
6182
+ # Unreachable while the trial is the real pipeline — and
6183
+ # load-bearing exactly when it is not: a post-write failure must
6184
+ # restore the pre-write state (bytes, mode, or ABSENCE) and say
6185
+ # so, never leave a corrupt registry behind a green screen.
6186
+ try:
6187
+ fresh = load_config(config_path)
6188
+ except LaunchError:
6189
+ fresh = None
6190
+ if fresh is None or answers["id"] not in load_review_methods(fresh):
6191
+ # Restore ONLY when the file still holds exactly our write:
6192
+ # unconditional rollback would destroy an edit that landed in
6193
+ # the meantime; if the bytes moved, the human owns the merge.
6194
+ current = (
6195
+ write_target.read_bytes() if write_target.is_file() else b""
5588
6196
  )
5589
- restore.write_bytes(before)
5590
- restore.chmod(mode)
5591
- os.replace(restore, write_target)
5592
- else:
5593
- write_target.unlink(missing_ok=True)
5594
- _corpus_info(ui, t("wizard.title"), [t("wizard.postwrite.line")],
5595
- back_hint=t("corpus.back.hint"))
5596
- return False # pre-write state restored above
5597
- config.clear()
5598
- config.update(fresh)
5599
- registry.clear()
5600
- registry.update(load_review_methods(config))
5601
- _corpus_info(
5602
- ui, t("wizard.title"),
5603
- [t("wizard.done.line").format(method_id=answers["id"])],
5604
- back_hint=t("corpus.back.hint"),
5605
- )
5606
- return True
6197
+ if current != before + block.encode():
6198
+ # Its own sentence, because this branch deliberately does NOT
6199
+ # roll back — someone else's edit landed and the human owns the
6200
+ # merge. Sharing the restore branch's message told the user the
6201
+ # file had been restored in the one case where it was left
6202
+ # exactly as the other writer wrote it.
6203
+ _corpus_info(
6204
+ ui, t("wizard.title"), [t("wizard.postwrite.raced.line")],
6205
+ back_hint=t("corpus.back.hint"),
6206
+ )
6207
+ return False
6208
+ if existed:
6209
+ restore = write_target.with_name(
6210
+ f".{write_target.name}.{os.getpid()}.restore"
6211
+ )
6212
+ restore.write_bytes(before)
6213
+ restore.chmod(mode)
6214
+ os.replace(restore, write_target)
6215
+ else:
6216
+ write_target.unlink(missing_ok=True)
6217
+ _corpus_info(ui, t("wizard.title"), [t("wizard.postwrite.line")],
6218
+ back_hint=t("corpus.back.hint"))
6219
+ return False # pre-write state restored above
6220
+ if verdict is None:
6221
+ config.clear()
6222
+ config.update(fresh)
6223
+ registry.clear()
6224
+ registry.update(load_review_methods(config))
6225
+ _corpus_info(
6226
+ ui, t("wizard.title"),
6227
+ [t("wizard.done.line").format(method_id=answers["id"])],
6228
+ back_hint=t("corpus.back.hint"),
6229
+ )
6230
+ return True
5607
6231
  action = choose(
5608
6232
  t("wizard.refused.title"),
5609
6233
  [
@@ -6039,6 +6663,13 @@ def preset_from_plan(
6039
6663
  "codex_execution_policy": plan["codex_execution_policy"],
6040
6664
  "claude_permission_mode": plan["claude_permission_mode"],
6041
6665
  }
6666
+ if plan.get("criterion"):
6667
+ # Save As from a criterion-toggled plan silently dropped the discipline: the
6668
+ # routed-name guard covers same-name shadowing, not a fresh name, and the
6669
+ # reload defaulted the absent field to false while Save reported success
6670
+ # (criterion round, #8). Written only when true, so every existing saved
6671
+ # preset stays byte-identical.
6672
+ fields["criterion"] = True
6042
6673
  if review_block is None:
6043
6674
  # Carrying both schemas on one preset is a validation error, so the legacy
6044
6675
  # names appear only when there is no authored block to write.
@@ -6140,21 +6771,25 @@ def preset_from_plan(
6140
6771
  f"is not an effort — saving would drop it and {other} would reload at "
6141
6772
  f"its host default. Fix that entry in the launch profile and save again."
6142
6773
  )
6774
+ # A non-table where the normalized home would go — `tier_overrides.<host>` or
6775
+ # its `frontier` entry authored as a value. The serializer now carries such a
6776
+ # value VERBATIM (spec round 7, #3), so this normalizer cannot write into it
6777
+ # (`setdefault("frontier", {})` on a string was round 24, #8's raw TypeError)
6778
+ # and cannot skip past it either: skipping used to be safe only because the
6779
+ # renderer refused the block, and with that refusal gone a skip would drop
6780
+ # the authored `frontier_effort` in silence. When the authored effort is that
6781
+ # host's default there is nothing to write and the carry is faithful; when it
6782
+ # is not, the home is genuinely occupied and the save refuses naming both —
6783
+ # the one case D-20260817-105da4 keeps a named refusal for.
6143
6784
  block = scoped.get(other)
6785
+ occupied = None
6786
+ existing = None
6144
6787
  if block is not None and not isinstance(block, dict):
6145
- # Left exactly as authored; the serializer names it rather than this
6146
- # silently repairing a shape it is not the owner of.
6147
- continue
6148
- existing = (block or {}).get("frontier")
6149
- if existing is not None and not isinstance(existing, dict):
6150
- # Left exactly as authored, for the reason the host-block branch above says
6151
- # in the same words: the serializer owns unwritable shapes and names them.
6152
- # This normalizer wrote into the block regardless, so `setdefault("frontier",
6153
- # {})` handed back the STRING and `["effort"] =` left Save as a raw
6154
- # TypeError — past the named boundary its workhorse twin arrives through
6155
- # (round 24, #8). Nothing is dropped by skipping: the renderer's `_table`
6156
- # door refuses this very entry by name a moment later.
6157
- continue
6788
+ occupied = f"tier_overrides.{other}"
6789
+ else:
6790
+ existing = (block or {}).get("frontier")
6791
+ if existing is not None and not isinstance(existing, dict):
6792
+ occupied = f"tier_overrides.{other}.frontier"
6158
6793
  if isinstance(existing, dict) and existing.get("effort") not in (None, effort):
6159
6794
  # The contradiction build_plan already refuses, reached from the host that
6160
6795
  # does not launch it. Named here rather than resolved: picking one of two
@@ -6179,6 +6814,19 @@ def preset_from_plan(
6179
6814
  .get("frontier", {}).get("effort")
6180
6815
  ):
6181
6816
  continue
6817
+ if occupied is not None:
6818
+ # AFTER the default elision on purpose, unlike the contradiction above: a
6819
+ # non-table home states no effort to contradict, so a default-valued
6820
+ # authoring writes no line and reloads faithfully. A non-default one has
6821
+ # exactly one home and that home is occupied by a value the save carries
6822
+ # verbatim — writing both is impossible, and dropping either is the
6823
+ # silence S4 forbids.
6824
+ raise LaunchError(
6825
+ f"preset {name!r}: {other} authors frontier_effort={effort!r}, whose "
6826
+ f"saved home is tier_overrides.{other}.frontier.effort, and {occupied} "
6827
+ f"holds a non-table value the save carries verbatim — it cannot write "
6828
+ f"both. Remove one of them in the launch profile and save again."
6829
+ )
6182
6830
  scoped.setdefault(other, {}).setdefault("frontier", {})["effort"] = effort
6183
6831
  return fields, scoped, review_block
6184
6832
 
@@ -6270,11 +6918,11 @@ def render_preset_block(
6270
6918
  tier_overrides: dict[str, Any],
6271
6919
  review_block: dict[str, Any] | None = None,
6272
6920
  ) -> str:
6273
- # One door for every table this writes back, hoisted here from inside the review-arm
6274
- # loop so the tier overrides get the same named boundary. Save failures are contracted
6275
- # to arrive as LaunchError at the settings hub, and the inactive-host overrides had no
6276
- # boundary at all: a scalar host block was silently FILTERED OUT before it arrived, and
6277
- # a scalar tier block reached `.items()` as a raw AttributeError (round 22, #9).
6921
+ # One door for every place only a table can stand an arm's `methods`, its `base`,
6922
+ # each binding. Save failures are contracted to arrive as LaunchError at the settings
6923
+ # hub, never a raw escape or a silent filter (round 22, #9). The tier overrides and
6924
+ # whole review arms no longer pass through it: a non-table node there is not
6925
+ # malformed, it is a VALUE its parent table spells (spec round 7, #2/#3).
6278
6926
  def _table(value, where):
6279
6927
  if not isinstance(value, dict):
6280
6928
  raise LaunchError(
@@ -6287,51 +6935,58 @@ def render_preset_block(
6287
6935
  lines = [f"[presets.{_toml_key(name)}]"]
6288
6936
  for key, value in fields.items():
6289
6937
  lines.append(f"{key} = {_toml_scalar(value)}")
6290
- for host, tiers in tier_overrides.items():
6291
- for tier, override in _table(tiers, f"tier_overrides.{host}").items():
6292
- # Through the SAME recursive emitter the review arms use. This was a flat loop
6293
- # over one level, so an inactive override holding a sub-table reached
6294
- # `_toml_scalar`, which gives a bare table no spellinga profile the launcher
6295
- # accepts died at Save with `cannot serialize preset value: {'nested': 1}`,
6296
- # naming neither the host, the tier, nor the key (spec round 3, #6). Verbatim
6297
- # carry has no depth limit here for the reason it has none in an arm (round 24,
6298
- # #7), and the emitter puts every leaf through `_toml_key` round 24 #10's half
6299
- # and every leaf VALUE through `_arm_scalar`, so a genuinely unwritable entry
6300
- # is refused naming `tier_overrides.<host>.<tier>.<key>` rather than its repr.
6301
- _emit_arm_table(
6302
- lines,
6303
- f"presets.{_toml_key(name)}.tier_overrides"
6304
- f".{_toml_key(host)}.{_toml_key(tier)}",
6305
- _table(override, f"tier_overrides.{host}.{tier}"),
6306
- name, f"tier_overrides.{host}.{tier}",
6307
- )
6938
+ if tier_overrides:
6939
+ # The whole tree through the SAME recursive emitter the review arms use, from the
6940
+ # `tier_overrides` root down. Verbatim carry has no depth limit (spec round 3, #6;
6941
+ # round 24, #7) and no FLOOR either: a non-table node at any depth —
6942
+ # `tier_overrides.claude = "x"`, or `…codex.workhorse = [1, 2]`is TOML the
6943
+ # launcher accepts, since only the active host's blocks are validated at load,
6944
+ # and TOML spells it perfectly well as a value in its parent table; refusing it
6945
+ # at Save made an accepted profile unsaveable, which is S4's complement (spec
6946
+ # round 7, #3 the per-tier loop this replaces held a table door at exactly the
6947
+ # two depths the emitter now writes as values). The active host's blocks arrive
6948
+ # normalized from `preset_from_plan`, so for them this emits the same
6949
+ # `[…tier_overrides.<host>.<tier>]` tables it always did; every leaf key goes
6950
+ # through `_toml_key` and every leaf value through `_arm_scalar`, so a genuinely
6951
+ # unwritable entry is refused naming its full `tier_overrides.…` path.
6952
+ _emit_arm_table(
6953
+ lines, f"presets.{_toml_key(name)}.tier_overrides",
6954
+ tier_overrides, name, "tier_overrides",
6955
+ )
6308
6956
  if review_block is not None:
6309
6957
  # One sub-table per binding, and the method id is quoted: it is a user-chosen
6310
6958
  # name, so a bare key would break on anything a TOML bare key cannot hold.
6311
6959
  # Adding method N therefore appends exactly one table and touches nothing else.
6312
6960
  arms = review_block.get(REVIEW_ARMS_KEY)
6961
+ # A whole arm authored as a VALUE — `hosts.codex = [1, 2]` — is TOML the launcher
6962
+ # accepts, because only the launching arm is parsed, and TOML writes it back as a
6963
+ # value in the parent hosts table; so that is what this does, rather than the
6964
+ # refusal that made an accepted profile unsaveable (spec round 7, #2). Emitted
6965
+ # before the table arms for the reason every emitter here puts scalars first; a
6966
+ # value with no TOML form at all is refused through `_arm_scalar`, naming
6967
+ # `review.hosts.<host>`.
6968
+ value_arms = (
6969
+ {host: arm for host, arm in arms.items() if not isinstance(arm, dict)}
6970
+ if arms else {}
6971
+ )
6972
+ if value_arms:
6973
+ lines.append("")
6974
+ lines.append(f"[presets.{_toml_key(name)}.review.{REVIEW_ARMS_KEY}]")
6975
+ for host in sorted(value_arms, key=_toml_key):
6976
+ lines.append(
6977
+ f"{_toml_key(host)} = "
6978
+ f"{_arm_scalar(value_arms[host], name, f'review.{REVIEW_ARMS_KEY}.{host}')}"
6979
+ )
6313
6980
  for prefix, block in (
6314
6981
  sorted((f"review.{REVIEW_ARMS_KEY}.{_toml_key(host)}", arm)
6315
- for host, arm in arms.items())
6982
+ for host, arm in arms.items() if isinstance(arm, dict))
6316
6983
  if arms else [("review", review_block)]
6317
6984
  ):
6318
- # An untouched arm is written back exactly as authored, and "as authored"
6319
- # includes shapes this cannot serialize: a scalar where a table belongs made
6320
- # `"base" in block` a raw TypeError out of Save, after the launch had already
6321
- # happened. Save failures are contracted to arrive as LaunchError at the
6322
- # settings hub, so this one does too, naming the arm to look at.
6323
- if not isinstance(block, dict):
6324
- raise LaunchError(
6325
- f"preset {name!r}: the review arm at {prefix!r} is "
6326
- f"{type(block).__name__}, not a table — it cannot be written back. "
6327
- f"Fix that entry in the launch profile and save again."
6328
- )
6329
6985
  # `.get`, because an untouched arm is written back exactly as authored and
6330
6986
  # a raw block need not carry both keys — only the edited arm is re-derived.
6331
- # The nested shapes get the same door as the arm itself: `base`, `methods` and
6332
- # each binding are tables or the save names the entry (round 19, #11) the
6333
- # outer check above caught a scalar arm and let `.items()` on a scalar
6334
- # `methods` escape as AttributeError past the same boundary.
6987
+ # The nested shapes each get a door: `base`, `methods` and each binding are
6988
+ # tables or the save names the entry (round 19, #11), which once escaped as
6989
+ # a raw AttributeError from `.items()` on a scalar `methods`.
6335
6990
  methods = _table(block.get("methods", {}), f"{prefix}.methods")
6336
6991
  # Everything else the arm carries. An untouched arm is promised back VERBATIM
6337
6992
  # and this serializer knew exactly two keys, so any other top-level field the
@@ -7799,14 +8454,17 @@ def child_agent_registrations(
7799
8454
  """THE Codex child-registration projection — `(tier, description, config path, config
7800
8455
  content)` per spawnable tier, in the order the backend receives them.
7801
8456
 
7802
- Pure: it reads the authored templates and derives, it writes nothing. One value, three
7803
- consumers — `run_contract` states it, `codex_agent_configs` materialises the content at
7804
- the path, and `project_args` writes the description and that path into argv — for the
7805
- same reason `review_mcp_servers` is one value: the template a tier resolves to decides
7806
- BOTH halves of what the backend is handed, and neither reached the contract. Repointing
7807
- one tier at another tier's template therefore changed the child's description and its
7808
- config path under a byte-identical contract, which is the one thing the contract's own
7809
- tail may not be false about (spec round 6, L7).
8457
+ Pure in what it writes nothing but not in what it reads: the authored template
8458
+ files and `XDG_CACHE_HOME`. One value, three consumers — `run_contract` states it,
8459
+ `codex_agent_configs` materialises the content at the path, and `project_args` writes
8460
+ the description and that path into argv for the same reason `review_mcp_servers` is
8461
+ one value: the template a tier resolves to decides BOTH halves of what the backend is
8462
+ handed, and neither reached the contract. Repointing one tier at another tier's
8463
+ template therefore changed the child's description and its config path under a
8464
+ byte-identical contract, which is the one thing the contract's own tail may not be
8465
+ false about (spec round 6, L7). And one CALL: `project_args` computes this once and
8466
+ hands the same list to all three, because a projection that rereads its sources gives
8467
+ each extra call a chance to answer differently (spec round 7, L7).
7810
8468
 
7811
8469
  The path is the config's IDENTITY, not an incidental location: its directory is a
7812
8470
  digest of the whole rendered set, so any edit to any template moves every path — and
@@ -7883,11 +8541,16 @@ def child_agent_registrations(
7883
8541
 
7884
8542
 
7885
8543
  def codex_agent_configs(
7886
- plan: dict[str, Any], materialize: bool
8544
+ plan: dict[str, Any], materialize: bool,
8545
+ registrations: list[tuple[str, str, pathlib.Path, str]] | None = None,
7887
8546
  ) -> dict[str, tuple[pathlib.Path, str]]:
7888
8547
  """The registration projection, written to disk. Everything decided is decided above;
7889
- this adds only the bytes and the failure modes writing them has."""
7890
- registrations = child_agent_registrations(plan)
8548
+ this adds only the bytes and the failure modes writing them has. A caller that also
8549
+ renders the contract passes the projection it rendered, so what is materialised is
8550
+ the value the contract states rather than a second computation of it (spec round 7,
8551
+ L7)."""
8552
+ if registrations is None:
8553
+ registrations = child_agent_registrations(plan)
7891
8554
  cache_root = registrations[0][2].parent
7892
8555
  # Every filesystem error here becomes a LaunchError, because `materialize` is exactly
7893
8556
  # the difference between --dry-run and a real launch: the projection skips these writes
@@ -8063,7 +8726,18 @@ def _cross_review_route(
8063
8726
  return " ".join(parts)
8064
8727
 
8065
8728
 
8066
- def run_contract(plan: dict[str, Any]) -> str:
8729
+ def run_contract(
8730
+ plan: dict[str, Any],
8731
+ mcp_registrations: list[tuple[str, str, list[str]]] | None = None,
8732
+ child_registrations: list[tuple[str, str, pathlib.Path, str]] | None = None,
8733
+ ) -> str:
8734
+ """The launch contract. A caller that also builds argv — `project_args` — passes the
8735
+ MCP and child registration projections it computed, so this text and that argv are
8736
+ generated from ONE call of each producer: the producers reread template files, the
8737
+ environment and PATH, so calling them once per consumer let a mid-launch change put
8738
+ one value here and another in argv under a byte-identical contract (spec round 7,
8739
+ L7). A caller that renders only the contract omits them and the projections are
8740
+ computed here — there is no argv beside the text to diverge from."""
8067
8741
  main_tier = plan["main_tier"]
8068
8742
  inactive = inactive_tiers(plan)
8069
8743
  if plan["delegation"]:
@@ -8152,7 +8826,9 @@ def run_contract(plan: dict[str, Any]) -> str:
8152
8826
  # Rendered from the whole triple, quoted as JSON, because reconciling this against argv
8153
8827
  # is the point: a command or an argument carrying a space would otherwise read as two.
8154
8828
  # Empty on every route that registers nothing, which includes every legacy one.
8155
- registrations = review_mcp_servers(plan)
8829
+ registrations = (
8830
+ review_mcp_servers(plan) if mcp_registrations is None else mcp_registrations
8831
+ )
8156
8832
  mcp_clause = ""
8157
8833
  if registrations:
8158
8834
  rendered = "; ".join(
@@ -8181,7 +8857,11 @@ def run_contract(plan: dict[str, Any]) -> str:
8181
8857
  if plan["delegation"] and plan["host"] == "codex":
8182
8858
  rendered_children = "; ".join(
8183
8859
  f"{tier} = {json.dumps(description)} at {json.dumps(str(path))}"
8184
- for tier, description, path, _ in child_agent_registrations(plan)
8860
+ for tier, description, path, _ in (
8861
+ child_agent_registrations(plan)
8862
+ if child_registrations is None
8863
+ else child_registrations
8864
+ )
8185
8865
  )
8186
8866
  child_clause = (
8187
8867
  f"Codex child agent registrations, in the order the backend receives them "
@@ -8340,9 +9020,12 @@ def review_mcp_servers(plan: dict[str, Any]) -> list[tuple[str, str, list[str]]]
8340
9020
 
8341
9021
  THE registration projection, and the only one: `run_contract` states this value and
8342
9022
  both argv builders write it, so the contract and the args cannot describe different
8343
- servers. It carries the args as well as the name and command because a triple is what
8344
- each backend receives leaving them to a constant read at the argv site put a third
8345
- of the registration outside the value the contract is generated from.
9023
+ servers and `project_args` computes it ONCE, handing the same list to the contract
9024
+ and to argv, because command resolution rereads PATH and a second call is a second
9025
+ answer (spec round 7, L7). It carries the args as well as the name and command
9026
+ because a triple is what each backend receives — leaving them to a constant read at
9027
+ the argv site put a third of the registration outside the value the contract is
9028
+ generated from.
8346
9029
 
8347
9030
  A rename here is a real change to what runs: the name below becomes the backend's
8348
9031
  `mcp_servers.<name>` namespace, which is how a tool call addresses the server."""
@@ -8565,7 +9248,21 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
8565
9248
  return []
8566
9249
  main = plan["tiers"][plan["main_tier"]]
8567
9250
  main_effort = tier_effort(plan, plan["main_tier"])
8568
- contract = run_contract(plan)
9251
+ # Each registration projection is computed ONCE and every consumer below receives the
9252
+ # same value. One producer was not enough: the contract render and the argv builders
9253
+ # each CALLED it, and the producers reread template files, `XDG_CACHE_HOME` and PATH —
9254
+ # so a change landing between the two calls put one value in the contract and another
9255
+ # in argv, under a tail that promises what is described is what runs (spec round 7,
9256
+ # L7). The child projection is computed exactly where both of its consumers live:
9257
+ # codex argv with delegation on, and the contract's child clause, which renders under
9258
+ # the same condition.
9259
+ mcp_registrations = review_mcp_servers(plan)
9260
+ child_registrations = (
9261
+ child_agent_registrations(plan)
9262
+ if plan["delegation"] and host == "codex"
9263
+ else None
9264
+ )
9265
+ contract = run_contract(plan, mcp_registrations, child_registrations)
8569
9266
  if host == "codex":
8570
9267
  args = [
8571
9268
  "--model", main["model"],
@@ -8582,12 +9279,14 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
8582
9279
  policy_args = ["--sandbox", policy]
8583
9280
  args += policy_args
8584
9281
  if plan["delegation"]:
8585
- for tier, (path, description) in codex_agent_configs(plan, materialize_agents).items():
9282
+ for tier, (path, description) in codex_agent_configs(
9283
+ plan, materialize_agents, child_registrations
9284
+ ).items():
8586
9285
  args += [
8587
9286
  "-c", f"agents.{tier}.description={json.dumps(description)}",
8588
9287
  "-c", f"agents.{tier}.config_file={json.dumps(str(path))}",
8589
9288
  ]
8590
- for name, command, server_args in review_mcp_servers(plan):
9289
+ for name, command, server_args in mcp_registrations:
8591
9290
  args += [
8592
9291
  "-c", f"mcp_servers.{name}.enabled=true",
8593
9292
  "-c", f"mcp_servers.{name}.command={json.dumps(command)}",
@@ -8609,7 +9308,7 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
8609
9308
  else:
8610
9309
  policy_args = ["--permission-mode", policy]
8611
9310
  args += policy_args
8612
- servers = review_mcp_servers(plan)
9311
+ servers = mcp_registrations
8613
9312
  if servers:
8614
9313
  mcp_config = {
8615
9314
  "mcpServers": {
@@ -8851,6 +9550,28 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
8851
9550
  "--fold-receipts", nargs=3, metavar=("DIR", "PACKET_FILE", "MAIN_DISPATCH_ID"),
8852
9551
  help=f"fold a directory of receipts into one {RECEIPT_BUNDLE_SCHEMA} bundle on stdout",
8853
9552
  )
9553
+ # The criterion side of the same per-review file surface. Compile turns a criterion
9554
+ # document into the canonical findings schema and prints the packet record line;
9555
+ # check-findings is the structural accepting channel standalone; check-schema-flag
9556
+ # probes the installed backend for its structured-output flag, because a declared
9557
+ # capability is docs and the rule is probe-over-docs.
9558
+ parser.add_argument(
9559
+ "--compile-criterion", nargs=2, metavar=("CRITERION_FILE", "SCHEMA_OUT"),
9560
+ help=f"validate a criterion document, write its compiled findings schema, and "
9561
+ f"print the {CRITERION_RECORD_SCHEMA} record line the packet carries",
9562
+ )
9563
+ parser.add_argument(
9564
+ "--check-findings", nargs=2, metavar=("RESULT_FILE", "SCHEMA_FILE"),
9565
+ help="refuse a findings result that fails the compiled criterion schema, each "
9566
+ "violation by name; the same check runs inside --emit-receipt when "
9567
+ f"${RECEIPT_CRITERION_ENV} is set",
9568
+ )
9569
+ parser.add_argument(
9570
+ "--check-schema-flag", metavar="PROBE_HOST",
9571
+ help="probe the resolved backend binary for its structured-output flag; exit 0 "
9572
+ f"when registered, {SCHEMA_FLAG_ABSENT_EXIT} when the binary runs and does "
9573
+ f"not register it",
9574
+ )
8854
9575
  # REMAINDER and not "+": the adapter's own command line carries flags, and "+" stops
8855
9576
  # at the first token starting with a dash — which handed `--model` to the host
8856
9577
  # positional and rejected the run before the adapter was ever dispatched.
@@ -8866,7 +9587,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
8866
9587
  args = parser.parse_args(argv)
8867
9588
  hostless = (
8868
9589
  args.verify_receipts or args.emit_receipt or args.fold_receipts
8869
- or args.check_adapter
9590
+ or args.check_adapter or args.compile_criterion or args.check_findings
9591
+ or args.check_schema_flag
8870
9592
  )
8871
9593
  if args.host is None and not hostless:
8872
9594
  parser.error("the following arguments are required: host")
@@ -8941,9 +9663,25 @@ def verify_receipts_command(
8941
9663
  # under audit. Both halves, because either alone is satisfiable by the wrong thing —
8942
9664
  # a digest agreeing with the anchor says these bytes were hashed, and the plan the
8943
9665
  # bytes carry says WHICH review they were hashed for.
9666
+ # Whether the plan declared the criterion discipline: read from the rows' rendered
9667
+ # instructions, where the launch snapshotted it — a ReviewPlan/v1 field would be a
9668
+ # schema change restating what the instruction already carries.
9669
+ criterion_active = any(
9670
+ CRITERION_CLAUSE_MARKER in row.instruction
9671
+ for row in (report.base, *report.methods)
9672
+ if row.status != STATUS_DROPPED
9673
+ )
9674
+ criterion_digest: "str | None" = None
8944
9675
  if packet_path is not None:
8945
9676
  packet_file = pathlib.Path(packet_path).expanduser()
8946
- digest = _sha256_file(str(packet_file))
9677
+ # ONE byte snapshot: hashing the path and then reopening it as text let the
9678
+ # equality speak for one file state and the plan/criterion extraction for
9679
+ # another (criterion round, #6 — the emit-side twin is #4).
9680
+ try:
9681
+ packet_bytes = packet_file.read_bytes()
9682
+ except OSError as exc:
9683
+ raise LaunchError(f"cannot read the packet {packet_file}: {exc}") from exc
9684
+ digest = hashlib.sha256(packet_bytes).hexdigest()
8947
9685
  anchor = bundle.get("packet_sha256") if isinstance(bundle, dict) else None
8948
9686
  if digest != anchor:
8949
9687
  raise LaunchError(
@@ -8952,8 +9690,8 @@ def verify_receipts_command(
8952
9690
  f"these bytes"
8953
9691
  )
8954
9692
  try:
8955
- packet_text = packet_file.read_text(encoding="utf-8")
8956
- except (OSError, UnicodeDecodeError) as exc:
9693
+ packet_text = packet_bytes.decode("utf-8")
9694
+ except UnicodeDecodeError as exc:
8957
9695
  raise LaunchError(f"cannot read the packet {packet_file}: {exc}") from exc
8958
9696
  carried = extract_review_plan_v1(packet_text)
8959
9697
  if carried is None:
@@ -8966,6 +9704,16 @@ def verify_receipts_command(
8966
9704
  f"the supplied packet carries a different {REVIEW_PLAN_SCHEMA} record than "
8967
9705
  f"{plan_path}; these receipts evidence a review of another plan"
8968
9706
  )
9707
+ if criterion_active:
9708
+ # A plan whose rows carry the discipline clause declared a criterion, and a
9709
+ # packet that omits the record cannot verify: extract_criterion refuses the
9710
+ # absence, the malformed record and the second record each by name. The
9711
+ # digest is a RECOMPILATION from the packet's own bytes — never read off
9712
+ # the receipts, which are the artifacts under audit.
9713
+ document = extract_criterion(packet_text, "the supplied packet")
9714
+ criterion_digest = hashlib.sha256(
9715
+ criterion_schema_bytes(document)
9716
+ ).hexdigest()
8969
9717
  # The offer that SERVES THE ROW'S HOST, selected by `select_capability_offer` — the
8970
9718
  # same function `derive_review_mechanism` seats a method with, rather than a second
8971
9719
  # loop written to the same description. The first offer for the operation was taken
@@ -9020,9 +9768,26 @@ def verify_receipts_command(
9020
9768
  method, capability, row_host
9021
9769
  )["evidence"]
9022
9770
  verified, verdicts = verify_review_receipts(
9023
- report, bundle, required_controls, required_evidence
9771
+ report, bundle, required_controls, required_evidence, criterion_digest
9024
9772
  )
9025
- print(render_receipt_verdicts(verified, verdicts, packet_bound=packet_path is not None))
9773
+ if not criterion_active:
9774
+ criterion_note = ""
9775
+ elif criterion_digest is not None:
9776
+ criterion_note = (
9777
+ " criterion=declared and bound: receipts carrying criterion_schema_sha256 "
9778
+ "were held to the schema recompiled from the packet's record; a receipt "
9779
+ "without it ran prose discipline — steering, not structural enforcement"
9780
+ )
9781
+ else:
9782
+ criterion_note = (
9783
+ " criterion=declared but UNBOUND: no packet was supplied, so the declared "
9784
+ "criterion was recompiled against nothing and no schema digest above was "
9785
+ "adjudicated"
9786
+ )
9787
+ print(render_receipt_verdicts(
9788
+ verified, verdicts, packet_bound=packet_path is not None,
9789
+ criterion_note=criterion_note,
9790
+ ))
9026
9791
  return 0 if verified.achievement == ACHIEVEMENT_COMPLETE else 1
9027
9792
 
9028
9793
 
@@ -9072,6 +9837,14 @@ def main(argv: list[str]) -> int:
9072
9837
  return emit_receipt_command(*args.emit_receipt, evidence=args.evidence)
9073
9838
  if args.fold_receipts:
9074
9839
  return fold_receipts_command(*args.fold_receipts)
9840
+ if args.compile_criterion:
9841
+ return compile_criterion_command(*args.compile_criterion)
9842
+ if args.check_findings:
9843
+ return check_findings_command(*args.check_findings)
9844
+ if args.check_schema_flag:
9845
+ return check_schema_flag_command(
9846
+ args.check_schema_flag, args.config.expanduser()
9847
+ )
9075
9848
  if args.check_adapter:
9076
9849
  if len(args.check_adapter) < 2:
9077
9850
  raise LaunchError("--check-adapter takes a seat and then the adapter command")