adopt-coverage 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.
@@ -0,0 +1,49 @@
1
+ """`recompute_coverage` and the cache-disagreement alarm.
2
+
3
+ Implemented in S4. Contracts §6, implementation spec §4.8.
4
+
5
+ **The invariants this package carries.** It is the only writer of
6
+ `covered_cache` and `covered_cache_at` -- enforced by the `no-covered-cache-write`
7
+ import contract, not by convention. The recompute result is the authority and the
8
+ cache is rebuilt from it, never the reverse. A disagreement alarms and is never
9
+ silently reconciled, because a quietly self-healing cache reintroduces exactly
10
+ the invisible coverage decay the rebuild exists to delete.
11
+
12
+ **Computing and writing are two calls on purpose.** `recompute_coverage` reads
13
+ and decides; `rebuild_cache` writes. `store doctor` calls the first and not the
14
+ second, which is what lets it report a disagreement without destroying the
15
+ evidence of who caused it.
16
+ """
17
+
18
+ from adopt_coverage.cache import CacheWriter, rebuild_cache
19
+ from adopt_coverage.recompute import (
20
+ COVERAGE_REASONS,
21
+ REASON_AUDIENCE_OR_ENVIRONMENT,
22
+ REASON_IDENTITY_NOT_ACTIVE,
23
+ REASON_NO_ACTIVE_KNOWLEDGE_REVISION,
24
+ REASON_NO_LIVE_BINDING,
25
+ REASON_NO_OBSERVABILITY_BOUNDARY,
26
+ REASON_VERIFICATION_CONFLICTED,
27
+ CoverageResult,
28
+ Disagreement,
29
+ IdentityCoverage,
30
+ recompute_coverage,
31
+ )
32
+ from adopt_coverage.records import CoverageRecords
33
+
34
+ __all__ = [
35
+ "COVERAGE_REASONS",
36
+ "REASON_AUDIENCE_OR_ENVIRONMENT",
37
+ "REASON_IDENTITY_NOT_ACTIVE",
38
+ "REASON_NO_ACTIVE_KNOWLEDGE_REVISION",
39
+ "REASON_NO_LIVE_BINDING",
40
+ "REASON_NO_OBSERVABILITY_BOUNDARY",
41
+ "REASON_VERIFICATION_CONFLICTED",
42
+ "CacheWriter",
43
+ "CoverageRecords",
44
+ "CoverageResult",
45
+ "Disagreement",
46
+ "IdentityCoverage",
47
+ "rebuild_cache",
48
+ "recompute_coverage",
49
+ ]
@@ -0,0 +1,85 @@
1
+ """The coverage cache write. **The only one in either repository.**
2
+
3
+ `no-covered-cache-write` scans every `.py` string literal and every `.sql` line
4
+ under `packages/`, `scripts/`, `tools/`, `bench/` and `schema/` for the cache
5
+ columns named alongside a write keyword, and `packages/adopt-coverage` is the
6
+ only source path it permits. That is why the statement is here and not beside the
7
+ other `identity` writes in `adopt_store.sqlite.records`, where it would be
8
+ rejected by the gate -- correctly, because a setter reachable from the store is a
9
+ setter every caller can reach.
10
+
11
+ **Why this module holds SQL when nothing else in the package does.** The
12
+ alternatives were each worse and are recorded so they are not re-proposed:
13
+
14
+ * a generic ``update_identity_columns(id, mapping)`` on the store passes the
15
+ regex while opening a *wider* hole than the specific setter it replaces --
16
+ every column becomes writable by every caller, which is dodging the gate
17
+ rather than satisfying it (CR-24: a gate people work around stops meaning
18
+ anything);
19
+ * splitting the statement so the column and the keyword land on different lines
20
+ is the same dodge, less visible;
21
+ * adding ``adopt_store.sqlite`` to the contract's ``allowed_paths`` makes the
22
+ write reachable by anyone holding a store, which is the invariant itself.
23
+
24
+ The executor is a **structural** protocol, so this package imports no store
25
+ module and no chain reaches `sqlite3` -- `no-raw-sqlite` names `adopt_coverage`
26
+ as a source module and would otherwise reject it.
27
+
28
+ **Known cost, stated rather than discovered later.** One SQL statement lives in a
29
+ package that is otherwise dialect-free, and the parameter marker differs under
30
+ psycopg. No sprint assigns the Postgres realization of coverage; whichever one
31
+ does inherits this seam and this note.
32
+ """
33
+
34
+ from collections.abc import Sequence
35
+ from contextlib import AbstractContextManager
36
+ from typing import Final, Protocol
37
+
38
+ from adopt_coverage.recompute import CoverageResult
39
+ from adopt_obs import format_timestamp
40
+
41
+ __all__ = ["CacheWriter", "rebuild_cache"]
42
+
43
+ #: The statement, in one place. Parameter order is (covered, at, identity_id).
44
+ _WRITE_CACHE: Final[str] = (
45
+ "UPDATE identity SET covered_cache = ?, covered_cache_at = ? WHERE id = ?"
46
+ )
47
+
48
+
49
+ class CacheWriter(Protocol):
50
+ """The two operations rebuilding the cache needs, and nothing else.
51
+
52
+ Satisfied structurally by `adopt_store.sqlite.store.SqliteStore`. Deliberately
53
+ **not** an import of that class: this package may not reach `sqlite3` even
54
+ transitively, and a protocol this narrow cannot be used to write anything the
55
+ caller did not already have the statement for.
56
+ """
57
+
58
+ def transaction(self) -> AbstractContextManager[None]: ...
59
+ def execute(self, sql: str, parameters: tuple[object, ...] = ()) -> None: ...
60
+
61
+
62
+ def rebuild_cache(writer: CacheWriter, result: CoverageResult) -> int:
63
+ """Rebuild `covered_cache` from a recompute result. **Never the reverse.**
64
+
65
+ The direction is the whole contract (PRD F7.4, CUJ-3 step 4). This function
66
+ takes a `CoverageResult` and no store-read of its own precisely so that there
67
+ is no expression here in which the cache could influence the value written.
68
+
69
+ Args:
70
+ writer: The store to write through.
71
+ result: What `recompute_coverage` decided.
72
+
73
+ Returns:
74
+ How many rows were written -- every identity in scope, not only the
75
+ disagreeing ones. A cache rebuilt only where it disagreed would leave
76
+ `covered_cache_at` lying about when the rest was last confirmed.
77
+ """
78
+ stamp = format_timestamp(result.computed_at)
79
+ rows: Sequence[tuple[object, ...]] = [
80
+ (int(entry.covered), stamp, entry.identity_id) for entry in result.identities
81
+ ]
82
+ with writer.transaction():
83
+ for parameters in rows:
84
+ writer.execute(_WRITE_CACHE, parameters)
85
+ return len(rows)
File without changes
@@ -0,0 +1,380 @@
1
+ """`recompute_coverage` -- the authority, and the cache-disagreement alarm.
2
+
3
+ **Breaking change 3 of 3, second half.** In the withdrawn `0.1.x` line
4
+ `identity_registry.covered` *was* truth, recomputed by whichever writer happened
5
+ to touch it. Here the function is truth and `identity.covered_cache` is a cache,
6
+ and the difference is the whole point: a cache that disagrees is a defect signal,
7
+ never a value to be quietly corrected.
8
+
9
+ **This module computes and never writes.** The write lives in
10
+ `adopt_coverage.cache`, one call away, so that `store doctor` can ask for the
11
+ comparison without the act of looking changing what is there. Implementation spec
12
+ §8's incident card is explicit -- rebuilding the cache first destroys the
13
+ evidence, and the writer that caused the drift is then unfindable.
14
+
15
+ **The six inputs are evaluated here, not in SQL.** The port hands back rows; each
16
+ predicate below is one input from contracts §6, named, so the property test that
17
+ compares this function against an independent reference implementation is
18
+ comparing two derivations rather than two callers of one clever query.
19
+ """
20
+
21
+ import datetime as _dt
22
+ from collections.abc import Mapping, Sequence
23
+ from dataclasses import dataclass
24
+ from typing import Final
25
+
26
+ from adopt_const import COVERAGE_ALARM_SAMPLE_MAX
27
+ from adopt_coverage.records import CoverageRecords
28
+ from adopt_model import Binding, Identity, KnowledgeItem, ObservabilityBoundary
29
+ from adopt_obs import Clock, ErrorCode, SystemClock, get_logger, truncate_to_millisecond
30
+
31
+ __all__ = [
32
+ "COVERAGE_REASONS",
33
+ "REASON_AUDIENCE_OR_ENVIRONMENT",
34
+ "REASON_IDENTITY_NOT_ACTIVE",
35
+ "REASON_NO_ACTIVE_KNOWLEDGE_REVISION",
36
+ "REASON_NO_LIVE_BINDING",
37
+ "REASON_NO_OBSERVABILITY_BOUNDARY",
38
+ "REASON_VERIFICATION_CONFLICTED",
39
+ "CoverageResult",
40
+ "Disagreement",
41
+ "IdentityCoverage",
42
+ "recompute_coverage",
43
+ ]
44
+
45
+ _LOGGER: Final = get_logger("adopt.coverage")
46
+
47
+ # --------------------------------------------------------------------------
48
+ # The six inputs of contracts §6, one reason each.
49
+ #
50
+ # A reason names why an identity is **not** covered. They are stable strings
51
+ # because they reach the CLI envelope and a `store doctor` finding, and an
52
+ # operator branching on "which of the six is missing" is the whole reason the
53
+ # result is not a bare boolean.
54
+ # --------------------------------------------------------------------------
55
+
56
+ #: Input 1 -- "an active `identity_revision`".
57
+ REASON_IDENTITY_NOT_ACTIVE: Final[str] = "identity_revision_not_active"
58
+
59
+ #: Input 2 -- "at least one non-retired `binding`".
60
+ REASON_NO_LIVE_BINDING: Final[str] = "no_live_binding"
61
+
62
+ #: Input 3 -- "an active `knowledge_revision` on the bound item".
63
+ REASON_NO_ACTIVE_KNOWLEDGE_REVISION: Final[str] = "no_active_knowledge_revision"
64
+
65
+ #: Input 4 -- "applicable audience and environment".
66
+ REASON_AUDIENCE_OR_ENVIRONMENT: Final[str] = "audience_or_environment_inapplicable"
67
+
68
+ #: Input 5 -- "the `observability_boundary` for the scope".
69
+ REASON_NO_OBSERVABILITY_BOUNDARY: Final[str] = "no_observability_boundary"
70
+
71
+ #: Input 6 -- "verification requirements". A `conflicted` verification is Bet 4
72
+ #: working as designed: intent and reality disagree, the disagreement is
73
+ #: representable, and the identity is **not** reported as covered while it
74
+ #: stands. `unverified` passes, because it is the honest state every item starts
75
+ #: in and requiring `verified` would make coverage unreachable by construction.
76
+ REASON_VERIFICATION_CONFLICTED: Final[str] = "verification_conflicted"
77
+
78
+ #: Every reason, in evaluation order. Exported so a caller can enumerate the six
79
+ #: without re-deriving the list and getting five.
80
+ COVERAGE_REASONS: Final[tuple[str, ...]] = (
81
+ REASON_IDENTITY_NOT_ACTIVE,
82
+ REASON_NO_LIVE_BINDING,
83
+ REASON_NO_ACTIVE_KNOWLEDGE_REVISION,
84
+ REASON_AUDIENCE_OR_ENVIRONMENT,
85
+ REASON_NO_OBSERVABILITY_BOUNDARY,
86
+ REASON_VERIFICATION_CONFLICTED,
87
+ )
88
+
89
+ #: The `identity_status` that counts as live. `moved` and `dead` do not: a moved
90
+ #: identity's coverage belongs to the identity it aliases, and a dead one covers
91
+ #: nothing.
92
+ _ACTIVE_IDENTITY_STATUS: Final[str] = "active"
93
+
94
+ #: The terminal `binding_status`. `active` and `moved` are both live -- a moved
95
+ #: binding still ties the item to the referent, which is what CUJ-2 turns on.
96
+ _RETIRED_BINDING_STATUS: Final[str] = "retired"
97
+
98
+ #: The terminal `freshness_state` on a knowledge item. Knowledge carries its
99
+ #: terminal state on the parent rather than on the revision (contracts §5
100
+ #: obligation 4), so this is where "the revision is not active" is read.
101
+ _RETIRED_ITEM_FRESHNESS: Final[str] = "retired"
102
+
103
+ #: The `verification` that blocks coverage.
104
+ _CONFLICTED_VERIFICATION: Final[str] = "conflicted"
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class IdentityCoverage:
109
+ """One identity's verdict, and why."""
110
+
111
+ identity_id: str
112
+ uri: str
113
+ covered: bool
114
+ #: Empty when covered. Sorted and deduplicated, so two runs over one store
115
+ #: produce one answer.
116
+ reasons: tuple[str, ...]
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class Disagreement:
121
+ """The cache said one thing and the recompute says another.
122
+
123
+ Alarm-grade on its own. Carries both values because "the cache is wrong" is
124
+ not actionable and "the cache says covered, the recompute says not" is.
125
+ """
126
+
127
+ identity_id: str
128
+ uri: str
129
+ cached: bool
130
+ recomputed: bool
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class CoverageResult:
135
+ """What `recompute_coverage` returns.
136
+
137
+ Nothing here is a cache and nothing here has been written anywhere. The
138
+ caller decides whether to rebuild the cache from it (`adopt_coverage.cache`)
139
+ or merely to look (`store doctor`).
140
+ """
141
+
142
+ system_id: str
143
+ environment_id: str | None
144
+ identities: tuple[IdentityCoverage, ...]
145
+ disagreements: tuple[Disagreement, ...]
146
+ computed_at: _dt.datetime
147
+
148
+ @property
149
+ def covered(self) -> int:
150
+ return sum(1 for entry in self.identities if entry.covered)
151
+
152
+ @property
153
+ def uncovered(self) -> int:
154
+ return sum(1 for entry in self.identities if not entry.covered)
155
+
156
+ def verdict(self, identity_id: str) -> bool | None:
157
+ """The verdict for one identity, or `None` when it is out of scope."""
158
+ for entry in self.identities:
159
+ if entry.identity_id == identity_id:
160
+ return entry.covered
161
+ return None
162
+
163
+
164
+ def _boundary_applies(boundary: ObservabilityBoundary, environment_id: str) -> bool:
165
+ """Whether a boundary row governs an identity in `environment_id`.
166
+
167
+ A boundary with no environment is the system-wide declaration and governs
168
+ every environment; one naming an environment governs only that one.
169
+ """
170
+ return boundary.environment_id is None or boundary.environment_id == environment_id
171
+
172
+
173
+ def _environment_applies(item: KnowledgeItem, environment_id: str) -> bool:
174
+ """Whether an item's environment is applicable to an identity's.
175
+
176
+ `knowledge_item.environment_id` is nullable *because an item may span
177
+ environments* -- so null is "applies everywhere", not "applies nowhere".
178
+ Reading it the other way would make every cross-environment item silently
179
+ stop covering anything.
180
+ """
181
+ return item.environment_id is None or item.environment_id == environment_id
182
+
183
+
184
+ def _binding_blockers(
185
+ binding: Binding,
186
+ *,
187
+ binding_status: str | None,
188
+ item: KnowledgeItem | None,
189
+ verification: str | None,
190
+ has_verification_row: bool,
191
+ audience_count: int,
192
+ environment_id: str,
193
+ ) -> frozenset[str]:
194
+ """Inputs 2, 3, 4 and 6, for one candidate binding.
195
+
196
+ Returns the reasons this binding fails to carry coverage. Empty means it
197
+ carries it, and one such binding is enough -- contracts §6 asks for "at least
198
+ one non-retired binding", not for all of them.
199
+ """
200
+ blockers: set[str] = set()
201
+
202
+ # Input 2 -- a binding whose head revision is retired is not live. A binding
203
+ # with no head revision at all is also not live: nothing has ever asserted
204
+ # the relationship.
205
+ if binding_status is None or binding_status == _RETIRED_BINDING_STATUS:
206
+ blockers.add(REASON_NO_LIVE_BINDING)
207
+
208
+ # Input 3 -- an active knowledge revision on the bound item.
209
+ if (
210
+ item is None
211
+ or item.current_revision_id is None
212
+ or item.freshness_state == _RETIRED_ITEM_FRESHNESS
213
+ ):
214
+ blockers.add(REASON_NO_ACTIVE_KNOWLEDGE_REVISION)
215
+ # Inputs 4 and 6 are statements about that item. With no item there is
216
+ # nothing to say about them, and inventing a second reason would report
217
+ # one defect as three.
218
+ return frozenset(blockers)
219
+
220
+ # Input 4 -- applicable audience and environment.
221
+ if audience_count == 0 or not _environment_applies(item, environment_id):
222
+ blockers.add(REASON_AUDIENCE_OR_ENVIRONMENT)
223
+
224
+ # Input 6 -- verification requirements.
225
+ if has_verification_row and verification == _CONFLICTED_VERIFICATION:
226
+ blockers.add(REASON_VERIFICATION_CONFLICTED)
227
+
228
+ return frozenset(blockers)
229
+
230
+
231
+ def _evaluate(
232
+ identity: Identity,
233
+ *,
234
+ identity_status: str | None,
235
+ bindings: Sequence[Binding],
236
+ binding_statuses: Mapping[str, str],
237
+ items: Mapping[str, KnowledgeItem],
238
+ verifications: Mapping[str, str | None],
239
+ audience_counts: Mapping[str, int],
240
+ boundaries: Sequence[ObservabilityBoundary],
241
+ ) -> IdentityCoverage:
242
+ """All six inputs for one identity."""
243
+ blockers: set[str] = set()
244
+
245
+ # Input 1 -- an active identity revision. An identity with no revision has
246
+ # never been asserted to exist by anything.
247
+ if identity_status != _ACTIVE_IDENTITY_STATUS:
248
+ blockers.add(REASON_IDENTITY_NOT_ACTIVE)
249
+
250
+ # Input 5 -- the observability boundary for the scope. Without one, nothing
251
+ # has declared what may be observed here, and coverage would be a claim
252
+ # about a system nobody agreed to look at.
253
+ if not any(_boundary_applies(row, identity.environment_id) for row in boundaries):
254
+ blockers.add(REASON_NO_OBSERVABILITY_BOUNDARY)
255
+
256
+ # Inputs 2, 3, 4 and 6, per candidate binding.
257
+ if not bindings:
258
+ blockers.add(REASON_NO_LIVE_BINDING)
259
+ else:
260
+ per_binding = [
261
+ _binding_blockers(
262
+ binding,
263
+ binding_status=binding_statuses.get(binding.id),
264
+ item=items.get(binding.item_id),
265
+ verification=verifications.get(binding.item_id),
266
+ has_verification_row=binding.item_id in verifications,
267
+ audience_count=audience_counts.get(binding.item_id, 0),
268
+ environment_id=identity.environment_id,
269
+ )
270
+ for binding in bindings
271
+ ]
272
+ if all(reasons for reasons in per_binding):
273
+ # Every candidate failed. Report every distinct reason rather than
274
+ # the first: an operator fixing one binding's audience should not
275
+ # then discover the next binding was retired all along.
276
+ blockers.update(*per_binding)
277
+
278
+ return IdentityCoverage(
279
+ identity_id=identity.id,
280
+ uri=identity.uri,
281
+ covered=not blockers,
282
+ reasons=tuple(sorted(blockers)),
283
+ )
284
+
285
+
286
+ def recompute_coverage(
287
+ records: CoverageRecords,
288
+ system_id: str,
289
+ environment_id: str | None = None,
290
+ *,
291
+ clock: Clock | None = None,
292
+ ) -> CoverageResult:
293
+ """Evaluate coverage for every identity in scope. **The authority.**
294
+
295
+ Args:
296
+ records: The read port. Supplied rather than reached for, because a
297
+ module-level store would make this function untestable against the
298
+ random graphs its correctness property needs.
299
+ system_id: The system whose identities are evaluated.
300
+ environment_id: One environment, or `None` for every environment of the
301
+ system.
302
+ clock: Injected clock; tests pass `ManualClock`.
303
+
304
+ Returns:
305
+ Per-identity coverage plus a `disagreements` list against
306
+ `covered_cache`. **Nothing is written.**
307
+
308
+ Emits:
309
+ `coverage_cache_disagreement` at `LogLevel.ALARM` when the disagreement
310
+ list is non-empty -- a defect signal that must page, not merely be
311
+ recorded (PRD F7.3). The **count is always complete**; the ids are a
312
+ sample bounded by `COVERAGE_ALARM_SAMPLE_MAX`, because a cold cache over
313
+ a 50k-identity store disagrees on every row and an uncapped field would
314
+ put a megabyte of ULIDs on one line. `store doctor` enumerates every
315
+ affected identity, so the alarm says *how bad* and the doctor says
316
+ *which*. Identity **ids** travel, never URIs: an id is minted by us and
317
+ carries no client-derived text.
318
+ """
319
+ now = truncate_to_millisecond((clock if clock is not None else SystemClock()).now())
320
+
321
+ identities = records.identities_in_scope(system_id=system_id, environment_id=environment_id)
322
+ identity_statuses = records.head_identity_statuses(
323
+ system_id=system_id, environment_id=environment_id
324
+ )
325
+ binding_statuses = records.head_binding_statuses(
326
+ system_id=system_id, environment_id=environment_id
327
+ )
328
+ items = {row.id: row for row in records.items_in_scope(system_id=system_id)}
329
+ verifications = records.head_item_verifications(system_id=system_id)
330
+ audience_counts = records.audience_counts(system_id=system_id)
331
+ boundaries = records.boundaries_for_system(system_id=system_id)
332
+
333
+ bindings_by_identity: dict[str, list[Binding]] = {}
334
+ for binding in records.bindings_in_scope(system_id=system_id, environment_id=environment_id):
335
+ bindings_by_identity.setdefault(binding.identity_id, []).append(binding)
336
+
337
+ verdicts = tuple(
338
+ _evaluate(
339
+ identity,
340
+ identity_status=identity_statuses.get(identity.id),
341
+ bindings=bindings_by_identity.get(identity.id, []),
342
+ binding_statuses=binding_statuses,
343
+ items=items,
344
+ verifications=verifications,
345
+ audience_counts=audience_counts,
346
+ boundaries=boundaries,
347
+ )
348
+ for identity in identities
349
+ )
350
+
351
+ cached = {row.id: row.covered_cache for row in identities}
352
+ disagreements = tuple(
353
+ Disagreement(
354
+ identity_id=verdict.identity_id,
355
+ uri=verdict.uri,
356
+ cached=cached[verdict.identity_id],
357
+ recomputed=verdict.covered,
358
+ )
359
+ for verdict in verdicts
360
+ if cached[verdict.identity_id] != verdict.covered
361
+ )
362
+
363
+ if disagreements:
364
+ _LOGGER.alarm(
365
+ "coverage_cache_disagreement",
366
+ code=str(ErrorCode.COVERAGE_CACHE_DISAGREEMENT),
367
+ system_id=system_id,
368
+ environment_id=environment_id,
369
+ disagreement_count=len(disagreements),
370
+ identity_ids=[entry.identity_id for entry in disagreements[:COVERAGE_ALARM_SAMPLE_MAX]],
371
+ identity_ids_truncated=len(disagreements) > COVERAGE_ALARM_SAMPLE_MAX,
372
+ )
373
+
374
+ return CoverageResult(
375
+ system_id=system_id,
376
+ environment_id=environment_id,
377
+ identities=verdicts,
378
+ disagreements=disagreements,
379
+ computed_at=now,
380
+ )
@@ -0,0 +1,98 @@
1
+ """The storage port `recompute_coverage` reads through.
2
+
3
+ Declared here rather than imported from `adopt_store`, following the precedent
4
+ `adopt_scope.records` set: `no-raw-sqlite` names `adopt_coverage` as a source
5
+ module and import-linter follows the chain, so a dependency on `adopt_store`
6
+ would reach `sqlite3` transitively and break the contract. A structural protocol
7
+ costs one file and keeps this package free of any driver.
8
+
9
+ **Every method is a read.** The cache write is not on this port -- it lives in
10
+ `adopt_coverage.cache`, which is the only place in either repository permitted to
11
+ hold the statement (`no-covered-cache-write`).
12
+
13
+ **The port fetches rows; it never decides.** Each method is one bulk read whose
14
+ result is a plain mapping or a sequence of generated models. Pushing any of the
15
+ six coverage inputs into SQL would move the authority out of
16
+ `recompute_coverage` and into whichever realization ran -- and the property test
17
+ that compares the function against an independent reference implementation would
18
+ then be comparing two callers of one query.
19
+ """
20
+
21
+ from collections.abc import Mapping, Sequence
22
+ from typing import Protocol
23
+
24
+ from adopt_model import Binding, Identity, KnowledgeItem, ObservabilityBoundary
25
+
26
+ __all__ = ["CoverageRecords"]
27
+
28
+
29
+ class CoverageRecords(Protocol):
30
+ """Row in, decision out. No SQL, connection or cursor crosses this boundary.
31
+
32
+ Every method takes the scope the recompute was asked for. `environment_id`
33
+ is optional because `recompute_coverage` is (contracts §6); `None` means
34
+ every environment of the system rather than "the environment that is null",
35
+ and the two readings differ for `knowledge_item`, whose `environment_id` is
36
+ nullable precisely because an item may span environments.
37
+ """
38
+
39
+ def identities_in_scope(
40
+ self, *, system_id: str, environment_id: str | None
41
+ ) -> Sequence[Identity]: ...
42
+
43
+ def systems_with_identities(self) -> Sequence[str]:
44
+ """Every `system_id` that has at least one identity.
45
+
46
+ `store doctor` sweeps coverage across the whole store and has no scope
47
+ argument to work from (implementation spec §4.7: `doctor(store)`). Making
48
+ it ask which systems exist is what stops the sweep silently checking
49
+ nothing when a caller forgets to name one.
50
+ """
51
+ ...
52
+
53
+ def head_identity_statuses(
54
+ self, *, system_id: str, environment_id: str | None
55
+ ) -> Mapping[str, str]:
56
+ """`identity_id` -> the status of its head revision.
57
+
58
+ `identity` carries no head pointer, so the head is *derived*: the
59
+ revision no other revision supersedes (contracts §5 obligation 3). An
60
+ identity with no revision at all is absent from the mapping rather than
61
+ present with a placeholder -- "no revision" and "a revision saying
62
+ nothing" are different facts and the caller treats them differently.
63
+ """
64
+ ...
65
+
66
+ def bindings_in_scope(
67
+ self, *, system_id: str, environment_id: str | None
68
+ ) -> Sequence[Binding]: ...
69
+
70
+ def head_binding_statuses(
71
+ self, *, system_id: str, environment_id: str | None
72
+ ) -> Mapping[str, str]:
73
+ """`binding_id` -> the status of its head revision."""
74
+ ...
75
+
76
+ def items_in_scope(self, *, system_id: str) -> Sequence[KnowledgeItem]:
77
+ """Scoped by system only.
78
+
79
+ `knowledge_item.environment_id` is nullable, so filtering it by
80
+ environment here would silently drop every item that spans environments
81
+ -- which is the population the environment check in `recompute_coverage`
82
+ exists to reason about.
83
+ """
84
+ ...
85
+
86
+ def head_item_verifications(self, *, system_id: str) -> Mapping[str, str | None]:
87
+ """`item_id` -> `verification` on its current knowledge revision.
88
+
89
+ Absent when the item has no current revision; `None` when the revision
90
+ carries no verification, which the column permits.
91
+ """
92
+ ...
93
+
94
+ def audience_counts(self, *, system_id: str) -> Mapping[str, int]:
95
+ """`item_id` -> how many `audience_tag` rows it carries."""
96
+ ...
97
+
98
+ def boundaries_for_system(self, *, system_id: str) -> Sequence[ObservabilityBoundary]: ...
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: adopt-coverage
3
+ Version: 0.3.0
4
+ Summary: recompute_coverage and the cache-disagreement alarm. Implemented in S4.
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
14
+ Requires-Dist: adopt-model
15
+ Requires-Dist: adopt-obs
@@ -0,0 +1,10 @@
1
+ adopt_coverage/__init__.py,sha256=B-m3s7xGuEa5ddl1cTD4x4-fbIEC5t4jfKXJxb0YZ-k,1723
2
+ adopt_coverage/cache.py,sha256=S_CV5gZUBuusO666FvP7W0LuyJf6Qxvm9Z8g2c5t6k8,3872
3
+ adopt_coverage/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ adopt_coverage/recompute.py,sha256=kYPMoP1D3unB3xhjWtNRJQQFZsoCLhjiErf9nd8SBSk,15036
5
+ adopt_coverage/records.py,sha256=Eq3yfzSj4oeyy1BA0mCOVrOlGTN1WK5G32gK3fFDpvk,4198
6
+ adopt_coverage-0.3.0.dist-info/METADATA,sha256=LgBJTD_3s3I3DocWFbfynGYjqhYr9pFC9ZhFOatglek,540
7
+ adopt_coverage-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ adopt_coverage-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
9
+ adopt_coverage-0.3.0.dist-info/licenses/NOTICE,sha256=2_mgo6v6IM9fAn52L5-wXFpISnC6PVU_geTutoRhbWk,1897
10
+ adopt_coverage-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.