arkaos 4.41.0 → 4.43.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.
Files changed (51) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/skills/checkpoint/SKILL.md +10 -3
  4. package/arka/skills/flow/SKILL.md +4 -0
  5. package/config/claude-agents/eduardo-copy.md +13 -2
  6. package/config/claude-agents/francisca-tech.md +12 -2
  7. package/config/claude-agents/marta-cqo.md +68 -10
  8. package/config/constitution.yaml +4 -1
  9. package/core/evals/record_cli.py +184 -24
  10. package/core/evals/runner_cli.py +5 -2
  11. package/core/evals/verdict_labels.py +19 -6
  12. package/core/governance/aggregate_guard.py +672 -0
  13. package/core/governance/qg_verdict.py +56 -2
  14. package/core/governance/redo_counter.py +13 -8
  15. package/core/governance/reviewer_ledger.py +94 -12
  16. package/core/hooks/session_end.py +16 -0
  17. package/departments/quality/SKILL.md +38 -18
  18. package/harness/codex/AGENTS.md +1 -1
  19. package/harness/copilot/copilot-instructions.md +1 -1
  20. package/harness/cursor/rules/arkaos.mdc +2 -2
  21. package/harness/gemini/GEMINI.md +1 -1
  22. package/harness/opencode/AGENTS.md +1 -1
  23. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  24. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  25. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  26. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  27. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  28. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  29. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  30. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  31. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  32. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  33. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  34. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  35. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  36. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  37. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  38. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  39. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  40. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  41. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  42. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  43. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  44. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  45. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  46. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  47. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  48. package/harness/zed/.rules +1 -1
  49. package/knowledge/skills-manifest.json +1 -1
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
@@ -0,0 +1,672 @@
1
+ """Anti-self-approval guard for Quality Gate aggregate verdicts (PR-B3).
2
+
3
+ On 2026-07-20 an aggregate verdict entered the eval corpus with zero
4
+ reviewer artifacts behind it — the orchestrator's own words, recorded
5
+ as a completed gate. This guard makes that shape unrecordable AS AN
6
+ AGGREGATE: an aggregate verdict is only recorded when the session's
7
+ reviewer ledger holds at least two HOOK-CAPTURED reviewer verdicts,
8
+ the digest chain holds (PR-B4 dispatch shape: the aggregate and every
9
+ counted reviewer artifact must carry evidence_digest, and each
10
+ reviewer's digest must match the aggregate's — or be excused by an
11
+ explicit, justified ``digest_carries`` entry naming the report that
12
+ reviewer actually reviewed), no CONFIRMED reviewer blocker disappears
13
+ silently, and an APPROVED aggregate stands over no rejecting reviewer.
14
+
15
+ Severity is verdict-aware (PR-B4): fabrication vectors — quorum, an
16
+ APPROVED over a rejecting reviewer, a vanishing CONFIRMED blocker —
17
+ refuse regardless of verdict; dispatch-shape issues (digest chain,
18
+ session binding) refuse only an APPROVED aggregate and demote to
19
+ warnings on a REJECTED one, so a rejection label survives exactly the
20
+ case where the CQO catches a bad delta.
21
+
22
+ Only records whose ``source`` is in ``CAPTURE_SOURCES`` count: a
23
+ record the orchestrator could write itself (any other source) would
24
+ let it fabricate its own quorum, which is the exact disease.
25
+
26
+ Digest comparison runs against the artifacts on disk — the JSON that
27
+ was passed through the gate — never against a re-run of the evidence
28
+ engine: ``report_digest`` is per-report, not per-run (a re-run
29
+ digests differently on volatile pytest output), so re-running would
30
+ refuse every honest aggregate.
31
+
32
+ Trust boundary, stated honestly: the ledger lives in the operator's
33
+ home directory and this guard runs in the same trust domain as the
34
+ orchestrator it polices — a determined orchestrator could forge
35
+ records with a file write, either to FABRICATE a quorum or to
36
+ SUPPRESS an existing verdict (a forged newer record supersedes it —
37
+ same capability ceiling, different mechanics). Session binding
38
+ (PR-B4) closes the COMMON reuse path, not every path: the SessionEnd
39
+ hook stamps a session's ledger directory (``.ended``), and an
40
+ APPROVED aggregate citing a stamped session is refused. A session
41
+ that crashes never fires SessionEnd and stays unstamped — and in
42
+ observed practice SessionEnd fires for a minority of sessions, so an
43
+ unstamped ledger proves nothing about liveness. A session still open
44
+ elsewhere is citable from this one, and the stamp itself is a file
45
+ the same account could delete: the binding is evidence from the
46
+ hook boundary, not a cryptographic seal. ``--kind
47
+ reviewer`` (record_cli) likewise remains an unguarded label path for
48
+ reviewer identities: its ledger cross-reference is provenance, not
49
+ admission. The guard turns ACCIDENTAL self-approval into deliberate,
50
+ transcript-visible forgery; it does not and cannot make forgery
51
+ impossible from inside the same account.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import contextlib
57
+ import json
58
+ import os
59
+ import re
60
+ from dataclasses import dataclass, field
61
+ from datetime import UTC, datetime
62
+ from pathlib import Path
63
+
64
+ from core.governance.reviewer_ledger import (
65
+ _RECORD_NAME_RE,
66
+ AGGREGATE_NAME,
67
+ CAPTURE_SOURCES,
68
+ IDENTITY_ALIASES,
69
+ REVIEWER_IDS,
70
+ _safe_id,
71
+ _write_temp,
72
+ ledger_root,
73
+ )
74
+
75
+ _MIN_REVIEWERS = 2
76
+
77
+ # A REFUTED drop needs a reason a reader can act on — "n/a" is not one.
78
+ _MIN_REFUTE_DETAIL = 40
79
+
80
+ # One id per person for operator-facing output: the deployed
81
+ # agent-file ids (.claude/agents/<id>.md). The config/constitution.yaml
82
+ # spellings are copy-director-eduardo / tech-director-francisca /
83
+ # cqo-marta — a different set (reviewer_ledger.py documents the map).
84
+ _CANONICAL_IDS = frozenset({"eduardo-copy", "francisca-tech", "marta-cqo"})
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class GuardResult:
89
+ ok: bool
90
+ reasons: list[str] = field(default_factory=list)
91
+ warnings: list[str] = field(default_factory=list)
92
+ artifacts: list[str] = field(default_factory=list)
93
+ reviewers: list[str] = field(default_factory=list)
94
+
95
+
96
+ def _seq(record: dict) -> int:
97
+ try:
98
+ return int(record.get("seq", 0) or 0)
99
+ except (TypeError, ValueError):
100
+ return 0 # a forged seq must not turn the CLI into a traceback
101
+
102
+
103
+ def _session_records(session_id: str) -> list[tuple[str, dict]]:
104
+ """(file name, record) for every readable ledger record, seq order."""
105
+ if not _safe_id(session_id):
106
+ return []
107
+ session_dir = ledger_root() / session_id
108
+ records: list[tuple[str, dict]] = []
109
+ try:
110
+ entries = sorted(session_dir.iterdir())
111
+ except OSError:
112
+ return records
113
+ for item in entries:
114
+ # Only names the ledger itself writes — operator notes, the
115
+ # aggregate record and dot-prefixed files never enter the pool.
116
+ # fullmatch, not match: $ accepts a trailing newline (the same
117
+ # class _safe_id just fixed one file over).
118
+ if item.name.startswith(".") or not _RECORD_NAME_RE.fullmatch(
119
+ item.name
120
+ ):
121
+ continue
122
+ try:
123
+ data = json.loads(item.read_text(encoding="utf-8"))
124
+ except (OSError, json.JSONDecodeError):
125
+ continue
126
+ if isinstance(data, dict):
127
+ records.append((item.name, data))
128
+ records.sort(key=lambda pair: _seq(pair[1]))
129
+ return records
130
+
131
+
132
+ def _counted(record: dict) -> bool:
133
+ """True when a record counts toward the reviewer quorum.
134
+
135
+ REVIEWER_IDS excludes the aggregator by construction — the
136
+ disjointness with AGGREGATOR_IDS is pinned by test (an explicit
137
+ exclusion clause here would be dead code the battery cannot kill).
138
+ """
139
+ reviewer_id = str(record.get("reviewer_id") or "")
140
+ return (
141
+ record.get("source") in CAPTURE_SOURCES
142
+ and reviewer_id in REVIEWER_IDS
143
+ and not record.get("parse_error")
144
+ and isinstance(record.get("verdict"), dict)
145
+ )
146
+
147
+
148
+ def _rejecting(record: dict) -> int:
149
+ """Severity rank: 2 = REJECTED verdict, 1 = carries a CONFIRMED
150
+ blocker, 0 = neither. Distinct ranks keep the r5 survivor shape
151
+ separable — an APPROVED-carrying-CONFIRMED never ties a REJECTED,
152
+ so filename byte order never decides between them."""
153
+ verdict = record.get("verdict") or {}
154
+ if _norm(verdict.get("verdict")) == "rejected":
155
+ return 2
156
+ if any(
157
+ isinstance(b, dict) and _norm(b.get("verdict")) == "confirmed"
158
+ for b in (verdict.get("blockers") or [])
159
+ ):
160
+ return 1
161
+ return 0
162
+
163
+
164
+ def _spelling_winner(
165
+ records: list[tuple[str, dict]]
166
+ ) -> tuple[str, dict]:
167
+ """The superseding record WITHIN one reviewer_id spelling.
168
+
169
+ seq dominates, because here it is trustworthy: the ledger assigns
170
+ it monotonically within a spelling (len of that reviewer_id's
171
+ records + 1). A backwards clock step must not invert two rounds
172
+ (QG r4), so the clock enters only LAST — on equal seq (the
173
+ two-writer divergent-text window, or a reused seq after a record
174
+ was removed) the higher severity wins, then the record clock;
175
+ filename byte order never decides (QG r5).
176
+ """
177
+ return max(records, key=lambda p: (
178
+ _seq(p[1]), _rejecting(p[1]), str(p[1].get("ts") or "")
179
+ ))
180
+
181
+
182
+ def _cross_key(record: dict) -> tuple[int, str, int]:
183
+ """Total order for choosing among spelling winners.
184
+
185
+ Across spellings only the clock can prove recency (seq restarts
186
+ per spelling — QG r3: an alias switch hid the newest REJECTED
187
+ under seq ordering; _build_record writes ``ts`` as ISO-8601 UTC,
188
+ so lexicographic order is chronological). A CLOCKLESS rejecting
189
+ record sorts above everything: nothing can prove itself newer
190
+ than it, so it is never superseded — fail-closed. Clocked records
191
+ order by the clock, then severity. A total, transitive order: the
192
+ winner never depends on iteration order (QG r5 M1).
193
+ """
194
+ ts = str(record.get("ts") or "")
195
+ if not ts and _rejecting(record):
196
+ return (1, "", 0) # unsupersedable
197
+ return (0, ts, _rejecting(record))
198
+
199
+
200
+ def _latest_per_reviewer(
201
+ counted: list[tuple[str, dict]]
202
+ ) -> list[tuple[str, dict]]:
203
+ """The LATEST record per reviewer identity, two-level.
204
+
205
+ A redo round supersedes that reviewer's earlier verdicts. Without
206
+ this no real multi-round session could close honestly: the union
207
+ over every historical record would demand that the final
208
+ aggregate cover blockers already fixed in earlier rounds — or
209
+ record them as REFUTED, which would be a false record (this
210
+ campaign's own sessions hold 14+ rounds in one ledger directory).
211
+ Level one reduces each SPELLING to its winner by seq
212
+ (_spelling_winner); level two chooses among spelling winners by
213
+ the total order of _cross_key — each signal used only where it
214
+ is trustworthy, and both levels are keyed, so the outcome never
215
+ depends on iteration order.
216
+ """
217
+ by_spelling: dict[str, list[tuple[str, dict]]] = {}
218
+ for name, rec in counted:
219
+ by_spelling.setdefault(
220
+ str(rec.get("reviewer_id")), []
221
+ ).append((name, rec))
222
+ latest: dict[str, tuple[str, dict]] = {}
223
+ for records in by_spelling.values():
224
+ name, rec = _spelling_winner(records)
225
+ identity = _identity(str(rec.get("reviewer_id")))
226
+ held = latest.get(identity)
227
+ if held is None or _cross_key(rec) > _cross_key(held[1]):
228
+ latest[identity] = (name, rec)
229
+ return list(latest.values())
230
+
231
+
232
+ def _identity(reviewer_id: str) -> str:
233
+ """Canonical identity: aliases of one person count as ONE reviewer.
234
+
235
+ Without this, ``eduardo-copy`` plus ``copy-director-eduardo`` —
236
+ the same director under two dispatch spellings — would satisfy a
237
+ two-reviewer quorum single-handedly. The deployed agent-file id is
238
+ preferred for operator-facing output.
239
+ """
240
+ for group in IDENTITY_ALIASES:
241
+ if reviewer_id in group:
242
+ return next(
243
+ (i for i in sorted(group) if i in _CANONICAL_IDS),
244
+ min(group),
245
+ )
246
+ return reviewer_id
247
+
248
+
249
+ def _norm(text: object) -> str:
250
+ return " ".join(str(text or "").lower().split())
251
+
252
+
253
+ def _tokens(text: object) -> frozenset[str]:
254
+ return frozenset(
255
+ t for t in re.split(r"[^a-z0-9]+", _norm(text)) if t
256
+ )
257
+
258
+
259
+ def _blocker_key(blocker: dict) -> frozenset[str]:
260
+ """Match key: check, else detail, else file — an empty check must
261
+ not make a CONFIRMED blocker permanently uncoverable (liveness)."""
262
+ return (
263
+ _tokens(blocker.get("check"))
264
+ or _tokens(blocker.get("detail"))
265
+ or _tokens(blocker.get("file"))
266
+ )
267
+
268
+
269
+ def _matches(a: frozenset[str], b: frozenset[str]) -> bool:
270
+ """Token-set subset, either direction — never raw substrings.
271
+
272
+ A substring rule let an aggregate entry with check "e" cover every
273
+ reviewer blocker containing the letter (reproduced by the gate:
274
+ two one-character REFUTED entries laundered five CONFIRMED
275
+ blockers). Token subsets make coverage mean shared vocabulary.
276
+ """
277
+ return bool(a) and bool(b) and (a <= b or b <= a)
278
+
279
+
280
+ def _covered(key: frozenset[str], aggregate: dict, approved: bool) -> str:
281
+ """Status of one CONFIRMED reviewer blocker against the aggregate.
282
+
283
+ Returns "ok", "absent", "bare-refute" (REFUTED without a
284
+ substantive reason) or "carried-approved" (kept un-refuted while
285
+ the aggregate approves — flipping the decision instead of hiding
286
+ the string is laundering too).
287
+ """
288
+ for entry in aggregate.get("blockers") or []:
289
+ if not isinstance(entry, dict):
290
+ continue
291
+ if not _matches(key, _blocker_key(entry)):
292
+ continue
293
+ if _norm(entry.get("verdict")) == "refuted":
294
+ if len(_norm(entry.get("detail"))) < _MIN_REFUTE_DETAIL:
295
+ return "bare-refute"
296
+ return "ok"
297
+ return "carried-approved" if approved else "ok"
298
+ return "absent"
299
+
300
+
301
+ def _carries(aggregate: dict) -> dict[str, dict]:
302
+ """Declared digest carries, keyed by canonical reviewer identity."""
303
+ carries: dict[str, dict] = {}
304
+ for entry in aggregate.get("digest_carries") or []:
305
+ if isinstance(entry, dict):
306
+ carries[_identity(str(entry.get("reviewer") or ""))] = entry
307
+ return carries
308
+
309
+
310
+ def _carry_issue(
311
+ reviewer_id: str, their: str, agg_digest: str, carry: dict | None
312
+ ) -> str | None:
313
+ """Why a digest mismatch is NOT excused by a declared carry.
314
+
315
+ None means the carry stands: it names the digest the reviewer's own
316
+ artifact carries, with a substantive reason. Anything else is the
317
+ plain mismatch — an undeclared carry, a carry pointing at a digest
318
+ the reviewer never reviewed, or a bare justification.
319
+ """
320
+ if carry is None:
321
+ return (
322
+ f"evidence_digest mismatch: {reviewer_id} reviewed "
323
+ f"{their[:12]}…, aggregate cites {agg_digest[:12]}… — not "
324
+ "the same evidence report; re-dispatch the reviewer or "
325
+ "declare a justified digest_carries entry (PR-B4)"
326
+ )
327
+ if _norm(carry.get("evidence_digest")) != their:
328
+ return (
329
+ f"digest carry for {reviewer_id} names "
330
+ f"{_norm(carry.get('evidence_digest'))[:12]}… but the "
331
+ f"reviewer's artifact carries {their[:12]}… — a carry must "
332
+ "cite the report that reviewer actually reviewed"
333
+ )
334
+ if len(_norm(carry.get("reason"))) < _MIN_REFUTE_DETAIL:
335
+ return (
336
+ f"digest carry for {reviewer_id} lacks a substantive reason "
337
+ f"(>= {_MIN_REFUTE_DETAIL} chars — the same bar as a REFUTED "
338
+ "drop): why does the earlier review still stand?"
339
+ )
340
+ return None
341
+
342
+
343
+ _MISSING_AGGREGATE_DIGEST = (
344
+ "aggregate carries no evidence_digest — the dispatch shape (PR-B4) "
345
+ "requires the aggregator to cite the report it aggregated "
346
+ "(report_digest from the evidence --json output)"
347
+ )
348
+ _MISSING_REVIEWER_DIGEST = (
349
+ "{rid} artifact carries no evidence_digest — the dispatch must "
350
+ "populate it (PR-B4 dispatch shape); re-dispatch that reviewer "
351
+ "with the report_digest"
352
+ )
353
+ _CARRY_ACCEPTED = (
354
+ "digest carry accepted: {rid} reviewed {their}… while the "
355
+ "aggregate cites {agg}… — justification on the record"
356
+ )
357
+
358
+
359
+ def _digest_issues(
360
+ aggregate: dict, verdicts: list[tuple[str, dict]]
361
+ ) -> tuple[list[str], list[str]]:
362
+ """Dispatch-shape and integrity issues on the digest chain (PR-B4).
363
+
364
+ Returns (issues, notes). Issues are verdict-aware at the caller:
365
+ they refuse an APPROVED aggregate and are demoted to warnings on a
366
+ REJECTED one. Notes (accepted carries) are always warnings — a
367
+ carry is legitimate, and legitimate is not invisible.
368
+ """
369
+ issues: list[str] = []
370
+ notes: list[str] = []
371
+ agg_digest = _norm(aggregate.get("evidence_digest"))
372
+ if not agg_digest:
373
+ issues.append(_MISSING_AGGREGATE_DIGEST)
374
+ carries = _carries(aggregate)
375
+ for reviewer_id, verdict in verdicts:
376
+ their = _norm(verdict.get("evidence_digest"))
377
+ if not their:
378
+ issues.append(_MISSING_REVIEWER_DIGEST.format(rid=reviewer_id))
379
+ continue
380
+ if not agg_digest or their == agg_digest:
381
+ continue
382
+ issue = _carry_issue(
383
+ reviewer_id, their, agg_digest, carries.get(_identity(reviewer_id))
384
+ )
385
+ if issue is not None:
386
+ issues.append(issue)
387
+ else:
388
+ notes.append(_CARRY_ACCEPTED.format(
389
+ rid=reviewer_id, their=their[:12], agg=agg_digest[:12]
390
+ ))
391
+ return issues, notes
392
+
393
+
394
+ def _ended_issues(session_id: str) -> list[str]:
395
+ """A stamped session is not a live quorum (PR-B4 session binding)."""
396
+ from core.governance.reviewer_ledger import ENDED_NAME
397
+
398
+ if not (ledger_root() / session_id / ENDED_NAME).is_file():
399
+ return []
400
+ return [
401
+ f"session {session_id} is marked ended (SessionEnd stamped its "
402
+ "ledger) — a past session's reviewer records are not a reusable "
403
+ "quorum token; run the reviews in the live session"
404
+ ]
405
+
406
+
407
+ def _check_key_warnings(
408
+ verdicts: list[tuple[str, dict]]
409
+ ) -> list[str]:
410
+ """CONFIRMED blockers filed without a check key — warning only.
411
+
412
+ Coverage still matches on detail/file tokens (_blocker_key keeps
413
+ liveness), but the dispatch shape asks reviewers to name the
414
+ evidence check so coverage means shared vocabulary, not prose luck.
415
+ """
416
+ warnings: list[str] = []
417
+ for reviewer_id, verdict in verdicts:
418
+ for blocker in verdict.get("blockers") or []:
419
+ if not isinstance(blocker, dict):
420
+ continue
421
+ if _norm(blocker.get("verdict")) != "confirmed":
422
+ continue
423
+ if not _tokens(blocker.get("check")) and _blocker_key(blocker):
424
+ warnings.append(
425
+ f"CONFIRMED blocker ({reviewer_id}) filed without a "
426
+ "check key — matched on detail/file tokens; the "
427
+ "dispatch shape (PR-B4) asks for the evidence check "
428
+ "name in 'check'"
429
+ )
430
+ return warnings
431
+
432
+
433
+ _COVERAGE_REASONS = {
434
+ "absent": (
435
+ "CONFIRMED blocker '{check}' ({rid}) absent from the aggregate "
436
+ "and not REFUTED — a CONFIRMED finding never disappears silently"
437
+ ),
438
+ "bare-refute": (
439
+ "CONFIRMED blocker '{check}' ({rid}) marked REFUTED without a "
440
+ "substantive reason (>= 40 chars) — a drop needs its reason on "
441
+ "the record"
442
+ ),
443
+ "carried-approved": (
444
+ "CONFIRMED blocker '{check}' ({rid}) carried un-refuted while "
445
+ "the aggregate is APPROVED — flipping the decision does not "
446
+ "close a finding"
447
+ ),
448
+ }
449
+
450
+
451
+ def _blocker_reasons(
452
+ aggregate: dict, verdicts: list[tuple[str, dict]], approved: bool
453
+ ) -> list[str]:
454
+ """CONFIRMED reviewer blockers must not vanish (or be out-voted)."""
455
+ reasons: list[str] = []
456
+ for reviewer_id, verdict in verdicts:
457
+ for blocker in verdict.get("blockers") or []:
458
+ if not isinstance(blocker, dict):
459
+ continue
460
+ if _norm(blocker.get("verdict")) != "confirmed":
461
+ continue
462
+ key = _blocker_key(blocker)
463
+ if not key:
464
+ # Liveness (r2 M2): an unmatchable blocker must name an
465
+ # achievable remedy, not deadlock the session forever.
466
+ reasons.append(
467
+ f"CONFIRMED blocker with empty check/detail/file "
468
+ f"({reviewer_id}) cannot be matched — re-dispatch "
469
+ "the reviewer to file it with an identifiable "
470
+ "check; the redo round supersedes this record"
471
+ )
472
+ continue
473
+ status = _covered(key, aggregate, approved)
474
+ if status != "ok":
475
+ reasons.append(_COVERAGE_REASONS[status].format(
476
+ check=_norm(blocker.get("check")) or "(no check field)",
477
+ rid=reviewer_id,
478
+ ))
479
+ return reasons
480
+
481
+
482
+ def _own_finding_warnings(
483
+ aggregate: dict, verdicts: list[tuple[str, dict]]
484
+ ) -> list[str]:
485
+ """Aggregate blockers matching no reviewer blocker — warning only.
486
+
487
+ Deliberate deviation from the plan's "never invent" literal, on
488
+ record for operator ratification: the aggregate reproducing a
489
+ defect neither reviewer saw is the veto working (marta-cqo-12
490
+ caught a stale evidence doc that way); refusing it would launder
491
+ the defect through.
492
+ """
493
+ reviewer_keys = [
494
+ _blocker_key(b)
495
+ for _, verdict in verdicts
496
+ for b in (verdict.get("blockers") or [])
497
+ if isinstance(b, dict)
498
+ ]
499
+ warnings: list[str] = []
500
+ for entry in aggregate.get("blockers") or []:
501
+ if not isinstance(entry, dict):
502
+ continue
503
+ key = _blocker_key(entry)
504
+ if key and not any(_matches(key, rk) for rk in reviewer_keys):
505
+ warnings.append(
506
+ f"aggregate blocker '{_norm(entry.get('check'))}' matches "
507
+ "no reviewer blocker (aggregate's own finding — allowed, "
508
+ "recorded)"
509
+ )
510
+ return warnings
511
+
512
+
513
+ def _verdict_reasons(
514
+ aggregate: dict, verdicts: list[tuple[str, dict]]
515
+ ) -> list[str]:
516
+ """An APPROVED aggregate stands over no rejecting reviewer."""
517
+ if _norm(aggregate.get("verdict")) != "approved":
518
+ return []
519
+ return [
520
+ f"{reviewer_id} returned REJECTED — an APPROVED aggregate over "
521
+ "a rejecting reviewer is refused (quality SKILL step 4: any "
522
+ "reviewer rejection loops back)"
523
+ for reviewer_id, verdict in verdicts
524
+ if _norm(verdict.get("verdict")) == "rejected"
525
+ ]
526
+
527
+
528
+ def _quorum_shortfall(
529
+ reviewers: list[str], artifacts: list[str]
530
+ ) -> GuardResult:
531
+ return GuardResult(
532
+ ok=False,
533
+ reasons=[
534
+ f"{len(reviewers)} hook-captured reviewer verdict(s) in "
535
+ f"the session ledger ({', '.join(reviewers) or 'none'}); "
536
+ f"an aggregate needs >= {_MIN_REVIEWERS} distinct "
537
+ "reviewers that are not the aggregator"
538
+ ],
539
+ artifacts=artifacts,
540
+ reviewers=reviewers,
541
+ )
542
+
543
+
544
+ def check_aggregate(aggregate: dict, session_id: str) -> GuardResult:
545
+ """Refuse an aggregate the session ledger cannot support.
546
+
547
+ Quorum, verdicts and blocker coverage are computed over the LATEST
548
+ hook-captured record per reviewer identity — see
549
+ _latest_per_reviewer.
550
+ """
551
+ if not session_id:
552
+ return GuardResult(ok=False, reasons=["--session-id is mandatory"])
553
+ if not _safe_id(session_id): # refuse loudly, with the reason
554
+ return GuardResult(ok=False, reasons=[
555
+ f"session id {session_id!r} fails the ledger safety rule "
556
+ "(path separators, leading dots and oversize ids are "
557
+ "refused — a session id is a directory name, not a path)"
558
+ ])
559
+ counted = _latest_per_reviewer([
560
+ (name, rec) for name, rec in _session_records(session_id)
561
+ if _counted(rec)
562
+ ])
563
+ reviewers = sorted({
564
+ _identity(str(r.get("reviewer_id"))) for _, r in counted
565
+ })
566
+ if len(reviewers) < _MIN_REVIEWERS:
567
+ return _quorum_shortfall(
568
+ reviewers, [name for name, _ in counted]
569
+ )
570
+ verdicts = [(str(r.get("reviewer_id")), r["verdict"]) for _, r in counted]
571
+ reasons, warnings = _reasons(aggregate, verdicts, session_id)
572
+ return GuardResult(
573
+ ok=not reasons,
574
+ reasons=reasons,
575
+ warnings=warnings,
576
+ artifacts=[name for name, _ in counted],
577
+ reviewers=reviewers,
578
+ )
579
+
580
+
581
+ def _reasons(
582
+ aggregate: dict, verdicts: list[tuple[str, dict]], session_id: str
583
+ ) -> tuple[list[str], list[str]]:
584
+ """Every refusal reason and warning for one aggregate.
585
+
586
+ Two severities, split on the aggregate's own verdict (PR-B4 item 7).
587
+ Fabrication vectors — an APPROVED standing over a rejecting
588
+ reviewer, a CONFIRMED blocker vanishing — refuse regardless of
589
+ verdict. Dispatch-shape issues (digest chain, session binding)
590
+ refuse only an APPROVED aggregate: refusing a REJECTED one over
591
+ shape would throw away the rejection label in exactly the case
592
+ where the CQO catches a bad delta, and a recorded rejection
593
+ launders nothing — the redo loop continues either way.
594
+ """
595
+ approved = _norm(aggregate.get("verdict")) == "approved"
596
+ issues, notes = _digest_issues(aggregate, verdicts)
597
+ issues += _ended_issues(session_id)
598
+ reasons = (
599
+ _verdict_reasons(aggregate, verdicts)
600
+ + _blocker_reasons(aggregate, verdicts, approved)
601
+ )
602
+ warnings = (
603
+ notes
604
+ + _check_key_warnings(verdicts)
605
+ + _own_finding_warnings(aggregate, verdicts)
606
+ )
607
+ if approved:
608
+ return issues + reasons, warnings
609
+ demoted = [
610
+ f"{issue} [recorded anyway: the refusal is verdict-aware "
611
+ "(PR-B4) — a REJECTED label is never lost to dispatch shape]"
612
+ for issue in issues
613
+ ]
614
+ return reasons, demoted + warnings
615
+
616
+
617
+ def write_aggregate(
618
+ session_id: str, aggregate: dict, result: GuardResult
619
+ ) -> Path | None:
620
+ """Persist the accepted aggregate atomically; None on any failure.
621
+
622
+ The caller must treat None as a refusal: an accepted-but-unwritten
623
+ aggregate recorded anyway would be the incident shape again, one
624
+ filesystem error away.
625
+ """
626
+ if not result.ok: # the guard's own writer never files a refusal
627
+ return None
628
+ if not _safe_id(session_id): # a hostile id never creates a dir
629
+ return None
630
+ session_dir = ledger_root() / session_id
631
+ try:
632
+ session_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
633
+ except OSError:
634
+ return None
635
+ tmp = _write_temp(
636
+ session_dir, AGGREGATE_NAME, _aggregate_record(session_id, aggregate, result)
637
+ )
638
+ if tmp is None:
639
+ return None
640
+ path = session_dir / AGGREGATE_NAME
641
+ try:
642
+ # Atomic replace: a redo round re-issues the aggregate and there
643
+ # must be no window with neither version on disk.
644
+ os.replace(tmp, path)
645
+ except OSError:
646
+ with contextlib.suppress(OSError):
647
+ tmp.unlink(missing_ok=True)
648
+ return None
649
+ return path if path.is_file() else None
650
+
651
+
652
+ def _aggregate_record(
653
+ session_id: str, aggregate: dict, result: GuardResult
654
+ ) -> dict:
655
+ return {
656
+ "ts": datetime.now(UTC).isoformat(),
657
+ "session_id": session_id,
658
+ "aggregate": aggregate,
659
+ "aggregate_verdict_digest": _aggregate_digest(aggregate),
660
+ "artifacts": result.artifacts,
661
+ "reviewers": result.reviewers,
662
+ "warnings": result.warnings,
663
+ }
664
+
665
+
666
+ def _aggregate_digest(aggregate: dict) -> str:
667
+ try:
668
+ from core.governance.qg_digest import verdict_digest
669
+
670
+ return verdict_digest(aggregate)
671
+ except Exception: # digest is provenance, never a blocker
672
+ return ""