@arbiterforge/ca-pi 0.14.6 → 0.14.8

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,971 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — host-owned prerequisite confirmation for structured plans.
3
+ """Capture an exact host-observed user decision and satisfy one prerequisite.
4
+
5
+ arm_user_prerequisite(...) -> dict
6
+ consume_user_prerequisite(...) -> dict
7
+ cancel_user_prerequisite(...) -> dict
8
+ supersede_user_prerequisite(...) -> dict
9
+ consume_from_hook(...) -> str
10
+
11
+ The adapter fixes the authority kind, verdict, actor, event shape, and engine
12
+ operation. Callers select only an approved plan and one of its exact current
13
+ prerequisite records. Confirmation is persisted before authority publication;
14
+ retries reuse the same event, receipt, operation ID, and mutation request.
15
+
16
+ The user-visible confirmation nonce is deliberately non-secret correlation
17
+ data, not a bearer credential. Possession never confers authority: the adapter
18
+ accepts it only in an exact prompt observed by the trusted UserPromptSubmit
19
+ boundary while the reviewed plan, approval, and prerequisite remain current.
20
+ The observed prompt is retained verbatim as the authority source because it is
21
+ the decision evidence; nonce unpredictability is not a security boundary.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ from contextlib import contextmanager
28
+ import hashlib
29
+ import json
30
+ import os
31
+ from pathlib import Path
32
+ import re
33
+ import secrets
34
+ import stat
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from typing import Any
39
+
40
+ import _artifactlib
41
+ from _hooklib import acquire_lock, release_lock
42
+
43
+
44
+ PENDING_DIR = Path(".codearbiter/.markers/pending-prerequisites")
45
+ SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
46
+ FORMAT = "codearbiter.pending-user-prerequisite/0.1.0"
47
+ POLICY = "explicit-user-prerequisite-confirmation/0.1.0"
48
+ CONFIRMATION_LIFETIME_SECONDS = 15 * 60
49
+ CONFIRMATION_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
50
+ ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
51
+ HOST_RE = re.compile(r"[A-Za-z0-9_-]{1,32}")
52
+ SESSION_RE = re.compile(r"[A-Za-z0-9._:-]{1,256}")
53
+ SHA256_RE = re.compile(r"[0-9a-f]{64}")
54
+ RECEIPT_RE = re.compile(
55
+ r"\.codearbiter/\.artifacts/receipts/[0-9a-f]{64}\.json"
56
+ )
57
+ STATES = frozenset({"ARMED", "CONFIRMED", "CAPTURED"})
58
+ STATE_FIELDS = {
59
+ "format", "state", "request_id", "artifact_id", "prerequisite_id",
60
+ "kind", "revision", "model_sha256", "normative_sha256", "record",
61
+ "review_packet_sha256", "confirmation_sha256", "issued_at", "expires_at",
62
+ "event", "authority_source", "receipt", "mutation_request",
63
+ }
64
+
65
+
66
+ class PrerequisiteError(RuntimeError):
67
+ def __init__(self, code: str, message: str):
68
+ self.code = code
69
+ super().__init__(f"{code}: {message}")
70
+
71
+
72
+ def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
73
+ value: dict[str, Any] = {}
74
+ for key, item in pairs:
75
+ if key in value:
76
+ raise ValueError("duplicate JSON key")
77
+ value[key] = item
78
+ return value
79
+
80
+
81
+ def _canonical(value: Any) -> bytes:
82
+ return json.dumps(
83
+ value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")
84
+ ).encode("utf-8")
85
+
86
+
87
+ def _digest(data: bytes) -> str:
88
+ return hashlib.sha256(data).hexdigest()
89
+
90
+
91
+ def _pending_relative(artifact_id: str) -> Path:
92
+ return PENDING_DIR / f"{_digest(artifact_id.encode('utf-8'))}.json"
93
+
94
+
95
+ def _prerequisite_busy() -> PrerequisiteError:
96
+ return PrerequisiteError(
97
+ "PREREQUISITE_BUSY",
98
+ "another session is resolving this plan's prerequisite request",
99
+ )
100
+
101
+
102
+ def _repository_lock_key(root: Path) -> bytes:
103
+ """Return one key for every path alias to the same repository directory."""
104
+ info = os.stat(root)
105
+ device = int(getattr(info, "st_dev", 0))
106
+ inode = int(getattr(info, "st_ino", 0))
107
+ if device or inode:
108
+ return f"stat:{device}:{inode}".encode("ascii")
109
+
110
+ # Some Windows filesystems report no stable inode. Their path comparison is
111
+ # case-insensitive, so casefold closes aliases that normcase alone may miss
112
+ # on a non-native test host.
113
+ resolved = str(root.resolve(strict=True))
114
+ return b"path:" + os.path.normcase(resolved).casefold().encode("utf-8")
115
+
116
+
117
+ def _pending_lock_directory_name(temporary: Path | None = None) -> str:
118
+ if hasattr(os, "getuid"):
119
+ namespace = f"uid-{os.getuid()}"
120
+ else:
121
+ if temporary is None:
122
+ temporary = Path(tempfile.gettempdir()).resolve(strict=True)
123
+ temp_identity = (
124
+ os.path.normcase(str(temporary)).casefold().encode("utf-8")
125
+ )
126
+ namespace = f"temp-{_digest(temp_identity)[:24]}"
127
+ return f"codearbiter-prerequisite-locks-{namespace}"
128
+
129
+
130
+ def _pending_lock_root() -> Path:
131
+ """Create and validate the private cross-process lock namespace."""
132
+ try:
133
+ temporary = Path(tempfile.gettempdir()).resolve(strict=True)
134
+ lock_root = temporary / _pending_lock_directory_name(temporary)
135
+ lock_root.mkdir(mode=0o700, exist_ok=True)
136
+ info = lock_root.lstat()
137
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
138
+ if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
139
+ raise OSError("prerequisite lock root is not a real directory")
140
+ if hasattr(os, "getuid"):
141
+ if info.st_uid != os.getuid():
142
+ raise OSError("prerequisite lock root has the wrong owner")
143
+ os.chmod(lock_root, 0o700)
144
+ info = lock_root.lstat()
145
+ if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
146
+ raise OSError("prerequisite lock root is not private")
147
+ return lock_root
148
+ except OSError as error:
149
+ raise _prerequisite_busy() from error
150
+
151
+
152
+ @contextmanager
153
+ def _pending_transition_lock(root: Path, artifact_id: str):
154
+ """Serialize one artifact's load-to-cleanup prerequisite transition."""
155
+ # Keep the live OS lock outside the repository. Verification snapshots
156
+ # recursively read ordinary marker files, and Windows denies that read
157
+ # while acquire_lock holds its byte-range lock. The resolved repository
158
+ # filesystem identity makes path aliases share a key, while the private
159
+ # per-user namespace prevents another local account from redirecting or
160
+ # pre-seeding the lock sidecar.
161
+ repository_key = _repository_lock_key(root)
162
+ lock_name = _digest(repository_key + b"\0" + artifact_id.encode("utf-8"))
163
+ lock_root = _pending_lock_root()
164
+ handle = acquire_lock(lock_root / lock_name)
165
+ if handle is None:
166
+ raise _prerequisite_busy()
167
+ try:
168
+ yield
169
+ finally:
170
+ release_lock(handle)
171
+
172
+
173
+ def _safe_directory(root: Path, relative: Path, *, create: bool) -> Path:
174
+ current = root
175
+ for part in relative.parts:
176
+ current = current / part
177
+ try:
178
+ info = current.lstat()
179
+ except FileNotFoundError:
180
+ if not create:
181
+ raise
182
+ current.mkdir()
183
+ info = current.lstat()
184
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
185
+ if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
186
+ raise PrerequisiteError(
187
+ "UNSAFE_PREREQUISITE_PATH",
188
+ "prerequisite state directories must be real directories",
189
+ )
190
+ return current
191
+
192
+
193
+ def _write_new(root: Path, relative: Path, data: bytes) -> None:
194
+ parent = _safe_directory(root, relative.parent, create=True)
195
+ path = parent / relative.name
196
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
197
+ if hasattr(os, "O_BINARY"):
198
+ flags |= os.O_BINARY
199
+ fd = os.open(path, flags, 0o600)
200
+ completed = False
201
+ try:
202
+ view = memoryview(data)
203
+ while view:
204
+ written = os.write(fd, view)
205
+ if written <= 0:
206
+ raise OSError("short write")
207
+ view = view[written:]
208
+ os.fsync(fd)
209
+ os.close(fd)
210
+ fd = None
211
+ completed = True
212
+ finally:
213
+ if fd is not None:
214
+ try:
215
+ os.close(fd)
216
+ except OSError:
217
+ pass
218
+ if not completed:
219
+ try:
220
+ path.unlink()
221
+ except OSError:
222
+ pass
223
+
224
+
225
+ def _replace_state(root: Path, relative: Path, value: dict[str, Any]) -> None:
226
+ suffix = secrets.token_hex(8)
227
+ temporary = relative.with_name(f".{relative.name}.{suffix}.tmp")
228
+ _write_new(root, temporary, _canonical(value))
229
+ source = root / temporary
230
+ target = root / relative
231
+ try:
232
+ os.replace(source, target)
233
+ if os.name != "nt":
234
+ directory = os.open(target.parent, os.O_RDONLY)
235
+ try:
236
+ os.fsync(directory)
237
+ finally:
238
+ os.close(directory)
239
+ finally:
240
+ try:
241
+ source.unlink()
242
+ except FileNotFoundError:
243
+ pass
244
+
245
+
246
+ def _publish_new(root: Path, relative: Path, data: bytes) -> None:
247
+ """Publish complete bytes atomically without replacing an existing name."""
248
+ suffix = secrets.token_hex(8)
249
+ temporary = relative.with_name(f".{relative.name}.{suffix}.tmp")
250
+ _write_new(root, temporary, data)
251
+ source = root / temporary
252
+ target = root / relative
253
+ linked = False
254
+ try:
255
+ os.link(source, target)
256
+ linked = True
257
+ if os.name != "nt":
258
+ directory = os.open(target.parent, os.O_RDONLY)
259
+ try:
260
+ os.fsync(directory)
261
+ finally:
262
+ os.close(directory)
263
+ finally:
264
+ try:
265
+ source.unlink()
266
+ except FileNotFoundError:
267
+ pass
268
+ if not linked:
269
+ raise OSError("content-addressed publication did not complete")
270
+
271
+
272
+ def _read_bounded_regular(root: Path, relative: Path, *, limit: int) -> bytes:
273
+ parent = _safe_directory(root, relative.parent, create=False)
274
+ path = parent / relative.name
275
+ info = path.lstat()
276
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
277
+ if (
278
+ stat.S_ISLNK(info.st_mode)
279
+ or reparse
280
+ or not stat.S_ISREG(info.st_mode)
281
+ or info.st_size > limit
282
+ ):
283
+ raise PrerequisiteError(
284
+ "INVALID_PENDING_PREREQUISITE",
285
+ "prerequisite state is not bounded regular data",
286
+ )
287
+ data = path.read_bytes()
288
+ if len(data) > limit:
289
+ raise PrerequisiteError(
290
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state exceeds its bound"
291
+ )
292
+ return data
293
+
294
+
295
+ def _identity(client: Any, artifact_id: str) -> dict[str, Any]:
296
+ identity = client.call("identity", {"artifact_id": artifact_id})
297
+ required = {
298
+ "artifact_id": str,
299
+ "kind": str,
300
+ "revision": int,
301
+ "model_sha256": str,
302
+ "normative_sha256": str,
303
+ }
304
+ if any(type(identity.get(key)) is not kind for key, kind in required.items()):
305
+ raise PrerequisiteError("INVALID_IDENTITY", "artifact identity is incomplete")
306
+ if identity["artifact_id"] != artifact_id or identity["kind"] != "plan":
307
+ raise PrerequisiteError("INVALID_IDENTITY", "prerequisites require the exact plan")
308
+ for key in ("model_sha256", "normative_sha256"):
309
+ if SHA256_RE.fullmatch(identity[key]) is None:
310
+ raise PrerequisiteError("INVALID_IDENTITY", f"{key} is malformed")
311
+ return identity
312
+
313
+
314
+ def _approved(client: Any, identity: dict[str, Any]) -> None:
315
+ result = client.call(
316
+ "validate",
317
+ {"artifact_id": identity["artifact_id"], "gate": "approved"},
318
+ permit_invalid=True,
319
+ )
320
+ authority = result.get("authority")
321
+ if (
322
+ result.get("valid") is not True
323
+ or result.get("artifact_id") != identity["artifact_id"]
324
+ or result.get("model_sha256") != identity["model_sha256"]
325
+ or result.get("normative_sha256") != identity["normative_sha256"]
326
+ or not isinstance(authority, dict)
327
+ or authority.get("state") != "approved"
328
+ or authority.get("authority_verified") is not True
329
+ ):
330
+ raise PrerequisiteError(
331
+ "AUTHORITY_UNVERIFIED",
332
+ "only the exact current approved plan can arm a prerequisite",
333
+ )
334
+
335
+
336
+ def _prerequisite_record(
337
+ client: Any, identity: dict[str, Any], prerequisite_id: str
338
+ ) -> dict[str, Any]:
339
+ result = client.call(
340
+ "read",
341
+ {
342
+ "artifact_id": identity["artifact_id"],
343
+ "symbol": prerequisite_id,
344
+ "mode": "exact",
345
+ },
346
+ )
347
+ if any(result.get(field) != identity[field] for field in (
348
+ "artifact_id", "revision", "model_sha256", "normative_sha256"
349
+ )):
350
+ raise PrerequisiteError("STALE_PREREQUISITE", "prerequisite read changed identity")
351
+ record = result.get("record")
352
+ if (
353
+ not isinstance(record, dict)
354
+ or set(record) != {"id", "title", "requirement", "source_refs"}
355
+ or record.get("id") != prerequisite_id
356
+ or not isinstance(record.get("title"), str)
357
+ or not record["title"]
358
+ or not isinstance(record.get("requirement"), str)
359
+ or not record["requirement"]
360
+ or not isinstance(record.get("source_refs"), list)
361
+ or any(not isinstance(value, str) for value in record["source_refs"])
362
+ ):
363
+ raise PrerequisiteError(
364
+ "UNKNOWN_PREREQUISITE", "symbol is not an exact prerequisite record"
365
+ )
366
+ return record
367
+
368
+
369
+ def _review_packet(identity: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]:
370
+ return {
371
+ "policy": POLICY,
372
+ "artifact": {
373
+ key: identity[key]
374
+ for key in (
375
+ "artifact_id", "kind", "revision", "model_sha256", "normative_sha256"
376
+ )
377
+ },
378
+ "prerequisite": record,
379
+ }
380
+
381
+
382
+ def _validate_record(value: Any, prerequisite_id: str) -> bool:
383
+ return (
384
+ isinstance(value, dict)
385
+ and set(value) == {"id", "title", "requirement", "source_refs"}
386
+ and value.get("id") == prerequisite_id
387
+ and isinstance(value.get("title"), str)
388
+ and bool(value["title"])
389
+ and isinstance(value.get("requirement"), str)
390
+ and bool(value["requirement"])
391
+ and isinstance(value.get("source_refs"), list)
392
+ and all(isinstance(item, str) for item in value["source_refs"])
393
+ )
394
+
395
+
396
+ def _validate_confirmed_state(value: dict[str, Any]) -> None:
397
+ event = value["event"]
398
+ expected_subject = {
399
+ "artifact_id": value["artifact_id"],
400
+ "normative_sha256": value["normative_sha256"],
401
+ "record_id": value["prerequisite_id"],
402
+ }
403
+ expected_payload = {
404
+ "adapter_policy": POLICY,
405
+ "request_id": value["request_id"],
406
+ "review_packet_sha256": value["review_packet_sha256"],
407
+ "prerequisite": value["record"],
408
+ }
409
+ if (
410
+ not isinstance(event, dict)
411
+ or set(event) != {
412
+ "format", "kind", "authority_kind", "subject", "actor", "origin",
413
+ "verdict", "payload", "source_text",
414
+ }
415
+ or event.get("format") != "codearbiter.workflow-event/0.1.0"
416
+ or event.get("kind") != "prerequisite"
417
+ or event.get("authority_kind") != "user_workflow"
418
+ or event.get("subject") != expected_subject
419
+ or event.get("actor") != "interactive repository user"
420
+ or not isinstance(event.get("origin"), str)
421
+ or re.fullmatch(
422
+ r"[A-Za-z0-9_-]{1,32}:UserPromptSubmit:[A-Za-z0-9._:-]{1,256}",
423
+ event["origin"],
424
+ ) is None
425
+ or event.get("verdict") != "satisfied"
426
+ or event.get("payload") != expected_payload
427
+ ):
428
+ raise PrerequisiteError(
429
+ "INVALID_PENDING_PREREQUISITE",
430
+ "confirmed event does not match the fixed prerequisite policy",
431
+ )
432
+ match = _match_prompt(event.get("source_text"))
433
+ if (
434
+ match is None
435
+ or match[:2] != (value["artifact_id"], value["prerequisite_id"])
436
+ or not secrets.compare_digest(
437
+ _digest(match[2].encode("ascii")), value["confirmation_sha256"]
438
+ )
439
+ ):
440
+ raise PrerequisiteError(
441
+ "INVALID_PENDING_PREREQUISITE",
442
+ "confirmed source text does not match the armed request",
443
+ )
444
+ expected_source = (
445
+ SOURCE_DIR / f"{_digest(_canonical(event))}.json"
446
+ ).as_posix()
447
+ if value["authority_source"] != expected_source:
448
+ raise PrerequisiteError(
449
+ "INVALID_PENDING_PREREQUISITE",
450
+ "confirmed authority source does not match the event",
451
+ )
452
+
453
+
454
+ def _expected_mutation_request(value: dict[str, Any]) -> dict[str, Any]:
455
+ operation_suffix = _digest(value["request_id"].encode("ascii"))[:24]
456
+ return {
457
+ "artifact_id": value["artifact_id"],
458
+ "operation_id": f"host-user-prerequisite-{operation_suffix}",
459
+ "expected": {
460
+ "revision": value["revision"],
461
+ "model_sha256": value["model_sha256"],
462
+ },
463
+ "prerequisite": value["prerequisite_id"],
464
+ "receipt": value["receipt"],
465
+ }
466
+
467
+
468
+ def _load_pending(root: Path, artifact_id: str) -> dict[str, Any] | None:
469
+ relative = _pending_relative(artifact_id)
470
+ try:
471
+ raw = _read_bounded_regular(root, relative, limit=65536)
472
+ except FileNotFoundError:
473
+ return None
474
+ except (OSError, UnicodeError) as exc:
475
+ raise PrerequisiteError(
476
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state is unreadable"
477
+ ) from exc
478
+ try:
479
+ value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_object)
480
+ except (ValueError, UnicodeError, RecursionError) as exc:
481
+ raise PrerequisiteError(
482
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state is invalid JSON"
483
+ ) from exc
484
+ if not isinstance(value, dict) or set(value) != STATE_FIELDS:
485
+ raise PrerequisiteError(
486
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state shape is invalid"
487
+ )
488
+ if (
489
+ value["format"] != FORMAT
490
+ or value["state"] not in STATES
491
+ or value["artifact_id"] != artifact_id
492
+ or any(not isinstance(value[key], str) or not value[key] for key in (
493
+ "request_id", "artifact_id", "prerequisite_id", "kind",
494
+ "model_sha256", "normative_sha256", "review_packet_sha256",
495
+ "confirmation_sha256",
496
+ ))
497
+ or type(value["revision"]) is not int
498
+ or type(value["issued_at"]) is not int
499
+ or type(value["expires_at"]) is not int
500
+ or value["expires_at"] <= value["issued_at"]
501
+ or any(SHA256_RE.fullmatch(value[key]) is None for key in (
502
+ "model_sha256", "normative_sha256", "review_packet_sha256", "confirmation_sha256"
503
+ ))
504
+ or not _validate_record(value["record"], value["prerequisite_id"])
505
+ ):
506
+ raise PrerequisiteError(
507
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state values are invalid"
508
+ )
509
+ if value["state"] == "ARMED":
510
+ if any(value[key] is not None for key in (
511
+ "event", "authority_source", "receipt", "mutation_request"
512
+ )):
513
+ raise PrerequisiteError(
514
+ "INVALID_PENDING_PREREQUISITE", "armed state contains later-phase data"
515
+ )
516
+ elif value["state"] == "CONFIRMED":
517
+ if (
518
+ not isinstance(value["event"], dict)
519
+ or not isinstance(value["authority_source"], str)
520
+ or value["receipt"] is not None
521
+ or value["mutation_request"] is not None
522
+ ):
523
+ raise PrerequisiteError(
524
+ "INVALID_PENDING_PREREQUISITE", "confirmed state is incomplete"
525
+ )
526
+ _validate_confirmed_state(value)
527
+ elif (
528
+ not isinstance(value["event"], dict)
529
+ or not isinstance(value["authority_source"], str)
530
+ or not isinstance(value["receipt"], str)
531
+ or RECEIPT_RE.fullmatch(value["receipt"]) is None
532
+ or not isinstance(value["mutation_request"], dict)
533
+ ):
534
+ raise PrerequisiteError(
535
+ "INVALID_PENDING_PREREQUISITE", "captured state is incomplete"
536
+ )
537
+ else:
538
+ _validate_confirmed_state(value)
539
+ if value["mutation_request"] != _expected_mutation_request(value):
540
+ raise PrerequisiteError(
541
+ "INVALID_PENDING_PREREQUISITE",
542
+ "captured mutation request does not match the confirmed decision",
543
+ )
544
+ return value
545
+
546
+
547
+ def arm_user_prerequisite(
548
+ root: str | Path,
549
+ client: Any,
550
+ artifact_id: str,
551
+ prerequisite_id: str,
552
+ *,
553
+ confirmation_nonce: str | None = None,
554
+ now: int | None = None,
555
+ ) -> dict[str, Any]:
556
+ root = Path(root).resolve(strict=True)
557
+ if ID_RE.fullmatch(artifact_id) is None or ID_RE.fullmatch(prerequisite_id) is None:
558
+ raise PrerequisiteError("INVALID_PREREQUISITE", "artifact and prerequisite IDs are invalid")
559
+ identity = _identity(client, artifact_id)
560
+ _approved(client, identity)
561
+ record = _prerequisite_record(client, identity, prerequisite_id)
562
+ final_identity = _identity(client, artifact_id)
563
+ if final_identity != identity:
564
+ raise PrerequisiteError("STALE_PREREQUISITE", "plan changed while arming")
565
+ _approved(client, final_identity)
566
+ packet_sha256 = _digest(_canonical(_review_packet(identity, record)))
567
+ confirmation_nonce = confirmation_nonce or secrets.token_urlsafe(32)
568
+ if CONFIRMATION_RE.fullmatch(confirmation_nonce) is None:
569
+ raise PrerequisiteError(
570
+ "INVALID_CONFIRMATION", "prerequisite confirmation nonce has an invalid shape"
571
+ )
572
+ issued_at = int(time.time()) if now is None else now
573
+ if type(issued_at) is not int or issued_at < 0:
574
+ raise PrerequisiteError("INVALID_TIME", "prerequisite issue time is invalid")
575
+ request_id = secrets.token_urlsafe(18)
576
+ pending = {
577
+ "format": FORMAT,
578
+ "state": "ARMED",
579
+ "request_id": request_id,
580
+ "artifact_id": artifact_id,
581
+ "prerequisite_id": prerequisite_id,
582
+ "kind": identity["kind"],
583
+ "revision": identity["revision"],
584
+ "model_sha256": identity["model_sha256"],
585
+ "normative_sha256": identity["normative_sha256"],
586
+ "record": record,
587
+ "review_packet_sha256": packet_sha256,
588
+ "confirmation_sha256": _digest(confirmation_nonce.encode("ascii")),
589
+ "issued_at": issued_at,
590
+ "expires_at": issued_at + CONFIRMATION_LIFETIME_SECONDS,
591
+ "event": None,
592
+ "authority_source": None,
593
+ "receipt": None,
594
+ "mutation_request": None,
595
+ }
596
+ relative = _pending_relative(artifact_id)
597
+ try:
598
+ _write_new(root, relative, _canonical(pending))
599
+ except FileExistsError as exc:
600
+ raise PrerequisiteError(
601
+ "PENDING_PREREQUISITE",
602
+ "resolve or cancel this plan's existing prerequisite request first",
603
+ ) from exc
604
+ return {
605
+ "artifact_id": artifact_id,
606
+ "prerequisite_id": prerequisite_id,
607
+ "title": record["title"],
608
+ "requirement": record["requirement"],
609
+ "review_packet_sha256": packet_sha256,
610
+ "reply": (
611
+ f"satisfy-prerequisite {artifact_id} {prerequisite_id} "
612
+ f"{confirmation_nonce}"
613
+ ),
614
+ "pending": relative.as_posix(),
615
+ }
616
+
617
+
618
+ def _match_prompt(prompt: Any) -> tuple[str, str, str] | None:
619
+ if not isinstance(prompt, str):
620
+ return None
621
+ parts = prompt.split(" ")
622
+ if (
623
+ len(parts) != 4
624
+ or parts[0] != "satisfy-prerequisite"
625
+ or ID_RE.fullmatch(parts[1]) is None
626
+ or ID_RE.fullmatch(parts[2]) is None
627
+ or CONFIRMATION_RE.fullmatch(parts[3]) is None
628
+ ):
629
+ return None
630
+ return parts[1], parts[2], parts[3]
631
+
632
+
633
+ def _confirm(
634
+ root: Path,
635
+ client: Any,
636
+ pending: dict[str, Any],
637
+ prompt: str,
638
+ *,
639
+ host: str,
640
+ session_id: str,
641
+ now: int,
642
+ ) -> dict[str, Any]:
643
+ if now > pending["expires_at"]:
644
+ raise PrerequisiteError(
645
+ "EXPIRED_PREREQUISITE", "the prerequisite confirmation request expired"
646
+ )
647
+ if not HOST_RE.fullmatch(host) or not SESSION_RE.fullmatch(session_id):
648
+ raise PrerequisiteError("INVALID_HOST_CONTEXT", "host prerequisite context is malformed")
649
+ current = _identity(client, pending["artifact_id"])
650
+ for field in ("kind", "revision", "model_sha256", "normative_sha256"):
651
+ if current[field] != pending[field]:
652
+ raise PrerequisiteError(
653
+ "STALE_PREREQUISITE", "plan changed after prerequisite review was armed"
654
+ )
655
+ _approved(client, current)
656
+ record = _prerequisite_record(client, current, pending["prerequisite_id"])
657
+ packet_sha256 = _digest(_canonical(_review_packet(current, record)))
658
+ if record != pending["record"] or packet_sha256 != pending["review_packet_sha256"]:
659
+ raise PrerequisiteError(
660
+ "STALE_PREREQUISITE", "prerequisite definition changed after review was armed"
661
+ )
662
+ event = {
663
+ "format": "codearbiter.workflow-event/0.1.0",
664
+ "kind": "prerequisite",
665
+ "authority_kind": "user_workflow",
666
+ "subject": {
667
+ "artifact_id": current["artifact_id"],
668
+ "normative_sha256": current["normative_sha256"],
669
+ "record_id": pending["prerequisite_id"],
670
+ },
671
+ "actor": "interactive repository user",
672
+ "origin": f"{host}:UserPromptSubmit:{session_id}",
673
+ "verdict": "satisfied",
674
+ "payload": {
675
+ "adapter_policy": POLICY,
676
+ "request_id": pending["request_id"],
677
+ "review_packet_sha256": pending["review_packet_sha256"],
678
+ "prerequisite": pending["record"],
679
+ },
680
+ "source_text": prompt,
681
+ }
682
+ event_bytes = _canonical(event)
683
+ source_rel = SOURCE_DIR / f"{_digest(event_bytes)}.json"
684
+ confirmed = dict(pending)
685
+ confirmed.update(
686
+ state="CONFIRMED",
687
+ event=event,
688
+ authority_source=source_rel.as_posix(),
689
+ )
690
+ _replace_state(root, _pending_relative(pending["artifact_id"]), confirmed)
691
+ return confirmed
692
+
693
+
694
+ def _publish_event(root: Path, pending: dict[str, Any]) -> tuple[str, str]:
695
+ event_bytes = _canonical(pending["event"])
696
+ digest = _digest(event_bytes)
697
+ source_rel = SOURCE_DIR / f"{digest}.json"
698
+ if pending["authority_source"] != source_rel.as_posix():
699
+ raise PrerequisiteError(
700
+ "INVALID_PENDING_PREREQUISITE", "authority source does not match the event"
701
+ )
702
+ try:
703
+ _publish_new(root, source_rel, event_bytes)
704
+ except FileExistsError:
705
+ try:
706
+ existing = _read_bounded_regular(root, source_rel, limit=65536)
707
+ except (FileNotFoundError, OSError) as exc:
708
+ raise PrerequisiteError(
709
+ "INVALID_AUTHORITY_SOURCE", "authority source is unreadable"
710
+ ) from exc
711
+ if existing != event_bytes:
712
+ raise PrerequisiteError(
713
+ "INVALID_AUTHORITY_SOURCE", "content-addressed source differs"
714
+ )
715
+ return source_rel.as_posix(), digest
716
+
717
+
718
+ def consume_user_prerequisite(
719
+ root: str | Path,
720
+ client: Any,
721
+ prompt: str,
722
+ *,
723
+ host: str,
724
+ session_id: str,
725
+ now: int | None = None,
726
+ ) -> dict[str, Any]:
727
+ root = Path(root).resolve(strict=True)
728
+ match = _match_prompt(prompt)
729
+ if match is None:
730
+ return {"matched": False, "satisfied": False}
731
+ artifact_id, prerequisite_id, confirmation_nonce = match
732
+ if not (root / _pending_relative(artifact_id)).exists():
733
+ return {"matched": False, "satisfied": False}
734
+ with _pending_transition_lock(root, artifact_id):
735
+ pending = _load_pending(root, artifact_id)
736
+ if pending is None or pending["prerequisite_id"] != prerequisite_id:
737
+ return {"matched": False, "satisfied": False}
738
+ if not secrets.compare_digest(
739
+ _digest(confirmation_nonce.encode("ascii")), pending["confirmation_sha256"]
740
+ ):
741
+ return {"matched": False, "satisfied": False}
742
+ observed_at = int(time.time()) if now is None else now
743
+ if type(observed_at) is not int or observed_at < 0:
744
+ raise PrerequisiteError("INVALID_TIME", "prerequisite confirmation time is invalid")
745
+ if pending["state"] == "ARMED":
746
+ pending = _confirm(
747
+ root,
748
+ client,
749
+ pending,
750
+ prompt,
751
+ host=host,
752
+ session_id=session_id,
753
+ now=observed_at,
754
+ )
755
+ source_ref, source_sha256 = _publish_event(root, pending)
756
+ if pending["state"] == "CONFIRMED":
757
+ captured = client.call(
758
+ "capture",
759
+ {"source_ref": source_ref, "source_sha256": source_sha256},
760
+ )
761
+ receipt = captured.get("receipt")
762
+ if not isinstance(receipt, str) or RECEIPT_RE.fullmatch(receipt) is None:
763
+ raise PrerequisiteError("INVALID_RECEIPT", "capture returned no valid receipt")
764
+ operation_suffix = _digest(pending["request_id"].encode("ascii"))[:24]
765
+ mutation_request = {
766
+ "artifact_id": pending["artifact_id"],
767
+ "operation_id": f"host-user-prerequisite-{operation_suffix}",
768
+ "expected": {
769
+ "revision": pending["revision"],
770
+ "model_sha256": pending["model_sha256"],
771
+ },
772
+ "prerequisite": pending["prerequisite_id"],
773
+ "receipt": receipt,
774
+ }
775
+ captured_state = dict(pending)
776
+ captured_state.update(
777
+ state="CAPTURED", receipt=receipt, mutation_request=mutation_request
778
+ )
779
+ _replace_state(root, _pending_relative(artifact_id), captured_state)
780
+ pending = captured_state
781
+ result = client.call("prerequisite", pending["mutation_request"])
782
+ (root / _pending_relative(artifact_id)).unlink()
783
+ return {
784
+ "matched": True,
785
+ "satisfied": True,
786
+ "artifact_id": artifact_id,
787
+ "prerequisite_id": prerequisite_id,
788
+ "authority_source": source_ref,
789
+ "receipt": pending["receipt"],
790
+ "revision": result.get("revision"),
791
+ "replayed": bool((result.get("transaction") or {}).get("replayed")),
792
+ }
793
+
794
+
795
+ def cancel_user_prerequisite(
796
+ root: str | Path, artifact_id: str, prerequisite_id: str
797
+ ) -> dict[str, Any]:
798
+ root = Path(root).resolve(strict=True)
799
+ if not (root / _pending_relative(artifact_id)).exists():
800
+ raise PrerequisiteError(
801
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to cancel"
802
+ )
803
+ with _pending_transition_lock(root, artifact_id):
804
+ pending = _load_pending(root, artifact_id)
805
+ if pending is None:
806
+ raise PrerequisiteError(
807
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to cancel"
808
+ )
809
+ if pending["prerequisite_id"] != prerequisite_id:
810
+ raise PrerequisiteError(
811
+ "PENDING_PREREQUISITE_MISMATCH",
812
+ "the pending request belongs to another prerequisite",
813
+ )
814
+ if pending["state"] != "ARMED":
815
+ raise PrerequisiteError(
816
+ "PREREQUISITE_RECOVERY_REQUIRED",
817
+ "a confirmed prerequisite decision must be recovered, not cancelled",
818
+ )
819
+ (root / _pending_relative(artifact_id)).unlink()
820
+ return {
821
+ "artifact_id": artifact_id,
822
+ "prerequisite_id": prerequisite_id,
823
+ "cancelled": True,
824
+ }
825
+
826
+
827
+ def supersede_user_prerequisite(
828
+ root: str | Path,
829
+ client: Any,
830
+ artifact_id: str,
831
+ prerequisite_id: str,
832
+ ) -> dict[str, Any]:
833
+ """Clear a confirmed stale request after excluding a committed replay."""
834
+ root = Path(root).resolve(strict=True)
835
+ if not (root / _pending_relative(artifact_id)).exists():
836
+ raise PrerequisiteError(
837
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to supersede"
838
+ )
839
+ with _pending_transition_lock(root, artifact_id):
840
+ pending = _load_pending(root, artifact_id)
841
+ if pending is None:
842
+ raise PrerequisiteError(
843
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to supersede"
844
+ )
845
+ if pending["prerequisite_id"] != prerequisite_id:
846
+ raise PrerequisiteError(
847
+ "PENDING_PREREQUISITE_MISMATCH",
848
+ "the pending request belongs to another prerequisite",
849
+ )
850
+ if pending["state"] == "ARMED":
851
+ raise PrerequisiteError(
852
+ "PREREQUISITE_NOT_CONFIRMED",
853
+ "an unconfirmed request should be cancelled, not superseded",
854
+ )
855
+ current = _identity(client, artifact_id)
856
+ if all(current[field] == pending[field] for field in (
857
+ "kind", "revision", "model_sha256", "normative_sha256"
858
+ )):
859
+ raise PrerequisiteError(
860
+ "PREREQUISITE_STILL_CURRENT",
861
+ "the confirmed request still matches the current plan identity",
862
+ )
863
+ if pending["state"] == "CAPTURED":
864
+ try:
865
+ result = client.call("prerequisite", pending["mutation_request"])
866
+ except _artifactlib.ArtifactError as exc:
867
+ if exc.code != "REVISION_CONFLICT":
868
+ raise
869
+ else:
870
+ (root / _pending_relative(artifact_id)).unlink()
871
+ return {
872
+ "artifact_id": artifact_id,
873
+ "prerequisite_id": prerequisite_id,
874
+ "superseded": False,
875
+ "satisfied": True,
876
+ "receipt": pending["receipt"],
877
+ "revision": result.get("revision"),
878
+ "replayed": bool((result.get("transaction") or {}).get("replayed")),
879
+ }
880
+ (root / _pending_relative(artifact_id)).unlink()
881
+ return {
882
+ "artifact_id": artifact_id,
883
+ "prerequisite_id": prerequisite_id,
884
+ "superseded": True,
885
+ "satisfied": False,
886
+ "authority_source": pending["authority_source"],
887
+ "receipt": pending["receipt"],
888
+ }
889
+
890
+
891
+ def consume_from_hook(
892
+ *,
893
+ root: str | Path,
894
+ plugin_root: str | Path,
895
+ prompt: str,
896
+ host: str,
897
+ session_id: str,
898
+ ) -> str:
899
+ attempted = isinstance(prompt, str) and prompt.startswith("satisfy-prerequisite")
900
+ match = _match_prompt(prompt)
901
+ if match is None:
902
+ return (
903
+ "codeArbiter: prerequisite capture failed: exact confirmation syntax required"
904
+ if attempted
905
+ else ""
906
+ )
907
+ if not (Path(root) / _pending_relative(match[0])).exists():
908
+ return (
909
+ "codeArbiter: prerequisite capture failed: no matching armed request"
910
+ if attempted
911
+ else ""
912
+ )
913
+ try:
914
+ client = _artifactlib.ArtifactClient(
915
+ root, Path(plugin_root) / "helpers" / "artifacts"
916
+ )
917
+ result = consume_user_prerequisite(
918
+ root, client, prompt, host=host, session_id=session_id
919
+ )
920
+ except (PrerequisiteError, _artifactlib.ArtifactError, OSError) as exc:
921
+ return f"codeArbiter: prerequisite capture failed: {exc}"
922
+ if not result.get("satisfied"):
923
+ return "codeArbiter: prerequisite capture failed: confirmation did not match"
924
+ return (
925
+ "codeArbiter: workflow prerequisite recorded for "
926
+ f"{result['artifact_id']}#{result['prerequisite_id']} "
927
+ f"(receipt {result['receipt']})."
928
+ )
929
+
930
+
931
+ def main(argv: list[str] | None = None) -> int:
932
+ parser = argparse.ArgumentParser()
933
+ sub = parser.add_subparsers(dest="command", required=True)
934
+ arm = sub.add_parser("arm")
935
+ arm.add_argument("--root", required=True)
936
+ arm.add_argument("--artifact-id", required=True)
937
+ arm.add_argument("--prerequisite-id", required=True)
938
+ cancel = sub.add_parser("cancel")
939
+ cancel.add_argument("--root", required=True)
940
+ cancel.add_argument("--artifact-id", required=True)
941
+ cancel.add_argument("--prerequisite-id", required=True)
942
+ supersede = sub.add_parser("supersede")
943
+ supersede.add_argument("--root", required=True)
944
+ supersede.add_argument("--artifact-id", required=True)
945
+ supersede.add_argument("--prerequisite-id", required=True)
946
+ args = parser.parse_args(argv)
947
+ if args.command == "cancel":
948
+ print(json.dumps(cancel_user_prerequisite(
949
+ args.root, args.artifact_id, args.prerequisite_id
950
+ )))
951
+ return 0
952
+ client = _artifactlib.ArtifactClient(
953
+ args.root, _artifactlib.helper_installation(__file__)
954
+ )
955
+ if args.command == "supersede":
956
+ print(json.dumps(supersede_user_prerequisite(
957
+ args.root, client, args.artifact_id, args.prerequisite_id
958
+ )))
959
+ return 0
960
+ print(json.dumps(arm_user_prerequisite(
961
+ args.root, client, args.artifact_id, args.prerequisite_id
962
+ )))
963
+ return 0
964
+
965
+
966
+ if __name__ == "__main__":
967
+ try:
968
+ raise SystemExit(main())
969
+ except (PrerequisiteError, _artifactlib.ArtifactError) as exc:
970
+ sys.stderr.write(str(exc) + "\n")
971
+ raise SystemExit(1)