3tears-agent-audit 0.14.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.
@@ -0,0 +1,216 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ # Claude Code local state
210
+ .claude/
211
+
212
+ # prawduct session evidence (local governance artifacts, never shipped)
213
+ .prawduct/
214
+
215
+ # macOS folder metadata
216
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Pace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: 3tears-agent-audit
3
+ Version: 0.14.0
4
+ Summary: unified audit envelope + fire-and-forget publish helper for the 3tears platform
5
+ Project-URL: Repository, https://github.com/pacepace/3tears
6
+ Author: pace
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: System :: Logging
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.14
19
+ Requires-Dist: 3tears-nats
20
+ Requires-Dist: 3tears-observe
21
+ Requires-Dist: pydantic>=2.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # 3tears-agent-audit
25
+
26
+ Unified audit envelope + fire-and-forget publish helper for the 3tears platform.
27
+
28
+ ## Purpose
29
+
30
+ Single `AuditEvent` envelope + single `publish_audit` helper used by every
31
+ domain (workspace, rbac, memory, custom tools) so the audit pipeline is
32
+ one subject tree, one consumer, one table, one admin query API. Replaces
33
+ domain-specific envelopes (`WorkspaceAuditEnvelope`,
34
+ `RbacAuditEnvelope`) that produced slightly-different wire shapes per domain
35
+ and made cross-domain audit queries require a UNION.
36
+
37
+ The package is pure Python with no NATS consumer code and no Postgres code.
38
+ Publish is the only direction: a consumer-side audit consumer owns
39
+ persistence to the audit events table.
40
+
41
+ ## Public API
42
+
43
+ ```python
44
+ from threetears.agent.audit import AuditEvent, publish_audit
45
+ ```
46
+
47
+ - `AuditEvent` -- pydantic `BaseModel` with `extra='forbid'`, timezone-aware
48
+ `timestamp` validator, closed `event_type` string family (dotted verb,
49
+ e.g. `workspace.fs_write`, `rbac.assignment.create`). All common identity
50
+ fields are typed columns on the envelope; event-type-specific extras live
51
+ in `details: dict[str, Any]`.
52
+ - `publish_audit(event, nats_client, namespace)` -- fire-and-forget async
53
+ helper. Serializes the envelope via `model_dump_json()` and awaits one
54
+ `nats_client.publish` on `{namespace}.audit.{event_type}`. On any publish
55
+ failure logs at WARN and returns; never raises.
56
+
57
+ ## Design commitments
58
+
59
+ - **Fire-and-forget.** Audit publish failures must never break the producing
60
+ call. The helper catches every exception, logs at WARN, and returns.
61
+ - **Typed wire contract.** `extra='forbid'` + timezone-aware validator catch
62
+ publisher-side drift at construction time, not at the consumer's decode.
63
+ - **No domain-specific envelope types.** Every domain publishes the same
64
+ model; `event_type` conveys the domain.
65
+ - **No dual-emit.** There is no legacy envelope the consumer still accepts in
66
+ parallel. Emission sites migrate in the same PR that deletes the legacy
67
+ envelope module.
68
+
69
+ ## Subject naming
70
+
71
+ `{namespace}.audit.{event_type}` where `event_type` is the dotted event
72
+ name (e.g. `{namespace}.audit.workspace.fs_write`). The consumer
73
+ subscribes to `{namespace}.audit.>` so new event types route automatically
74
+ without consumer-side changes.
@@ -0,0 +1,51 @@
1
+ # 3tears-agent-audit
2
+
3
+ Unified audit envelope + fire-and-forget publish helper for the 3tears platform.
4
+
5
+ ## Purpose
6
+
7
+ Single `AuditEvent` envelope + single `publish_audit` helper used by every
8
+ domain (workspace, rbac, memory, custom tools) so the audit pipeline is
9
+ one subject tree, one consumer, one table, one admin query API. Replaces
10
+ domain-specific envelopes (`WorkspaceAuditEnvelope`,
11
+ `RbacAuditEnvelope`) that produced slightly-different wire shapes per domain
12
+ and made cross-domain audit queries require a UNION.
13
+
14
+ The package is pure Python with no NATS consumer code and no Postgres code.
15
+ Publish is the only direction: a consumer-side audit consumer owns
16
+ persistence to the audit events table.
17
+
18
+ ## Public API
19
+
20
+ ```python
21
+ from threetears.agent.audit import AuditEvent, publish_audit
22
+ ```
23
+
24
+ - `AuditEvent` -- pydantic `BaseModel` with `extra='forbid'`, timezone-aware
25
+ `timestamp` validator, closed `event_type` string family (dotted verb,
26
+ e.g. `workspace.fs_write`, `rbac.assignment.create`). All common identity
27
+ fields are typed columns on the envelope; event-type-specific extras live
28
+ in `details: dict[str, Any]`.
29
+ - `publish_audit(event, nats_client, namespace)` -- fire-and-forget async
30
+ helper. Serializes the envelope via `model_dump_json()` and awaits one
31
+ `nats_client.publish` on `{namespace}.audit.{event_type}`. On any publish
32
+ failure logs at WARN and returns; never raises.
33
+
34
+ ## Design commitments
35
+
36
+ - **Fire-and-forget.** Audit publish failures must never break the producing
37
+ call. The helper catches every exception, logs at WARN, and returns.
38
+ - **Typed wire contract.** `extra='forbid'` + timezone-aware validator catch
39
+ publisher-side drift at construction time, not at the consumer's decode.
40
+ - **No domain-specific envelope types.** Every domain publishes the same
41
+ model; `event_type` conveys the domain.
42
+ - **No dual-emit.** There is no legacy envelope the consumer still accepts in
43
+ parallel. Emission sites migrate in the same PR that deletes the legacy
44
+ envelope module.
45
+
46
+ ## Subject naming
47
+
48
+ `{namespace}.audit.{event_type}` where `event_type` is the dotted event
49
+ name (e.g. `{namespace}.audit.workspace.fs_write`). The consumer
50
+ subscribes to `{namespace}.audit.>` so new event types route automatically
51
+ without consumer-side changes.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "3tears-agent-audit"
7
+ version = "0.14.0"
8
+ description = "unified audit envelope + fire-and-forget publish helper for the 3tears platform"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ authors = [{name = "pace"}]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Security",
21
+ "Topic :: System :: Logging",
22
+ "Topic :: Software Development :: Libraries",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "3tears-nats",
27
+ "3tears-observe",
28
+ "pydantic>=2.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/pacepace/3tears"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/threetears"]
36
+
37
+ [tool.uv.sources]
38
+ 3tears-nats = { workspace = true }
39
+ 3tears-observe = { workspace = true }
40
+
41
+ [tool.mypy]
42
+ strict = true
43
+ mypy_path = "src"
44
+ packages = ["threetears.agent.audit"]
45
+ explicit_package_bases = true
46
+ ignore_missing_imports = true
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ markers = [
51
+ "integration: integration tests requiring docker or real infra",
52
+ ]
@@ -0,0 +1,38 @@
1
+ """unified audit envelope + publish helper for the 3tears platform.
2
+
3
+ single :class:`AuditEvent` + :func:`publish_audit` shared by every
4
+ producing domain (workspace tools, hub rbac endpoints, agent memory,
5
+ custom tools). the hub-side ``unified_audit_consumer`` subscribes to
6
+ ``{namespace}.audit.>`` and persists every event to
7
+ ``platform_audit.audit_events`` without a domain-specific consumer.
8
+
9
+ produced by ``audit-task-01``; supersedes
10
+ :class:`threetears.agent.workspace.audit.WorkspaceAuditEnvelope` and
11
+ :class:`threetears.agent.acl.audit.RbacAuditEnvelope` (both deleted in
12
+ the same shard).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from threetears.agent.audit.envelope import AuditEvent
18
+ from threetears.agent.audit.publish import publish_audit
19
+
20
+ # Version derived from pyproject.toml so the metadata is the single
21
+ # source of truth -- a future release that bumps pyproject without
22
+ # updating ``__init__.py`` can't drift the runtime ``__version__``.
23
+ # The except guard handles the rare case where the package isn't
24
+ # installed via importlib.metadata (e.g. running directly from a
25
+ # checked-out source tree without ``uv sync``); the fallback keeps
26
+ # imports working but reports ``unknown`` rather than crashing.
27
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
28
+ from importlib.metadata import version as _version
29
+
30
+ try:
31
+ __version__ = _version("3tears-agent-audit")
32
+ except _PackageNotFoundError: # pragma: no cover - dev fallback
33
+ __version__ = "unknown"
34
+
35
+ __all__ = [
36
+ "AuditEvent",
37
+ "publish_audit",
38
+ ]
@@ -0,0 +1,217 @@
1
+ """unified audit envelope for every domain on the 3tears platform.
2
+
3
+ the pre-audit-task-01 world had per-domain envelope types
4
+ (:class:`WorkspaceAuditEnvelope`, :class:`RbacAuditEnvelope`, etc.)
5
+ each on a different NATS subject with a slightly different wire shape.
6
+ cross-domain audit queries paid for this with UNIONs across lightly-
7
+ different schemas and the admin query API had to special-case each
8
+ domain. audit-task-01 collapses all of it into one envelope, one
9
+ publish helper, one subject tree, one consumer, one table.
10
+
11
+ the envelope below is the single wire contract. every domain
12
+ (:mod:`threetears.agent.workspace.tools.*`, hub rbac endpoints, agent
13
+ memory, user tools) builds an :class:`AuditEvent` and hands it to
14
+ :func:`publish_audit`. the hub-side ``unified_audit_consumer``
15
+ subscribes to ``{namespace}.audit.>`` and persists to
16
+ ``platform_audit.audit_events``.
17
+
18
+ anti-drift guarantees:
19
+
20
+ - ``model_config`` uses ``extra='forbid'`` -- any stray field on the
21
+ wire fails parse loudly. a miswired publisher surfaces at construct
22
+ time rather than silently dropping the field.
23
+ - ``timestamp`` must be timezone-aware UTC; a validator rejects naive
24
+ datetimes with a clear message.
25
+ - ``event_type`` uses the dotted ``{domain}.{verb}[.{sub_verb}]``
26
+ convention (e.g. ``workspace.fs_write``, ``rbac.assignment.create``,
27
+ ``rbac.introspect.explain``). it is an open ``str`` rather than a
28
+ closed ``Literal``: the envelope intentionally does not gate new
29
+ event types at the wire layer because new domains (memory, custom
30
+ tools) must be able to publish without editing this module. per-
31
+ domain closed enums belong at the producer site, not on the shared
32
+ envelope.
33
+ - ``details: dict[str, Any]`` is the escape hatch for event-type-
34
+ specific extras. commonly-queried fields (actor, resource, action,
35
+ outcome) are typed columns so admin queries don't JSONB-parse on
36
+ every row.
37
+
38
+ identity axes on the envelope:
39
+
40
+ - ``actor_user_id`` -- the human (or service account) whose action
41
+ triggered the event; the load-bearing "who did this?" field.
42
+ - ``calling_agent_id`` -- the agent whose pod ran the tool. equal to
43
+ ``owner_agent_id`` for owner-path calls; differs under cross-agent
44
+ sharing.
45
+ - ``owner_agent_id`` -- the agent that owns the resource being
46
+ touched. sourced from the resource's namespace row.
47
+ - ``customer_id`` -- the owning customer. ``None`` only for
48
+ platform-scoped events (e.g. platform role CRUD).
49
+ - ``resource_namespace_id`` -- the resource's namespace row id. every
50
+ resource on the platform is a namespace post-workspace-task-19, so
51
+ one column answers "whose data?" across every domain.
52
+ - ``resource_namespace_type`` -- short taxonomy tag (``workspace`` /
53
+ ``group`` / ``role`` / ``assignment`` / ``introspection`` / ...).
54
+ matches the ``namespaces.namespace_type`` taxonomy.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from datetime import datetime
60
+ from typing import Any
61
+ from uuid import UUID
62
+
63
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
64
+
65
+ __all__ = ["AuditEvent"]
66
+
67
+
68
+ class AuditEvent(BaseModel):
69
+ """typed wire-format envelope for every platform audit event.
70
+
71
+ single canonical envelope shared by every producing domain and
72
+ the hub-side ``unified_audit_consumer``. built on the producer
73
+ side (workspace tool, rbac endpoint, memory subsystem, custom
74
+ tool) and handed to :func:`publish_audit`. decoded on the hub
75
+ side from ``{namespace}.audit.>`` by the unified consumer.
76
+
77
+ :param id: envelope identity (uuid7 time-ordered); doubles as the
78
+ audit row primary key when the consumer persists it
79
+ :ptype id: UUID
80
+ :param timestamp: timezone-aware UTC instant the event occurred
81
+ :ptype timestamp: datetime
82
+ :param event_type: dotted event family, e.g. ``workspace.fs_write``,
83
+ ``rbac.assignment.create``, ``rbac.introspect.explain``. used
84
+ as the last segment of the NATS subject and as the primary
85
+ ``event_type`` column on ``audit_events``
86
+ :ptype event_type: str
87
+ :param actor_user_id: human / service-account actor; ``None`` for
88
+ system-triggered events (e.g. scheduled retention)
89
+ :ptype actor_user_id: UUID | None
90
+ :param calling_agent_id: agent whose pod ran the tool; ``None`` for
91
+ events emitted from the hub directly (e.g. rbac admin endpoints)
92
+ :ptype calling_agent_id: UUID | None
93
+ :param owner_agent_id: agent that owns the resource being touched;
94
+ ``None`` for platform-scoped events
95
+ :ptype owner_agent_id: UUID | None
96
+ :param customer_id: owning customer; ``None`` only for platform-
97
+ scoped events (platform role CRUD, platform-scoped groups)
98
+ :ptype customer_id: UUID | None
99
+ :param resource_namespace_id: namespace row id of the resource;
100
+ ``None`` for events whose subject is not a single namespace
101
+ (introspection queries, cross-resource dry-runs)
102
+ :ptype resource_namespace_id: UUID | None
103
+ :param resource_namespace_type: short taxonomy tag matching
104
+ ``namespaces.namespace_type`` (``workspace`` / ``group`` /
105
+ ``role`` / ``assignment`` / ``introspection`` / ...). ``None``
106
+ when ``resource_namespace_id`` is ``None``
107
+ :ptype resource_namespace_type: str | None
108
+ :param action: short verb (``read`` / ``write`` / ``create`` /
109
+ ``update`` / ``delete`` / ``add_member`` / ``remove_member`` /
110
+ ``explain`` / ...). kept open-set so new domains don't edit
111
+ the envelope module
112
+ :ptype action: str
113
+ :param outcome: one of ``success``, ``failure``, ``error``. tool-
114
+ dispatch auto-emission stamps this based on the handler return
115
+ path; explicit emissions pass ``success`` by convention unless
116
+ they know otherwise
117
+ :ptype outcome: str
118
+ :param correlation_id: request / tool-call correlation UUID; used
119
+ with ``event_type`` as the idempotency key for at-least-once
120
+ NATS redelivery
121
+ :ptype correlation_id: UUID
122
+ :param conversation_id: conversation UUID when the event was
123
+ emitted on behalf of a user-facing conversation (agent-tools
124
+ baseline emission, memory writes, context save). ``None`` for
125
+ platform-scoped events (rbac admin endpoints, scheduled
126
+ retention) and for hub-direct calls without a conversation.
127
+ added in data-layer-task-01 sub-task 5: the ``audit_events``
128
+ table already carries the column (added in v054 partition
129
+ primitive); the envelope now propagates it from
130
+ :class:`CallContext` into the persisted row so admin queries
131
+ can filter audit trails per conversation
132
+ :ptype conversation_id: UUID | None
133
+ :param details: event-type-specific structured payload (sha/bytes/
134
+ version for fs_write; added_member_id for group.member.add;
135
+ etc.). admin queries must not depend on JSONB fields for hot
136
+ paths
137
+ :ptype details: dict[str, Any]
138
+ """
139
+
140
+ model_config = ConfigDict(extra="forbid")
141
+
142
+ id: UUID
143
+ timestamp: datetime
144
+ event_type: str
145
+ actor_user_id: UUID | None = None
146
+ calling_agent_id: UUID | None = None
147
+ owner_agent_id: UUID | None = None
148
+ customer_id: UUID | None = None
149
+ resource_namespace_id: UUID | None = None
150
+ resource_namespace_type: str | None = None
151
+ action: str
152
+ outcome: str = "success"
153
+ correlation_id: UUID
154
+ conversation_id: UUID | None = None
155
+ details: dict[str, Any] = Field(default_factory=dict)
156
+
157
+ @field_validator("timestamp")
158
+ @classmethod
159
+ def _require_timezone_aware(cls, value: datetime) -> datetime:
160
+ """reject naive datetimes -- every audit envelope is UTC-aware.
161
+
162
+ :param value: parsed datetime
163
+ :ptype value: datetime
164
+ :return: the same datetime when it carries a tzinfo
165
+ :rtype: datetime
166
+ :raises ValueError: when the input is naive
167
+ """
168
+ if value.tzinfo is None:
169
+ raise ValueError(
170
+ "AuditEvent.timestamp must be timezone-aware (UTC); received a naive datetime",
171
+ )
172
+ return value
173
+
174
+ @field_validator("outcome")
175
+ @classmethod
176
+ def _require_known_outcome(cls, value: str) -> str:
177
+ """restrict ``outcome`` to the three recognised values.
178
+
179
+ the closed set keeps the admin query API's outcome filter
180
+ well-defined without forcing a ``Literal[...]`` on the whole
181
+ envelope (which would break dict-based construction from YAML
182
+ test fixtures).
183
+
184
+ :param value: candidate outcome string
185
+ :ptype value: str
186
+ :return: the input value unchanged when valid
187
+ :rtype: str
188
+ :raises ValueError: when ``value`` is not success/failure/error
189
+ """
190
+ allowed = {"success", "failure", "error"}
191
+ if value not in allowed:
192
+ raise ValueError(
193
+ f"AuditEvent.outcome must be one of {sorted(allowed)}; received {value!r}",
194
+ )
195
+ return value
196
+
197
+ @field_validator("event_type")
198
+ @classmethod
199
+ def _require_dotted_event_type(cls, value: str) -> str:
200
+ """require the ``{domain}.{verb}`` dotted convention.
201
+
202
+ the envelope does not gate the set of event types (new domains
203
+ must be able to publish without editing this module) but it
204
+ does require the dotted shape so consumers can parse the
205
+ leading domain segment reliably.
206
+
207
+ :param value: candidate event_type
208
+ :ptype value: str
209
+ :return: the input value unchanged when it matches the shape
210
+ :rtype: str
211
+ :raises ValueError: when the value is empty or lacks a dot
212
+ """
213
+ if not value or "." not in value:
214
+ raise ValueError(
215
+ f"AuditEvent.event_type must be dotted '{{domain}}.{{verb}}[.{{sub_verb}}]'; received {value!r}",
216
+ )
217
+ return value
@@ -0,0 +1,81 @@
1
+ """fire-and-forget audit publish helper.
2
+
3
+ :func:`publish_audit` is the single publish path for every domain.
4
+ serializes the :class:`AuditEvent` via pydantic, awaits one
5
+ :meth:`NatsClient.publish` on ``{namespace}.audit.{event_type}``, and
6
+ swallows every exception at WARN. tool-call success must never depend
7
+ on audit infrastructure availability; this invariant is load-bearing.
8
+
9
+ the hub-side ``unified_audit_consumer`` subscribes to
10
+ ``{namespace}.audit.>`` so new event types route automatically without
11
+ a consumer-side change.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from threetears.nats import NatsClient, Subject
17
+ from threetears.observe import get_logger
18
+
19
+ from threetears.agent.audit.envelope import AuditEvent
20
+
21
+ __all__ = ["publish_audit"]
22
+
23
+
24
+ log = get_logger(__name__)
25
+
26
+
27
+ async def publish_audit(
28
+ event: AuditEvent,
29
+ *,
30
+ nats_client: NatsClient | None,
31
+ namespace: str,
32
+ ) -> None:
33
+ """
34
+ publish one audit envelope on ``{namespace}.audit.{event_type}``.
35
+
36
+ fire-and-forget: any exception during publish is caught and logged
37
+ at WARN; no exception propagates to the caller. when
38
+ ``nats_client`` is ``None`` the call is an explicit no-op (useful
39
+ in tests and bootstrap windows before NATS wiring is complete).
40
+
41
+ the subject is built with the explicit ``namespace`` argument
42
+ rather than reading the :class:`Subjects` ContextVar so callers
43
+ that route audit traffic on a per-call namespace (multi-tenant
44
+ test fixtures, in-process audit consumers under explicit prefix
45
+ control) get the namespace they passed regardless of which
46
+ ContextVar value happens to be bound on the calling task.
47
+ ``event.event_type`` carries dots verbatim (e.g.
48
+ ``workspace.fs_write``); they form the subject hierarchy and are
49
+ NOT sanitized.
50
+
51
+ :param event: typed audit envelope to publish
52
+ :ptype event: AuditEvent
53
+ :param nats_client: connected canonical
54
+ :class:`threetears.nats.NatsClient` wrapper; ``None`` is a no-op
55
+ :ptype nats_client: NatsClient | None
56
+ :param namespace: NATS subject namespace (environment-scoped
57
+ prefix from ``THREETEARS_NATS_SUBJECT_NAMESPACE``)
58
+ :ptype namespace: str
59
+ :return: nothing
60
+ :rtype: None
61
+ """
62
+ if nats_client is None:
63
+ # bootstrap / test scenario; explicit no-op
64
+ return
65
+ subject = Subject.raw(f"{namespace}.audit.{event.event_type}")
66
+ try:
67
+ await nats_client.publish(subject=subject, message=event)
68
+ # NOSILENT: audit publish is fire-and-forget; failures log at WARN
69
+ # so the producing call path is never blocked by audit health.
70
+ except Exception as exc:
71
+ log.warning(
72
+ "audit publish failed",
73
+ extra={
74
+ "extra_data": {
75
+ "subject": subject.path,
76
+ "event_type": event.event_type,
77
+ "namespace": namespace,
78
+ "error": str(exc),
79
+ },
80
+ },
81
+ )
File without changes
File without changes
@@ -0,0 +1,184 @@
1
+ """unit tests for :class:`threetears.agent.audit.AuditEvent`.
2
+
3
+ covers the envelope's anti-drift guarantees (``extra='forbid'``,
4
+ timezone-aware ``timestamp``, closed ``outcome``, dotted
5
+ ``event_type``) and round-trip ``model_dump_json`` /
6
+ ``model_validate_json`` semantics used by the NATS publisher +
7
+ consumer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import UTC, datetime
13
+ from uuid import UUID, uuid7
14
+
15
+ import pytest
16
+ from pydantic import ValidationError
17
+
18
+ from threetears.agent.audit import AuditEvent
19
+
20
+
21
+ def _base_kwargs() -> dict[str, object]:
22
+ """
23
+ build a baseline valid kwargs dict for :class:`AuditEvent`.
24
+
25
+ :return: kwargs dict populated with a consistent set of field
26
+ values; individual tests mutate one key to exercise a
27
+ specific validator
28
+ :rtype: dict[str, object]
29
+ """
30
+ return {
31
+ "id": uuid7(),
32
+ "timestamp": datetime.now(UTC),
33
+ "event_type": "workspace.fs_write",
34
+ "actor_user_id": uuid7(),
35
+ "calling_agent_id": uuid7(),
36
+ "owner_agent_id": uuid7(),
37
+ "customer_id": uuid7(),
38
+ "resource_namespace_id": uuid7(),
39
+ "resource_namespace_type": "workspace",
40
+ "action": "write",
41
+ "outcome": "success",
42
+ "correlation_id": uuid7(),
43
+ "details": {"sha256": "abc123", "bytes": 42},
44
+ }
45
+
46
+
47
+ def test_constructs_with_all_required_fields() -> None:
48
+ """envelope builds cleanly when every field is supplied."""
49
+ envelope = AuditEvent(**_base_kwargs()) # type: ignore[arg-type]
50
+
51
+ assert envelope.event_type == "workspace.fs_write"
52
+ assert envelope.action == "write"
53
+ assert envelope.outcome == "success"
54
+
55
+
56
+ def test_optional_identity_fields_default_none() -> None:
57
+ """envelope builds with only the required scalar axes populated."""
58
+ envelope = AuditEvent(
59
+ id=uuid7(),
60
+ timestamp=datetime.now(UTC),
61
+ event_type="platform.role.create",
62
+ action="create",
63
+ correlation_id=uuid7(),
64
+ )
65
+
66
+ assert envelope.actor_user_id is None
67
+ assert envelope.calling_agent_id is None
68
+ assert envelope.owner_agent_id is None
69
+ assert envelope.customer_id is None
70
+ assert envelope.resource_namespace_id is None
71
+ assert envelope.resource_namespace_type is None
72
+ assert envelope.conversation_id is None
73
+ assert envelope.outcome == "success"
74
+ assert envelope.details == {}
75
+
76
+
77
+ def test_conversation_id_field_round_trips() -> None:
78
+ """data-layer-task-01 sub-task 5: conversation_id propagates.
79
+
80
+ the ``audit_events`` table already carries the column; the
81
+ envelope now propagates it from CallContext into the persisted
82
+ row so admin queries can filter audit trails per conversation.
83
+ """
84
+ conv_id = uuid7()
85
+ envelope = AuditEvent(
86
+ id=uuid7(),
87
+ timestamp=datetime.now(UTC),
88
+ event_type="tool.call",
89
+ action="call",
90
+ correlation_id=uuid7(),
91
+ conversation_id=conv_id,
92
+ )
93
+ assert envelope.conversation_id == conv_id
94
+ wire = envelope.model_dump_json()
95
+ decoded = AuditEvent.model_validate_json(wire)
96
+ assert decoded.conversation_id == conv_id
97
+
98
+
99
+ def test_naive_timestamp_rejected() -> None:
100
+ """``timestamp`` must carry a tzinfo; a naive datetime raises."""
101
+ kwargs = _base_kwargs()
102
+ kwargs["timestamp"] = datetime(2026, 1, 1, 12, 0, 0)
103
+
104
+ with pytest.raises(ValidationError):
105
+ AuditEvent(**kwargs) # type: ignore[arg-type]
106
+
107
+
108
+ def test_unknown_outcome_rejected() -> None:
109
+ """``outcome`` is closed to success/failure/error."""
110
+ kwargs = _base_kwargs()
111
+ kwargs["outcome"] = "partial"
112
+
113
+ with pytest.raises(ValidationError):
114
+ AuditEvent(**kwargs) # type: ignore[arg-type]
115
+
116
+
117
+ @pytest.mark.parametrize("outcome", ["success", "failure", "error"])
118
+ def test_every_declared_outcome_accepted(outcome: str) -> None:
119
+ """each recognised outcome string constructs the envelope."""
120
+ kwargs = _base_kwargs()
121
+ kwargs["outcome"] = outcome
122
+
123
+ envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
124
+
125
+ assert envelope.outcome == outcome
126
+
127
+
128
+ def test_non_dotted_event_type_rejected() -> None:
129
+ """``event_type`` must be dotted ``{domain}.{verb}``."""
130
+ kwargs = _base_kwargs()
131
+ kwargs["event_type"] = "justaword"
132
+
133
+ with pytest.raises(ValidationError):
134
+ AuditEvent(**kwargs) # type: ignore[arg-type]
135
+
136
+
137
+ def test_empty_event_type_rejected() -> None:
138
+ """an empty ``event_type`` string is rejected."""
139
+ kwargs = _base_kwargs()
140
+ kwargs["event_type"] = ""
141
+
142
+ with pytest.raises(ValidationError):
143
+ AuditEvent(**kwargs) # type: ignore[arg-type]
144
+
145
+
146
+ def test_extra_fields_rejected() -> None:
147
+ """``extra='forbid'`` rejects any field not declared on the envelope."""
148
+ kwargs = _base_kwargs()
149
+ kwargs["mystery_field"] = "oops"
150
+
151
+ with pytest.raises(ValidationError):
152
+ AuditEvent(**kwargs) # type: ignore[arg-type]
153
+
154
+
155
+ def test_round_trip_preserves_every_field() -> None:
156
+ """JSON round trip preserves UUID, datetime, and details shape."""
157
+ kwargs = _base_kwargs()
158
+ envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
159
+
160
+ wire = envelope.model_dump_json()
161
+ decoded = AuditEvent.model_validate_json(wire)
162
+
163
+ assert decoded == envelope
164
+ assert decoded.details == envelope.details
165
+
166
+
167
+ def test_details_defaults_to_empty_dict() -> None:
168
+ """omitting ``details`` yields an empty dict (not None)."""
169
+ kwargs = _base_kwargs()
170
+ del kwargs["details"]
171
+
172
+ envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
173
+
174
+ assert envelope.details == {}
175
+
176
+
177
+ def test_id_round_trips_as_uuid() -> None:
178
+ """``id`` is a UUID object, not a string, after JSON round trip."""
179
+ kwargs = _base_kwargs()
180
+ envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
181
+ decoded = AuditEvent.model_validate_json(envelope.model_dump_json())
182
+
183
+ assert isinstance(decoded.id, UUID)
184
+ assert decoded.id == envelope.id
@@ -0,0 +1,163 @@
1
+ """unit tests for :func:`threetears.agent.audit.publish_audit`.
2
+
3
+ covers subject-naming, payload-shape, and the fire-and-forget
4
+ invariant: every publish failure logs at WARN and returns cleanly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from uuid import uuid7
12
+
13
+ import pytest
14
+ from pydantic import BaseModel
15
+
16
+ from threetears.agent.audit import AuditEvent, publish_audit
17
+ from threetears.nats import Subject, set_default_namespace
18
+
19
+
20
+ @dataclass
21
+ class _FakeWrapper:
22
+ """minimal fake :class:`threetears.nats.NatsClient` recording each publish.
23
+
24
+ matches the wrapper's :meth:`publish` shape (kw-only ``subject`` +
25
+ ``message``) so tests exercise the same call surface production
26
+ code uses. payload bytes are derived from the recorded
27
+ :class:`BaseModel` to keep round-trip assertions intact.
28
+ """
29
+
30
+ publish_calls: list[tuple[Subject, BaseModel]] = field(default_factory=list)
31
+ raise_on_publish: BaseException | None = None
32
+
33
+ async def publish(
34
+ self,
35
+ *,
36
+ subject: Subject,
37
+ message: BaseModel,
38
+ reply_to: Subject | None = None,
39
+ ) -> None:
40
+ """record the publish invocation or raise when configured to."""
41
+ del reply_to # unused in audit publish
42
+ self.publish_calls.append((subject, message))
43
+ if self.raise_on_publish is not None:
44
+ raise self.raise_on_publish
45
+
46
+
47
+ @pytest.fixture(autouse=True)
48
+ def _bind_namespace(request: pytest.FixtureRequest) -> None:
49
+ """bind a known default namespace for each test.
50
+
51
+ individual tests override via the ``namespace`` parameter on
52
+ :func:`publish_audit` (passed through for diagnostic logging) and
53
+ by calling :func:`set_default_namespace` directly to control the
54
+ subject that :class:`Subjects.audit_event` produces.
55
+ """
56
+ set_default_namespace("3tears")
57
+
58
+
59
+ def _build_event(event_type: str = "workspace.fs_write") -> AuditEvent:
60
+ """build a valid envelope for the publish-helper tests."""
61
+ return AuditEvent(
62
+ id=uuid7(),
63
+ timestamp=datetime.now(UTC),
64
+ event_type=event_type,
65
+ actor_user_id=uuid7(),
66
+ calling_agent_id=uuid7(),
67
+ owner_agent_id=uuid7(),
68
+ customer_id=uuid7(),
69
+ resource_namespace_id=uuid7(),
70
+ resource_namespace_type="workspace",
71
+ action="write",
72
+ correlation_id=uuid7(),
73
+ details={"bytes": 42},
74
+ )
75
+
76
+
77
+ async def test_publish_posts_to_namespace_dot_audit_dot_event_type() -> None:
78
+ """subject is ``{namespace}.audit.{event_type}`` verbatim."""
79
+ nats = _FakeWrapper()
80
+ event = _build_event("workspace.fs_write")
81
+
82
+ await publish_audit(event, nats_client=nats, namespace="dev")
83
+
84
+ assert len(nats.publish_calls) == 1
85
+ subject, _ = nats.publish_calls[0]
86
+ assert subject.path == "dev.audit.workspace.fs_write"
87
+
88
+
89
+ async def test_publish_emits_typed_audit_event() -> None:
90
+ """recorded message is the same :class:`AuditEvent` instance round-trippable to JSON."""
91
+ nats = _FakeWrapper()
92
+ event = _build_event("rbac.assignment.create")
93
+
94
+ await publish_audit(event, nats_client=nats, namespace="staging")
95
+
96
+ _, message = nats.publish_calls[0]
97
+ assert isinstance(message, AuditEvent)
98
+ assert message == event
99
+ # round-trip the wire form to confirm the typed payload survives serialization
100
+ decoded = AuditEvent.model_validate_json(message.model_dump_json())
101
+ assert decoded == event
102
+
103
+
104
+ async def test_publish_failure_is_swallowed_not_raised() -> None:
105
+ """publish failure must NOT propagate -- audit is fire-and-forget."""
106
+ nats = _FakeWrapper(raise_on_publish=RuntimeError("nats down"))
107
+ event = _build_event()
108
+
109
+ # must NOT raise; tool-call success never depends on audit health
110
+ await publish_audit(event, nats_client=nats, namespace="dev")
111
+
112
+
113
+ async def test_publish_none_client_is_noop() -> None:
114
+ """``nats_client=None`` is an explicit no-op (bootstrap / tests)."""
115
+ event = _build_event()
116
+
117
+ # must NOT raise; no-op
118
+ await publish_audit(event, nats_client=None, namespace="dev")
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ "event_type",
123
+ [
124
+ "workspace.fs_write",
125
+ "workspace.fs_edit",
126
+ "rbac.group.create",
127
+ "rbac.assignment.delete",
128
+ "rbac.introspect.explain",
129
+ "memory.retrieve",
130
+ "custom.tool.call",
131
+ ],
132
+ )
133
+ async def test_publish_preserves_dotted_event_type_in_subject(
134
+ event_type: str,
135
+ ) -> None:
136
+ """every dotted event_type appears verbatim in the subject."""
137
+ nats = _FakeWrapper()
138
+ event = _build_event(event_type)
139
+
140
+ await publish_audit(event, nats_client=nats, namespace="prod")
141
+
142
+ subject, _ = nats.publish_calls[0]
143
+ assert subject.path == f"prod.audit.{event_type}"
144
+
145
+
146
+ async def test_publish_serialization_error_does_not_raise() -> None:
147
+ """a mid-publish exception is caught and logged."""
148
+
149
+ class _BrokenWrapper:
150
+ async def publish(
151
+ self,
152
+ *,
153
+ subject: Subject,
154
+ message: BaseModel,
155
+ reply_to: Subject | None = None,
156
+ ) -> None:
157
+ del subject, message, reply_to
158
+ raise TimeoutError("publish timeout")
159
+
160
+ event = _build_event()
161
+
162
+ # must NOT raise
163
+ await publish_audit(event, nats_client=_BrokenWrapper(), namespace="dev")