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.
- adopt_scope/__init__.py +47 -0
- adopt_scope/hierarchy.py +257 -0
- adopt_scope/lifecycle.py +157 -0
- adopt_scope/py.typed +0 -0
- adopt_scope/records.py +58 -0
- adopt_scope/resolve.py +112 -0
- adopt_scope/slug.py +114 -0
- adopt_scope-0.3.0.dist-info/METADATA +15 -0
- adopt_scope-0.3.0.dist-info/RECORD +12 -0
- adopt_scope-0.3.0.dist-info/WHEEL +4 -0
- adopt_scope-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_scope-0.3.0.dist-info/licenses/NOTICE +39 -0
adopt_scope/__init__.py
ADDED
|
@@ -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
|
+
]
|
adopt_scope/hierarchy.py
ADDED
|
@@ -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
|
+
)
|
adopt_scope/lifecycle.py
ADDED
|
@@ -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,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.
|