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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.