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.
- agentledger/__init__.py +15 -0
- agentledger/canonical.py +43 -0
- agentledger/events.py +113 -0
- agentledger/ledger.py +544 -0
- agentledger/schemas/audit_value_packaging/AuditEvent-1.0.0.schema.json +159 -0
- agentledger/schemas/audit_value_packaging/AuditQueryRequest-1.0.0.schema.json +177 -0
- agentledger/schemas/audit_value_packaging/AuditTrace-1.0.0.schema.json +279 -0
- agentledger/schemas/core_wire/RecordEnvelope-1.0.0.schema.json +115 -0
- agentledger/validation.py +214 -0
- phb_agentledger-0.1.0.dist-info/METADATA +135 -0
- phb_agentledger-0.1.0.dist-info/RECORD +13 -0
- phb_agentledger-0.1.0.dist-info/WHEEL +4 -0
- phb_agentledger-0.1.0.dist-info/licenses/LICENSE +40 -0
agentledger/__init__.py
ADDED
|
@@ -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
|
+
]
|
agentledger/canonical.py
ADDED
|
@@ -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
|