lorebase-keeper 0.2.3__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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,222 @@
1
+ """Who may feed values of vault Attributes through bulk paths (design 13.5).
2
+
3
+ Commits of the origins of `vault.tombstone_origins` (imports, Source syncs, extraction) check
4
+ values of vault Attributes against the tombstones of erased values: an erased value is stored
5
+ erased or refused. Whoever can choose such a value can therefore learn whether it was erased.
6
+ Feeding values of vault Attributes through these paths needs `pii.import` on the whole Domain
7
+ (only `admin` holds it among the built-in roles), checked before anything is committed:
8
+
9
+ - the built-in imports (`import_records`, `import_batch`) when a row holds a value of a vault
10
+ Attribute (`import_rows_vault_attributes`);
11
+ - schema changes and merges that add a Source mapping columns to vault Attributes, change such
12
+ a Source, or make an Attribute a Source maps a vault Attribute (`fed_vault_sources`);
13
+ - triggering a synchronization of a Source that maps vault Attributes (`source_vault_targets`);
14
+ - reviewer edits of extraction items that set a value of a vault Attribute
15
+ (`item_edits_vault_attributes`). Scribe never accepts such items at once: a reviewer does.
16
+
17
+ Scheduled synchronizations run as configured: the Source was vetted when it was defined.
18
+ Sources defined before `pii.import` existed were not: `python -m lorebase_keeper sources vault`
19
+ lists the Sources that map vault Attributes (`installation_vault_sources`), for an
20
+ administrator to confirm or disable.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Mapping
26
+ from dataclasses import dataclass
27
+
28
+ from pydantic import JsonValue
29
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
30
+
31
+ from lorebase_core.model import Schema, Source
32
+ from lorebase_keeper.auth.deps import missing_permission
33
+ from lorebase_keeper.auth.permissions import Permission
34
+ from lorebase_keeper.auth.principals import Principal
35
+ from lorebase_keeper.auth.rbac import NO_CONTEXT, Authorizer
36
+ from lorebase_keeper.reading.sensitivity import require_lowering
37
+ from lorebase_keeper.services.operation_catalog import (
38
+ KIND,
39
+ Action,
40
+ BuiltinOperation,
41
+ )
42
+ from lorebase_store.commit.schema import load_compiled
43
+ from lorebase_store.extraction import ItemEditError, normalize_edits
44
+ from lorebase_store.repositories import DomainRepository, MaterializedRepository, scope_to_domain
45
+ from lorebase_store.repositories.proposal_items import LINK_ITEM, ItemRow
46
+ from lorebase_store.settings import WriteSettings
47
+
48
+
49
+ def source_vault_targets(schema: Schema, source: Source) -> frozenset[str]:
50
+ """Ids of the vault Attributes of the Source's target Kind that the Source maps."""
51
+ kind = schema.kinds.get(source.target_kind)
52
+ if kind is None:
53
+ return frozenset()
54
+ return frozenset(
55
+ attribute_id
56
+ for attribute_id in source.attribute_mapping
57
+ if (attribute := kind.attributes.get(attribute_id)) is not None and attribute.in_vault
58
+ )
59
+
60
+
61
+ def vault_sources(schema: Schema) -> list[tuple[Source, list[str]]]:
62
+ """The Sources of `schema` that map vault Attributes, with those Attributes' api names,
63
+ by Source api name."""
64
+ found: list[tuple[Source, list[str]]] = []
65
+ for source in sorted(schema.sources.values(), key=lambda s: s.api_name):
66
+ targets = source_vault_targets(schema, source)
67
+ if targets:
68
+ kind = schema.kinds[source.target_kind]
69
+ found.append((source, sorted(kind.attributes[a].api_name for a in targets)))
70
+ return found
71
+
72
+
73
+ def item_edits_vault_attributes(
74
+ schema: Schema, item: ItemRow, edits: Mapping[str, object]
75
+ ) -> list[str]:
76
+ """Api names of the vault Attributes whose value reviewer `edits` of an extraction item
77
+ set. Invalid edits are left to the review, which refuses them."""
78
+ if item.item_type == LINK_ITEM:
79
+ return [] # Link Attributes are never in the vault
80
+ kind = schema.kinds.get(str(item.subject.get("kind")))
81
+ if kind is None:
82
+ return []
83
+ try:
84
+ normalized = normalize_edits(item, edits, schema)
85
+ except ItemEditError:
86
+ return []
87
+ values = normalized.get("values") or {}
88
+ set_ids = {attribute_id for attribute_id, value in values.items() if value is not None}
89
+ if normalized.get("value") is not None:
90
+ set_ids.add(str(item.subject.get("attribute")))
91
+ return sorted(
92
+ attribute.api_name
93
+ for attribute_id in set_ids
94
+ if (attribute := kind.attributes.get(attribute_id)) is not None and attribute.in_vault
95
+ )
96
+
97
+
98
+ def fed_vault_sources(old: Schema, new: Schema) -> list[str]:
99
+ """Ids of the Sources of `new` that map vault Attributes and are new, changed, or map
100
+ vault Attributes `old` did not."""
101
+ fed: list[str] = []
102
+ for source_id, source in new.sources.items():
103
+ targets = source_vault_targets(new, source)
104
+ if not targets:
105
+ continue
106
+ before = old.sources.get(source_id)
107
+ if before is None or before != source or not targets <= source_vault_targets(old, before):
108
+ fed.append(source_id)
109
+ return sorted(fed)
110
+
111
+
112
+ def import_rows_vault_attributes(
113
+ schema: Schema, definition: BuiltinOperation, parameters: Mapping[str, JsonValue]
114
+ ) -> list[str]:
115
+ """Api names of the vault Attributes some row of a built-in import holds a value of.
116
+ Malformed rows are skipped: binding refuses them later."""
117
+ rows: list[tuple[object, object]] = []
118
+ if definition.action is Action.IMPORT_RECORDS:
119
+ found = parameters.get("rows")
120
+ if isinstance(found, list):
121
+ rows = [(parameters.get(KIND), row) for row in found]
122
+ elif definition.action is Action.IMPORT_BATCH:
123
+ found = parameters.get("records")
124
+ if isinstance(found, list):
125
+ rows = [(row.get(KIND) if isinstance(row, dict) else None, row) for row in found]
126
+ names: set[str] = set()
127
+ for kind_name, row in rows:
128
+ if not isinstance(row, dict) or not isinstance(kind_name, str):
129
+ continue
130
+ kind = schema.kind_by_api_name(kind_name)
131
+ if kind is None:
132
+ continue
133
+ names.update(
134
+ a.api_name
135
+ for a in kind.attributes.values()
136
+ if a.in_vault and row.get(a.api_name) is not None
137
+ )
138
+ return sorted(names)
139
+
140
+
141
+ async def require_pii_import(authorizer: Authorizer, principal: Principal, domain_id: str) -> None:
142
+ """403 naming `pii.import` unless the caller holds it through a Domain-wide grant."""
143
+ if not await authorizer.allows(principal, Permission.PII_IMPORT, domain_id, NO_CONTEXT):
144
+ raise missing_permission(Permission.PII_IMPORT, domain_id)
145
+
146
+
147
+ async def require_source_feeding(
148
+ authorizer: Authorizer, principal: Principal, domain_id: str, old: Schema, new: Schema
149
+ ) -> None:
150
+ """403 naming `pii.import` when `new` adds or changes a Source feeding vault Attributes
151
+ and the caller does not hold it Domain-wide."""
152
+ if fed_vault_sources(old, new):
153
+ await require_pii_import(authorizer, principal, domain_id)
154
+
155
+
156
+ async def require_schema_change(
157
+ authorizer: Authorizer, principal: Principal, domain_id: str, old: Schema, new: Schema
158
+ ) -> None:
159
+ """The checks of a caller's schema change or merge from `old` to `new`: removed
160
+ sensitivity tags need their clearance (`reading.sensitivity.require_lowering`), Sources
161
+ feeding vault Attributes need `pii.import`."""
162
+ await require_lowering(authorizer, principal, domain_id, old, new)
163
+ await require_source_feeding(authorizer, principal, domain_id, old, new)
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class VaultSource:
168
+ """A Source of a Domain's default branch that maps vault Attributes."""
169
+
170
+ domain: str
171
+ source: str
172
+ api_name: str
173
+ kind: str
174
+ attributes: tuple[str, ...]
175
+ schedule: str | None
176
+ status: str
177
+
178
+ def document(self) -> dict[str, object]:
179
+ return {
180
+ "domain": self.domain,
181
+ "source": self.source,
182
+ "api_name": self.api_name,
183
+ "kind": self.kind,
184
+ "attributes": list(self.attributes),
185
+ "schedule": self.schedule,
186
+ "status": self.status,
187
+ }
188
+
189
+
190
+ async def installation_vault_sources(
191
+ sessions: async_sessionmaker[AsyncSession], writes: WriteSettings, *, batch: int = 100
192
+ ) -> list[VaultSource]:
193
+ """The Sources mapping vault Attributes in the default branch of every Domain: Sources
194
+ defined before `pii.import` existed were not vetted by a holder of it."""
195
+ found: list[VaultSource] = []
196
+ after: str | None = None
197
+ while True:
198
+ async with sessions() as session, session.begin():
199
+ domains = await DomainRepository(session).list(limit=batch, after=after)
200
+ for domain in domains:
201
+ await scope_to_domain(session, domain.id)
202
+ compiled = await MaterializedRepository(
203
+ session, domain.id, domain.default_branch, writes=writes
204
+ ).schema()
205
+ if compiled is None:
206
+ continue
207
+ schema = load_compiled(compiled.compiled)
208
+ found.extend(
209
+ VaultSource(
210
+ domain=domain.id,
211
+ source=source.id,
212
+ api_name=source.api_name,
213
+ kind=schema.kinds[source.target_kind].api_name,
214
+ attributes=tuple(names),
215
+ schedule=source.schedule,
216
+ status=source.status.value,
217
+ )
218
+ for source, names in vault_sources(schema)
219
+ )
220
+ if len(domains) < batch:
221
+ return found
222
+ after = domains[-1].id
@@ -0,0 +1,158 @@
1
+ """Authorization of Proposal reads against both of their branches (design 10.2, 10.3).
2
+
3
+ A Proposal and its Conflicts carry content of its source and target branches (Conflicts hold
4
+ the base, ours and theirs values), so reading them follows the read rules of
5
+ `services.reads` on both refs:
6
+
7
+ - `data.read` on the source and on the target branch shows every data Conflict;
8
+ - otherwise grants narrowed to Kinds (which cover every branch and Revision) show the Conflicts
9
+ of those Kinds only: Records of those Kinds, and Links of Relations between two of them;
10
+ - schema Conflicts need `schema.read` on both branches, through grants not narrowed to a Kind;
11
+ - callers with neither get 403, also for unknown Proposals when their grants are narrowed.
12
+
13
+ Listings keep the Proposals the caller may read in this sense. Writes follow the same scope:
14
+ creating a Proposal needs read access to its target branch, and resolving a Conflict (or
15
+ receiving the attempt of a merge) is limited to the Conflicts the caller may read.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Sequence
21
+ from dataclasses import dataclass, replace
22
+
23
+ from lorebase_core.model import Schema
24
+ from lorebase_core.tree import PARTITION_SCHEMA, kind_partition, relation_partition
25
+ from lorebase_keeper.auth.permissions import Permission
26
+ from lorebase_keeper.errors import FORBIDDEN, NOT_FOUND, ProblemError
27
+ from lorebase_keeper.reading.visibility import projection
28
+ from lorebase_keeper.services.reads import ReadGuard
29
+ from lorebase_store.repositories import (
30
+ EVERY_PARTITION,
31
+ ConflictRow,
32
+ PartitionFilter,
33
+ ProposalRow,
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ConflictScope:
39
+ """The Conflicts of a Proposal a caller may read: those of every Kind (`kinds` is None)
40
+ or of the Kinds named (api names), and schema Conflicts when `schema` is true."""
41
+
42
+ kinds: frozenset[str] | None = None
43
+ schema: bool = True
44
+ invisible: bool = False
45
+ """The other Kinds are invisible to the caller (design 13.3): a Conflict outside the scope
46
+ answers like an unknown one."""
47
+
48
+ @property
49
+ def everything(self) -> bool:
50
+ return self.kinds is None and self.schema
51
+
52
+
53
+ FULL_SCOPE = ConflictScope()
54
+ NO_SCOPE = ConflictScope(kinds=frozenset(), schema=False)
55
+ """Shows no Conflict."""
56
+
57
+
58
+ async def _both(guard: ReadGuard, proposal: ProposalRow) -> bool:
59
+ return await guard.may(proposal.source_ref) and await guard.may(proposal.target_ref)
60
+
61
+
62
+ async def conflict_scope(data: ReadGuard, proposal: ProposalRow) -> ConflictScope:
63
+ """What `data` (a `data.read` guard) may read of `proposal`; 403 when nothing."""
64
+ schema = replace(data, permissions=(Permission.SCHEMA_READ,), kind=None)
65
+ schema_readable = await _both(schema, proposal)
66
+ if await _both(data, proposal):
67
+ return ConflictScope(schema=schema_readable)
68
+ kinds = await data.granted_kinds()
69
+ if not kinds:
70
+ raise data.denied()
71
+ return ConflictScope(
72
+ kinds=kinds, schema=schema_readable, invisible=data.authorizer.invisible_kinds
73
+ )
74
+
75
+
76
+ async def reads_both(data: ReadGuard, proposal: ProposalRow) -> bool:
77
+ """Whether `data` reads both branches of `proposal` (not only some Kinds)."""
78
+ return await _both(data, proposal)
79
+
80
+
81
+ async def visible_scope(data: ReadGuard, proposal: ProposalRow) -> ConflictScope:
82
+ """`conflict_scope`, or `NO_SCOPE` instead of 403: for responses of writes whose
83
+ authorization does not depend on reading the Proposal."""
84
+ try:
85
+ return await conflict_scope(data, proposal)
86
+ except ProblemError as error:
87
+ if error.status_code != 403:
88
+ raise
89
+ return NO_SCOPE
90
+
91
+
92
+ async def readable_proposals(data: ReadGuard, rows: Sequence[ProposalRow]) -> list[ProposalRow]:
93
+ """The Proposals of `rows` the caller of `data` may read (see module doc)."""
94
+ if await data.wide() or await data.granted_kinds():
95
+ return list(rows)
96
+ return [row for row in rows if await _both(data, row)]
97
+
98
+
99
+ async def require_target_read(data: ReadGuard, target_ref: str) -> None:
100
+ """403 unless the caller of `data` may read the branch `target_ref`: `data.read` on it,
101
+ or grants narrowed to Kinds (which cover every branch)."""
102
+ if await data.may(target_ref) or await data.granted_kinds():
103
+ return
104
+ raise data.denied()
105
+
106
+
107
+ def require_visible(
108
+ partitions: PartitionFilter, conflict: ConflictRow, attempt_id: str, *, invisible: bool = False
109
+ ) -> None:
110
+ """403 for a Conflict stored in a partition outside `partitions`; with `invisible`, the
111
+ 404 of a Conflict that is not part of the attempt."""
112
+ if not partitions.allows(conflict.partition, conflict.attribute_id):
113
+ if invisible:
114
+ raise unknown_conflict(conflict.fingerprint, attempt_id)
115
+ raise ProblemError(
116
+ FORBIDDEN,
117
+ f"Conflict {conflict.fingerprint} is outside what the caller may read.",
118
+ extensions={"conflict": conflict.fingerprint},
119
+ )
120
+
121
+
122
+ def visible_partitions(scope: ConflictScope, schemas: Iterable[Schema]) -> PartitionFilter:
123
+ """The partitions holding Conflicts `scope` shows, given the schemas of the attempt's
124
+ Revisions (Kind and Relation ids are stable across them)."""
125
+ if scope.everything:
126
+ return EVERY_PARTITION
127
+ if scope.kinds is None:
128
+ return PartitionFilter(exclude=frozenset({PARTITION_SCHEMA}))
129
+ seen = list(schemas)
130
+ hidden: frozenset[str] = frozenset()
131
+ if scope.invisible:
132
+ projections = [projection(schema, scope.kinds) for schema in seen]
133
+ seen = [p.schema for p in projections]
134
+ # Conflicts about Attributes left out of a visible Kind (a reference to an invisible
135
+ # Kind) are left out too: their values are keys of invisible Records.
136
+ hidden = frozenset().union(*(p.hidden for p in projections))
137
+ kind_ids = {
138
+ kind.id for schema in seen for kind in schema.kinds.values() if kind.api_name in scope.kinds
139
+ }
140
+ partitions = {kind_partition(kind_id) for kind_id in kind_ids}
141
+ partitions |= {
142
+ relation_partition(relation.id)
143
+ for schema in seen
144
+ for relation in schema.relations.values()
145
+ if relation.from_kind in kind_ids and relation.to_kind in kind_ids
146
+ }
147
+ if scope.schema:
148
+ partitions.add(PARTITION_SCHEMA)
149
+ return PartitionFilter(include=frozenset(partitions), exclude_attributes=hidden)
150
+
151
+
152
+ def unknown_conflict(fingerprint: str, attempt_id: str) -> ProblemError:
153
+ """404 for a Conflict that is not part of the current merge attempt."""
154
+ return ProblemError(
155
+ NOT_FOUND,
156
+ f"Conflict {fingerprint} is not part of the current merge attempt.",
157
+ extensions={"conflict": fingerprint, "attempt": attempt_id},
158
+ )