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.
@@ -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