monogate-capcard-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capcard_cli/__init__.py +55 -0
- capcard_cli/attest.py +444 -0
- capcard_cli/bundle.py +403 -0
- capcard_cli/cli.py +1530 -0
- capcard_cli/playbook.py +561 -0
- capcard_cli/registry.py +484 -0
- capcard_cli/training.py +463 -0
- capcard_cli/verify.py +901 -0
- monogate_capcard_cli-1.0.0.dist-info/METADATA +663 -0
- monogate_capcard_cli-1.0.0.dist-info/RECORD +14 -0
- monogate_capcard_cli-1.0.0.dist-info/WHEEL +5 -0
- monogate_capcard_cli-1.0.0.dist-info/entry_points.txt +14 -0
- monogate_capcard_cli-1.0.0.dist-info/licenses/LICENSE +17 -0
- monogate_capcard_cli-1.0.0.dist-info/top_level.txt +1 -0
capcard_cli/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CapCard CLI — persistent identity + verification for Monogate agents.
|
|
2
|
+
|
|
3
|
+
Phase 1 (0.1.0): agent registry + ``register`` / ``status`` CLIs.
|
|
4
|
+
Phase 2 (0.2.0): ``verify`` CLI — records evidence-backed outcomes,
|
|
5
|
+
appends a JSONL log, bumps registry counters per the result-type
|
|
6
|
+
contract.
|
|
7
|
+
Phase 3 (0.3.0): ``playbook`` CLI — emits a session-end summary with
|
|
8
|
+
lessons / tactics / open_questions and a trust delta vs. the prior
|
|
9
|
+
playbook. Next agent inherits via ``capcard-playbook search``.
|
|
10
|
+
Phase 4 (0.4.0): weighted trust score. ``trust = sum(w_i) / sum(|w_i|)``
|
|
11
|
+
clamped to ``[0, 1]``; ``--evidence-type`` declares evidence
|
|
12
|
+
strength (lean-proof = 1.0, self-report = 0.2, ci-broken = -0.3, …).
|
|
13
|
+
Phase 6 (0.5.0): cross-agent attestation. ``capcard-verify
|
|
14
|
+
--needs-attestation`` writes a pending record; another agent runs
|
|
15
|
+
``capcard-attest --verification-id <id> --verifier-agent <vid>
|
|
16
|
+
--pass | --reject | --dispute`` to confirm or reject. Both parties'
|
|
17
|
+
trust update under the registry lock. Builder = verifier is rejected
|
|
18
|
+
(no self-grading); trust is transitive via a verifier-trust
|
|
19
|
+
modulator with a 0.5 floor for new verifiers.
|
|
20
|
+
Phase 6.5 (0.6.0): dispute resolution + trust hygiene.
|
|
21
|
+
``attestation_status_for`` collapses per-verifier votes so flip-flops
|
|
22
|
+
count as one current stance, then quorum-resolves disputes when 2+
|
|
23
|
+
distinct non-disputer verifiers agree. ``capcard-recompute-trust
|
|
24
|
+
--all --use-current-verifier-trust`` walks the registry with a 2-pass
|
|
25
|
+
converge so verifier-trust changes propagate back into builders they
|
|
26
|
+
attested. Read-only views: ``capcard-attestations --pending | --by |
|
|
27
|
+
--on``.
|
|
28
|
+
Phase 5 (0.7.0): training-data extraction + agent lineage. The CLI
|
|
29
|
+
doesn't train models — it turns the CapCard archive into trainable
|
|
30
|
+
signal. ``AgentRecord`` gains ``generation: int`` and
|
|
31
|
+
``parent_id: str | None``; ``capcard-register --generation N
|
|
32
|
+
--parent ID`` and ``capcard list --by-lineage / --descendants-of``
|
|
33
|
+
walk the chain. New module ``capcard_cli.training`` exposes
|
|
34
|
+
``reward_for_playbook`` (shaped: trust_delta + 0.2 honest-negative
|
|
35
|
+
bonus + -0.3 ci-broken extra; clamped [-1, 1]),
|
|
36
|
+
``export_episodes``, ``rank_tactics`` (trust-weighted),
|
|
37
|
+
``agent_stats``, and ``evidence_type_histogram``. New CLIs:
|
|
38
|
+
``capcard-export``, ``capcard-tactics``, ``capcard-stats``.
|
|
39
|
+
Phase 6.7 + 7 (1.0.0): closes the BACKLOG and ships the wire
|
|
40
|
+
format. Time-decay opt-in clears stale dispute markers
|
|
41
|
+
(``capcard-recompute-trust --dispute-decay-days 7``); iterative
|
|
42
|
+
trust converge (``--iterative --epsilon 0.001``) handles
|
|
43
|
+
verifier-cycle pathologies. New ``capcard_cli.bundle`` module +
|
|
44
|
+
``capcard-bundle export/import`` ship a versioned, idempotent-
|
|
45
|
+
merge JSON wire format ready for a Phase 7+ network gossip layer.
|
|
46
|
+
``capcard-fingerprint --verification-id VID --rerun`` re-executes
|
|
47
|
+
the original evidence command and compares the SHA-256 output
|
|
48
|
+
digest. ``AgentRecord`` gains a forward-reserved ``pubkey: str |
|
|
49
|
+
None`` field for cross-registry signature verification once a
|
|
50
|
+
network protocol exists. Stdlib-only constraint preserved — actual
|
|
51
|
+
ZK proofs and gossip are an out-of-CLI research project (see
|
|
52
|
+
``docs/BACKLOG.md``).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
__version__ = "1.0.0"
|
capcard_cli/attest.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Cross-agent attestation — Phases 6, 6.5, 6.7.
|
|
2
|
+
|
|
3
|
+
A verification can be self-attested (the default; Phase 4 behavior
|
|
4
|
+
preserved) or cross-attested. Cross-attestation requires:
|
|
5
|
+
|
|
6
|
+
1. The builder records the verification with ``--needs-attestation``
|
|
7
|
+
so the row is written with ``needs_attestation=True`` and starts
|
|
8
|
+
in the ``pending`` state, contributing 0 to the trust score.
|
|
9
|
+
|
|
10
|
+
2. A separate agent (the verifier) calls ``capcard-attest`` with
|
|
11
|
+
the verification's id, their own agent id, and a ``--pass`` /
|
|
12
|
+
``--reject`` / ``--dispute`` outcome. We refuse to record an
|
|
13
|
+
attestation where the verifier and the builder are the same
|
|
14
|
+
agent — the whole point is independent review.
|
|
15
|
+
|
|
16
|
+
3. The attestation lands in ``~/.capcard/attestations.jsonl``
|
|
17
|
+
(override via ``CAPCARD_ATTESTATION_LOG_PATH``). Both the
|
|
18
|
+
builder's and the verifier's trust scores are recomputed and
|
|
19
|
+
re-cached on the registry.
|
|
20
|
+
|
|
21
|
+
State machine for one verification:
|
|
22
|
+
|
|
23
|
+
::
|
|
24
|
+
|
|
25
|
+
self-attested ← needs_attestation=False, immediate trust
|
|
26
|
+
pending ← needs_attestation=True, no attestation yet
|
|
27
|
+
confirmed ← needs_attestation=True, latest non-dispute is "pass"
|
|
28
|
+
rejected ← needs_attestation=True, latest non-dispute is "reject"
|
|
29
|
+
disputed ← any "dispute" outcome present (treated as 0
|
|
30
|
+
until full Phase 6.5+ resolution flow ships)
|
|
31
|
+
|
|
32
|
+
Attestation log is append-only JSONL — overturning a previous
|
|
33
|
+
verdict means recording a new attestation, not editing the old row.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import contextlib
|
|
38
|
+
import dataclasses
|
|
39
|
+
import datetime as _dt
|
|
40
|
+
import errno
|
|
41
|
+
import fcntl
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import uuid
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from capcard_cli.registry import (
|
|
49
|
+
AgentNotFound, AgentRecord, RegistryError, _atomic_write, _load_raw,
|
|
50
|
+
_locked, _utc_now_iso, registry_path,
|
|
51
|
+
)
|
|
52
|
+
from capcard_cli.verify import (
|
|
53
|
+
VerificationRecord, _utc_now_iso_us, find_verification_by_id,
|
|
54
|
+
read_verifications, weighted_trust_for_agent,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ──────────────────────────── Outcomes ──────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
KNOWN_ATTESTATION_OUTCOMES: tuple[str, ...] = ("pass", "reject", "dispute")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ──────────────────────────── Attestation record ────────────────
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclasses.dataclass(frozen=True)
|
|
68
|
+
class Attestation:
|
|
69
|
+
"""One verifier signature on a verification record.
|
|
70
|
+
|
|
71
|
+
Attestations are append-only — overturning a previous verdict
|
|
72
|
+
means recording a *new* attestation, not editing the old row.
|
|
73
|
+
The ``attestation_status_for`` resolver reads "the latest non-
|
|
74
|
+
dispute outcome wins, plus any dispute marker raises the row
|
|
75
|
+
into the disputed state."
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
attestation_id: str # uuid4 hex (12 chars)
|
|
79
|
+
verification_id: str # foreign key into verifications.jsonl
|
|
80
|
+
verifier_agent_id: str
|
|
81
|
+
outcome: str # one of KNOWN_ATTESTATION_OUTCOMES
|
|
82
|
+
verifier_trust_at_time: float # snapshot at the time of attestation
|
|
83
|
+
evidence: str | None # what the verifier looked at
|
|
84
|
+
note: str | None
|
|
85
|
+
timestamp: str # microsecond ISO-8601 UTC
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return dataclasses.asdict(self)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ──────────────────────────── Path + IO ─────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def attestation_log_path() -> Path:
|
|
95
|
+
"""Resolve the attestation log path.
|
|
96
|
+
|
|
97
|
+
Precedence: ``CAPCARD_ATTESTATION_LOG_PATH`` env var, else
|
|
98
|
+
``~/.capcard/attestations.jsonl``.
|
|
99
|
+
"""
|
|
100
|
+
override = os.environ.get("CAPCARD_ATTESTATION_LOG_PATH")
|
|
101
|
+
if override:
|
|
102
|
+
return Path(override).expanduser()
|
|
103
|
+
return Path.home() / ".capcard" / "attestations.jsonl"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _append_jsonl(path: Path, row: dict[str, Any]) -> None:
|
|
107
|
+
"""Append one JSON object as a single line under a sidecar
|
|
108
|
+
flock so concurrent writers don't interleave bytes mid-line.
|
|
109
|
+
Mirrors the verification log's IO contract."""
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
112
|
+
fd_lock = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
113
|
+
try:
|
|
114
|
+
try:
|
|
115
|
+
fcntl.flock(fd_lock, fcntl.LOCK_EX)
|
|
116
|
+
except OSError as e:
|
|
117
|
+
if e.errno in (errno.ENOSYS, errno.ENOTSUP):
|
|
118
|
+
raise RegistryError(
|
|
119
|
+
f"flock unsupported on filesystem hosting {path}; "
|
|
120
|
+
f"set CAPCARD_ATTESTATION_LOG_PATH to a local path."
|
|
121
|
+
) from e
|
|
122
|
+
raise
|
|
123
|
+
with open(path, "ab") as f:
|
|
124
|
+
line = json.dumps(row, sort_keys=True).encode("utf-8") + b"\n"
|
|
125
|
+
f.write(line)
|
|
126
|
+
f.flush()
|
|
127
|
+
os.fsync(f.fileno())
|
|
128
|
+
finally:
|
|
129
|
+
with contextlib.suppress(OSError):
|
|
130
|
+
fcntl.flock(fd_lock, fcntl.LOCK_UN)
|
|
131
|
+
os.close(fd_lock)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _new_attestation_id() -> str:
|
|
135
|
+
return uuid.uuid4().hex[:12]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ──────────────────────────── State resolver ────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _collapse_per_verifier(
|
|
142
|
+
relevant: list[Attestation],
|
|
143
|
+
) -> list[Attestation]:
|
|
144
|
+
"""Phase 6.5: keep only the latest attestation from each verifier.
|
|
145
|
+
|
|
146
|
+
A single verifier flipping their vote counts as one current vote
|
|
147
|
+
(their latest stance), not two cancelling votes. The append-only
|
|
148
|
+
log preserves the audit trail; this collapse is read-side only.
|
|
149
|
+
"""
|
|
150
|
+
by_verifier: dict[str, Attestation] = {}
|
|
151
|
+
for a in relevant:
|
|
152
|
+
prev = by_verifier.get(a.verifier_agent_id)
|
|
153
|
+
if prev is None or a.timestamp > prev.timestamp:
|
|
154
|
+
by_verifier[a.verifier_agent_id] = a
|
|
155
|
+
return list(by_verifier.values())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_iso8601(ts: str) -> _dt.datetime | None:
|
|
159
|
+
"""Parse an ISO-8601 UTC timestamp (supports both second and
|
|
160
|
+
microsecond resolutions, with or without a trailing Z). Returns
|
|
161
|
+
None for unparseable strings — defensive against legacy log
|
|
162
|
+
rows from before microsecond timestamps."""
|
|
163
|
+
if not ts:
|
|
164
|
+
return None
|
|
165
|
+
s = ts.rstrip("Z")
|
|
166
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
|
167
|
+
try:
|
|
168
|
+
return _dt.datetime.strptime(s, fmt).replace(tzinfo=_dt.timezone.utc)
|
|
169
|
+
except ValueError:
|
|
170
|
+
continue
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def attestation_status_for(
|
|
175
|
+
rec: VerificationRecord,
|
|
176
|
+
attestations: list[Attestation],
|
|
177
|
+
*,
|
|
178
|
+
now: _dt.datetime | None = None,
|
|
179
|
+
dispute_decay_days: float | None = None,
|
|
180
|
+
) -> tuple[str, Attestation | None]:
|
|
181
|
+
"""Resolve the attestation state of one verification record.
|
|
182
|
+
|
|
183
|
+
Returns ``(status, latest_decision)`` where ``status`` is one of
|
|
184
|
+
``self-attested`` / ``pending`` / ``confirmed`` / ``rejected`` /
|
|
185
|
+
``disputed`` and ``latest_decision`` is the most recent ``pass``
|
|
186
|
+
/ ``reject`` attestation that drove the status (or None for
|
|
187
|
+
``self-attested`` / ``pending`` / ``disputed``-with-no-decision).
|
|
188
|
+
|
|
189
|
+
Phase 6.5 added two refinements:
|
|
190
|
+
|
|
191
|
+
* **Per-verifier collapse:** multiple attestations from the same
|
|
192
|
+
verifier on the same record collapse to their latest stance,
|
|
193
|
+
so a flip-flop counts as one current vote, not two.
|
|
194
|
+
|
|
195
|
+
* **Dispute quorum resolution:** a ``dispute`` marker can be
|
|
196
|
+
cleared when ≥2 *distinct* non-disputer verifiers' current
|
|
197
|
+
votes agree on the same verdict and that verdict has a strict
|
|
198
|
+
majority among non-disputer current votes. Otherwise the
|
|
199
|
+
record stays in the ``disputed`` state.
|
|
200
|
+
|
|
201
|
+
Phase 6.7 added time-decay (opt-in):
|
|
202
|
+
|
|
203
|
+
* **dispute_decay_days:** when set together with ``now``, a
|
|
204
|
+
dispute marker whose latest occurrence is older than the
|
|
205
|
+
threshold is ignored (treated as cleared). The underlying
|
|
206
|
+
decision (the latest pass/reject among current votes) drives
|
|
207
|
+
the status. This closes the "stuck disputed" edge from the
|
|
208
|
+
Phase 6.5 review without requiring an arbiter actor — it's
|
|
209
|
+
an explicit hygiene knob the caller turns on (default
|
|
210
|
+
behaviour preserves the Phase 6.5 strict-quorum semantics).
|
|
211
|
+
"""
|
|
212
|
+
if not rec.needs_attestation:
|
|
213
|
+
return "self-attested", None
|
|
214
|
+
|
|
215
|
+
if not rec.verification_id:
|
|
216
|
+
# Old log row missing an id — can't be attested. Treat as
|
|
217
|
+
# self-attested even with needs_attestation flagged (defensive).
|
|
218
|
+
return "self-attested", None
|
|
219
|
+
|
|
220
|
+
relevant = [a for a in attestations if a.verification_id == rec.verification_id]
|
|
221
|
+
if not relevant:
|
|
222
|
+
return "pending", None
|
|
223
|
+
|
|
224
|
+
# Per-verifier collapse. A verifier's current stance is their
|
|
225
|
+
# latest attestation; earlier votes from the same verifier are
|
|
226
|
+
# superseded. Length of `current` ≤ number of distinct verifiers.
|
|
227
|
+
current = _collapse_per_verifier(relevant)
|
|
228
|
+
|
|
229
|
+
has_dispute = any(a.outcome == "dispute" for a in current)
|
|
230
|
+
|
|
231
|
+
# Phase 6.7 time-decay. If the caller supplied a decay threshold
|
|
232
|
+
# AND the most recent dispute among current votes is older than
|
|
233
|
+
# that threshold, treat the dispute as cleared. We only consider
|
|
234
|
+
# the *most recent* dispute timestamp because a fresh dispute
|
|
235
|
+
# from a new verifier should always reset the clock.
|
|
236
|
+
if has_dispute and dispute_decay_days is not None and now is not None:
|
|
237
|
+
latest_dispute_time: _dt.datetime | None = None
|
|
238
|
+
for a in current:
|
|
239
|
+
if a.outcome != "dispute":
|
|
240
|
+
continue
|
|
241
|
+
t = _parse_iso8601(a.timestamp)
|
|
242
|
+
if t is None:
|
|
243
|
+
continue
|
|
244
|
+
if latest_dispute_time is None or t > latest_dispute_time:
|
|
245
|
+
latest_dispute_time = t
|
|
246
|
+
if latest_dispute_time is not None:
|
|
247
|
+
age_seconds = (now - latest_dispute_time).total_seconds()
|
|
248
|
+
if age_seconds > dispute_decay_days * 86400:
|
|
249
|
+
has_dispute = False
|
|
250
|
+
|
|
251
|
+
decisions = [a for a in current if a.outcome in ("pass", "reject")]
|
|
252
|
+
|
|
253
|
+
if not decisions:
|
|
254
|
+
# Only disputes (current). No verdicts to resolve to.
|
|
255
|
+
if has_dispute:
|
|
256
|
+
return "disputed", None
|
|
257
|
+
return "pending", None # defensive — shouldn't reach here
|
|
258
|
+
|
|
259
|
+
# Latest decision among current verdicts.
|
|
260
|
+
latest = max(decisions, key=lambda a: a.timestamp)
|
|
261
|
+
|
|
262
|
+
if has_dispute:
|
|
263
|
+
# Phase 6.5: quorum resolution. Count distinct non-disputer
|
|
264
|
+
# verdicts (each verifier's current vote). 2+ matching the
|
|
265
|
+
# majority → resolve.
|
|
266
|
+
passes = [a for a in decisions if a.outcome == "pass"]
|
|
267
|
+
rejects = [a for a in decisions if a.outcome == "reject"]
|
|
268
|
+
if len(passes) >= 2 and len(passes) > len(rejects):
|
|
269
|
+
return "confirmed", max(passes, key=lambda a: a.timestamp)
|
|
270
|
+
if len(rejects) >= 2 and len(rejects) > len(passes):
|
|
271
|
+
return "rejected", max(rejects, key=lambda a: a.timestamp)
|
|
272
|
+
# Tied / not enough non-disputer verdicts → stay disputed.
|
|
273
|
+
return "disputed", latest
|
|
274
|
+
|
|
275
|
+
if latest.outcome == "pass":
|
|
276
|
+
return "confirmed", latest
|
|
277
|
+
return "rejected", latest
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ──────────────────────────── Read API ──────────────────────────
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def read_attestations(
|
|
284
|
+
*,
|
|
285
|
+
verification_id: str | None = None,
|
|
286
|
+
verifier_agent_id: str | None = None,
|
|
287
|
+
limit: int | None = None,
|
|
288
|
+
) -> list[Attestation]:
|
|
289
|
+
"""Read the attestation log, optionally filtered. Newest-first."""
|
|
290
|
+
path = attestation_log_path()
|
|
291
|
+
if not path.exists():
|
|
292
|
+
return []
|
|
293
|
+
out: list[Attestation] = []
|
|
294
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
295
|
+
for line in f:
|
|
296
|
+
line = line.strip()
|
|
297
|
+
if not line:
|
|
298
|
+
continue
|
|
299
|
+
try:
|
|
300
|
+
d = json.loads(line)
|
|
301
|
+
except json.JSONDecodeError:
|
|
302
|
+
# Skip torn lines; same contract as the verification log.
|
|
303
|
+
continue
|
|
304
|
+
if verification_id is not None and d.get("verification_id") != verification_id:
|
|
305
|
+
continue
|
|
306
|
+
if verifier_agent_id is not None and d.get("verifier_agent_id") != verifier_agent_id:
|
|
307
|
+
continue
|
|
308
|
+
known = {f.name for f in dataclasses.fields(Attestation)}
|
|
309
|
+
out.append(Attestation(**{k: v for k, v in d.items() if k in known}))
|
|
310
|
+
out.reverse()
|
|
311
|
+
if limit is not None:
|
|
312
|
+
out = out[:limit]
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ──────────────────────────── Public write API ──────────────────
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def record_attestation(
|
|
320
|
+
*,
|
|
321
|
+
verification_id: str,
|
|
322
|
+
verifier_agent_id: str,
|
|
323
|
+
outcome: str,
|
|
324
|
+
evidence: str | None = None,
|
|
325
|
+
note: str | None = None,
|
|
326
|
+
) -> tuple[Attestation, AgentRecord, AgentRecord]:
|
|
327
|
+
"""Record a cross-agent attestation.
|
|
328
|
+
|
|
329
|
+
Looks up the target verification, validates the actors, appends
|
|
330
|
+
one row to ``attestations.jsonl``, and recomputes BOTH the
|
|
331
|
+
builder's and the verifier's trust scores under the registry
|
|
332
|
+
lock.
|
|
333
|
+
|
|
334
|
+
Returns ``(attestation, builder_record, verifier_record)``.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
* :class:`RegistryError` for unknown outcomes, empty fields,
|
|
338
|
+
attestations of self-attested rows, or builder = verifier
|
|
339
|
+
(the no-self-grading rule).
|
|
340
|
+
* :class:`AgentNotFound` if the verifier_agent_id has no
|
|
341
|
+
registry row OR the target verification doesn't exist.
|
|
342
|
+
"""
|
|
343
|
+
if not verification_id.strip():
|
|
344
|
+
raise RegistryError("--verification-id must be non-empty")
|
|
345
|
+
if not verifier_agent_id.strip():
|
|
346
|
+
raise RegistryError("--verifier-agent must be non-empty")
|
|
347
|
+
if outcome not in KNOWN_ATTESTATION_OUTCOMES:
|
|
348
|
+
raise RegistryError(
|
|
349
|
+
f"unknown outcome {outcome!r}; "
|
|
350
|
+
f"valid: {', '.join(KNOWN_ATTESTATION_OUTCOMES)}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Look up the target verification. Linear scan but fine at scale.
|
|
354
|
+
target = find_verification_by_id(verification_id)
|
|
355
|
+
if target is None:
|
|
356
|
+
raise AgentNotFound(
|
|
357
|
+
f"no verification with id {verification_id!r} "
|
|
358
|
+
f"in {attestation_log_path().parent}/verifications.jsonl"
|
|
359
|
+
)
|
|
360
|
+
if not target.needs_attestation:
|
|
361
|
+
raise RegistryError(
|
|
362
|
+
f"verification {verification_id!r} is self-attested "
|
|
363
|
+
f"(needs_attestation=False); cross-attestation is only "
|
|
364
|
+
f"meaningful when the builder asked for it via "
|
|
365
|
+
f"--needs-attestation"
|
|
366
|
+
)
|
|
367
|
+
if target.agent_id == verifier_agent_id:
|
|
368
|
+
raise RegistryError(
|
|
369
|
+
f"refuse to attest: verifier {verifier_agent_id!r} is "
|
|
370
|
+
f"the same as the builder. Cross-attestation requires "
|
|
371
|
+
f"an independent agent."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Verifier must be a registered agent so we can snapshot their
|
|
375
|
+
# trust at attestation time.
|
|
376
|
+
rpath = registry_path()
|
|
377
|
+
with _locked(rpath):
|
|
378
|
+
raw = _load_raw(rpath)
|
|
379
|
+
v_d = raw["agents"].get(verifier_agent_id)
|
|
380
|
+
if v_d is None:
|
|
381
|
+
raise AgentNotFound(
|
|
382
|
+
f"no agent with id {verifier_agent_id!r} (verifier "
|
|
383
|
+
f"must be registered via capcard-register)"
|
|
384
|
+
)
|
|
385
|
+
verifier_trust_snapshot = float(v_d.get("trust_score", 0.0))
|
|
386
|
+
|
|
387
|
+
# Build the attestation record.
|
|
388
|
+
a = Attestation(
|
|
389
|
+
attestation_id=_new_attestation_id(),
|
|
390
|
+
verification_id=verification_id,
|
|
391
|
+
verifier_agent_id=verifier_agent_id,
|
|
392
|
+
outcome=outcome,
|
|
393
|
+
verifier_trust_at_time=verifier_trust_snapshot,
|
|
394
|
+
evidence=evidence,
|
|
395
|
+
note=note,
|
|
396
|
+
timestamp=_utc_now_iso_us(),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Append the log first so the recompute walk sees this attestation.
|
|
400
|
+
_append_jsonl(attestation_log_path(), a.to_dict())
|
|
401
|
+
|
|
402
|
+
# Recompute trust for BOTH parties in one registry lock so the
|
|
403
|
+
# two writes are seen atomically by readers.
|
|
404
|
+
with _locked(rpath):
|
|
405
|
+
raw = _load_raw(rpath)
|
|
406
|
+
|
|
407
|
+
history = read_verifications()
|
|
408
|
+
attns = read_attestations()
|
|
409
|
+
|
|
410
|
+
builder_d = raw["agents"].get(target.agent_id)
|
|
411
|
+
if builder_d is None:
|
|
412
|
+
# Builder was deleted between the verification write and
|
|
413
|
+
# this attestation. Surface it — the attestation lives on
|
|
414
|
+
# disk regardless.
|
|
415
|
+
raise AgentNotFound(
|
|
416
|
+
f"builder {target.agent_id!r} disappeared; "
|
|
417
|
+
f"attestation logged at {a.attestation_id} but trust "
|
|
418
|
+
f"could not be updated"
|
|
419
|
+
)
|
|
420
|
+
builder_d["trust_score"] = weighted_trust_for_agent(
|
|
421
|
+
target.agent_id, records=history, attestations=attns,
|
|
422
|
+
)
|
|
423
|
+
builder_d["last_active"] = _utc_now_iso()
|
|
424
|
+
raw["agents"][target.agent_id] = builder_d
|
|
425
|
+
|
|
426
|
+
v_d = raw["agents"].get(verifier_agent_id)
|
|
427
|
+
# We re-read the verifier row inside the lock to avoid
|
|
428
|
+
# clobbering any concurrent update from elsewhere.
|
|
429
|
+
if v_d is None:
|
|
430
|
+
raise AgentNotFound(
|
|
431
|
+
f"verifier {verifier_agent_id!r} disappeared mid-attest"
|
|
432
|
+
)
|
|
433
|
+
v_d["trust_score"] = weighted_trust_for_agent(
|
|
434
|
+
verifier_agent_id, records=history, attestations=attns,
|
|
435
|
+
)
|
|
436
|
+
v_d["last_active"] = _utc_now_iso()
|
|
437
|
+
raw["agents"][verifier_agent_id] = v_d
|
|
438
|
+
|
|
439
|
+
_atomic_write(rpath, raw)
|
|
440
|
+
|
|
441
|
+
builder_rec = AgentRecord.from_dict(builder_d)
|
|
442
|
+
verifier_rec = AgentRecord.from_dict(v_d)
|
|
443
|
+
|
|
444
|
+
return a, builder_rec, verifier_rec
|