adopt-coverage 0.4.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,51 @@
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
+ REASON_VERIFICATION_UNVERIFIED,
28
+ CoverageResult,
29
+ Disagreement,
30
+ IdentityCoverage,
31
+ recompute_coverage,
32
+ )
33
+ from adopt_coverage.records import CoverageRecords
34
+
35
+ __all__ = [
36
+ "COVERAGE_REASONS",
37
+ "REASON_AUDIENCE_OR_ENVIRONMENT",
38
+ "REASON_IDENTITY_NOT_ACTIVE",
39
+ "REASON_NO_ACTIVE_KNOWLEDGE_REVISION",
40
+ "REASON_NO_LIVE_BINDING",
41
+ "REASON_NO_OBSERVABILITY_BOUNDARY",
42
+ "REASON_VERIFICATION_CONFLICTED",
43
+ "REASON_VERIFICATION_UNVERIFIED",
44
+ "CacheWriter",
45
+ "CoverageRecords",
46
+ "CoverageResult",
47
+ "Disagreement",
48
+ "IdentityCoverage",
49
+ "rebuild_cache",
50
+ "recompute_coverage",
51
+ ]
@@ -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,408 @@
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
+ "REASON_VERIFICATION_UNVERIFIED",
40
+ "CoverageResult",
41
+ "Disagreement",
42
+ "IdentityCoverage",
43
+ "recompute_coverage",
44
+ ]
45
+
46
+ _LOGGER: Final = get_logger("adopt.coverage")
47
+
48
+ # --------------------------------------------------------------------------
49
+ # The six inputs of contracts §6, one reason each.
50
+ #
51
+ # A reason names why an identity is **not** covered. They are stable strings
52
+ # because they reach the CLI envelope and a `store doctor` finding, and an
53
+ # operator branching on "which of the six is missing" is the whole reason the
54
+ # result is not a bare boolean.
55
+ # --------------------------------------------------------------------------
56
+
57
+ #: Input 1 -- "an active `identity_revision`".
58
+ REASON_IDENTITY_NOT_ACTIVE: Final[str] = "identity_revision_not_active"
59
+
60
+ #: Input 2 -- "at least one non-retired `binding`".
61
+ REASON_NO_LIVE_BINDING: Final[str] = "no_live_binding"
62
+
63
+ #: Input 3 -- "an active `knowledge_revision` on the bound item".
64
+ REASON_NO_ACTIVE_KNOWLEDGE_REVISION: Final[str] = "no_active_knowledge_revision"
65
+
66
+ #: Input 4 -- "applicable audience and environment".
67
+ REASON_AUDIENCE_OR_ENVIRONMENT: Final[str] = "audience_or_environment_inapplicable"
68
+
69
+ #: Input 5 -- "the `observability_boundary` for the scope".
70
+ REASON_NO_OBSERVABILITY_BOUNDARY: Final[str] = "no_observability_boundary"
71
+
72
+ #: Input 6 -- "verification requirements". A `conflicted` verification is Bet 4
73
+ #: working as designed: intent and reality disagree, the disagreement is
74
+ #: representable, and the identity is **not** reported as covered while it
75
+ #: stands.
76
+ REASON_VERIFICATION_CONFLICTED: Final[str] = "verification_conflicted"
77
+
78
+ #: Input 6, second half -- **only `verified` knowledge counts** (v6.1 §6 Build 2,
79
+ #: F6; plan decision D5).
80
+ #:
81
+ #: This tightens what Build 0 shipped, and the reason the original rule was
82
+ #: written the other way is worth keeping: until Build 2 nothing could *make* an
83
+ #: item verified, so requiring it would have made coverage unreachable by
84
+ #: construction. Build 2 supplies both doors -- `adopt ingest` writes a
85
+ #: human-authored document as `verified`, and confirming in `adopt review`
86
+ #: promotes a mined candidate -- so the objection no longer holds, and the rule
87
+ #: v6.1 actually requires can be enforced.
88
+ #:
89
+ #: What it buys is the honesty invariant: an unverified harvest candidate bound
90
+ #: to an identity must not make `adopt gaps` stop asking for that identity's
91
+ #: knowledge. A machine's unreviewed guess is not coverage, and counting it as
92
+ #: coverage is how a gap report becomes a report about itself.
93
+ REASON_VERIFICATION_UNVERIFIED: Final[str] = "verification_unverified"
94
+
95
+ #: Every reason, in evaluation order. Exported so a caller can enumerate them
96
+ #: without re-deriving the list and getting one fewer.
97
+ COVERAGE_REASONS: Final[tuple[str, ...]] = (
98
+ REASON_IDENTITY_NOT_ACTIVE,
99
+ REASON_NO_LIVE_BINDING,
100
+ REASON_NO_ACTIVE_KNOWLEDGE_REVISION,
101
+ REASON_AUDIENCE_OR_ENVIRONMENT,
102
+ REASON_NO_OBSERVABILITY_BOUNDARY,
103
+ REASON_VERIFICATION_CONFLICTED,
104
+ REASON_VERIFICATION_UNVERIFIED,
105
+ )
106
+
107
+ #: The `identity_status` that counts as live. `moved` and `dead` do not: a moved
108
+ #: identity's coverage belongs to the identity it aliases, and a dead one covers
109
+ #: nothing.
110
+ _ACTIVE_IDENTITY_STATUS: Final[str] = "active"
111
+
112
+ #: The terminal `binding_status`. `active` and `moved` are both live -- a moved
113
+ #: binding still ties the item to the referent, which is what CUJ-2 turns on.
114
+ _RETIRED_BINDING_STATUS: Final[str] = "retired"
115
+
116
+ #: The terminal `freshness_state` on a knowledge item. Knowledge carries its
117
+ #: terminal state on the parent rather than on the revision (contracts §5
118
+ #: obligation 4), so this is where "the revision is not active" is read.
119
+ _RETIRED_ITEM_FRESHNESS: Final[str] = "retired"
120
+
121
+ #: The `verification` that blocks coverage as a contradiction.
122
+ _CONFLICTED_VERIFICATION: Final[str] = "conflicted"
123
+
124
+ #: The only `verification` that carries coverage. A `NULL` verification blocks
125
+ #: exactly as `unverified` does: a revision that never stated its verification
126
+ #: has not been verified, and treating the absence as permission would let any
127
+ #: writer that omitted the field manufacture coverage.
128
+ _VERIFIED_VERIFICATION: Final[str] = "verified"
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class IdentityCoverage:
133
+ """One identity's verdict, and why."""
134
+
135
+ identity_id: str
136
+ uri: str
137
+ covered: bool
138
+ #: Empty when covered. Sorted and deduplicated, so two runs over one store
139
+ #: produce one answer.
140
+ reasons: tuple[str, ...]
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class Disagreement:
145
+ """The cache said one thing and the recompute says another.
146
+
147
+ Alarm-grade on its own. Carries both values because "the cache is wrong" is
148
+ not actionable and "the cache says covered, the recompute says not" is.
149
+ """
150
+
151
+ identity_id: str
152
+ uri: str
153
+ cached: bool
154
+ recomputed: bool
155
+
156
+
157
+ @dataclass(frozen=True, slots=True)
158
+ class CoverageResult:
159
+ """What `recompute_coverage` returns.
160
+
161
+ Nothing here is a cache and nothing here has been written anywhere. The
162
+ caller decides whether to rebuild the cache from it (`adopt_coverage.cache`)
163
+ or merely to look (`store doctor`).
164
+ """
165
+
166
+ system_id: str
167
+ environment_id: str | None
168
+ identities: tuple[IdentityCoverage, ...]
169
+ disagreements: tuple[Disagreement, ...]
170
+ computed_at: _dt.datetime
171
+
172
+ @property
173
+ def covered(self) -> int:
174
+ return sum(1 for entry in self.identities if entry.covered)
175
+
176
+ @property
177
+ def uncovered(self) -> int:
178
+ return sum(1 for entry in self.identities if not entry.covered)
179
+
180
+ def verdict(self, identity_id: str) -> bool | None:
181
+ """The verdict for one identity, or `None` when it is out of scope."""
182
+ for entry in self.identities:
183
+ if entry.identity_id == identity_id:
184
+ return entry.covered
185
+ return None
186
+
187
+
188
+ def _boundary_applies(boundary: ObservabilityBoundary, environment_id: str) -> bool:
189
+ """Whether a boundary row governs an identity in `environment_id`.
190
+
191
+ A boundary with no environment is the system-wide declaration and governs
192
+ every environment; one naming an environment governs only that one.
193
+ """
194
+ return boundary.environment_id is None or boundary.environment_id == environment_id
195
+
196
+
197
+ def _environment_applies(item: KnowledgeItem, environment_id: str) -> bool:
198
+ """Whether an item's environment is applicable to an identity's.
199
+
200
+ `knowledge_item.environment_id` is nullable *because an item may span
201
+ environments* -- so null is "applies everywhere", not "applies nowhere".
202
+ Reading it the other way would make every cross-environment item silently
203
+ stop covering anything.
204
+ """
205
+ return item.environment_id is None or item.environment_id == environment_id
206
+
207
+
208
+ def _binding_blockers(
209
+ binding: Binding,
210
+ *,
211
+ binding_status: str | None,
212
+ item: KnowledgeItem | None,
213
+ verification: str | None,
214
+ has_verification_row: bool,
215
+ audience_count: int,
216
+ environment_id: str,
217
+ ) -> frozenset[str]:
218
+ """Inputs 2, 3, 4 and 6, for one candidate binding.
219
+
220
+ Returns the reasons this binding fails to carry coverage. Empty means it
221
+ carries it, and one such binding is enough -- contracts §6 asks for "at least
222
+ one non-retired binding", not for all of them.
223
+ """
224
+ blockers: set[str] = set()
225
+
226
+ # Input 2 -- a binding whose head revision is retired is not live. A binding
227
+ # with no head revision at all is also not live: nothing has ever asserted
228
+ # the relationship.
229
+ if binding_status is None or binding_status == _RETIRED_BINDING_STATUS:
230
+ blockers.add(REASON_NO_LIVE_BINDING)
231
+
232
+ # Input 3 -- an active knowledge revision on the bound item.
233
+ if (
234
+ item is None
235
+ or item.current_revision_id is None
236
+ or item.freshness_state == _RETIRED_ITEM_FRESHNESS
237
+ ):
238
+ blockers.add(REASON_NO_ACTIVE_KNOWLEDGE_REVISION)
239
+ # Inputs 4 and 6 are statements about that item. With no item there is
240
+ # nothing to say about them, and inventing a second reason would report
241
+ # one defect as three.
242
+ return frozenset(blockers)
243
+
244
+ # Input 4 -- applicable audience and environment.
245
+ if audience_count == 0 or not _environment_applies(item, environment_id):
246
+ blockers.add(REASON_AUDIENCE_OR_ENVIRONMENT)
247
+
248
+ # Input 6 -- verification requirements. Two distinct failures, reported
249
+ # separately because they send an operator to different places: a conflict
250
+ # needs adjudicating, an unverified item needs reviewing.
251
+ if has_verification_row and verification == _CONFLICTED_VERIFICATION:
252
+ blockers.add(REASON_VERIFICATION_CONFLICTED)
253
+ elif verification != _VERIFIED_VERIFICATION:
254
+ blockers.add(REASON_VERIFICATION_UNVERIFIED)
255
+
256
+ return frozenset(blockers)
257
+
258
+
259
+ def _evaluate(
260
+ identity: Identity,
261
+ *,
262
+ identity_status: str | None,
263
+ bindings: Sequence[Binding],
264
+ binding_statuses: Mapping[str, str],
265
+ items: Mapping[str, KnowledgeItem],
266
+ verifications: Mapping[str, str | None],
267
+ audience_counts: Mapping[str, int],
268
+ boundaries: Sequence[ObservabilityBoundary],
269
+ ) -> IdentityCoverage:
270
+ """All six inputs for one identity."""
271
+ blockers: set[str] = set()
272
+
273
+ # Input 1 -- an active identity revision. An identity with no revision has
274
+ # never been asserted to exist by anything.
275
+ if identity_status != _ACTIVE_IDENTITY_STATUS:
276
+ blockers.add(REASON_IDENTITY_NOT_ACTIVE)
277
+
278
+ # Input 5 -- the observability boundary for the scope. Without one, nothing
279
+ # has declared what may be observed here, and coverage would be a claim
280
+ # about a system nobody agreed to look at.
281
+ if not any(_boundary_applies(row, identity.environment_id) for row in boundaries):
282
+ blockers.add(REASON_NO_OBSERVABILITY_BOUNDARY)
283
+
284
+ # Inputs 2, 3, 4 and 6, per candidate binding.
285
+ if not bindings:
286
+ blockers.add(REASON_NO_LIVE_BINDING)
287
+ else:
288
+ per_binding = [
289
+ _binding_blockers(
290
+ binding,
291
+ binding_status=binding_statuses.get(binding.id),
292
+ item=items.get(binding.item_id),
293
+ verification=verifications.get(binding.item_id),
294
+ has_verification_row=binding.item_id in verifications,
295
+ audience_count=audience_counts.get(binding.item_id, 0),
296
+ environment_id=identity.environment_id,
297
+ )
298
+ for binding in bindings
299
+ ]
300
+ if all(reasons for reasons in per_binding):
301
+ # Every candidate failed. Report every distinct reason rather than
302
+ # the first: an operator fixing one binding's audience should not
303
+ # then discover the next binding was retired all along.
304
+ blockers.update(*per_binding)
305
+
306
+ return IdentityCoverage(
307
+ identity_id=identity.id,
308
+ uri=identity.uri,
309
+ covered=not blockers,
310
+ reasons=tuple(sorted(blockers)),
311
+ )
312
+
313
+
314
+ def recompute_coverage(
315
+ records: CoverageRecords,
316
+ system_id: str,
317
+ environment_id: str | None = None,
318
+ *,
319
+ clock: Clock | None = None,
320
+ ) -> CoverageResult:
321
+ """Evaluate coverage for every identity in scope. **The authority.**
322
+
323
+ Args:
324
+ records: The read port. Supplied rather than reached for, because a
325
+ module-level store would make this function untestable against the
326
+ random graphs its correctness property needs.
327
+ system_id: The system whose identities are evaluated.
328
+ environment_id: One environment, or `None` for every environment of the
329
+ system.
330
+ clock: Injected clock; tests pass `ManualClock`.
331
+
332
+ Returns:
333
+ Per-identity coverage plus a `disagreements` list against
334
+ `covered_cache`. **Nothing is written.**
335
+
336
+ Emits:
337
+ `coverage_cache_disagreement` at `LogLevel.ALARM` when the disagreement
338
+ list is non-empty -- a defect signal that must page, not merely be
339
+ recorded (PRD F7.3). The **count is always complete**; the ids are a
340
+ sample bounded by `COVERAGE_ALARM_SAMPLE_MAX`, because a cold cache over
341
+ a 50k-identity store disagrees on every row and an uncapped field would
342
+ put a megabyte of ULIDs on one line. `store doctor` enumerates every
343
+ affected identity, so the alarm says *how bad* and the doctor says
344
+ *which*. Identity **ids** travel, never URIs: an id is minted by us and
345
+ carries no client-derived text.
346
+ """
347
+ now = truncate_to_millisecond((clock if clock is not None else SystemClock()).now())
348
+
349
+ identities = records.identities_in_scope(system_id=system_id, environment_id=environment_id)
350
+ identity_statuses = records.head_identity_statuses(
351
+ system_id=system_id, environment_id=environment_id
352
+ )
353
+ binding_statuses = records.head_binding_statuses(
354
+ system_id=system_id, environment_id=environment_id
355
+ )
356
+ items = {row.id: row for row in records.items_in_scope(system_id=system_id)}
357
+ verifications = records.head_item_verifications(system_id=system_id)
358
+ audience_counts = records.audience_counts(system_id=system_id)
359
+ boundaries = records.boundaries_for_system(system_id=system_id)
360
+
361
+ bindings_by_identity: dict[str, list[Binding]] = {}
362
+ for binding in records.bindings_in_scope(system_id=system_id, environment_id=environment_id):
363
+ bindings_by_identity.setdefault(binding.identity_id, []).append(binding)
364
+
365
+ verdicts = tuple(
366
+ _evaluate(
367
+ identity,
368
+ identity_status=identity_statuses.get(identity.id),
369
+ bindings=bindings_by_identity.get(identity.id, []),
370
+ binding_statuses=binding_statuses,
371
+ items=items,
372
+ verifications=verifications,
373
+ audience_counts=audience_counts,
374
+ boundaries=boundaries,
375
+ )
376
+ for identity in identities
377
+ )
378
+
379
+ cached = {row.id: row.covered_cache for row in identities}
380
+ disagreements = tuple(
381
+ Disagreement(
382
+ identity_id=verdict.identity_id,
383
+ uri=verdict.uri,
384
+ cached=cached[verdict.identity_id],
385
+ recomputed=verdict.covered,
386
+ )
387
+ for verdict in verdicts
388
+ if cached[verdict.identity_id] != verdict.covered
389
+ )
390
+
391
+ if disagreements:
392
+ _LOGGER.alarm(
393
+ "coverage_cache_disagreement",
394
+ code=str(ErrorCode.COVERAGE_CACHE_DISAGREEMENT),
395
+ system_id=system_id,
396
+ environment_id=environment_id,
397
+ disagreement_count=len(disagreements),
398
+ identity_ids=[entry.identity_id for entry in disagreements[:COVERAGE_ALARM_SAMPLE_MAX]],
399
+ identity_ids_truncated=len(disagreements) > COVERAGE_ALARM_SAMPLE_MAX,
400
+ )
401
+
402
+ return CoverageResult(
403
+ system_id=system_id,
404
+ environment_id=environment_id,
405
+ identities=verdicts,
406
+ disagreements=disagreements,
407
+ computed_at=now,
408
+ )
@@ -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.4.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=i8waVAe9McdGcNU1lG3zz-sUc_NYsOZVqyvSbk8oJPo,1797
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=LCi02yZAROIi9HazA7VgHtNHACBv4vPZll3KXhylZvo,16584
5
+ adopt_coverage/records.py,sha256=Eq3yfzSj4oeyy1BA0mCOVrOlGTN1WK5G32gK3fFDpvk,4198
6
+ adopt_coverage-0.4.0.dist-info/METADATA,sha256=eQXEgqSp3RlrvPwOsv95dw9w3S7s6uB0wkVlaAqDZAY,540
7
+ adopt_coverage-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ adopt_coverage-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
9
+ adopt_coverage-0.4.0.dist-info/licenses/NOTICE,sha256=2_mgo6v6IM9fAn52L5-wXFpISnC6PVU_geTutoRhbWk,1897
10
+ adopt_coverage-0.4.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.