phb-agentledger 0.1.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,15 @@
1
+ from .canonical import compute_record_digest, verify_record_digest
2
+ from .events import make_event
3
+ from .ledger import AuditLedger, AuditEventRejected, LedgerEntry
4
+ from .validation import validate_audit_event, ValidationResult
5
+
6
+ __all__ = [
7
+ "AuditLedger",
8
+ "make_event",
9
+ "AuditEventRejected",
10
+ "LedgerEntry",
11
+ "validate_audit_event",
12
+ "ValidationResult",
13
+ "compute_record_digest",
14
+ "verify_record_digest",
15
+ ]
@@ -0,0 +1,43 @@
1
+ """
2
+ Canonical JSON serialization and digest helpers.
3
+
4
+ The RecordEnvelope contract (urn:neo:contract:RecordEnvelope:1.0.0) requires every
5
+ record to be content-addressable: ``record_digest`` must equal SHA-256 over the
6
+ record's JCS-1 canonical JSON with ``record_digest`` itself omitted.
7
+
8
+ This module implements a canonicalization profile sufficient for records that
9
+ never contain floats (Neo's schemas only use strings, ints, bools, null, arrays
10
+ and objects), which covers every contract in this pack. It intentionally does
11
+ NOT depend on any third-party library.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from typing import Any, Mapping
18
+
19
+
20
+ def canonicalize(value: Any) -> str:
21
+ """Serialize ``value`` to canonical JSON text (sorted keys, no insignificant
22
+ whitespace, UTF-8 safe)."""
23
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
24
+
25
+
26
+ def sha256_hex(text: str) -> str:
27
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def compute_record_digest(record: Mapping[str, Any]) -> str:
31
+ """Compute the record_digest a well-formed record MUST carry: SHA-256 of the
32
+ canonical JSON of the record with ``record_digest`` omitted."""
33
+ stripped = {k: v for k, v in record.items() if k != "record_digest"}
34
+ return sha256_hex(canonicalize(stripped))
35
+
36
+
37
+ def verify_record_digest(record: Mapping[str, Any]) -> bool:
38
+ """True iff record['record_digest'] matches the value computed from the rest
39
+ of the record."""
40
+ claimed = record.get("record_digest")
41
+ if not isinstance(claimed, str):
42
+ return False
43
+ return claimed == compute_record_digest(record)
agentledger/events.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Build a valid AuditEvent without hand-assembling the envelope.
3
+
4
+ The `AuditEvent` contract this ledger enforces is deliberately strict: 22
5
+ required fields, a closed shape, two content digests and a canonicalisation
6
+ profile. That strictness is the point — it is what makes a stored record
7
+ independently checkable months later — but it makes the first five minutes
8
+ with the library harder than they need to be.
9
+
10
+ `make_event()` fills in everything that can be derived (the contract
11
+ constants, the timestamps, the identifiers, the schema hash, and both
12
+ digests) and leaves the caller with the handful of fields that actually carry
13
+ meaning: what happened, who did it, what it was about. The result is a plain
14
+ dict that `AuditLedger.append_event()` accepts, and that `validate_audit_event`
15
+ independently agrees is well-formed — this helper is a convenience, never a
16
+ bypass.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import uuid
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Iterable, Mapping, Optional, Sequence
23
+
24
+ from .canonical import compute_record_digest, sha256_hex
25
+
26
+ # Imported lazily-by-name to keep the module graph one-directional
27
+ # (events -> ledger is fine; ledger never imports events).
28
+ from .ledger import SCHEMA_HASH_CATALOG
29
+
30
+ __all__ = ["make_event"]
31
+
32
+
33
+ def _now_iso() -> str:
34
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
35
+
36
+
37
+ def _new_id(prefix: str) -> str:
38
+ # record_id / event_id must be 8..128 chars; a prefixed uuid4 hex is 37.
39
+ return f"{prefix}_{uuid.uuid4().hex}"
40
+
41
+
42
+ def make_event(
43
+ event_type: str,
44
+ actor: str,
45
+ *,
46
+ summary: Optional[str] = None,
47
+ payload_ref: Optional[str] = None,
48
+ subject_refs: Sequence[str] = (),
49
+ truth_refs: Sequence[str] = (),
50
+ authority_refs: Sequence[str] = (),
51
+ data_classification: str = "OWNER_PRIVATE",
52
+ producer: Optional[str] = None,
53
+ correlation_ids: Iterable[Mapping[str, str]] = (),
54
+ provenance: Iterable[Mapping[str, str]] = (),
55
+ payload_digest: Optional[str] = None,
56
+ occurred_at: Optional[str] = None,
57
+ clock_quality: str = "SYNCHRONIZED",
58
+ record_id: Optional[str] = None,
59
+ event_id: Optional[str] = None,
60
+ created_at: Optional[str] = None,
61
+ ) -> dict[str, Any]:
62
+ """Return a complete, digest-correct AuditEvent dict.
63
+
64
+ `event_type` and `data_classification` are SCREAMING_SNAKE labels you
65
+ choose (e.g. `TOOL_CALL`, `POLICY_DECISION`). `actor` is the component
66
+ that did the thing, as `VENDOR-COMPONENT-NNN` (e.g. `ACME-AGENT-001`);
67
+ `producer` is the component writing the record, and defaults to `actor`.
68
+
69
+ Exactly one of `summary` or `payload_ref` must be given: a short
70
+ human-readable line, or a pointer to the payload held elsewhere. The
71
+ ledger stores the pointer or the summary — never a payload it was not
72
+ given ownership of.
73
+
74
+ `payload_digest` should be the SHA-256 of the real payload when you have
75
+ it. When omitted it is derived from the summary or ref, which keeps the
76
+ record structurally valid and self-consistent but says nothing about a
77
+ payload the ledger never saw.
78
+ """
79
+ if (summary is None) == (payload_ref is None):
80
+ raise ValueError("make_event: pass exactly one of summary= or payload_ref=")
81
+
82
+ payload = {"summary": summary} if summary is not None else {"payload_ref": payload_ref}
83
+ now = created_at or _now_iso()
84
+
85
+ record: dict[str, Any] = {
86
+ "contract_name": "AuditEvent",
87
+ "contract_version": "1.0.0",
88
+ "record_id": record_id or _new_id("record"),
89
+ "created_at": now,
90
+ "producer_module_id": producer or actor,
91
+ "correlation_ids": [dict(c) for c in correlation_ids],
92
+ "provenance": [dict(p) for p in provenance],
93
+ "schema_hash": SCHEMA_HASH_CATALOG[("AuditEvent", "1.0.0")],
94
+ "digest_algorithm": "SHA-256",
95
+ "canonicalization_profile": "JCS-1",
96
+ "event_id": event_id or _new_id("event"),
97
+ "event_schema_version": "1.0.0",
98
+ "event_type": event_type,
99
+ "actor_module": actor,
100
+ "subject_refs": list(subject_refs),
101
+ "authority_refs_if_relevant": list(authority_refs),
102
+ "truth_refs_if_relevant": list(truth_refs),
103
+ "payload_ref_or_summary": payload,
104
+ "payload_digest": payload_digest or sha256_hex(summary if summary is not None else payload_ref),
105
+ "data_classification": data_classification,
106
+ "producer_emitted_at": now,
107
+ "producer_clock_quality": clock_quality,
108
+ }
109
+ if occurred_at is not None:
110
+ record["producer_occurred_at_if_known"] = occurred_at
111
+
112
+ record["record_digest"] = compute_record_digest(record)
113
+ return record