adopt-scope 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,47 @@
1
+ """The firm -> engagement -> system -> environment hierarchy.
2
+
3
+ Implementation spec §4.5. Three invariants hold across this package, and they are
4
+ why it is a package rather than a few helpers inside the store:
5
+
6
+ 1. **No state transition without an event.** `lifecycle.transition` is the only
7
+ caller of `ScopeRecords.set_system_state`, and it writes the
8
+ `system_lifecycle_event` in the same transaction.
9
+ 2. **No slug mutation, ever** -- and no reissue after `ARCHIVED` or
10
+ `DISCONNECTED`, because reuse would silently re-point every historical URI.
11
+ 3. **`is_billable` and `data_residency_region` are recorded and never
12
+ interpreted** in Build 0 (owner decisions 14 and 17).
13
+
14
+ `resolve()` returns ids **and** slugs: the URI builder needs slugs, the store
15
+ needs ids, and returning only one pushes a lookup into whatever loop the caller
16
+ happens to have written.
17
+ """
18
+
19
+ from adopt_scope.hierarchy import ID_PREFIXES, INITIAL_LIFECYCLE_STATE, ScopeFacade
20
+ from adopt_scope.lifecycle import LIFECYCLE_EVENT_PREFIX, RETIRED_STATES, transition
21
+ from adopt_scope.records import ScopeRecords
22
+ from adopt_scope.resolve import SCOPE_LEVELS, Scope, ScopeLevel, ScopeNode, ScopePath
23
+ from adopt_scope.slug import (
24
+ ensure_slug_available,
25
+ ensure_slug_unchanged,
26
+ is_valid_slug,
27
+ validate_slug,
28
+ )
29
+
30
+ __all__ = [
31
+ "ID_PREFIXES",
32
+ "INITIAL_LIFECYCLE_STATE",
33
+ "LIFECYCLE_EVENT_PREFIX",
34
+ "RETIRED_STATES",
35
+ "SCOPE_LEVELS",
36
+ "Scope",
37
+ "ScopeFacade",
38
+ "ScopeLevel",
39
+ "ScopeNode",
40
+ "ScopePath",
41
+ "ScopeRecords",
42
+ "ensure_slug_available",
43
+ "ensure_slug_unchanged",
44
+ "is_valid_slug",
45
+ "transition",
46
+ "validate_slug",
47
+ ]
@@ -0,0 +1,257 @@
1
+ """The four-level hierarchy: creation, resolution, and the storage port.
2
+
3
+ `ScopeFacade` is both the `adopt-scope` public API (implementation spec §4.5)
4
+ and the `Store.scope()` facade (contracts §10.3). It is one class rather than
5
+ two because a second implementation of the same rules is a second place for them
6
+ to drift — and the rules here are the ones tenant isolation is expressed from.
7
+
8
+ **Storage is a port, not a dependency.** `ScopeRecords` is implemented once over
9
+ SQLite in `adopt_store` and once over Postgres in `plane_store`, so the escape
10
+ suite drives the *same* facade the local CLI drives. If the facade were written
11
+ against SQLite directly, the plane would need its own copy and the escape suite
12
+ would be testing code the CLI never runs.
13
+
14
+ **Caller-supplied ids and scope are rejected by being unrepresentable.**
15
+ Contracts §10.3 requires that ids are generated inside the facade and scope is
16
+ injected by it. No method below accepts an `id`, and each accepts only the
17
+ parent it hangs from — so there is no argument to reject at runtime, and no
18
+ future caller can find a way to pass one. That is a stronger guarantee than a
19
+ validation branch, which only rejects what someone remembered to check.
20
+ """
21
+
22
+ import datetime as _dt
23
+ from typing import Final
24
+
25
+ from adopt_model import Engagement, Environment, Firm, System, SystemLifecycleEvent
26
+ from adopt_model._enums import Archetype, DeploymentMode, LifecycleState
27
+ from adopt_obs import AdoptError, Clock, ErrorCode, SystemClock, new_id, truncate_to_millisecond
28
+ from adopt_scope.lifecycle import transition as _transition
29
+ from adopt_scope.records import ScopeRecords
30
+ from adopt_scope.resolve import Scope, ScopeNode, ScopePath
31
+ from adopt_scope.slug import ensure_slug_available, validate_slug
32
+
33
+ __all__ = ["ID_PREFIXES", "INITIAL_LIFECYCLE_STATE", "ScopeFacade"]
34
+
35
+ #: Contracts §1.1. Minted only through `adopt_obs.new_id`, which rejects an
36
+ #: unregistered prefix, so a typo here fails at the first call rather than
37
+ #: producing ids that look valid and join to nothing.
38
+ ID_PREFIXES: Final[dict[str, str]] = {
39
+ "firm": "firm",
40
+ "engagement": "eng",
41
+ "system": "sys",
42
+ "environment": "env",
43
+ }
44
+
45
+ #: A system enters the hierarchy recorded but not yet worked. PRD F3.5 keeps the
46
+ #: eight states distinct; nothing here collapses them.
47
+ INITIAL_LIFECYCLE_STATE: Final[LifecycleState] = "DISCOVERED"
48
+
49
+
50
+ def _missing(level: str, slug: str, parent: str | None) -> AdoptError:
51
+ where = f" under {parent!r}" if parent else ""
52
+ return AdoptError(
53
+ ErrorCode.SCOPE_SLUG_INVALID,
54
+ message=f"no {level} with slug {slug!r} exists{where}",
55
+ hint=f"Create the {level} first, or check the scope path for a typo.",
56
+ )
57
+
58
+
59
+ class ScopeFacade:
60
+ """Create and resolve the `firm → engagement → system → environment` chain."""
61
+
62
+ def __init__(self, records: ScopeRecords, *, clock: Clock | None = None) -> None:
63
+ self._records = records
64
+ self._clock: Clock = clock if clock is not None else SystemClock()
65
+
66
+ def _now(self) -> _dt.datetime:
67
+ """The clock reading at the precision the store keeps.
68
+
69
+ Truncated here rather than on the way to SQL, so the row this facade
70
+ *returns* is the row a later read produces. An untruncated timestamp on
71
+ the returned model is a value that exists only in memory.
72
+ """
73
+ return truncate_to_millisecond(self._clock.now())
74
+
75
+ # -- creation ---------------------------------------------------------
76
+
77
+ def create_firm(self, *, slug: str, name: str) -> Firm:
78
+ """Create the root of a scope chain.
79
+
80
+ Raises:
81
+ AdoptError: ``SCOPE_SLUG_INVALID`` or ``SCOPE_SLUG_REUSED``.
82
+ """
83
+ validate_slug(slug, level="firm")
84
+ with self._records.transaction():
85
+ existing = self._records.find_firm(slug)
86
+ ensure_slug_available(slug, [existing.slug] if existing else [], level="firm")
87
+ row = Firm(
88
+ id=new_id(ID_PREFIXES["firm"]),
89
+ slug=slug,
90
+ name=name,
91
+ created_at=self._now(),
92
+ )
93
+ self._records.insert_firm(row)
94
+ return row
95
+
96
+ def create_engagement(
97
+ self, *, firm_id: str, slug: str, name: str, client_label: str | None = None
98
+ ) -> Engagement:
99
+ """Create an engagement under a firm.
100
+
101
+ `client_label` is free text: the client-account entity is deferred
102
+ (PRD F3 non-goals), and inventing one here would be a schema change.
103
+ """
104
+ validate_slug(slug, level="engagement")
105
+ with self._records.transaction():
106
+ existing = self._records.find_engagement(firm_id, slug)
107
+ ensure_slug_available(slug, [existing.slug] if existing else [], level="engagement")
108
+ row = Engagement(
109
+ id=new_id(ID_PREFIXES["engagement"]),
110
+ firm_id=firm_id,
111
+ slug=slug,
112
+ name=name,
113
+ client_label=client_label,
114
+ created_at=self._now(),
115
+ )
116
+ self._records.insert_engagement(row)
117
+ return row
118
+
119
+ def create_system(
120
+ self,
121
+ *,
122
+ engagement_id: str,
123
+ slug: str,
124
+ name: str,
125
+ archetype: Archetype | None = None,
126
+ deployment_mode: DeploymentMode | None = None,
127
+ ) -> System:
128
+ """Create a system under an engagement, in `DISCOVERED`.
129
+
130
+ The initial state is written directly rather than transitioned into:
131
+ there is no prior state for a `system_lifecycle_event` to record a move
132
+ from, and inventing one would make the event log claim a transition that
133
+ never happened. Every state change *after* creation writes an event.
134
+ """
135
+ validate_slug(slug, level="system")
136
+ with self._records.transaction():
137
+ existing = self._records.find_system(engagement_id, slug)
138
+ ensure_slug_available(slug, [existing.slug] if existing else [], level="system")
139
+ created = self._now()
140
+ row = System(
141
+ id=new_id(ID_PREFIXES["system"]),
142
+ engagement_id=engagement_id,
143
+ slug=slug,
144
+ name=name,
145
+ archetype=archetype,
146
+ lifecycle_state=INITIAL_LIFECYCLE_STATE,
147
+ deployment_mode=deployment_mode,
148
+ created_at=created,
149
+ updated_at=created,
150
+ )
151
+ self._records.insert_system(row)
152
+ return row
153
+
154
+ def create_environment(
155
+ self,
156
+ *,
157
+ system_id: str,
158
+ slug: str,
159
+ name: str,
160
+ is_billable: bool = False,
161
+ data_residency_region: str | None = None,
162
+ ) -> Environment:
163
+ """Create an environment under a system.
164
+
165
+ `is_billable` and `data_residency_region` are **recorded and never
166
+ interpreted** in Build 0 (PRD F3.6, owner decisions 14 and 17). Nothing
167
+ in this repository reads either column; a grep for a reader is the test.
168
+ """
169
+ validate_slug(slug, level="environment")
170
+ with self._records.transaction():
171
+ existing = self._records.find_environment(system_id, slug)
172
+ ensure_slug_available(slug, [existing.slug] if existing else [], level="environment")
173
+ row = Environment(
174
+ id=new_id(ID_PREFIXES["environment"]),
175
+ system_id=system_id,
176
+ slug=slug,
177
+ name=name,
178
+ is_billable=is_billable,
179
+ data_residency_region=data_residency_region,
180
+ created_at=self._now(),
181
+ )
182
+ self._records.insert_environment(row)
183
+ return row
184
+
185
+ # -- resolution -------------------------------------------------------
186
+
187
+ def resolve(self, path: str | ScopePath) -> Scope:
188
+ """Resolve a scope path to ids **and** slugs at every requested level.
189
+
190
+ Raises:
191
+ AdoptError: ``SCOPE_SLUG_INVALID`` when a path segment is malformed
192
+ or names a scope that does not exist.
193
+ """
194
+ parsed = ScopePath.parse(path) if isinstance(path, str) else path
195
+
196
+ firm = self._records.find_firm(parsed.firm)
197
+ if firm is None:
198
+ raise _missing("firm", parsed.firm, None)
199
+ scope = Scope(firm=ScopeNode(id=firm.id, slug=firm.slug))
200
+ if parsed.engagement is None:
201
+ return scope
202
+
203
+ engagement = self._records.find_engagement(firm.id, parsed.engagement)
204
+ if engagement is None:
205
+ raise _missing("engagement", parsed.engagement, firm.slug)
206
+ scope = Scope(
207
+ firm=scope.firm,
208
+ engagement=ScopeNode(id=engagement.id, slug=engagement.slug),
209
+ )
210
+ if parsed.system is None:
211
+ return scope
212
+
213
+ system = self._records.find_system(engagement.id, parsed.system)
214
+ if system is None:
215
+ raise _missing("system", parsed.system, engagement.slug)
216
+ scope = Scope(
217
+ firm=scope.firm,
218
+ engagement=scope.engagement,
219
+ system=ScopeNode(id=system.id, slug=system.slug),
220
+ )
221
+ if parsed.environment is None:
222
+ return scope
223
+
224
+ environment = self._records.find_environment(system.id, parsed.environment)
225
+ if environment is None:
226
+ raise _missing("environment", parsed.environment, system.slug)
227
+ return Scope(
228
+ firm=scope.firm,
229
+ engagement=scope.engagement,
230
+ system=scope.system,
231
+ environment=ScopeNode(id=environment.id, slug=environment.slug),
232
+ )
233
+
234
+ # -- lifecycle --------------------------------------------------------
235
+
236
+ def transition(
237
+ self,
238
+ system_id: str,
239
+ to_state: LifecycleState,
240
+ reason: str,
241
+ actor_id: str | None = None,
242
+ related_system_id: str | None = None,
243
+ ) -> tuple[SystemLifecycleEvent, ...]:
244
+ """Move a system's lifecycle state, writing its event in the same transaction.
245
+
246
+ Delegates to `adopt_scope.lifecycle.transition`, which is where the
247
+ no-silent-transition guarantee is implemented and property-tested.
248
+ """
249
+ return _transition(
250
+ self._records,
251
+ system_id=system_id,
252
+ to_state=to_state,
253
+ reason=reason,
254
+ actor_id=actor_id,
255
+ related_system_id=related_system_id,
256
+ clock=self._clock,
257
+ )
@@ -0,0 +1,157 @@
1
+ """Lifecycle transitions — and the guarantee that none of them is silent.
2
+
3
+ PRD F3.1: *a lifecycle transition is never silent.* Implementation spec §4.5
4
+ behaviour 3 states the mechanism: every `lifecycle_state` change writes a
5
+ `system_lifecycle_event` **in the same transaction**, and there is no code path
6
+ that changes state without one.
7
+
8
+ That guarantee is implemented by making this module the only caller of
9
+ `ScopeRecords.set_system_state`. The state write and the event write are adjacent
10
+ statements inside one transaction, so there is no ordering in which one lands
11
+ without the other — not "the caller should remember to log it", which is the
12
+ version of this rule that silently stops being true.
13
+
14
+ **Merges and splits write paired events** (F3.5, §4.5 behaviour 4). Both systems
15
+ receive an event and each names the other in `related_system_id`, so the history
16
+ reads correctly from either side. A one-sided merge is the failure this pairing
17
+ exists to prevent: the surviving system's log would show an absorption and the
18
+ absorbed system's log would show nothing at all.
19
+ """
20
+
21
+ import datetime as _dt
22
+
23
+ from adopt_model import SystemLifecycleEvent
24
+ from adopt_model._enums import LifecycleState
25
+ from adopt_obs import AdoptError, Clock, ErrorCode, SystemClock, new_id, truncate_to_millisecond
26
+ from adopt_scope.records import ScopeRecords
27
+
28
+ __all__ = ["LIFECYCLE_EVENT_PREFIX", "RETIRED_STATES", "transition"]
29
+
30
+ #: Contracts §1.1 prefix for `system_lifecycle_event`.
31
+ LIFECYCLE_EVENT_PREFIX: str = "sle"
32
+
33
+ #: The two states after which a slug is never reissued (PRD F3.4). Named here
34
+ #: rather than inline so the slug rule and the lifecycle rule cannot disagree
35
+ #: about which states are terminal.
36
+ RETIRED_STATES: frozenset[LifecycleState] = frozenset({"ARCHIVED", "DISCONNECTED"})
37
+
38
+
39
+ def _event(
40
+ *,
41
+ system_id: str,
42
+ from_state: LifecycleState | None,
43
+ to_state: LifecycleState,
44
+ reason: str,
45
+ related_system_id: str | None,
46
+ actor_id: str | None,
47
+ occurred_at: _dt.datetime,
48
+ ) -> SystemLifecycleEvent:
49
+ return SystemLifecycleEvent(
50
+ id=new_id(LIFECYCLE_EVENT_PREFIX),
51
+ system_id=system_id,
52
+ from_state=from_state,
53
+ to_state=to_state,
54
+ reason=reason,
55
+ related_system_id=related_system_id,
56
+ occurred_at=occurred_at,
57
+ actor_id=actor_id,
58
+ )
59
+
60
+
61
+ def transition(
62
+ records: ScopeRecords,
63
+ *,
64
+ system_id: str,
65
+ to_state: LifecycleState,
66
+ reason: str,
67
+ actor_id: str | None = None,
68
+ related_system_id: str | None = None,
69
+ clock: Clock | None = None,
70
+ ) -> tuple[SystemLifecycleEvent, ...]:
71
+ """Move a system to ``to_state``, writing its event in the same transaction.
72
+
73
+ When ``related_system_id`` is given the transition is a merge or a split, and
74
+ a **paired** event is written on the counterpart system naming this one. The
75
+ counterpart's own state is not changed: what its state should become is a
76
+ policy question item 12 owns (PRD F3 non-goals), and deciding it here would
77
+ be inventing merge semantics this build is explicitly not specifying.
78
+
79
+ Args:
80
+ records: The storage port.
81
+ system_id: The system whose state changes.
82
+ to_state: The state to move to.
83
+ reason: Why. Required — an unexplained transition is a gap in the audit
84
+ trail that nobody can reconstruct later.
85
+ actor_id: Who caused it, where a human or agent did.
86
+ related_system_id: The counterpart on a merge or split.
87
+ clock: Injected clock; tests pass `ManualClock`.
88
+
89
+ Returns:
90
+ The events written, this system's first.
91
+
92
+ Raises:
93
+ AdoptError: ``SCOPE_SLUG_INVALID`` when the system or its counterpart
94
+ does not exist. ``SCOPE_VIOLATION`` when a system is asked to
95
+ transition against itself.
96
+ """
97
+ active_clock = clock or SystemClock()
98
+
99
+ if related_system_id is not None and related_system_id == system_id:
100
+ raise AdoptError(
101
+ ErrorCode.SCOPE_VIOLATION,
102
+ message=f"system {system_id!r} cannot be its own merge or split counterpart",
103
+ hint="A paired event needs two distinct systems. Check the counterpart id.",
104
+ )
105
+
106
+ with records.transaction():
107
+ system = records.get_system(system_id)
108
+ if system is None:
109
+ raise AdoptError(
110
+ ErrorCode.SCOPE_SLUG_INVALID,
111
+ message=f"no system with id {system_id!r} exists",
112
+ hint="Resolve the scope path first; `transition` does not create systems.",
113
+ )
114
+
115
+ counterpart = None
116
+ if related_system_id is not None:
117
+ counterpart = records.get_system(related_system_id)
118
+ if counterpart is None:
119
+ raise AdoptError(
120
+ ErrorCode.SCOPE_SLUG_INVALID,
121
+ message=f"no counterpart system with id {related_system_id!r} exists",
122
+ hint="A merge or split names an existing system on both sides.",
123
+ )
124
+
125
+ occurred_at = truncate_to_millisecond(active_clock.now())
126
+ from_state = system.lifecycle_state
127
+
128
+ # The state write and its event are adjacent inside one transaction.
129
+ # This adjacency *is* the no-silent-transition guarantee.
130
+ records.set_system_state(system_id, to_state, occurred_at)
131
+ events = [
132
+ _event(
133
+ system_id=system_id,
134
+ from_state=from_state,
135
+ to_state=to_state,
136
+ reason=reason,
137
+ related_system_id=related_system_id,
138
+ actor_id=actor_id,
139
+ occurred_at=occurred_at,
140
+ )
141
+ ]
142
+ if counterpart is not None:
143
+ events.append(
144
+ _event(
145
+ system_id=counterpart.id,
146
+ from_state=counterpart.lifecycle_state,
147
+ to_state=counterpart.lifecycle_state,
148
+ reason=reason,
149
+ related_system_id=system_id,
150
+ actor_id=actor_id,
151
+ occurred_at=occurred_at,
152
+ )
153
+ )
154
+ for event in events:
155
+ records.insert_lifecycle_event(event)
156
+
157
+ return tuple(events)
adopt_scope/py.typed ADDED
File without changes
adopt_scope/records.py ADDED
@@ -0,0 +1,58 @@
1
+ """The storage port the scope facade is written against.
2
+
3
+ Its own module so that `hierarchy` and `lifecycle` can both depend on it without
4
+ depending on each other. The alternative — a function-local import to break the
5
+ cycle — hides a real dependency from every tool that reads imports, including the
6
+ twelve contracts in `importlinter.ini`.
7
+
8
+ Implemented twice and only twice: over SQLite in `adopt_store.facades.scope`, and
9
+ over Postgres in `plane_store.facades.scope`. That is what lets the tenant-escape
10
+ suite drive the *same* facade the local CLI drives, rather than a parallel copy
11
+ whose behaviour is asserted nowhere.
12
+ """
13
+
14
+ import datetime as _dt
15
+ from contextlib import AbstractContextManager
16
+ from typing import Protocol
17
+
18
+ from adopt_model import Engagement, Environment, Firm, System, SystemLifecycleEvent
19
+ from adopt_model._enums import LifecycleState
20
+
21
+ __all__ = ["ScopeRecords"]
22
+
23
+
24
+ class ScopeRecords(Protocol):
25
+ """Row in, row out. No SQL, connection or cursor crosses this boundary.
26
+
27
+ Every `find_*` deliberately ignores `lifecycle_state`: a slug belonging to an
28
+ `ARCHIVED` or `DISCONNECTED` scope must still be found, or it would be
29
+ reissued and every URI ever emitted for the earlier scope would silently
30
+ re-point (implementation spec §4.5 behaviour 2).
31
+ """
32
+
33
+ def transaction(self) -> AbstractContextManager[None]:
34
+ """A unit of work. Nested use joins the outermost transaction."""
35
+ ...
36
+
37
+ def insert_firm(self, row: Firm) -> None: ...
38
+ def insert_engagement(self, row: Engagement) -> None: ...
39
+ def insert_system(self, row: System) -> None: ...
40
+ def insert_environment(self, row: Environment) -> None: ...
41
+ def insert_lifecycle_event(self, row: SystemLifecycleEvent) -> None: ...
42
+
43
+ def find_firm(self, slug: str) -> Firm | None: ...
44
+ def find_engagement(self, firm_id: str, slug: str) -> Engagement | None: ...
45
+ def find_system(self, engagement_id: str, slug: str) -> System | None: ...
46
+ def find_environment(self, system_id: str, slug: str) -> Environment | None: ...
47
+
48
+ def get_system(self, system_id: str) -> System | None: ...
49
+
50
+ def set_system_state(
51
+ self, system_id: str, to_state: LifecycleState, updated_at: _dt.datetime
52
+ ) -> None:
53
+ """Advance a system's stored state.
54
+
55
+ Called from `adopt_scope.lifecycle.transition` and nowhere else, because
56
+ that is the only place the paired `system_lifecycle_event` is written.
57
+ """
58
+ ...
adopt_scope/resolve.py ADDED
@@ -0,0 +1,112 @@
1
+ """The resolved scope chain: ids **and** slugs, at every level.
2
+
3
+ `resolve()` returns both because the two consumers need different halves — the
4
+ identity URI builder needs slugs (CR-05: a URI that depends on an internal ULID
5
+ stops resolving the moment the store leaves our hands) and the store needs ids
6
+ (a ULID is the join key). Returning one and making the caller look up the other
7
+ is how a lookup ends up in a loop that runs per identity.
8
+
9
+ A `Scope` is resolved to whatever depth was asked for. `firm` is always present;
10
+ the three levels below it are `None` when the caller resolved above them. That
11
+ is deliberately not modelled as four separate classes: every consumer walks the
12
+ same chain, and a partial chain is a normal state rather than an error.
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Final, Literal
17
+
18
+ from adopt_obs import AdoptError, ErrorCode
19
+
20
+ __all__ = [
21
+ "SCOPE_LEVELS",
22
+ "Scope",
23
+ "ScopeLevel",
24
+ "ScopeNode",
25
+ "ScopePath",
26
+ ]
27
+
28
+ ScopeLevel = Literal["firm", "engagement", "system", "environment"]
29
+
30
+ #: Outermost first. The order the path grammar and every parent walk follow.
31
+ SCOPE_LEVELS: Final[tuple[ScopeLevel, ...]] = ("firm", "engagement", "system", "environment")
32
+
33
+ _PATH_SEPARATOR: Final[str] = "/"
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ScopeNode:
38
+ """One level of the chain, carrying both of its names."""
39
+
40
+ id: str
41
+ slug: str
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Scope:
46
+ """A resolved chain. Only `firm` is guaranteed present."""
47
+
48
+ firm: ScopeNode
49
+ engagement: ScopeNode | None = None
50
+ system: ScopeNode | None = None
51
+ environment: ScopeNode | None = None
52
+
53
+ @property
54
+ def depth(self) -> int:
55
+ """How many levels resolved, 1-4."""
56
+ levels = (self.firm, self.engagement, self.system, self.environment)
57
+ return sum(1 for level in levels if level is not None)
58
+
59
+ def slugs(self) -> tuple[str, ...]:
60
+ """The resolved slugs, outermost first — the URI builder's input."""
61
+ levels = (self.firm, self.engagement, self.system, self.environment)
62
+ return tuple(level.slug for level in levels if level is not None)
63
+
64
+ def path(self) -> str:
65
+ """The canonical `firm/engagement/system/environment` rendering."""
66
+ return _PATH_SEPARATOR.join(self.slugs())
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class ScopePath:
71
+ """A parsed scope path: between one and four slugs, outermost first.
72
+
73
+ Parsing is separated from resolution so that a malformed path fails before
74
+ any query runs, and so the same grammar serves the CLI and the facade.
75
+ """
76
+
77
+ firm: str
78
+ engagement: str | None = None
79
+ system: str | None = None
80
+ environment: str | None = None
81
+
82
+ @classmethod
83
+ def parse(cls, path: str) -> "ScopePath":
84
+ """Parse ``firm[/engagement[/system[/environment]]]``.
85
+
86
+ Raises:
87
+ AdoptError: ``SCOPE_SLUG_INVALID`` when the path is empty, has more
88
+ than four segments, or contains an empty segment.
89
+ """
90
+ segments = path.strip().split(_PATH_SEPARATOR) if path.strip() else []
91
+ if not segments or len(segments) > len(SCOPE_LEVELS) or any(not s for s in segments):
92
+ raise AdoptError(
93
+ ErrorCode.SCOPE_SLUG_INVALID,
94
+ message=f"scope path {path!r} is not one to four non-empty slugs",
95
+ hint=(
96
+ "A scope path is `firm`, `firm/engagement`, "
97
+ "`firm/engagement/system` or `firm/engagement/system/environment`."
98
+ ),
99
+ )
100
+ padded = (*segments, *(None,) * (len(SCOPE_LEVELS) - len(segments)))
101
+ # const-sync: ok -- positional indexes into the four scope levels, not SCHEMA_VERSION.
102
+ return cls(firm=segments[0], engagement=padded[1], system=padded[2], environment=padded[3])
103
+
104
+ def levels(self) -> tuple[tuple[ScopeLevel, str], ...]:
105
+ """The requested levels in order, stopping at the first absent one."""
106
+ values = (self.firm, self.engagement, self.system, self.environment)
107
+ resolved: list[tuple[ScopeLevel, str]] = []
108
+ for level, value in zip(SCOPE_LEVELS, values, strict=True):
109
+ if value is None:
110
+ break
111
+ resolved.append((level, value))
112
+ return tuple(resolved)
adopt_scope/slug.py ADDED
@@ -0,0 +1,114 @@
1
+ """Slug validation, uniqueness and immutability.
2
+
3
+ A slug is the only part of a scope row the identity URI is built from, which is
4
+ what makes an exported bundle resolvable after the store leaves our hands
5
+ (CR-05). Three rules follow from that and are enforced here rather than by
6
+ whoever writes the next facade:
7
+
8
+ 1. **A slug matches `SLUG_PATTERN` and its length bounds.** The pattern permits
9
+ a single character; `SLUG_MIN_CHARS` does not. Both are checked, because the
10
+ pattern is the character class and the bounds are the length policy, and a
11
+ value satisfying one but not the other is still not a slug.
12
+ 2. **A slug is set once.** `name` is free to change at any time; a slug is not,
13
+ because every historical URI already contains it.
14
+ 3. **A slug is never reissued -- and the availability check deliberately ignores
15
+ lifecycle state.** Implementation spec §4.5 behaviour 2 states the rule that
16
+ way round for a reason: consulting lifecycle state is what would let an
17
+ `ARCHIVED` system's slug be handed to a new system, silently re-pointing
18
+ every URI ever emitted for the old one. There is therefore one check and one
19
+ error code for both cases, and no branch on state exists to get wrong.
20
+
21
+ Every function here is pure. The caller supplies the sibling slugs already taken,
22
+ which keeps this module free of any storage dependency and lets the SQLite and
23
+ Postgres realizations share one rule set instead of two.
24
+ """
25
+
26
+ import re
27
+ from collections.abc import Iterable
28
+ from typing import Final
29
+
30
+ from adopt_const import SLUG_MAX_CHARS, SLUG_MIN_CHARS, SLUG_PATTERN
31
+ from adopt_obs import AdoptError, ErrorCode
32
+
33
+ __all__ = [
34
+ "ensure_slug_available",
35
+ "ensure_slug_unchanged",
36
+ "is_valid_slug",
37
+ "validate_slug",
38
+ ]
39
+
40
+ #: Compiled once. The pattern itself lives in `adopt_const` and is never
41
+ #: restated here -- a second copy of a format rule is a second thing to update.
42
+ _SLUG_RE: Final[re.Pattern[str]] = re.compile(SLUG_PATTERN)
43
+
44
+
45
+ def is_valid_slug(value: str) -> bool:
46
+ """Whether ``value`` satisfies both the character class and the length bounds."""
47
+ return bool(_SLUG_RE.match(value)) and SLUG_MIN_CHARS <= len(value) <= SLUG_MAX_CHARS
48
+
49
+
50
+ def validate_slug(value: str, *, level: str) -> None:
51
+ """Raise ``SCOPE_SLUG_INVALID`` unless ``value`` is a well-formed slug.
52
+
53
+ Args:
54
+ value: The proposed slug.
55
+ level: The scope level being named, quoted back in the message so the
56
+ failure says which of the four levels rejected the value.
57
+
58
+ Raises:
59
+ AdoptError: ``SCOPE_SLUG_INVALID``.
60
+ """
61
+ if is_valid_slug(value):
62
+ return
63
+ raise AdoptError(
64
+ ErrorCode.SCOPE_SLUG_INVALID,
65
+ message=f"{level} slug {value!r} is not a valid slug",
66
+ hint=(
67
+ f"A slug is {SLUG_MIN_CHARS}-{SLUG_MAX_CHARS} characters of lowercase "
68
+ f"letters, digits and hyphens, starting and ending with a letter or "
69
+ f"digit ({SLUG_PATTERN}). Slugs are lowercase because they appear in "
70
+ f"identity URIs, which compare byte-exact and never case-fold."
71
+ ),
72
+ )
73
+
74
+
75
+ def ensure_slug_available(value: str, taken: Iterable[str], *, level: str) -> None:
76
+ """Raise ``SCOPE_SLUG_REUSED`` when a sibling already holds ``value``.
77
+
78
+ ``taken`` must contain **every** sibling slug ever assigned under the parent,
79
+ including those belonging to `ARCHIVED` and `DISCONNECTED` scopes. Filtering
80
+ it by lifecycle state is the defect this function exists to make impossible.
81
+
82
+ Raises:
83
+ AdoptError: ``SCOPE_SLUG_REUSED``.
84
+ """
85
+ if value not in set(taken):
86
+ return
87
+ raise AdoptError(
88
+ ErrorCode.SCOPE_SLUG_REUSED,
89
+ message=f"{level} slug {value!r} is already assigned under this parent",
90
+ hint=(
91
+ "Slugs are never reissued, including after ARCHIVED or DISCONNECTED. "
92
+ "Reuse would silently re-point every identity URI ever emitted for the "
93
+ "earlier scope. Choose a different slug."
94
+ ),
95
+ )
96
+
97
+
98
+ def ensure_slug_unchanged(current: str, proposed: str, *, level: str) -> None:
99
+ """Raise ``SCOPE_SLUG_IMMUTABLE`` when a write would change an assigned slug.
100
+
101
+ Raises:
102
+ AdoptError: ``SCOPE_SLUG_IMMUTABLE``.
103
+ """
104
+ if current == proposed:
105
+ return
106
+ raise AdoptError(
107
+ ErrorCode.SCOPE_SLUG_IMMUTABLE,
108
+ message=f"{level} slug {current!r} cannot be renamed to {proposed!r}",
109
+ hint=(
110
+ "A slug is set once at creation. `name` is freely mutable and is what "
111
+ "should carry a change of wording; the slug is load-bearing in every "
112
+ "identity URI already emitted."
113
+ ),
114
+ )
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: adopt-scope
3
+ Version: 0.3.0
4
+ Summary: The firm-engagement-system-environment hierarchy, slugs, lifecycle events.
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,12 @@
1
+ adopt_scope/__init__.py,sha256=mF61QJMkdUB5kAZ4Ic9XmJC032b77ofFTpnrFpP46Bg,1655
2
+ adopt_scope/hierarchy.py,sha256=yleZRSKCmn0nPqIpFs1NYPPGLWObb0EEw1BTy06oaVo,10367
3
+ adopt_scope/lifecycle.py,sha256=8JZE_8lJBw4_uO_UkX0mVQirEUm65rqEdy78RluaPH0,6179
4
+ adopt_scope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ adopt_scope/records.py,sha256=H2fBK6p4ZnWvo_V90zQdoLJpfoedoR5P6nYcniK2nrk,2464
6
+ adopt_scope/resolve.py,sha256=yBTSOcPmER6487Ph8lXKWDd2qNghF7co9zui31YAg-Q,4284
7
+ adopt_scope/slug.py,sha256=9CFWFqHBZIU8Gn9Pswii2jQEOX4fWwg8ce62Q-Muh2o,4651
8
+ adopt_scope-0.3.0.dist-info/METADATA,sha256=NQ3rqja88zOewH0qE8fLGUX-fM-L_SKKmiae0dEnJi4,540
9
+ adopt_scope-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ adopt_scope-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ adopt_scope-0.3.0.dist-info/licenses/NOTICE,sha256=2_mgo6v6IM9fAn52L5-wXFpISnC6PVU_geTutoRhbWk,1897
12
+ adopt_scope-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.