adopt-obs 0.3.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.
- adopt_obs/__init__.py +63 -0
- adopt_obs/clock.py +112 -0
- adopt_obs/errors.py +254 -0
- adopt_obs/ids.py +182 -0
- adopt_obs/log.py +169 -0
- adopt_obs/py.typed +0 -0
- adopt_obs/redact.py +80 -0
- adopt_obs-0.3.0.dist-info/METADATA +13 -0
- adopt_obs-0.3.0.dist-info/RECORD +12 -0
- adopt_obs-0.3.0.dist-info/WHEEL +4 -0
- adopt_obs-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_obs-0.3.0.dist-info/licenses/NOTICE +39 -0
adopt_obs/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Structured logging, typed errors, id generation, redaction, the clock.
|
|
2
|
+
|
|
3
|
+
The public API of this package is the whole of the programme's observability
|
|
4
|
+
surface. Three invariants hold across it and are enforced by CI rather than by
|
|
5
|
+
review:
|
|
6
|
+
|
|
7
|
+
1. **No log line ever contains client source, item bodies, prompt text or model
|
|
8
|
+
output.** There is no free-text log parameter, and deny-listed fields are
|
|
9
|
+
dropped at any depth and counted.
|
|
10
|
+
2. **Every error code is in the contracts §13 registry.** `error-registry-sync`
|
|
11
|
+
fails the build in either direction.
|
|
12
|
+
3. **Ids are generated nowhere else.** An unregistered prefix is rejected.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from adopt_obs.clock import (
|
|
16
|
+
Clock,
|
|
17
|
+
ManualClock,
|
|
18
|
+
SystemClock,
|
|
19
|
+
format_timestamp,
|
|
20
|
+
now,
|
|
21
|
+
truncate_to_millisecond,
|
|
22
|
+
)
|
|
23
|
+
from adopt_obs.errors import (
|
|
24
|
+
CATEGORY_EXIT_CODES,
|
|
25
|
+
ERROR_CATEGORIES,
|
|
26
|
+
AdoptError,
|
|
27
|
+
ErrorCategory,
|
|
28
|
+
ErrorCode,
|
|
29
|
+
ExitCode,
|
|
30
|
+
exit_code_for,
|
|
31
|
+
)
|
|
32
|
+
from adopt_obs.ids import PREFIX_REGISTRY, UnknownPrefixError, new_id, split_id
|
|
33
|
+
from adopt_obs.log import Logger, LogLevel, get_logger, new_run_id, set_sink
|
|
34
|
+
from adopt_obs.redact import DENIED_FIELDS, REDACTED, RedactionResult, redact
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CATEGORY_EXIT_CODES",
|
|
38
|
+
"DENIED_FIELDS",
|
|
39
|
+
"ERROR_CATEGORIES",
|
|
40
|
+
"PREFIX_REGISTRY",
|
|
41
|
+
"REDACTED",
|
|
42
|
+
"AdoptError",
|
|
43
|
+
"Clock",
|
|
44
|
+
"ErrorCategory",
|
|
45
|
+
"ErrorCode",
|
|
46
|
+
"ExitCode",
|
|
47
|
+
"LogLevel",
|
|
48
|
+
"Logger",
|
|
49
|
+
"ManualClock",
|
|
50
|
+
"RedactionResult",
|
|
51
|
+
"SystemClock",
|
|
52
|
+
"UnknownPrefixError",
|
|
53
|
+
"exit_code_for",
|
|
54
|
+
"format_timestamp",
|
|
55
|
+
"get_logger",
|
|
56
|
+
"new_id",
|
|
57
|
+
"new_run_id",
|
|
58
|
+
"now",
|
|
59
|
+
"redact",
|
|
60
|
+
"set_sink",
|
|
61
|
+
"split_id",
|
|
62
|
+
"truncate_to_millisecond",
|
|
63
|
+
]
|
adopt_obs/clock.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The injectable clock. The only source of "now" in the programme.
|
|
2
|
+
|
|
3
|
+
Sleeps in tests are banned (implementation spec §5). Time-window logic takes a
|
|
4
|
+
:class:`Clock` and a test supplies :class:`ManualClock`, which makes the test
|
|
5
|
+
both instant and deterministic -- a sleep is a flake with a delay attached.
|
|
6
|
+
|
|
7
|
+
All times are RFC 3339, UTC, millisecond precision, ``Z``-suffixed. Local time
|
|
8
|
+
never appears in a stored value or a wire payload.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
from typing import Final, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Clock",
|
|
16
|
+
"ManualClock",
|
|
17
|
+
"SystemClock",
|
|
18
|
+
"format_timestamp",
|
|
19
|
+
"now",
|
|
20
|
+
"truncate_to_millisecond",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_TIMESTAMP_FORMAT: Final[str] = "%Y-%m-%dT%H:%M:%S"
|
|
24
|
+
_MICROSECONDS_PER_MILLISECOND: Final[int] = 1000
|
|
25
|
+
# const-sync: ok -- the width of a millisecond field, not SCHEMA_VERSION.
|
|
26
|
+
_MILLISECOND_DIGITS: Final[int] = 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_timestamp(value: _dt.datetime) -> str:
|
|
30
|
+
"""Render RFC 3339, UTC, millisecond precision, ``Z`` suffix.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: the datetime is naive. A naive datetime has no defined
|
|
34
|
+
instant, and guessing that it means UTC is how a store ends up with
|
|
35
|
+
timestamps an hour apart from the same event.
|
|
36
|
+
"""
|
|
37
|
+
if value.tzinfo is None:
|
|
38
|
+
raise ValueError("naive datetime: every timestamp must carry a timezone")
|
|
39
|
+
utc = value.astimezone(_dt.UTC)
|
|
40
|
+
millis = utc.microsecond // _MICROSECONDS_PER_MILLISECOND
|
|
41
|
+
return f"{utc.strftime(_TIMESTAMP_FORMAT)}.{millis:0{_MILLISECOND_DIGITS}d}Z"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def truncate_to_millisecond(value: _dt.datetime) -> _dt.datetime:
|
|
45
|
+
"""Drop precision below a millisecond, in UTC.
|
|
46
|
+
|
|
47
|
+
Contracts §1.2 stores millisecond precision, so an instant a writer keeps in
|
|
48
|
+
memory at microsecond precision is **not** the instant that comes back out.
|
|
49
|
+
Anything that persists a timestamp truncates first, or the in-memory row and
|
|
50
|
+
its stored form differ by a value nobody can see and every equality check
|
|
51
|
+
trips over -- including the byte-identical export round-trip.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: the datetime is naive.
|
|
55
|
+
"""
|
|
56
|
+
if value.tzinfo is None:
|
|
57
|
+
raise ValueError("naive datetime: every timestamp must carry a timezone")
|
|
58
|
+
utc = value.astimezone(_dt.UTC)
|
|
59
|
+
millis = utc.microsecond // _MICROSECONDS_PER_MILLISECOND
|
|
60
|
+
return utc.replace(microsecond=millis * _MICROSECONDS_PER_MILLISECOND)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class Clock(Protocol):
|
|
65
|
+
"""The seam. Production passes :class:`SystemClock`; tests pass
|
|
66
|
+
:class:`ManualClock`."""
|
|
67
|
+
|
|
68
|
+
def now(self) -> _dt.datetime:
|
|
69
|
+
"""The current instant, timezone-aware and in UTC."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SystemClock:
|
|
74
|
+
"""The wall clock. The only implementation permitted in production code."""
|
|
75
|
+
|
|
76
|
+
def now(self) -> _dt.datetime:
|
|
77
|
+
return _dt.datetime.now(_dt.UTC)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ManualClock:
|
|
81
|
+
"""A clock a test drives by hand.
|
|
82
|
+
|
|
83
|
+
``advance()`` moves it forward; it never moves on its own, so a test that
|
|
84
|
+
depends on elapsed time states exactly how much elapsed instead of waiting
|
|
85
|
+
for it.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, start: _dt.datetime) -> None:
|
|
89
|
+
if start.tzinfo is None:
|
|
90
|
+
raise ValueError("ManualClock requires a timezone-aware start instant")
|
|
91
|
+
self._now = start.astimezone(_dt.UTC)
|
|
92
|
+
|
|
93
|
+
def now(self) -> _dt.datetime:
|
|
94
|
+
return self._now
|
|
95
|
+
|
|
96
|
+
def advance(self, delta: _dt.timedelta) -> None:
|
|
97
|
+
if delta < _dt.timedelta(0):
|
|
98
|
+
raise ValueError("a clock does not run backwards; use a new ManualClock")
|
|
99
|
+
self._now += delta
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_default: Final[SystemClock] = SystemClock()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def now() -> _dt.datetime:
|
|
106
|
+
"""The process-default clock reading.
|
|
107
|
+
|
|
108
|
+
Library code that can take a :class:`Clock` should take one. This exists
|
|
109
|
+
for the places that genuinely cannot -- module import time, and the logger's
|
|
110
|
+
own timestamp.
|
|
111
|
+
"""
|
|
112
|
+
return _default.now()
|
adopt_obs/errors.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Typed errors, the code registry, categories, and the exit-code mapping.
|
|
2
|
+
|
|
3
|
+
Every error the programme raises across a package boundary is an
|
|
4
|
+
:class:`AdoptError` carrying a code from :class:`ErrorCode`. The registry below
|
|
5
|
+
is the executable half of ``02-contracts-build0.md`` §13;
|
|
6
|
+
``scripts/error_registry_sync.py`` fails the build when the two disagree in
|
|
7
|
+
either direction, so a code cannot be added to the code without being
|
|
8
|
+
documented, nor documented without being implemented.
|
|
9
|
+
|
|
10
|
+
**Category is derived from the code, not supplied by the caller.** The
|
|
11
|
+
implementation spec's signature ``AdoptError(code, category, message, hint)``
|
|
12
|
+
still works, but passing a category that contradicts the registry raises rather
|
|
13
|
+
than being accepted -- one code meaning two categories on two call sites is
|
|
14
|
+
exactly the drift the registry exists to prevent.
|
|
15
|
+
|
|
16
|
+
The exit-code mapping lives here rather than in the CLI because it is a
|
|
17
|
+
function of the category, and the category is owned here. A second copy in the
|
|
18
|
+
CLI would be a second place to get it wrong.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CATEGORY_EXIT_CODES",
|
|
26
|
+
"ERROR_CATEGORIES",
|
|
27
|
+
"AdoptError",
|
|
28
|
+
"ErrorCategory",
|
|
29
|
+
"ErrorCode",
|
|
30
|
+
"ExitCode",
|
|
31
|
+
"exit_code_for",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ErrorCategory(StrEnum):
|
|
36
|
+
"""The five categories in contracts §13."""
|
|
37
|
+
|
|
38
|
+
USAGE = "usage"
|
|
39
|
+
POLICY = "policy"
|
|
40
|
+
INTEGRITY = "integrity"
|
|
41
|
+
TRANSIENT = "transient"
|
|
42
|
+
INTERNAL = "internal"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExitCode:
|
|
46
|
+
"""Stable process exit codes (contracts §13, PRD F16.7).
|
|
47
|
+
|
|
48
|
+
Not an enum: these are returned to a shell, compared by integrators in
|
|
49
|
+
scripts, and must stay plain integers at every boundary.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
SUCCESS: Final[int] = 0
|
|
53
|
+
OPERATIONAL_FAILURE: Final[int] = 1
|
|
54
|
+
USAGE_ERROR: Final[int] = 2
|
|
55
|
+
# const-sync: ok -- a contracts §13 exit code, fixed by contract, not a tunable.
|
|
56
|
+
POLICY_REFUSAL: Final[int] = 3
|
|
57
|
+
DEGRADED_WITH_FINDINGS: Final[int] = 4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
CATEGORY_EXIT_CODES: Final[dict[ErrorCategory, int]] = {
|
|
61
|
+
ErrorCategory.USAGE: ExitCode.USAGE_ERROR,
|
|
62
|
+
ErrorCategory.POLICY: ExitCode.POLICY_REFUSAL,
|
|
63
|
+
ErrorCategory.INTEGRITY: ExitCode.OPERATIONAL_FAILURE,
|
|
64
|
+
ErrorCategory.INTERNAL: ExitCode.OPERATIONAL_FAILURE,
|
|
65
|
+
# A transient failure is an operational failure to the caller. It is
|
|
66
|
+
# retriable, but the process still did not do what it was asked.
|
|
67
|
+
ErrorCategory.TRANSIENT: ExitCode.OPERATIONAL_FAILURE,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ErrorCode(StrEnum):
|
|
72
|
+
"""Every error code in contracts §13. Adding one here without adding it
|
|
73
|
+
there (or the reverse) fails `error-registry-sync`."""
|
|
74
|
+
|
|
75
|
+
ADOPT_OFFLINE_DENIED = "ADOPT_OFFLINE_DENIED"
|
|
76
|
+
ADOPT_CONFIG_UNRESOLVED = "ADOPT_CONFIG_UNRESOLVED"
|
|
77
|
+
|
|
78
|
+
SCHEMA_NON_ADDITIVE = "SCHEMA_NON_ADDITIVE"
|
|
79
|
+
SCHEMA_GENERATED_DRIFT = "SCHEMA_GENERATED_DRIFT"
|
|
80
|
+
SCHEMA_VERSION_TOO_NEW = "SCHEMA_VERSION_TOO_NEW"
|
|
81
|
+
SCHEMA_MIGRATION_PENDING = "SCHEMA_MIGRATION_PENDING"
|
|
82
|
+
SCHEMA_MIGRATION_FAILED = "SCHEMA_MIGRATION_FAILED"
|
|
83
|
+
SCHEMA_ASSETS_MISSING = "SCHEMA_ASSETS_MISSING"
|
|
84
|
+
|
|
85
|
+
STORE_READ_ONLY = "STORE_READ_ONLY"
|
|
86
|
+
|
|
87
|
+
SCOPE_SLUG_INVALID = "SCOPE_SLUG_INVALID"
|
|
88
|
+
SCOPE_SLUG_IMMUTABLE = "SCOPE_SLUG_IMMUTABLE"
|
|
89
|
+
SCOPE_SLUG_REUSED = "SCOPE_SLUG_REUSED"
|
|
90
|
+
SCOPE_VIOLATION = "SCOPE_VIOLATION"
|
|
91
|
+
|
|
92
|
+
URI_MALFORMED = "URI_MALFORMED"
|
|
93
|
+
URI_TOO_LONG = "URI_TOO_LONG"
|
|
94
|
+
URI_SCHEME_UNKNOWN = "URI_SCHEME_UNKNOWN"
|
|
95
|
+
URI_DOUBLE_ENCODED = "URI_DOUBLE_ENCODED"
|
|
96
|
+
|
|
97
|
+
REVISION_CHAIN_FORK = "REVISION_CHAIN_FORK"
|
|
98
|
+
REVISION_IMMUTABLE = "REVISION_IMMUTABLE"
|
|
99
|
+
REVISION_HEAD_DANGLING = "REVISION_HEAD_DANGLING"
|
|
100
|
+
|
|
101
|
+
COVERAGE_CACHE_DISAGREEMENT = "COVERAGE_CACHE_DISAGREEMENT"
|
|
102
|
+
FRESHNESS_SENSOR_DEGRADED = "FRESHNESS_SENSOR_DEGRADED"
|
|
103
|
+
|
|
104
|
+
EXPORT_VERSION_UNSUPPORTED = "EXPORT_VERSION_UNSUPPORTED"
|
|
105
|
+
EXPORT_DIGEST_MISMATCH = "EXPORT_DIGEST_MISMATCH"
|
|
106
|
+
EXPORT_ROUNDTRIP_UNSTABLE = "EXPORT_ROUNDTRIP_UNSTABLE"
|
|
107
|
+
EXPORT_SCOPE_AMBIGUOUS = "EXPORT_SCOPE_AMBIGUOUS"
|
|
108
|
+
EXPORT_BUNDLE_MALFORMED = "EXPORT_BUNDLE_MALFORMED"
|
|
109
|
+
EXPORT_TARGET_NOT_EMPTY = "EXPORT_TARGET_NOT_EMPTY"
|
|
110
|
+
|
|
111
|
+
DETECT_AMBIGUOUS = "DETECT_AMBIGUOUS"
|
|
112
|
+
TIER_INSUFFICIENT = "TIER_INSUFFICIENT"
|
|
113
|
+
TIER_DECLINE_RECOMMENDED = "TIER_DECLINE_RECOMMENDED"
|
|
114
|
+
TIER_ANSWERS_INVALID = "TIER_ANSWERS_INVALID"
|
|
115
|
+
|
|
116
|
+
MANIFEST_UNDECLARED_HOST = "MANIFEST_UNDECLARED_HOST"
|
|
117
|
+
MANIFEST_MISSING_SAFE_PATH = "MANIFEST_MISSING_SAFE_PATH"
|
|
118
|
+
MANIFEST_INVALID = "MANIFEST_INVALID"
|
|
119
|
+
|
|
120
|
+
ENVELOPE_CONTENT_UNDER_METADATA_ONLY = "ENVELOPE_CONTENT_UNDER_METADATA_ONLY"
|
|
121
|
+
ENVELOPE_POLICY_NOT_PERMITTED = "ENVELOPE_POLICY_NOT_PERMITTED"
|
|
122
|
+
|
|
123
|
+
AGENT_ADAPTER_UNKNOWN = "AGENT_ADAPTER_UNKNOWN"
|
|
124
|
+
AGENT_OFFLINE_ADAPTER_DENIED = "AGENT_OFFLINE_ADAPTER_DENIED"
|
|
125
|
+
AGENT_BUDGET_EXHAUSTED = "AGENT_BUDGET_EXHAUSTED"
|
|
126
|
+
AGENT_OUTPUT_SCHEMA = "AGENT_OUTPUT_SCHEMA"
|
|
127
|
+
AGENT_PROVIDER_ERROR = "AGENT_PROVIDER_ERROR"
|
|
128
|
+
|
|
129
|
+
WORKFLOW_STEP_EXHAUSTED = "WORKFLOW_STEP_EXHAUSTED"
|
|
130
|
+
WORKFLOW_BODY_IMPURE = "WORKFLOW_BODY_IMPURE"
|
|
131
|
+
WORKFLOW_DUPLICATE_START = "WORKFLOW_DUPLICATE_START"
|
|
132
|
+
|
|
133
|
+
LICENCE_POLICY_VIOLATION = "LICENCE_POLICY_VIOLATION"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
#: Code -> category, verbatim from the contracts §13 table.
|
|
137
|
+
ERROR_CATEGORIES: Final[dict[ErrorCode, ErrorCategory]] = {
|
|
138
|
+
ErrorCode.ADOPT_OFFLINE_DENIED: ErrorCategory.POLICY,
|
|
139
|
+
ErrorCode.ADOPT_CONFIG_UNRESOLVED: ErrorCategory.USAGE,
|
|
140
|
+
ErrorCode.SCHEMA_NON_ADDITIVE: ErrorCategory.POLICY,
|
|
141
|
+
ErrorCode.SCHEMA_GENERATED_DRIFT: ErrorCategory.INTEGRITY,
|
|
142
|
+
ErrorCode.SCHEMA_VERSION_TOO_NEW: ErrorCategory.POLICY,
|
|
143
|
+
ErrorCode.SCHEMA_MIGRATION_PENDING: ErrorCategory.USAGE,
|
|
144
|
+
ErrorCode.SCHEMA_MIGRATION_FAILED: ErrorCategory.INTEGRITY,
|
|
145
|
+
# The installed artefact does not carry the schema assets it needs. Integrity
|
|
146
|
+
# rather than usage: the operator did nothing wrong and no flag fixes it --
|
|
147
|
+
# what they hold was built incompletely.
|
|
148
|
+
ErrorCode.SCHEMA_ASSETS_MISSING: ErrorCategory.INTEGRITY,
|
|
149
|
+
ErrorCode.STORE_READ_ONLY: ErrorCategory.POLICY,
|
|
150
|
+
ErrorCode.SCOPE_SLUG_INVALID: ErrorCategory.USAGE,
|
|
151
|
+
ErrorCode.SCOPE_SLUG_IMMUTABLE: ErrorCategory.POLICY,
|
|
152
|
+
ErrorCode.SCOPE_SLUG_REUSED: ErrorCategory.POLICY,
|
|
153
|
+
ErrorCode.SCOPE_VIOLATION: ErrorCategory.POLICY,
|
|
154
|
+
ErrorCode.URI_MALFORMED: ErrorCategory.USAGE,
|
|
155
|
+
ErrorCode.URI_TOO_LONG: ErrorCategory.USAGE,
|
|
156
|
+
ErrorCode.URI_SCHEME_UNKNOWN: ErrorCategory.USAGE,
|
|
157
|
+
ErrorCode.URI_DOUBLE_ENCODED: ErrorCategory.USAGE,
|
|
158
|
+
ErrorCode.REVISION_CHAIN_FORK: ErrorCategory.INTEGRITY,
|
|
159
|
+
ErrorCode.REVISION_IMMUTABLE: ErrorCategory.POLICY,
|
|
160
|
+
ErrorCode.REVISION_HEAD_DANGLING: ErrorCategory.INTEGRITY,
|
|
161
|
+
ErrorCode.COVERAGE_CACHE_DISAGREEMENT: ErrorCategory.INTEGRITY,
|
|
162
|
+
ErrorCode.FRESHNESS_SENSOR_DEGRADED: ErrorCategory.POLICY,
|
|
163
|
+
ErrorCode.EXPORT_VERSION_UNSUPPORTED: ErrorCategory.POLICY,
|
|
164
|
+
ErrorCode.EXPORT_DIGEST_MISMATCH: ErrorCategory.INTEGRITY,
|
|
165
|
+
ErrorCode.EXPORT_ROUNDTRIP_UNSTABLE: ErrorCategory.INTEGRITY,
|
|
166
|
+
ErrorCode.EXPORT_SCOPE_AMBIGUOUS: ErrorCategory.POLICY,
|
|
167
|
+
ErrorCode.EXPORT_BUNDLE_MALFORMED: ErrorCategory.INTEGRITY,
|
|
168
|
+
ErrorCode.EXPORT_TARGET_NOT_EMPTY: ErrorCategory.POLICY,
|
|
169
|
+
ErrorCode.DETECT_AMBIGUOUS: ErrorCategory.USAGE,
|
|
170
|
+
ErrorCode.TIER_INSUFFICIENT: ErrorCategory.POLICY,
|
|
171
|
+
ErrorCode.TIER_DECLINE_RECOMMENDED: ErrorCategory.POLICY,
|
|
172
|
+
ErrorCode.TIER_ANSWERS_INVALID: ErrorCategory.USAGE,
|
|
173
|
+
ErrorCode.MANIFEST_UNDECLARED_HOST: ErrorCategory.POLICY,
|
|
174
|
+
ErrorCode.MANIFEST_MISSING_SAFE_PATH: ErrorCategory.POLICY,
|
|
175
|
+
ErrorCode.MANIFEST_INVALID: ErrorCategory.USAGE,
|
|
176
|
+
ErrorCode.ENVELOPE_CONTENT_UNDER_METADATA_ONLY: ErrorCategory.POLICY,
|
|
177
|
+
ErrorCode.ENVELOPE_POLICY_NOT_PERMITTED: ErrorCategory.POLICY,
|
|
178
|
+
ErrorCode.AGENT_ADAPTER_UNKNOWN: ErrorCategory.USAGE,
|
|
179
|
+
ErrorCode.AGENT_OFFLINE_ADAPTER_DENIED: ErrorCategory.POLICY,
|
|
180
|
+
ErrorCode.AGENT_BUDGET_EXHAUSTED: ErrorCategory.POLICY,
|
|
181
|
+
ErrorCode.AGENT_OUTPUT_SCHEMA: ErrorCategory.INTERNAL,
|
|
182
|
+
ErrorCode.AGENT_PROVIDER_ERROR: ErrorCategory.TRANSIENT,
|
|
183
|
+
ErrorCode.WORKFLOW_STEP_EXHAUSTED: ErrorCategory.INTERNAL,
|
|
184
|
+
ErrorCode.WORKFLOW_BODY_IMPURE: ErrorCategory.POLICY,
|
|
185
|
+
ErrorCode.WORKFLOW_DUPLICATE_START: ErrorCategory.USAGE,
|
|
186
|
+
ErrorCode.LICENCE_POLICY_VIOLATION: ErrorCategory.POLICY,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#: `AGENT_BUDGET_EXHAUSTED` is returned as `AgentResult.status` and is **never
|
|
190
|
+
#: raised** (contracts §13). Constructing it as an exception is a programming
|
|
191
|
+
#: error, not a runtime condition, so it is refused at construction.
|
|
192
|
+
_NEVER_RAISED: Final[frozenset[ErrorCode]] = frozenset({ErrorCode.AGENT_BUDGET_EXHAUSTED})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def exit_code_for(code: ErrorCode) -> int:
|
|
196
|
+
"""The process exit code a given error code should terminate with."""
|
|
197
|
+
return CATEGORY_EXIT_CODES[ERROR_CATEGORIES[code]]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AdoptError(Exception):
|
|
201
|
+
"""The one error type that crosses a package boundary.
|
|
202
|
+
|
|
203
|
+
Never raise a bare exception across a package boundary and never swallow
|
|
204
|
+
one. A message is for a human; the ``code`` is what callers, tests and the
|
|
205
|
+
CLI's exit status branch on.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
code: ErrorCode,
|
|
211
|
+
category: ErrorCategory | None = None,
|
|
212
|
+
message: str = "",
|
|
213
|
+
hint: str | None = None,
|
|
214
|
+
*,
|
|
215
|
+
run_id: str | None = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
registered = ERROR_CATEGORIES[code]
|
|
218
|
+
if category is not None and category is not registered:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"{code} is registered as {registered}, not {category}. "
|
|
221
|
+
"Change contracts §13 and ERROR_CATEGORIES together, or use the "
|
|
222
|
+
"registered category."
|
|
223
|
+
)
|
|
224
|
+
if code in _NEVER_RAISED:
|
|
225
|
+
raise ValueError(f"{code} is a returned status, never a raised error (contracts §13).")
|
|
226
|
+
self.code: Final[ErrorCode] = code
|
|
227
|
+
self.category: Final[ErrorCategory] = registered
|
|
228
|
+
self.message: Final[str] = message
|
|
229
|
+
self.hint: Final[str | None] = hint
|
|
230
|
+
self.run_id: Final[str | None] = run_id
|
|
231
|
+
super().__init__(message or str(code))
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def exit_code(self) -> int:
|
|
235
|
+
"""The process exit code this error should terminate with."""
|
|
236
|
+
return exit_code_for(self.code)
|
|
237
|
+
|
|
238
|
+
def to_envelope(self) -> dict[str, dict[str, str | None]]:
|
|
239
|
+
"""Render the one documented error envelope from contracts §13.
|
|
240
|
+
|
|
241
|
+
Only the fields the contract declares. In particular the traceback and
|
|
242
|
+
the exception chain are deliberately absent: an envelope is emitted to
|
|
243
|
+
a client-facing surface, and a traceback carries file paths and, via
|
|
244
|
+
local variables in some renderings, content.
|
|
245
|
+
"""
|
|
246
|
+
return {
|
|
247
|
+
"error": {
|
|
248
|
+
"code": str(self.code),
|
|
249
|
+
"category": str(self.category),
|
|
250
|
+
"message": self.message,
|
|
251
|
+
"hint": self.hint,
|
|
252
|
+
"run_id": self.run_id,
|
|
253
|
+
}
|
|
254
|
+
}
|
adopt_obs/ids.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Prefixed ULID generation. The only place an id is created in the programme.
|
|
2
|
+
|
|
3
|
+
Ids are ``<prefix>_<26-char Crockford base32>``, generated store-side and never
|
|
4
|
+
by callers. The prefix registry below is the executable copy of
|
|
5
|
+
``02-contracts-build0.md`` §1.1: an unregistered prefix is rejected rather than
|
|
6
|
+
minted, because a typo'd prefix produces an id that looks valid, joins to
|
|
7
|
+
nothing, and is indistinguishable from data loss six months later.
|
|
8
|
+
|
|
9
|
+
Ids are **monotonic**. Two ids minted in the same millisecond still sort in
|
|
10
|
+
creation order, which is what lets export row ordering be deterministic by
|
|
11
|
+
primary key without a separate sequence column.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
__all__ = ["PREFIX_REGISTRY", "UnknownPrefixError", "new_id", "split_id"]
|
|
20
|
+
|
|
21
|
+
# Crockford base32: no I, L, O or U, so a transcribed id cannot become a
|
|
22
|
+
# different valid id through a reading mistake.
|
|
23
|
+
_ALPHABET: Final[str] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
24
|
+
# const-sync: ok -- base32 packs five bits per character, not WORKFLOW_STEP_MAX_ATTEMPTS.
|
|
25
|
+
_BITS_PER_CHAR: Final[int] = 5
|
|
26
|
+
_CHAR_MASK: Final[int] = 0x1F
|
|
27
|
+
# const-sync: ok -- a byte is eight bits, not CONFORMANCE_CI_MAX_MINUTES.
|
|
28
|
+
_BITS_PER_BYTE: Final[int] = 8
|
|
29
|
+
|
|
30
|
+
# ULID layout: 48 bits of millisecond timestamp, 80 bits of randomness.
|
|
31
|
+
# const-sync: ok -- 48 here is the ULID timestamp width, not SLUG_MAX_CHARS.
|
|
32
|
+
_TIMESTAMP_BITS: Final[int] = 48
|
|
33
|
+
_RANDOM_BITS: Final[int] = 80
|
|
34
|
+
# const-sync: ok -- ten base32 characters cover the timestamp, not a minute budget.
|
|
35
|
+
_TIMESTAMP_CHARS: Final[int] = 10
|
|
36
|
+
_RANDOM_CHARS: Final[int] = 16
|
|
37
|
+
_ULID_CHARS: Final[int] = _TIMESTAMP_CHARS + _RANDOM_CHARS
|
|
38
|
+
_MAX_RANDOM: Final[int] = (1 << _RANDOM_BITS) - 1
|
|
39
|
+
_MAX_TIMESTAMP: Final[int] = (1 << _TIMESTAMP_BITS) - 1
|
|
40
|
+
_RANDOM_BYTES: Final[int] = _RANDOM_BITS // _BITS_PER_BYTE
|
|
41
|
+
_MS_PER_SECOND: Final[int] = 1000
|
|
42
|
+
|
|
43
|
+
#: The prefix registry, verbatim from contracts §1.1.
|
|
44
|
+
#:
|
|
45
|
+
#: `run_` is the log and trace correlation id. It appears in the error envelope
|
|
46
|
+
#: (`error.run_id`) and on every structured log line, so it must be mintable
|
|
47
|
+
#: here; it was added to §1.1 in the same change that introduced this module.
|
|
48
|
+
PREFIX_REGISTRY: Final[dict[str, str]] = {
|
|
49
|
+
"firm": "firm",
|
|
50
|
+
"eng": "engagement",
|
|
51
|
+
"sys": "system",
|
|
52
|
+
"env": "environment",
|
|
53
|
+
"sle": "system_lifecycle_event",
|
|
54
|
+
"idn": "identity",
|
|
55
|
+
"irev": "identity_revision",
|
|
56
|
+
"ki": "knowledge_item",
|
|
57
|
+
"krev": "knowledge_revision",
|
|
58
|
+
"prov": "provenance",
|
|
59
|
+
"bnd": "binding",
|
|
60
|
+
"brev": "binding_revision",
|
|
61
|
+
"cf": "conflict",
|
|
62
|
+
"conn": "connector",
|
|
63
|
+
"sen": "sensor",
|
|
64
|
+
"pd": "probe_definition",
|
|
65
|
+
"pdrev": "probe_definition_revision",
|
|
66
|
+
"prun": "probe_run",
|
|
67
|
+
"pobs": "probe_observation",
|
|
68
|
+
"bv": "baseline_version",
|
|
69
|
+
"ce": "change_event",
|
|
70
|
+
"cls": "classification",
|
|
71
|
+
"clsv": "classifier_version",
|
|
72
|
+
"sre": "silent_repair_eligibility",
|
|
73
|
+
"rb": "review_batch",
|
|
74
|
+
"ri": "review_item",
|
|
75
|
+
"apr": "approval",
|
|
76
|
+
"esc": "escalation",
|
|
77
|
+
"own": "ownership_assignment",
|
|
78
|
+
"aud": "audit_event",
|
|
79
|
+
"ob": "observability_boundary",
|
|
80
|
+
"vb": "value_baseline",
|
|
81
|
+
"ve": "value_event",
|
|
82
|
+
"act": "actor (external reference)",
|
|
83
|
+
"ag": "agent_run (runtime annex)",
|
|
84
|
+
"run": "run (log and trace correlation)",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UnknownPrefixError(ValueError):
|
|
89
|
+
"""Raised when a caller asks for an id with an unregistered prefix."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_lock = threading.Lock()
|
|
93
|
+
_last_timestamp_ms: int = -1
|
|
94
|
+
_last_random: int = 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _wall_clock_ms() -> int:
|
|
98
|
+
"""Wall-clock milliseconds.
|
|
99
|
+
|
|
100
|
+
Deliberately not the injectable clock: a ULID's timestamp component is an
|
|
101
|
+
ordering device, not an observation. Freezing it in a test would make ids
|
|
102
|
+
non-monotonic across the freeze and would be asserting the wrong thing --
|
|
103
|
+
monotonicity is guaranteed by the counter below, not by time moving.
|
|
104
|
+
"""
|
|
105
|
+
return int(time.time() * _MS_PER_SECOND)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _encode(value: int, length: int) -> str:
|
|
109
|
+
out = ["0"] * length
|
|
110
|
+
for i in range(length - 1, -1, -1):
|
|
111
|
+
out[i] = _ALPHABET[value & _CHAR_MASK]
|
|
112
|
+
value >>= _BITS_PER_CHAR
|
|
113
|
+
return "".join(out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _next_ulid() -> str:
|
|
117
|
+
"""Mint a monotonic ULID.
|
|
118
|
+
|
|
119
|
+
Within one millisecond the random component is incremented rather than
|
|
120
|
+
redrawn, so ordering is total even under a tight write loop. On the
|
|
121
|
+
astronomically unlikely overflow the timestamp advances by one millisecond,
|
|
122
|
+
which keeps ordering correct at the cost of a timestamp one tick ahead.
|
|
123
|
+
"""
|
|
124
|
+
global _last_timestamp_ms, _last_random
|
|
125
|
+
|
|
126
|
+
with _lock:
|
|
127
|
+
now = _wall_clock_ms()
|
|
128
|
+
if now > _MAX_TIMESTAMP: # pragma: no cover -- year 10889
|
|
129
|
+
raise OverflowError("ULID timestamp space exhausted")
|
|
130
|
+
if now == _last_timestamp_ms:
|
|
131
|
+
if _last_random >= _MAX_RANDOM:
|
|
132
|
+
_last_timestamp_ms += 1
|
|
133
|
+
_last_random = int.from_bytes(os.urandom(_RANDOM_BYTES)) >> 1
|
|
134
|
+
else:
|
|
135
|
+
_last_random += 1
|
|
136
|
+
elif now < _last_timestamp_ms:
|
|
137
|
+
# The wall clock moved backwards (NTP step, VM restore). Hold the
|
|
138
|
+
# previous millisecond and keep incrementing: an id that sorts
|
|
139
|
+
# correctly matters more than an id whose timestamp is accurate.
|
|
140
|
+
_last_random += 1
|
|
141
|
+
else:
|
|
142
|
+
_last_timestamp_ms = now
|
|
143
|
+
_last_random = int.from_bytes(os.urandom(_RANDOM_BYTES)) >> 1
|
|
144
|
+
timestamp, randomness = _last_timestamp_ms, _last_random
|
|
145
|
+
|
|
146
|
+
return _encode(timestamp, _TIMESTAMP_CHARS) + _encode(randomness, _RANDOM_CHARS)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def new_id(prefix: str) -> str:
|
|
150
|
+
"""Mint a prefixed ULID.
|
|
151
|
+
|
|
152
|
+
``prefix`` is accepted with or without its trailing underscore -- both
|
|
153
|
+
``new_id("firm")`` and ``new_id("firm_")`` yield ``firm_01J...``.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
UnknownPrefixError: the prefix is not in the contracts §1.1 registry.
|
|
157
|
+
"""
|
|
158
|
+
key = prefix[:-1] if prefix.endswith("_") else prefix
|
|
159
|
+
if key not in PREFIX_REGISTRY:
|
|
160
|
+
known = ", ".join(sorted(PREFIX_REGISTRY))
|
|
161
|
+
raise UnknownPrefixError(
|
|
162
|
+
f"unregistered id prefix {prefix!r}; "
|
|
163
|
+
f"register it in contracts §1.1 and PREFIX_REGISTRY first. Known: {known}"
|
|
164
|
+
)
|
|
165
|
+
return f"{key}_{_next_ulid()}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def split_id(value: str) -> tuple[str, str]:
|
|
169
|
+
"""Split a prefixed id into ``(prefix, ulid)``, validating both halves.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
UnknownPrefixError: the prefix is unregistered.
|
|
173
|
+
ValueError: the ULID component is malformed.
|
|
174
|
+
"""
|
|
175
|
+
prefix, separator, ulid = value.partition("_")
|
|
176
|
+
if not separator:
|
|
177
|
+
raise ValueError(f"malformed id {value!r}: no prefix separator")
|
|
178
|
+
if prefix not in PREFIX_REGISTRY:
|
|
179
|
+
raise UnknownPrefixError(f"unregistered id prefix {prefix!r} in {value!r}")
|
|
180
|
+
if len(ulid) != _ULID_CHARS or any(c not in _ALPHABET for c in ulid):
|
|
181
|
+
raise ValueError(f"malformed ULID component in {value!r}")
|
|
182
|
+
return prefix, ulid
|
adopt_obs/log.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Structured logging. One JSON object per line, no free text, ever.
|
|
2
|
+
|
|
3
|
+
The signature is the control. :meth:`Logger.emit` takes an ``event`` -- a stable
|
|
4
|
+
snake_case name -- and keyword fields. There is **no message parameter**, so
|
|
5
|
+
there is no channel through which a body, a prompt, a model output or a chunk of
|
|
6
|
+
client source can be logged as prose. That is what makes the log queryable and
|
|
7
|
+
what makes "no client content in any log line" a structural property rather than
|
|
8
|
+
a habit.
|
|
9
|
+
|
|
10
|
+
Every line carries ``ts``, ``level``, ``event`` and ``run_id``. Deny-listed
|
|
11
|
+
fields are dropped at any depth and the drop count is emitted as
|
|
12
|
+
``redacted_fields``, so an attempted leak is visible in the log rather than
|
|
13
|
+
silent.
|
|
14
|
+
|
|
15
|
+
OSS mode is local only, with zero telemetry, permanently. Lines go to stderr or
|
|
16
|
+
to a local file the operator chose. There is no opt-in switch to add later.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Any, Final, TextIO
|
|
23
|
+
|
|
24
|
+
from adopt_obs.clock import Clock, SystemClock, format_timestamp
|
|
25
|
+
from adopt_obs.ids import new_id
|
|
26
|
+
from adopt_obs.redact import redact
|
|
27
|
+
|
|
28
|
+
__all__ = ["LogLevel", "Logger", "get_logger", "new_run_id", "set_sink"]
|
|
29
|
+
|
|
30
|
+
_RESERVED_FIELDS: Final[frozenset[str]] = frozenset(
|
|
31
|
+
{"ts", "level", "event", "logger", "run_id", "redacted_fields"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LogLevel(StrEnum):
|
|
36
|
+
DEBUG = "debug"
|
|
37
|
+
INFO = "info"
|
|
38
|
+
WARN = "warn"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
#: Alarm-grade: a defect signal that must page, not merely be recorded.
|
|
41
|
+
#: `COVERAGE_CACHE_DISAGREEMENT` is the canonical example.
|
|
42
|
+
ALARM = "alarm"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Ordering is derived from declaration order rather than written out, so the
|
|
46
|
+
# ranks cannot drift from the enum and no integer literal appears here at all.
|
|
47
|
+
_LEVEL_ORDER: Final[dict[LogLevel, int]] = {
|
|
48
|
+
level: rank
|
|
49
|
+
for rank, level in enumerate(
|
|
50
|
+
(LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR, LogLevel.ALARM)
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_sink: TextIO = sys.stderr
|
|
55
|
+
_min_level: LogLevel = LogLevel.INFO
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_sink(stream: TextIO, *, min_level: LogLevel = LogLevel.INFO) -> None:
|
|
59
|
+
"""Point the log at a stream. Used by the CLI and by tests.
|
|
60
|
+
|
|
61
|
+
There is no network sink and no remote handler to configure, by design.
|
|
62
|
+
"""
|
|
63
|
+
global _sink, _min_level
|
|
64
|
+
_sink = stream
|
|
65
|
+
_min_level = min_level
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def new_run_id() -> str:
|
|
69
|
+
"""Mint a correlation id for one CLI invocation or one unit of work."""
|
|
70
|
+
return new_id("run")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Logger:
|
|
74
|
+
"""A named emitter. Obtain one with :func:`get_logger`.
|
|
75
|
+
|
|
76
|
+
A logger may carry its own ``sink``. Without one it writes to the process
|
|
77
|
+
sink set by :func:`set_sink`. Per-logger sinks exist so a caller -- a test,
|
|
78
|
+
or an embedded use where two components log to different destinations --
|
|
79
|
+
never has to mutate process-wide state to redirect output.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
__slots__ = ("_clock", "_name", "_run_id", "_sink")
|
|
83
|
+
|
|
84
|
+
def __init__(self, name: str, run_id: str, clock: Clock, sink: TextIO | None = None) -> None:
|
|
85
|
+
self._name = name
|
|
86
|
+
self._run_id = run_id
|
|
87
|
+
self._clock = clock
|
|
88
|
+
self._sink = sink
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def run_id(self) -> str:
|
|
92
|
+
return self._run_id
|
|
93
|
+
|
|
94
|
+
def bind_run(self, run_id: str) -> "Logger":
|
|
95
|
+
"""A copy of this logger correlated to a different run."""
|
|
96
|
+
return Logger(self._name, run_id, self._clock, self._sink)
|
|
97
|
+
|
|
98
|
+
def emit(self, level: LogLevel, event: str, /, **fields: Any) -> None:
|
|
99
|
+
"""Emit one line.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
level: severity.
|
|
103
|
+
event: a stable snake_case event name. Positional-only, so it can
|
|
104
|
+
never be mistaken for a formatted message.
|
|
105
|
+
**fields: structured fields. Deny-listed names are dropped at any
|
|
106
|
+
depth and counted.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: the event name is not a stable lowercase identifier, or
|
|
110
|
+
a field collides with a reserved key.
|
|
111
|
+
"""
|
|
112
|
+
if _LEVEL_ORDER[level] < _LEVEL_ORDER[_min_level]:
|
|
113
|
+
return
|
|
114
|
+
if not event or not event.replace("_", "").replace(".", "").isalnum():
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"event name {event!r} must be a stable snake_case identifier "
|
|
117
|
+
"(dots permitted for namespacing); log lines carry no free text"
|
|
118
|
+
)
|
|
119
|
+
if event != event.lower():
|
|
120
|
+
raise ValueError(f"event name {event!r} must be lowercase")
|
|
121
|
+
collisions = _RESERVED_FIELDS & fields.keys()
|
|
122
|
+
if collisions:
|
|
123
|
+
raise ValueError(f"fields {sorted(collisions)} collide with reserved log keys")
|
|
124
|
+
|
|
125
|
+
result = redact(fields)
|
|
126
|
+
line: dict[str, Any] = {
|
|
127
|
+
"ts": format_timestamp(self._clock.now()),
|
|
128
|
+
"level": str(level),
|
|
129
|
+
"event": event,
|
|
130
|
+
"logger": self._name,
|
|
131
|
+
"run_id": self._run_id,
|
|
132
|
+
}
|
|
133
|
+
line.update(result.value)
|
|
134
|
+
if result.dropped:
|
|
135
|
+
line["redacted_fields"] = result.dropped
|
|
136
|
+
# `default=str` renders an unexpected object as its repr rather than
|
|
137
|
+
# raising mid-emit. A logger that can throw while reporting a failure
|
|
138
|
+
# turns one incident into two.
|
|
139
|
+
sink = self._sink if self._sink is not None else _sink
|
|
140
|
+
sink.write(json.dumps(line, sort_keys=False, default=str) + "\n")
|
|
141
|
+
sink.flush()
|
|
142
|
+
|
|
143
|
+
def debug(self, event: str, /, **fields: Any) -> None:
|
|
144
|
+
self.emit(LogLevel.DEBUG, event, **fields)
|
|
145
|
+
|
|
146
|
+
def info(self, event: str, /, **fields: Any) -> None:
|
|
147
|
+
self.emit(LogLevel.INFO, event, **fields)
|
|
148
|
+
|
|
149
|
+
def warn(self, event: str, /, **fields: Any) -> None:
|
|
150
|
+
self.emit(LogLevel.WARN, event, **fields)
|
|
151
|
+
|
|
152
|
+
def error(self, event: str, /, **fields: Any) -> None:
|
|
153
|
+
self.emit(LogLevel.ERROR, event, **fields)
|
|
154
|
+
|
|
155
|
+
def alarm(self, event: str, /, **fields: Any) -> None:
|
|
156
|
+
"""Alarm-grade: a defect signal that pages. Use sparingly and never for
|
|
157
|
+
an expected condition."""
|
|
158
|
+
self.emit(LogLevel.ALARM, event, **fields)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_logger(
|
|
162
|
+
name: str,
|
|
163
|
+
*,
|
|
164
|
+
run_id: str | None = None,
|
|
165
|
+
clock: Clock | None = None,
|
|
166
|
+
sink: TextIO | None = None,
|
|
167
|
+
) -> Logger:
|
|
168
|
+
"""Obtain a named logger, minting a run id when one is not supplied."""
|
|
169
|
+
return Logger(name, run_id or new_run_id(), clock or SystemClock(), sink)
|
adopt_obs/py.typed
ADDED
|
File without changes
|
adopt_obs/redact.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The field deny-list: the mechanism behind "no client content in a log line".
|
|
2
|
+
|
|
3
|
+
Deny-listed field names are **dropped and counted**, at any nesting depth, on
|
|
4
|
+
every structured log line and every error envelope. Counting matters as much as
|
|
5
|
+
dropping: a silent drop looks identical to a caller that never passed the field,
|
|
6
|
+
so the count is what makes an attempted leak visible in the log itself.
|
|
7
|
+
|
|
8
|
+
This is defence in depth, not the primary control. The primary control is that
|
|
9
|
+
:func:`adopt_obs.log.get_logger` has **no free-text message argument** -- there
|
|
10
|
+
is no parameter through which a body, a prompt or a model output can be
|
|
11
|
+
smuggled as prose. The deny-list catches the remaining case: a caller who puts
|
|
12
|
+
content into a structured field.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Final
|
|
16
|
+
|
|
17
|
+
__all__ = ["DENIED_FIELDS", "REDACTED", "RedactionResult", "redact"]
|
|
18
|
+
|
|
19
|
+
#: Verbatim from implementation spec §4.2 and PRD F16.4.
|
|
20
|
+
#:
|
|
21
|
+
#: Matching is on the field *name*, case-insensitively, at any depth. It is not
|
|
22
|
+
#: a heuristic over values: a value-sniffing redactor gives false confidence,
|
|
23
|
+
#: because the moment it misses once the promise "no content in logs" is false
|
|
24
|
+
#: and nobody knows.
|
|
25
|
+
DENIED_FIELDS: Final[frozenset[str]] = frozenset(
|
|
26
|
+
{"body", "content", "prompt", "output", "source", "text", "answer", "question"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: What a dropped field is replaced by when the caller asked for replacement
|
|
30
|
+
#: rather than removal. Never the value, never a truncation of the value, and
|
|
31
|
+
#: never a hash of the value -- a hash of a short secret is a lookup away from
|
|
32
|
+
#: the secret.
|
|
33
|
+
REDACTED: Final[str] = "[redacted]"
|
|
34
|
+
|
|
35
|
+
_MAX_DEPTH: Final[int] = 32
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RedactionResult:
|
|
39
|
+
"""A sanitized value together with how many fields were dropped."""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("dropped", "value")
|
|
42
|
+
|
|
43
|
+
def __init__(self, value: Any, dropped: int) -> None:
|
|
44
|
+
self.value = value
|
|
45
|
+
self.dropped = dropped
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"RedactionResult(dropped={self.dropped})"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _walk(value: Any, depth: int, counter: list[int]) -> Any:
|
|
52
|
+
if depth > _MAX_DEPTH:
|
|
53
|
+
# A structure this deep is not a log field. Refusing to descend caps
|
|
54
|
+
# the work done on a hostile input and cannot leak, because the value
|
|
55
|
+
# is replaced rather than rendered.
|
|
56
|
+
return REDACTED
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
cleaned: dict[str, Any] = {}
|
|
59
|
+
for key, item in value.items():
|
|
60
|
+
name = str(key)
|
|
61
|
+
if name.casefold() in DENIED_FIELDS:
|
|
62
|
+
counter[0] += 1
|
|
63
|
+
continue
|
|
64
|
+
cleaned[name] = _walk(item, depth + 1, counter)
|
|
65
|
+
return cleaned
|
|
66
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
67
|
+
return [_walk(item, depth + 1, counter) for item in value]
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def redact(value: Any) -> RedactionResult:
|
|
72
|
+
"""Drop every deny-listed field at any depth, counting the drops.
|
|
73
|
+
|
|
74
|
+
Scalars pass through untouched: this function sanitizes *shapes*, and a
|
|
75
|
+
caller who passes a bare secret string as the whole payload has already
|
|
76
|
+
bypassed the field vocabulary the logger enforces.
|
|
77
|
+
"""
|
|
78
|
+
counter = [0]
|
|
79
|
+
cleaned = _walk(value, 0, counter)
|
|
80
|
+
return RedactionResult(cleaned, counter[0])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: adopt-obs
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Structured logging, typed errors, id generation, redaction, the injectable clock.
|
|
5
|
+
Project-URL: Homepage, https://github.com/onboardux/onboard-core
|
|
6
|
+
Project-URL: Source, https://github.com/onboardux/onboard-core
|
|
7
|
+
Project-URL: Issues, https://github.com/onboardux/onboard-core/issues
|
|
8
|
+
Author: The Adopt Authors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: adopt-const
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
adopt_obs/__init__.py,sha256=rwCPTYzBP-S7cL_nOdlHtDlNgoNZLDYa3_3-3gkn_Tg,1681
|
|
2
|
+
adopt_obs/clock.py,sha256=bRVSZUgyCZtUhrEvuKKdrevl4SnWCHnP4BH9qcRr63M,3836
|
|
3
|
+
adopt_obs/errors.py,sha256=Rz5mOULoQXUpAaiNiJZdCKdhcbxV2POJp5TnFXYTt7U,10737
|
|
4
|
+
adopt_obs/ids.py,sha256=BeVnHaNarosoFtc4Kx0ZSF1WkqT_FDnNKu8dvOYJ5_o,6857
|
|
5
|
+
adopt_obs/log.py,sha256=EQI8qwoybrYx9EbV_JoCf6rodU5DjciMbWSnkr0W23o,6189
|
|
6
|
+
adopt_obs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
adopt_obs/redact.py,sha256=180EC96sh_lN82VoP2y1hq3V8w4cK0KsxuONFxteLgk,3171
|
|
8
|
+
adopt_obs-0.3.0.dist-info/METADATA,sha256=yCYb9UZ0o_ze-v7TmN-f0kJhDcP22SBt9xDZ6UhAfvE,493
|
|
9
|
+
adopt_obs-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
adopt_obs-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
11
|
+
adopt_obs-0.3.0.dist-info/licenses/NOTICE,sha256=2_mgo6v6IM9fAn52L5-wXFpISnC6PVU_geTutoRhbWk,1897
|
|
12
|
+
adopt_obs-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Adopt — Adoption-Phase Platform, shared substrate (`adopt-core`)
|
|
2
|
+
Copyright 2026 The Adopt Authors
|
|
3
|
+
|
|
4
|
+
This product includes software developed by The Adopt Authors.
|
|
5
|
+
|
|
6
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
you may not use this file except in compliance with the License.
|
|
8
|
+
You may obtain a copy of the License at
|
|
9
|
+
|
|
10
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
|
|
12
|
+
Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
See the License for the specific language governing permissions and
|
|
16
|
+
limitations under the License.
|
|
17
|
+
|
|
18
|
+
--------------------------------------------------------------------------------
|
|
19
|
+
Attribution note
|
|
20
|
+
--------------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
The copyright holder is recorded here as "The Adopt Authors" pending the legal
|
|
23
|
+
entity name. The owner must settle that attribution before the 0.3.0 tag,
|
|
24
|
+
because published package metadata cannot be changed retroactively for a
|
|
25
|
+
release that has already left the machine. The product name itself is settled:
|
|
26
|
+
handoff-index CR-50 keeps `Adopt` distinct from the `onboard` URI namespace.
|
|
27
|
+
|
|
28
|
+
--------------------------------------------------------------------------------
|
|
29
|
+
Third-party dependencies
|
|
30
|
+
--------------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
Every third-party dependency linked into this distribution is permissively
|
|
33
|
+
licensed. The complete list, with licence hash, security status, usage mode,
|
|
34
|
+
owner and re-verification date, is maintained in `licence-verifications.md` and
|
|
35
|
+
enforced by `scripts/licence_gate.py`.
|
|
36
|
+
|
|
37
|
+
Copyleft-licensed tools are invoked as subprocesses only and are never linked
|
|
38
|
+
into this distribution. They are declared in `subprocess-deps.toml` together
|
|
39
|
+
with their invocation sites.
|