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,682 @@
1
+ """Schema reads and schema change sets (design 11.1).
2
+
3
+ | Route | Permission |
4
+ |-------|------------|
5
+ | `GET /domains/{d}/schema`, `GET /domains/{d}/schema/{section}/{name}` | `schema.read` |
6
+ | `POST /domains/{d}/schema:validate` | `schema.read` |
7
+ | `POST /domains/{d}/branches/{b}/schema/changes` | `schema.write` on the branch |
8
+ | `POST /domains/{d}/branches/{b}/rebase-tags` | `schema.write` on the branch |
9
+
10
+ `schema.read` is checked against the Revision that `branch`/`at` resolve to, not the name given
11
+ (`services/reads.py`).
12
+
13
+ The schema is served as JSON (elements keyed by id, with api names and descriptions) or, with
14
+ `Accept: text/yaml` or `format=yaml`, as the YAML document of `lorebase_core.model.yaml_schema`.
15
+ A change set is sent as JSON (`yaml`: a full document, or `patch` and `remove`) or as a raw
16
+ YAML document (`Content-Type: text/yaml` or `application/yaml`, options as query
17
+ parameters). Protected branches refuse direct changes: the change goes to another branch and
18
+ then through a Proposal.
19
+
20
+ `rebase-tags` brings a branch forked before the default branch tagged an Attribute level with
21
+ it: one schema change adds the default branch's sensitivity tags (never removes one) and
22
+ recomputes the titles and search vectors of the Kinds it changes. With `rows_only`, the schema
23
+ is left alone and a `store.sensitivity_refresh` job rewrites those rows in batches.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Annotated, Any, Literal, Self
29
+
30
+ from fastapi import APIRouter, Body, Depends, Query, Request, Response
31
+ from pydantic import BaseModel, Field, JsonValue, ValidationError, model_validator
32
+
33
+ from lorebase_core.canonical import hex
34
+ from lorebase_core.model import Schema, Sensitivity, dump_schema
35
+ from lorebase_keeper.auth.audit import annotate
36
+ from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, require
37
+ from lorebase_keeper.auth.permissions import Permission
38
+ from lorebase_keeper.auth.principals import Principal
39
+ from lorebase_keeper.auth.rbac import AccessContext
40
+ from lorebase_keeper.auth.runtime import AuthRuntime
41
+ from lorebase_keeper.context import StoreAccess, StoreDep, parse_revision_id
42
+ from lorebase_keeper.deps import KeeperSettingsDep
43
+ from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, Problem, ProblemError
44
+ from lorebase_keeper.reading.runtime import ReadRuntime, ReadRuntimeDep
45
+ from lorebase_keeper.reading.sensitivity import clearance_of, default_schema
46
+ from lorebase_keeper.reading.visibility import visible
47
+ from lorebase_keeper.services import schema as service
48
+ from lorebase_keeper.services.errors import (
49
+ ProtectedBranchError,
50
+ commit_problem,
51
+ finding_documents,
52
+ protected_branch_problem,
53
+ violations_problem,
54
+ )
55
+ from lorebase_keeper.services.operation_catalog import builtin_definition
56
+ from lorebase_keeper.services.pii_import import require_schema_change
57
+ from lorebase_keeper.services.reads import (
58
+ ReadGuard,
59
+ ReadTarget,
60
+ resolve_kind_limited,
61
+ resolve_readable,
62
+ )
63
+ from lorebase_keeper.settings import KeeperSettings
64
+ from lorebase_store.commit import CommitError
65
+ from lorebase_store.repositories.scope import scope_to_domain
66
+
67
+ router = APIRouter(tags=["schema"])
68
+
69
+ YAML_MEDIA_TYPES = ("text/yaml", "application/yaml", "application/x-yaml")
70
+ REVISION_HEADER = "Lorebase-Revision"
71
+ Section = Literal["kinds", "relations", "traits", "operations", "derivations", "sources"]
72
+
73
+ _ERRORS: dict[int | str, dict[str, Any]] = {
74
+ 403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
75
+ 404: {"model": Problem, "description": "Domain, branch, Revision or element not found."},
76
+ 409: {"model": Problem, "description": "Protected branch or moved head."},
77
+ 422: {"model": Problem, "description": "The change set is invalid; `issues` locate why."},
78
+ 503: {"model": Problem, "description": "The branch is locked by another writer."},
79
+ }
80
+
81
+ _YAML_BODY = {
82
+ "requestBody": {
83
+ "content": {
84
+ "text/yaml": {"schema": {"type": "string", "description": "Full schema document."}}
85
+ }
86
+ }
87
+ }
88
+
89
+
90
+ _BODY_DOC = (
91
+ "JSON change set, or the full schema document as text/yaml (options as query parameters)."
92
+ )
93
+
94
+
95
+ class ElementRemoval(BaseModel):
96
+ """An element to remove."""
97
+
98
+ section: Section
99
+ name: str = Field(min_length=1, description="Api name or id of the element.")
100
+
101
+
102
+ class ChangeSetBody(BaseModel):
103
+ """A full schema document, or a patch of elements."""
104
+
105
+ yaml: str | None = Field(
106
+ default=None,
107
+ description="Full schema document; elements it leaves out are removed.",
108
+ )
109
+ patch: dict[str, JsonValue] | None = Field(
110
+ default=None,
111
+ description="Partial document (same layout as the YAML document): each element "
112
+ "replaces the element with the same api name, or is added.",
113
+ )
114
+ remove: list[ElementRemoval] = Field(default_factory=list)
115
+
116
+ @model_validator(mode="after")
117
+ def _one_form(self) -> Self:
118
+ if (self.yaml is not None) == (self.patch is not None or bool(self.remove)):
119
+ raise ValueError("give either `yaml`, or `patch` and/or `remove`")
120
+ return self
121
+
122
+ def to_input(self) -> service.ChangeSetInput:
123
+ return service.ChangeSetInput(
124
+ yaml=self.yaml,
125
+ patch=self.patch,
126
+ remove=[service.ElementRemoval(r.section, r.name) for r in self.remove],
127
+ )
128
+
129
+
130
+ class SchemaChangeRequest(ChangeSetBody):
131
+ """A schema change set for one branch."""
132
+
133
+ message: str | None = Field(default=None, max_length=2000)
134
+ expected_head: str | None = Field(
135
+ default=None, description="Fail with 409 unless the branch head is this Revision."
136
+ )
137
+ dry_run: bool = Field(default=False, description="Validate and plan without committing.")
138
+ cascade: bool = Field(
139
+ default=False,
140
+ description="Confirm that removing Kinds or Relations deletes their Records or Links.",
141
+ )
142
+
143
+
144
+ class SchemaValidateRequest(ChangeSetBody):
145
+ """A change set to validate against the schema of a branch or Revision."""
146
+
147
+ branch: str | None = None
148
+ at: str | None = Field(default=None, description="Revision id, tag or branch name.")
149
+
150
+
151
+ class ElementChangeOut(BaseModel):
152
+ """One element written or removed."""
153
+
154
+ op: Literal["create", "update", "delete"]
155
+ section: str
156
+ id: str
157
+ api_name: str
158
+
159
+
160
+ class EmbeddingOut(BaseModel):
161
+ """The embedding model of a Domain: what a caller embeds `query_vector` with."""
162
+
163
+ model: str = Field(description="Name the stored vectors are keyed by.")
164
+ dims: int = Field(description="Length of a query vector.")
165
+
166
+
167
+ class SchemaSearchOut(BaseModel):
168
+ """How the Domain can be searched at this Revision."""
169
+
170
+ modes: list[Literal["lexical", "semantic", "hybrid"]]
171
+ embedding: EmbeddingOut | None = Field(
172
+ description="Set when some Kind has `search.embedding_template`: semantic and hybrid "
173
+ "search need a `query_vector` made by this model; null otherwise."
174
+ )
175
+
176
+
177
+ class SchemaOut(BaseModel):
178
+ """The schema of a Revision."""
179
+
180
+ domain: str
181
+ branch: str | None
182
+ revision: str
183
+ schema_: dict[str, Any] = Field(alias="schema")
184
+ search: SchemaSearchOut
185
+
186
+
187
+ class SchemaChangeOut(BaseModel):
188
+ """Outcome of a schema change set."""
189
+
190
+ status: Literal["committed", "dry_run", "no_op"]
191
+ branch: str
192
+ head: str = Field(description="Branch head the change set was applied to.")
193
+ revision: str | None = Field(description="New Revision; null for dry runs.")
194
+ changes: list[ElementChangeOut]
195
+ change_count: int
196
+ data_changes: int = Field(description="Record and Link changes made by data migrations.")
197
+ issues: list[dict[str, Any]]
198
+ issue_count: int
199
+
200
+
201
+ class RebaseTagsRequest(BaseModel):
202
+ """A tag rebase of one branch."""
203
+
204
+ message: str | None = Field(default=None, max_length=2000)
205
+ expected_head: str | None = Field(
206
+ default=None, description="Fail with 409 unless the branch head is this Revision."
207
+ )
208
+ dry_run: bool = Field(default=False, description="Plan without committing or queueing.")
209
+ rows_only: bool = Field(
210
+ default=False,
211
+ description="Leave the branch's schema as it is: queue the batched rewrite of the "
212
+ "titles and search vectors it materialized before the default branch's tags.",
213
+ )
214
+
215
+
216
+ class TagChangeOut(BaseModel):
217
+ """Sensitivity tags added to one Attribute."""
218
+
219
+ section: Literal["kinds", "relations"]
220
+ owner: str = Field(description="Api name of the Kind or Relation.")
221
+ attribute: str = Field(description="Api name of the Attribute.")
222
+ tags: list[Sensitivity]
223
+
224
+
225
+ class RebaseTagsOut(BaseModel):
226
+ """Outcome of a tag rebase."""
227
+
228
+ status: Literal["committed", "dry_run", "no_op", "queued"]
229
+ branch: str
230
+ head: str = Field(description="Branch head the rebase was planned against.")
231
+ revision: str | None = Field(description="New Revision; null for dry runs and rows_only.")
232
+ tags: list[TagChangeOut] = Field(description="Tags the schema change adds.")
233
+ changes: list[ElementChangeOut]
234
+ change_count: int
235
+ issues: list[dict[str, Any]]
236
+ issue_count: int
237
+ job: int | None = Field(
238
+ default=None, description="The queued store.sensitivity_refresh job (rows_only)."
239
+ )
240
+
241
+
242
+ class SchemaValidationOut(BaseModel):
243
+ """Static validation of a change set."""
244
+
245
+ valid: bool
246
+ issues: list[dict[str, Any]]
247
+ issue_count: int
248
+ changes: list[ElementChangeOut]
249
+
250
+
251
+ def _invalid_body(error: ValidationError) -> ProblemError:
252
+ errors = [
253
+ {
254
+ "pointer": "/body/" + "/".join(str(p) for p in e["loc"]),
255
+ "message": str(e["msg"]),
256
+ "code": str(e["type"]),
257
+ }
258
+ for e in error.errors()
259
+ ]
260
+ return ProblemError(
261
+ VALIDATION_FAILED,
262
+ "One or more request members are invalid.",
263
+ extensions={"errors": errors},
264
+ )
265
+
266
+
267
+ def _parse[M: BaseModel](body: M | str, model: type[M], options: dict[str, Any]) -> M:
268
+ """The request model; a YAML document body takes its options from the query."""
269
+ if not isinstance(body, str):
270
+ return body
271
+ try:
272
+ return model.model_validate(
273
+ {"yaml": body, **{k: v for k, v in options.items() if v is not None}}
274
+ )
275
+ except ValidationError as error:
276
+ raise _invalid_body(error) from None
277
+
278
+
279
+ def _wants_yaml(request: Request, format_: str | None) -> bool:
280
+ if format_ is not None:
281
+ return format_ == "yaml"
282
+ accept = request.headers.get("accept", "")
283
+ return any(media in accept for media in YAML_MEDIA_TYPES)
284
+
285
+
286
+ async def _readable(
287
+ request: Request,
288
+ auth: AuthRuntime,
289
+ principal: Principal,
290
+ store: StoreAccess,
291
+ keeper: KeeperSettings,
292
+ domain_id: str,
293
+ branch: str | None,
294
+ at: str | None,
295
+ ) -> ReadTarget:
296
+ """The Revision a read names, once the caller may read it (not just the named branch)."""
297
+ annotate(request, domain_id=domain_id, permission=Permission.SCHEMA_READ.value)
298
+ return await resolve_readable(
299
+ store,
300
+ auth.authorizer,
301
+ principal,
302
+ (Permission.SCHEMA_READ,),
303
+ domain_id,
304
+ branch=branch,
305
+ at=at,
306
+ max_depth=keeper.reads.reachability_max_depth,
307
+ )
308
+
309
+
310
+ async def _visible_read(
311
+ request: Request,
312
+ auth: AuthRuntime,
313
+ principal: Principal,
314
+ store: StoreAccess,
315
+ keeper: KeeperSettings,
316
+ domain_id: str,
317
+ branch: str | None,
318
+ at: str | None,
319
+ ) -> tuple[ReadTarget, Schema]:
320
+ """The Revision a schema read names and its schema as the caller sees it: in full, or,
321
+ for a caller whose grants are narrowed to Kinds that are invisible to it, projected to
322
+ those Kinds (design 13.3)."""
323
+ try:
324
+ target = await _readable(request, auth, principal, store, keeper, domain_id, branch, at)
325
+ except ProblemError as error:
326
+ guard = ReadGuard(
327
+ auth.authorizer,
328
+ principal,
329
+ (Permission.SCHEMA_READ,),
330
+ domain_id,
331
+ keeper.reads.reachability_max_depth,
332
+ )
333
+ kinds = (
334
+ await guard.granted_kinds()
335
+ if error.status_code == 403 and auth.authorizer.invisible_kinds
336
+ else frozenset()
337
+ )
338
+ if not kinds:
339
+ raise
340
+ target = await resolve_kind_limited(store, guard, branch=branch, at=at)
341
+ return target, visible(await service.read(store, domain_id, target), kinds)
342
+ return target, await service.read(store, domain_id, target)
343
+
344
+
345
+ def _changes(changes: list[service.ElementChange], limit: int) -> list[ElementChangeOut]:
346
+ return [
347
+ ElementChangeOut(op=c.op, section=c.section, id=c.id, api_name=c.api_name)
348
+ for c in changes[:limit]
349
+ ]
350
+
351
+
352
+ @router.get(
353
+ "/domains/{domain_id}/schema",
354
+ summary="Get the schema",
355
+ responses={**_ERRORS, 200: {"content": {"text/yaml": {}}}},
356
+ response_model=SchemaOut,
357
+ )
358
+ async def get_schema(
359
+ domain_id: str,
360
+ request: Request,
361
+ principal: PrincipalDep,
362
+ auth: AuthDep,
363
+ store: StoreDep,
364
+ keeper: KeeperSettingsDep,
365
+ runtime: ReadRuntimeDep,
366
+ branch: Annotated[str | None, Query(description="Default: the default branch.")] = None,
367
+ at: Annotated[str | None, Query(description="Revision id, tag or branch name.")] = None,
368
+ format_: Annotated[Literal["json", "yaml"] | None, Query(alias="format")] = None,
369
+ ) -> Response:
370
+ """The schema at a branch head or at a Revision, as JSON or YAML."""
371
+ target, schema = await _visible_read(
372
+ request, auth, principal, store, keeper, domain_id, branch, at
373
+ )
374
+ revision = hex(target.revision)
375
+ headers = {REVISION_HEADER: revision}
376
+ if _wants_yaml(request, format_):
377
+ return Response(dump_schema(schema), media_type="text/yaml", headers=headers)
378
+ body = SchemaOut(
379
+ domain=domain_id,
380
+ branch=target.ref,
381
+ revision=revision,
382
+ schema=schema.content(),
383
+ search=_search(schema, runtime),
384
+ )
385
+ return Response(
386
+ body.model_dump_json(by_alias=True), media_type="application/json", headers=headers
387
+ )
388
+
389
+
390
+ def _search(schema: Schema, runtime: ReadRuntime) -> SchemaSearchOut:
391
+ """Search modes of the schema; the embedding model comes from the settings (vectors are
392
+ keyed by its name), never from the inference package."""
393
+ if not any(kind.search.embedding_template for kind in schema.kinds.values()):
394
+ return SchemaSearchOut(modes=["lexical"], embedding=None)
395
+ embedding = EmbeddingOut(
396
+ model=runtime.search.semantic.model, dims=runtime.store_settings.embeddings.dims
397
+ )
398
+ return SchemaSearchOut(modes=["lexical", "semantic", "hybrid"], embedding=embedding)
399
+
400
+
401
+ def _element(schema: Schema, section: Section, name: str) -> dict[str, Any] | None:
402
+ elements: dict[str, Any] = getattr(schema, section)
403
+ found = elements.get(name) or next((e for e in elements.values() if e.api_name == name), None)
404
+ if found is not None:
405
+ return dict(found.content())
406
+ if section == "operations":
407
+ builtin = builtin_definition(schema, name)
408
+ return None if builtin is None else builtin.document()
409
+ return None
410
+
411
+
412
+ @router.get(
413
+ "/domains/{domain_id}/schema/{section}/{name}",
414
+ summary="Get a schema element",
415
+ responses=_ERRORS,
416
+ )
417
+ async def get_element(
418
+ domain_id: str,
419
+ section: Section,
420
+ name: str,
421
+ request: Request,
422
+ principal: PrincipalDep,
423
+ auth: AuthDep,
424
+ store: StoreDep,
425
+ keeper: KeeperSettingsDep,
426
+ response: Response,
427
+ branch: Annotated[str | None, Query(description="Default: the default branch.")] = None,
428
+ at: Annotated[str | None, Query(description="Revision id, tag or branch name.")] = None,
429
+ ) -> dict[str, Any]:
430
+ """A Kind, Relation, Trait, Operation, Derivation or Source by api name or id.
431
+
432
+ Built-in Operations (`create_supplier`, `link_supplied_by`, `import_records`, ...) are
433
+ described with the parameters derived from the schema.
434
+ """
435
+ target, schema = await _visible_read(
436
+ request, auth, principal, store, keeper, domain_id, branch, at
437
+ )
438
+ element = _element(schema, section, name)
439
+ if element is None:
440
+ raise ProblemError(NOT_FOUND, f"No element {name!r} in {section}.")
441
+ response.headers[REVISION_HEADER] = hex(target.revision)
442
+ return element
443
+
444
+
445
+ @router.post(
446
+ "/domains/{domain_id}/schema:validate",
447
+ summary="Validate a schema change set",
448
+ responses=_ERRORS,
449
+ openapi_extra=_YAML_BODY,
450
+ )
451
+ async def validate_schema(
452
+ domain_id: str,
453
+ payload: Annotated[SchemaValidateRequest | str, Body(description=_BODY_DOC)],
454
+ request: Request,
455
+ principal: PrincipalDep,
456
+ auth: AuthDep,
457
+ store: StoreDep,
458
+ keeper: KeeperSettingsDep,
459
+ branch: Annotated[str | None, Query(description="For YAML bodies.")] = None,
460
+ at: Annotated[str | None, Query(description="For YAML bodies.")] = None,
461
+ ) -> SchemaValidationOut:
462
+ """Static validation of a document or patch against the schema of a branch or Revision.
463
+
464
+ Issue paths point into the schema document (api names). Nothing is written.
465
+ """
466
+ body = _parse(payload, SchemaValidateRequest, {"branch": branch, "at": at})
467
+ target = await _readable(
468
+ request, auth, principal, store, keeper, domain_id, body.branch, body.at
469
+ )
470
+ limits = keeper.schema_changes
471
+ issues, changes = await service.validate(store, domain_id, body.to_input(), target)
472
+ return SchemaValidationOut(
473
+ valid=not issues,
474
+ issues=finding_documents(issues, limits.max_issues),
475
+ issue_count=len(issues),
476
+ changes=_changes(changes, limits.max_listed_changes),
477
+ )
478
+
479
+
480
+ @router.post(
481
+ "/domains/{domain_id}/branches/{branch:path}/schema/changes",
482
+ summary="Apply a schema change set",
483
+ responses=_ERRORS,
484
+ openapi_extra=_YAML_BODY,
485
+ )
486
+ async def apply_schema_changes(
487
+ domain_id: str,
488
+ branch: str,
489
+ payload: Annotated[SchemaChangeRequest | str, Body(description=_BODY_DOC)],
490
+ request: Request,
491
+ store: StoreDep,
492
+ keeper: KeeperSettingsDep,
493
+ access: Annotated[Access, Depends(require(Permission.SCHEMA_WRITE, branch_param="branch"))],
494
+ auth: AuthDep,
495
+ dry_run: Annotated[bool | None, Query(description="For YAML bodies.")] = None,
496
+ cascade: Annotated[bool | None, Query(description="For YAML bodies.")] = None,
497
+ message: Annotated[str | None, Query(description="For YAML bodies.")] = None,
498
+ expected_head: Annotated[str | None, Query(description="For YAML bodies.")] = None,
499
+ ) -> SchemaChangeOut:
500
+ """Apply a change set as one Revision (data migrations included), or dry-run it.
501
+
502
+ A dry run returns the element changes, the number of data changes and the validation
503
+ issues without committing. Protected branches refuse direct changes (409).
504
+ """
505
+ body = _parse(
506
+ payload,
507
+ SchemaChangeRequest,
508
+ {
509
+ "dry_run": dry_run,
510
+ "cascade": cascade,
511
+ "message": message,
512
+ "expected_head": expected_head,
513
+ },
514
+ )
515
+ limits = keeper.schema_changes
516
+ expected = (
517
+ None
518
+ if body.expected_head is None
519
+ else parse_revision_id(body.expected_head, "/body/expected_head")
520
+ )
521
+ async with store.sessions() as session, session.begin():
522
+ await scope_to_domain(session, domain_id)
523
+ reference = await default_schema(session, domain_id, store.runtime)
524
+ # Kind-narrowed grants count for what issues show; removing tags needs Domain-wide ones.
525
+ clearance = await clearance_of(auth.authorizer, access.principal, domain_id, reference)
526
+ try:
527
+ outcome = await service.apply(
528
+ store,
529
+ domain_id,
530
+ branch,
531
+ body.to_input(),
532
+ access.principal,
533
+ message=body.message,
534
+ expected_head=expected,
535
+ dry_run=body.dry_run,
536
+ cascade=body.cascade,
537
+ guard=lambda old, new: require_schema_change(
538
+ auth.authorizer, access.principal, domain_id, old, new
539
+ ),
540
+ cleared=clearance.everywhere,
541
+ clearance=clearance,
542
+ )
543
+ except (service.ChangeSetInvalidError, service.RemovalBlockedError) as invalid:
544
+ raise violations_problem(invalid.issues, limit=limits.max_issues) from None
545
+ except ProtectedBranchError:
546
+ raise protected_branch_problem(
547
+ domain_id, branch, f"{keeper.api_prefix}/domains/{domain_id}/proposals"
548
+ ) from None
549
+ except CommitError as error:
550
+ raise commit_problem(
551
+ error,
552
+ max_issues=limits.max_issues,
553
+ retry_after_s=keeper.branches.lock_retry_after_s,
554
+ ) from None
555
+ revision = None if outcome.revision is None else hex(outcome.revision)
556
+ annotate(
557
+ request,
558
+ action="schema.dry_run" if body.dry_run else "schema.change",
559
+ target=branch,
560
+ status=outcome.status.value,
561
+ revision=revision,
562
+ changes=len(outcome.changes),
563
+ )
564
+ return SchemaChangeOut(
565
+ status=outcome.status.value,
566
+ branch=branch,
567
+ head=hex(outcome.head),
568
+ revision=revision,
569
+ changes=_changes(outcome.changes, limits.max_listed_changes),
570
+ change_count=len(outcome.changes),
571
+ data_changes=outcome.data_changes,
572
+ issues=finding_documents(outcome.issues, limits.max_issues),
573
+ issue_count=len(outcome.issues),
574
+ )
575
+
576
+
577
+ @router.post(
578
+ "/domains/{domain_id}/branches/{branch:path}/rebase-tags",
579
+ summary="Take the default branch's sensitivity tags",
580
+ responses=_ERRORS,
581
+ )
582
+ async def rebase_tags(
583
+ domain_id: str,
584
+ branch: str,
585
+ body: RebaseTagsRequest,
586
+ request: Request,
587
+ store: StoreDep,
588
+ keeper: KeeperSettingsDep,
589
+ access: Annotated[Access, Depends(require(Permission.SCHEMA_WRITE, branch_param="branch"))],
590
+ auth: AuthDep,
591
+ ) -> RebaseTagsOut:
592
+ """Give a branch forked before the default branch tagged an Attribute the tags it lacks.
593
+
594
+ One schema change adds, to every Attribute the branch shares with the default branch, the
595
+ tags the default branch gives it, and to derived Attributes the tags of what they read;
596
+ no tag is removed. It recomputes the titles and search vectors of the Kinds it changes,
597
+ so verification no longer reports them as `sensitivity_stale`. A branch that already has
598
+ the tags gives `no_op`. Protected branches refuse the change (409) unless `rows_only`:
599
+ then only a `store.sensitivity_refresh` job is queued, which rewrites those rows in
600
+ batches and leaves the schema as it is. `tags` lists the tags added only to callers
601
+ who may read the default branch's schema (empty otherwise).
602
+ """
603
+ limits = keeper.schema_changes
604
+ if body.rows_only:
605
+ head, job = await service.queue_tag_refresh(store, domain_id, branch, dry_run=body.dry_run)
606
+ annotate(request, action="schema.rebase_tags", target=branch, job=job)
607
+ return RebaseTagsOut(
608
+ status="dry_run" if body.dry_run else "queued",
609
+ branch=branch,
610
+ head=hex(head),
611
+ revision=None,
612
+ tags=[],
613
+ changes=[],
614
+ change_count=0,
615
+ issues=[],
616
+ issue_count=0,
617
+ job=job,
618
+ )
619
+ expected = (
620
+ None
621
+ if body.expected_head is None
622
+ else parse_revision_id(body.expected_head, "/body/expected_head")
623
+ )
624
+ try:
625
+ rebase = await service.rebase_tags(
626
+ store,
627
+ domain_id,
628
+ branch,
629
+ access.principal,
630
+ message=body.message,
631
+ expected_head=expected,
632
+ dry_run=body.dry_run,
633
+ )
634
+ except service.ChangeSetInvalidError as invalid:
635
+ raise violations_problem(invalid.issues, limit=limits.max_issues) from None
636
+ except ProtectedBranchError:
637
+ raise protected_branch_problem(
638
+ domain_id, branch, f"{keeper.api_prefix}/domains/{domain_id}/proposals"
639
+ ) from None
640
+ except CommitError as error:
641
+ raise commit_problem(
642
+ error,
643
+ max_issues=limits.max_issues,
644
+ retry_after_s=keeper.branches.lock_retry_after_s,
645
+ ) from None
646
+ outcome = rebase.outcome
647
+ revision = None if outcome.revision is None else hex(outcome.revision)
648
+ shown = rebase.tags
649
+ if shown and not await auth.authorizer.allows(
650
+ access.principal,
651
+ Permission.SCHEMA_READ,
652
+ domain_id,
653
+ AccessContext(branch=rebase.default_branch),
654
+ ):
655
+ shown = [] # the names of the tagged Attributes come from the default branch
656
+ annotate(
657
+ request,
658
+ action="schema.rebase_tags",
659
+ target=branch,
660
+ status=outcome.status.value,
661
+ revision=revision,
662
+ changes=len(outcome.changes),
663
+ )
664
+ return RebaseTagsOut(
665
+ status=outcome.status.value,
666
+ branch=branch,
667
+ head=hex(outcome.head),
668
+ revision=revision,
669
+ tags=[
670
+ TagChangeOut(
671
+ section="kinds" if t.section == "kinds" else "relations",
672
+ owner=t.owner,
673
+ attribute=t.attribute,
674
+ tags=t.tags,
675
+ )
676
+ for t in shown
677
+ ],
678
+ changes=_changes(outcome.changes, limits.max_listed_changes),
679
+ change_count=len(outcome.changes),
680
+ issues=finding_documents(outcome.issues, limits.max_issues),
681
+ issue_count=len(outcome.issues),
682
+ )