phb-agentledger 0.1.0__tar.gz
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.
- phb_agentledger-0.1.0/.gitignore +8 -0
- phb_agentledger-0.1.0/LICENSE +40 -0
- phb_agentledger-0.1.0/PKG-INFO +135 -0
- phb_agentledger-0.1.0/README.md +114 -0
- phb_agentledger-0.1.0/agentledger/__init__.py +15 -0
- phb_agentledger-0.1.0/agentledger/canonical.py +43 -0
- phb_agentledger-0.1.0/agentledger/events.py +113 -0
- phb_agentledger-0.1.0/agentledger/ledger.py +544 -0
- phb_agentledger-0.1.0/agentledger/schemas/audit_value_packaging/AuditEvent-1.0.0.schema.json +159 -0
- phb_agentledger-0.1.0/agentledger/schemas/audit_value_packaging/AuditQueryRequest-1.0.0.schema.json +177 -0
- phb_agentledger-0.1.0/agentledger/schemas/audit_value_packaging/AuditTrace-1.0.0.schema.json +279 -0
- phb_agentledger-0.1.0/agentledger/schemas/core_wire/RecordEnvelope-1.0.0.schema.json +115 -0
- phb_agentledger-0.1.0/agentledger/validation.py +214 -0
- phb_agentledger-0.1.0/demo.py +53 -0
- phb_agentledger-0.1.0/pyproject.toml +37 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/missing_required.json +30 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/null_where_forbidden.json +31 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/record_digest_mismatch.json +31 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/schema_hash_mismatch.json +31 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/unknown_field.json +32 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/unsupported_version.json +31 -0
- phb_agentledger-0.1.0/tests/fixtures/invalid/wrong_type.json +31 -0
- phb_agentledger-0.1.0/tests/fixtures/valid/golden_full.json +33 -0
- phb_agentledger-0.1.0/tests/fixtures/valid/golden_minimal.json +31 -0
- phb_agentledger-0.1.0/tests/test_durability.py +329 -0
- phb_agentledger-0.1.0/tests/test_ledger.py +220 -0
- phb_agentledger-0.1.0/tests/test_make_event.py +112 -0
- phb_agentledger-0.1.0/tests/test_reload_fast_path.py +154 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
AgentLedger — Licence
|
|
2
|
+
Copyright (c) 2026 Przemyslaw H. Budrewicz trading as PHB Studio.
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is source-available, not open source. Two licences are offered.
|
|
6
|
+
|
|
7
|
+
1. NON-COMMERCIAL LICENCE (free)
|
|
8
|
+
|
|
9
|
+
You may use, copy, modify, and run this software free of charge for personal
|
|
10
|
+
use, education, academic research, and internal evaluation, including
|
|
11
|
+
evaluation inside a company. You may not use it in or as part of a product,
|
|
12
|
+
service, or internal system that supports a commercial activity beyond
|
|
13
|
+
evaluation. You must keep this notice intact in any copy you distribute.
|
|
14
|
+
|
|
15
|
+
2. COMMERCIAL LICENCE (paid)
|
|
16
|
+
|
|
17
|
+
Any use beyond the above — including use in production, in a product or
|
|
18
|
+
service offered to others, or in the internal operations of a business —
|
|
19
|
+
requires a commercial licence. A commercial licence is purchased directly at:
|
|
20
|
+
|
|
21
|
+
https://budrewicz.gumroad.com/l/AgentLedger
|
|
22
|
+
|
|
23
|
+
It is perpetual for the version purchased, covers unlimited developers within
|
|
24
|
+
the purchasing organisation, and does not require a separate negotiated
|
|
25
|
+
agreement.
|
|
26
|
+
|
|
27
|
+
NO WARRANTY
|
|
28
|
+
|
|
29
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
30
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
31
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
32
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
33
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
34
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
35
|
+
|
|
36
|
+
COMPLIANCE NOTE
|
|
37
|
+
|
|
38
|
+
This software helps you keep a tamper-evident record of what an AI system did.
|
|
39
|
+
It is not legal advice and it does not by itself make any system compliant with
|
|
40
|
+
any regulation.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phb-agentledger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tamper-evident, hash-chained audit ledger for AI agent actions.
|
|
5
|
+
Project-URL: Homepage, https://budrewicz.gumroad.com/l/AgentLedger
|
|
6
|
+
Project-URL: Commercial licence, https://budrewicz.gumroad.com/l/AgentLedger
|
|
7
|
+
Author: PHB Studio
|
|
8
|
+
License-Expression: LicenseRef-PHB-Studio-Commercial
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,audit,compliance,ledger,provenance,tamper-evident
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: System :: Logging
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: filelock>=3.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# agentledger
|
|
23
|
+
|
|
24
|
+
A tamper-evident, hash-chained audit ledger for AI agent actions.
|
|
25
|
+
|
|
26
|
+
When an AI system does something consequential — calls a tool, spends money,
|
|
27
|
+
escalates to a person, refuses a request — you need a record that still means
|
|
28
|
+
something six months later, when someone asks what happened and why. An
|
|
29
|
+
application log does not survive that question: anyone with file access can
|
|
30
|
+
edit it, and nothing in the file says whether they did.
|
|
31
|
+
|
|
32
|
+
`agentledger` writes each action as a validated, content-addressed record in a
|
|
33
|
+
SHA-256 hash chain. Editing any stored record, in any field, breaks the chain,
|
|
34
|
+
and `verify_chain()` says so.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install phb-agentledger
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Installed as `phb-agentledger`, imported as `agentledger`. One runtime
|
|
43
|
+
dependency (`filelock`). Python 3.10+.
|
|
44
|
+
|
|
45
|
+
## Sixty seconds
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from agentledger import AuditLedger, make_event
|
|
49
|
+
|
|
50
|
+
ledger = AuditLedger(path="audit.jsonl")
|
|
51
|
+
|
|
52
|
+
ledger.append_event(make_event(
|
|
53
|
+
"TOOL_CALL", "ACME-AGENT-001",
|
|
54
|
+
summary="looked up order 1234",
|
|
55
|
+
subject_refs=["order:1234"],
|
|
56
|
+
))
|
|
57
|
+
ledger.append_event(make_event(
|
|
58
|
+
"POLICY_DECISION", "ACME-GATE-001",
|
|
59
|
+
summary="refund above the auto-approve limit: escalated to a human",
|
|
60
|
+
subject_refs=["order:1234"],
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
ledger.verify_chain() # True
|
|
64
|
+
ledger.query(subject_refs=["order:1234"]) # an AuditTrace of both events
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run `python demo.py` to see the same thing end to end, including a hand-edit of
|
|
68
|
+
the stored file and the chain check catching it.
|
|
69
|
+
|
|
70
|
+
## What it guarantees
|
|
71
|
+
|
|
72
|
+
- **Order is assigned by the ledger, never by the caller.** Sequence numbers
|
|
73
|
+
and chain hashes are computed on append. A producer cannot claim a position
|
|
74
|
+
in history.
|
|
75
|
+
- **Every record carries its own digest**, SHA-256 over JCS-1 canonical JSON
|
|
76
|
+
with the digest field omitted. A record that does not match its own content
|
|
77
|
+
is rejected before it is stored.
|
|
78
|
+
- **Appends are idempotent** on `(record_id, event_id)`. Resubmitting the same
|
|
79
|
+
record is a no-op; resubmitting different content under the same key is a
|
|
80
|
+
rejected conflict, not a silent overwrite.
|
|
81
|
+
- **Queries never overclaim.** A result is explicitly scoped to a ledger cut,
|
|
82
|
+
so "no matches in this view" is never returned as "this never happened".
|
|
83
|
+
- **Concurrent writers are safe.** The load → mint → append critical section
|
|
84
|
+
is held under a cross-process file lock, and a torn tail from a killed
|
|
85
|
+
process is quarantined rather than silently truncating history.
|
|
86
|
+
- **Two refusals are enforced in code, not documentation.** The ledger will
|
|
87
|
+
not store records classified as raw internal model reasoning, and it rejects
|
|
88
|
+
any event that asserts externally-verified truth without evidence lineage.
|
|
89
|
+
|
|
90
|
+
## What it deliberately does not do
|
|
91
|
+
|
|
92
|
+
- **It does not verify payloads it never saw.** `payload_digest` is your
|
|
93
|
+
assertion about content held elsewhere; the ledger stores and chains it, but
|
|
94
|
+
cannot confirm it.
|
|
95
|
+
- **It is not a signing system.** Records are tamper-*evident* against edits to
|
|
96
|
+
the stored file. They are not signed, so a party who can rewrite the whole
|
|
97
|
+
file, including recomputing the chain, is out of scope. Signing is the next
|
|
98
|
+
layer, not this one.
|
|
99
|
+
- **It does not make you compliant with anything.** It gives you a defensible
|
|
100
|
+
record. Whether that record satisfies a given regulation is a question for
|
|
101
|
+
your counsel.
|
|
102
|
+
- **`SCHEMA_HASH_CATALOG` ships with a placeholder hash** for AuditEvent 1.0.0
|
|
103
|
+
rather than the digest of the shipped schema file. Set it from your deployed
|
|
104
|
+
schemas at boot if you want that check to be meaningful.
|
|
105
|
+
|
|
106
|
+
## Event shape
|
|
107
|
+
|
|
108
|
+
`make_event()` fills the 22-field envelope for you and computes both digests.
|
|
109
|
+
The fields you supply are the ones that carry meaning:
|
|
110
|
+
|
|
111
|
+
| Argument | Meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `event_type` | What happened, as a `SCREAMING_SNAKE` label you choose |
|
|
114
|
+
| `actor` | Who did it, as `VENDOR-COMPONENT-NNN` (e.g. `ACME-AGENT-001`) |
|
|
115
|
+
| `summary` *or* `payload_ref` | A short line, or a pointer to the payload held elsewhere |
|
|
116
|
+
| `subject_refs` | What it was about (`order:1234`, `user:42`) |
|
|
117
|
+
| `data_classification` | Sensitivity label; drives the refusals above |
|
|
118
|
+
| `truth_refs` | Evidence lineage, required for external-truth claims |
|
|
119
|
+
|
|
120
|
+
Hand-built dicts are still accepted — `make_event` is a convenience, not a
|
|
121
|
+
bypass. `validate_audit_event()` judges both the same way.
|
|
122
|
+
|
|
123
|
+
## Tests
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
python -m pytest tests -q
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
43 tests, no network, under a second.
|
|
131
|
+
|
|
132
|
+
## Licence
|
|
133
|
+
|
|
134
|
+
Source-available. Free for personal, educational, and evaluation use;
|
|
135
|
+
commercial use requires a paid licence. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# agentledger
|
|
2
|
+
|
|
3
|
+
A tamper-evident, hash-chained audit ledger for AI agent actions.
|
|
4
|
+
|
|
5
|
+
When an AI system does something consequential — calls a tool, spends money,
|
|
6
|
+
escalates to a person, refuses a request — you need a record that still means
|
|
7
|
+
something six months later, when someone asks what happened and why. An
|
|
8
|
+
application log does not survive that question: anyone with file access can
|
|
9
|
+
edit it, and nothing in the file says whether they did.
|
|
10
|
+
|
|
11
|
+
`agentledger` writes each action as a validated, content-addressed record in a
|
|
12
|
+
SHA-256 hash chain. Editing any stored record, in any field, breaks the chain,
|
|
13
|
+
and `verify_chain()` says so.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install phb-agentledger
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Installed as `phb-agentledger`, imported as `agentledger`. One runtime
|
|
22
|
+
dependency (`filelock`). Python 3.10+.
|
|
23
|
+
|
|
24
|
+
## Sixty seconds
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from agentledger import AuditLedger, make_event
|
|
28
|
+
|
|
29
|
+
ledger = AuditLedger(path="audit.jsonl")
|
|
30
|
+
|
|
31
|
+
ledger.append_event(make_event(
|
|
32
|
+
"TOOL_CALL", "ACME-AGENT-001",
|
|
33
|
+
summary="looked up order 1234",
|
|
34
|
+
subject_refs=["order:1234"],
|
|
35
|
+
))
|
|
36
|
+
ledger.append_event(make_event(
|
|
37
|
+
"POLICY_DECISION", "ACME-GATE-001",
|
|
38
|
+
summary="refund above the auto-approve limit: escalated to a human",
|
|
39
|
+
subject_refs=["order:1234"],
|
|
40
|
+
))
|
|
41
|
+
|
|
42
|
+
ledger.verify_chain() # True
|
|
43
|
+
ledger.query(subject_refs=["order:1234"]) # an AuditTrace of both events
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run `python demo.py` to see the same thing end to end, including a hand-edit of
|
|
47
|
+
the stored file and the chain check catching it.
|
|
48
|
+
|
|
49
|
+
## What it guarantees
|
|
50
|
+
|
|
51
|
+
- **Order is assigned by the ledger, never by the caller.** Sequence numbers
|
|
52
|
+
and chain hashes are computed on append. A producer cannot claim a position
|
|
53
|
+
in history.
|
|
54
|
+
- **Every record carries its own digest**, SHA-256 over JCS-1 canonical JSON
|
|
55
|
+
with the digest field omitted. A record that does not match its own content
|
|
56
|
+
is rejected before it is stored.
|
|
57
|
+
- **Appends are idempotent** on `(record_id, event_id)`. Resubmitting the same
|
|
58
|
+
record is a no-op; resubmitting different content under the same key is a
|
|
59
|
+
rejected conflict, not a silent overwrite.
|
|
60
|
+
- **Queries never overclaim.** A result is explicitly scoped to a ledger cut,
|
|
61
|
+
so "no matches in this view" is never returned as "this never happened".
|
|
62
|
+
- **Concurrent writers are safe.** The load → mint → append critical section
|
|
63
|
+
is held under a cross-process file lock, and a torn tail from a killed
|
|
64
|
+
process is quarantined rather than silently truncating history.
|
|
65
|
+
- **Two refusals are enforced in code, not documentation.** The ledger will
|
|
66
|
+
not store records classified as raw internal model reasoning, and it rejects
|
|
67
|
+
any event that asserts externally-verified truth without evidence lineage.
|
|
68
|
+
|
|
69
|
+
## What it deliberately does not do
|
|
70
|
+
|
|
71
|
+
- **It does not verify payloads it never saw.** `payload_digest` is your
|
|
72
|
+
assertion about content held elsewhere; the ledger stores and chains it, but
|
|
73
|
+
cannot confirm it.
|
|
74
|
+
- **It is not a signing system.** Records are tamper-*evident* against edits to
|
|
75
|
+
the stored file. They are not signed, so a party who can rewrite the whole
|
|
76
|
+
file, including recomputing the chain, is out of scope. Signing is the next
|
|
77
|
+
layer, not this one.
|
|
78
|
+
- **It does not make you compliant with anything.** It gives you a defensible
|
|
79
|
+
record. Whether that record satisfies a given regulation is a question for
|
|
80
|
+
your counsel.
|
|
81
|
+
- **`SCHEMA_HASH_CATALOG` ships with a placeholder hash** for AuditEvent 1.0.0
|
|
82
|
+
rather than the digest of the shipped schema file. Set it from your deployed
|
|
83
|
+
schemas at boot if you want that check to be meaningful.
|
|
84
|
+
|
|
85
|
+
## Event shape
|
|
86
|
+
|
|
87
|
+
`make_event()` fills the 22-field envelope for you and computes both digests.
|
|
88
|
+
The fields you supply are the ones that carry meaning:
|
|
89
|
+
|
|
90
|
+
| Argument | Meaning |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `event_type` | What happened, as a `SCREAMING_SNAKE` label you choose |
|
|
93
|
+
| `actor` | Who did it, as `VENDOR-COMPONENT-NNN` (e.g. `ACME-AGENT-001`) |
|
|
94
|
+
| `summary` *or* `payload_ref` | A short line, or a pointer to the payload held elsewhere |
|
|
95
|
+
| `subject_refs` | What it was about (`order:1234`, `user:42`) |
|
|
96
|
+
| `data_classification` | Sensitivity label; drives the refusals above |
|
|
97
|
+
| `truth_refs` | Evidence lineage, required for external-truth claims |
|
|
98
|
+
|
|
99
|
+
Hand-built dicts are still accepted — `make_event` is a convenience, not a
|
|
100
|
+
bypass. `validate_audit_event()` judges both the same way.
|
|
101
|
+
|
|
102
|
+
## Tests
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install -e ".[dev]"
|
|
106
|
+
python -m pytest tests -q
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
43 tests, no network, under a second.
|
|
110
|
+
|
|
111
|
+
## Licence
|
|
112
|
+
|
|
113
|
+
Source-available. Free for personal, educational, and evaluation use;
|
|
114
|
+
commercial use requires a paid licence. See [LICENSE](LICENSE).
|
|
@@ -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)
|
|
@@ -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
|