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.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/arka/skills/checkpoint/SKILL.md +10 -3
- package/arka/skills/flow/SKILL.md +4 -0
- package/config/claude-agents/eduardo-copy.md +13 -2
- package/config/claude-agents/francisca-tech.md +12 -2
- package/config/claude-agents/marta-cqo.md +68 -10
- package/config/constitution.yaml +4 -1
- package/core/evals/record_cli.py +184 -24
- package/core/evals/runner_cli.py +5 -2
- package/core/evals/verdict_labels.py +19 -6
- package/core/governance/aggregate_guard.py +672 -0
- package/core/governance/qg_verdict.py +56 -2
- package/core/governance/redo_counter.py +13 -8
- package/core/governance/reviewer_ledger.py +94 -12
- package/core/hooks/session_end.py +16 -0
- package/departments/quality/SKILL.md +38 -18
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- 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 ""
|