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,1229 @@
1
+ """Operation execution (design 10.1).
2
+
3
+ resolve the Operation (stored, or built-in from the schema of the branch head)
4
+ -> authorize (built-in: operation.execute; custom: operation.execute_custom and the
5
+ Operation's own `permissions`; built-in imports holding values of vault Attributes
6
+ also pii.import on the whole Domain, `services.pii_import`)
7
+ -> expressions gate (only when `expressions_enabled` is off: preconditions are refused
8
+ or, when settings allow, skipped with a warning; steps and expression review policies
9
+ are refused)
10
+ -> idempotency check (principal, key): same parameters replay, others conflict
11
+ -> review policy (custom `always`, or any Operation on a protected branch unless settings
12
+ allow direct commits): refused with a pointer to Proposals (`services.agent_writes`
13
+ turns that refusal into a task branch and a Proposal for callers that name a task;
14
+ such an execution runs with `reviewed_by_proposal`)
15
+ -> take the branch lock, check the expected head
16
+ -> bind parameters, compute edits (stored Operations with `expressions_enabled`: evaluate
17
+ preconditions (false: 422 `precondition-failed` with the expression and its position),
18
+ the review policy expression (true: 409 `review-required` as above) and the steps with
19
+ `lorebase_core.expr`, loading from the materialized head the Records `record()` reads
20
+ and the Links `links()` follows)
21
+ -> dry run: commit inside a transaction that is rolled back; report edits and violations
22
+ -> commit (imports in batches of whole units, a Record with its Links, one Revision
23
+ each), then the invocation record, an `operation.invoke` audit entry and the outbox
24
+ events of the Operation's side effects (`services.operation_events`; deferred to the
25
+ merge of the Proposal with `reviewed_by_proposal`) in the transaction of the last
26
+ Revision.
27
+
28
+ Every Revision has origin `operation` (or `import` for bulk imports) with the invocation id as
29
+ reference; every changed Attribute is attributed to a provenance entry naming the invocation,
30
+ the Operation and the principal. The parameters hash is kept in the invocation record and in
31
+ the audit entry.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import copy
37
+ import json
38
+ import time
39
+ from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
40
+ from dataclasses import dataclass, field
41
+ from datetime import datetime
42
+ from enum import StrEnum
43
+ from types import MappingProxyType
44
+ from typing import Any
45
+
46
+ from pydantic import JsonValue
47
+ from sqlalchemy.exc import IntegrityError
48
+ from sqlalchemy.ext.asyncio import AsyncSession
49
+
50
+ from lorebase_core.canonical import from_hex, hex
51
+ from lorebase_core.expr import (
52
+ CompiledExpression,
53
+ EvaluationContext,
54
+ ExprError,
55
+ ExprErrorCode,
56
+ ExprLimits,
57
+ LinkedRecord,
58
+ LinksRequest,
59
+ MemoryResolver,
60
+ RecordNotLoaded,
61
+ RecordRequest,
62
+ check_preconditions,
63
+ compile_operation,
64
+ evaluate_review_policy,
65
+ plan_steps,
66
+ )
67
+ from lorebase_core.expr import Principal as ExpressionPrincipal
68
+ from lorebase_core.expr.ast import parse
69
+ from lorebase_core.expr.errors import PathPart
70
+ from lorebase_core.expr.sensitivity import (
71
+ attributes_named,
72
+ expression_fields,
73
+ operation_reads,
74
+ with_tags,
75
+ )
76
+ from lorebase_core.model import (
77
+ Attribute,
78
+ CreateLinkStep,
79
+ CreateRecordStep,
80
+ IdPrefix,
81
+ ImportOrigin,
82
+ Kind,
83
+ LinkDirection,
84
+ Operation,
85
+ OperationOrigin,
86
+ OperationStep,
87
+ ProvenanceEntry,
88
+ Relation,
89
+ ReviewMode,
90
+ Schema,
91
+ UpdateRecordStep,
92
+ new_id,
93
+ new_ulid,
94
+ )
95
+ from lorebase_core.revision import Origin
96
+ from lorebase_core.validate import DataChange, RecordUpsert, operation_sensitivity_issues
97
+ from lorebase_keeper.auth.permissions import Permission
98
+ from lorebase_keeper.auth.principals import Principal
99
+ from lorebase_keeper.auth.rbac import AccessContext
100
+ from lorebase_keeper.context import StoreAccess, author_of, require_branch, require_domain
101
+ from lorebase_keeper.errors import FORBIDDEN, NOT_FOUND, VALIDATION_FAILED, ProblemError
102
+ from lorebase_keeper.reading.sensitivity import (
103
+ Clearance,
104
+ Disclosure,
105
+ Masking,
106
+ default_schema,
107
+ generalize_issues,
108
+ reference_tags,
109
+ )
110
+ from lorebase_keeper.services.config import OperationSettings
111
+ from lorebase_keeper.services.errors import (
112
+ EXPRESSIONS_NOT_ENABLED,
113
+ IDEMPOTENCY_CONFLICT,
114
+ REVIEW_REQUIRED,
115
+ Finding,
116
+ finding_documents,
117
+ findings_of,
118
+ )
119
+ from lorebase_keeper.services.errors import (
120
+ PRECONDITION_FAILED as PRECONDITION_PROBLEM,
121
+ )
122
+ from lorebase_keeper.services.operation_catalog import (
123
+ Action,
124
+ BuiltinOperation,
125
+ Parameter,
126
+ RecordReader,
127
+ batches,
128
+ bind,
129
+ builtin_definition,
130
+ delete_keys,
131
+ edit_document,
132
+ import_rows,
133
+ plan_edits,
134
+ )
135
+ from lorebase_keeper.services.operation_events import (
136
+ counted,
137
+ execution_fields,
138
+ write_side_effects,
139
+ )
140
+ from lorebase_keeper.services.pii_import import import_rows_vault_attributes
141
+ from lorebase_store.auth import AuditRepository, NewAuditEntry
142
+ from lorebase_store.commit import (
143
+ BranchNotMaterializedError,
144
+ Change,
145
+ StaleHeadError,
146
+ ValidationFailedError,
147
+ commit,
148
+ )
149
+ from lorebase_store.commit.schema import load_compiled
150
+ from lorebase_store.digests import DigestKeys
151
+ from lorebase_store.materialize.links import PathCapExceededError, path_links_of
152
+ from lorebase_store.models.enums import InvocationStatus
153
+ from lorebase_store.reads import Direction, head_schema, links_of_record
154
+ from lorebase_store.repositories import (
155
+ InvocationRepository,
156
+ InvocationRow,
157
+ MaterializedRepository,
158
+ RefRepository,
159
+ )
160
+
161
+ REVIEW_PROTECTED = "protected-branch"
162
+ REVIEW_POLICY = "review-policy"
163
+ """Why an execution needs review: its branch is protected, or its review policy says so."""
164
+
165
+ INVOKE_ACTION = "operation.invoke"
166
+ INVOCATION_PREFIX = "inv"
167
+ PRECONDITIONS_SKIPPED = "operation.preconditions_skipped"
168
+ PRECONDITION_FAILED = "operation.precondition_failed"
169
+
170
+ Check = Callable[[Permission, AccessContext], Awaitable[None]]
171
+ Reveal = Callable[[AsyncSession], Awaitable[Masking]]
172
+ """The caller's masking in the Domain (`reading.sensitivity`), read in the given session."""
173
+ LinkReader = Callable[[str, LinkDirection, str], Awaitable[Sequence[LinkedRecord]]]
174
+ """Records linked to a key through a Relation id, traversed in a direction (`links()`)."""
175
+
176
+
177
+ class ExecutionStatus(StrEnum):
178
+ COMMITTED = "committed"
179
+ DRY_RUN = "dry_run"
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class ExecutionRequest:
184
+ """What the caller asked for."""
185
+
186
+ operation: str
187
+ parameters: Mapping[str, JsonValue]
188
+ branch: str | None = None
189
+ dry_run: bool = False
190
+ idempotency_key: str | None = None
191
+ expected_head: bytes | None = None
192
+ message: str | None = None
193
+ reviewed_by_proposal: bool = False
194
+ """The edits go to a task branch whose Proposal is their review (agent writes): the
195
+ review policy and the protection of the branch no longer refuse the execution."""
196
+
197
+
198
+ @dataclass
199
+ class ExecutionOutcome:
200
+ """Result of an execution or a dry run."""
201
+
202
+ status: ExecutionStatus
203
+ operation: str
204
+ branch: str
205
+ invocation_id: str
206
+ parameters_hash: str
207
+ head: bytes
208
+ revisions: list[bytes] = field(default_factory=list)
209
+ edits: list[dict[str, Any]] = field(default_factory=list)
210
+ edit_count: int = 0
211
+ violations: list[Finding] = field(default_factory=list)
212
+ warnings: list[Finding] = field(default_factory=list)
213
+ review_required: bool = False
214
+ replayed: bool = False
215
+ preconditions_failed: bool = False
216
+ """A dry run found the violations in `violations` in failed preconditions."""
217
+ parameters: dict[str, Any] = field(default_factory=dict)
218
+ """The parameters as recorded (`recorded_parameters`): values that may be sensitive are
219
+ masked."""
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class _Resolved:
224
+ name: str
225
+ custom: Operation | None
226
+ builtin: BuiltinOperation | None
227
+
228
+
229
+ REDACTED = "***"
230
+ """Stands for a parameter value that is not recorded."""
231
+
232
+
233
+ def recorded_parameters(
234
+ schema: Schema,
235
+ resolved: _Resolved,
236
+ parameters: Mapping[str, JsonValue],
237
+ sensitive: Callable[[Attribute], bool] = lambda a: a.is_sensitive,
238
+ ) -> dict[str, JsonValue]:
239
+ """Parameters as kept in the invocation record and Proposal descriptions. A value that
240
+ may hold a value of a sensitive Attribute is replaced by `REDACTED`: those records are
241
+ read without clearance and erasure does not reach them (design 13.5). Built-in Operations
242
+ keep the values of non-sensitive Attributes; other parameters are kept only when nothing
243
+ the Operation writes is sensitive."""
244
+ owners: list[Kind | Relation | None]
245
+ builtin = resolved.builtin
246
+ if builtin is not None:
247
+ owners = [builtin.kind or builtin.relation]
248
+ if owners == [None]:
249
+ owners = [*schema.kinds.values(), *schema.relations.values()]
250
+ else:
251
+ assert resolved.custom is not None
252
+ owners = []
253
+ for step in resolved.custom.steps:
254
+ kind_id = getattr(step, "kind", None)
255
+ relation_id = getattr(step, "relation", None)
256
+ owners.append(
257
+ schema.kinds.get(kind_id) if kind_id else schema.relations.get(relation_id or "")
258
+ )
259
+ if not any(
260
+ owner is None or any(sensitive(a) for a in owner.attributes.values()) for owner in owners
261
+ ):
262
+ return dict(parameters)
263
+ plain: set[str] = set()
264
+ if builtin is not None and (builtin.kind or builtin.relation) is not None:
265
+ owner = builtin.kind or builtin.relation
266
+ assert owner is not None
267
+ plain = {a.api_name for a in owner.attributes.values() if not sensitive(a)}
268
+ return {name: value if name in plain else REDACTED for name, value in parameters.items()}
269
+
270
+
271
+ def parameters_hash(
272
+ operation: str,
273
+ branch: str,
274
+ parameters: Mapping[str, JsonValue],
275
+ *,
276
+ domain_id: str,
277
+ digests: DigestKeys,
278
+ ) -> str:
279
+ """Stable digest of an invocation's inputs (idempotency, provenance, audit), keyed with the
280
+ installation's digest key (`lorebase_store.digests`, ready), so a stored digest does not let
281
+ anyone confirm guessed parameter values."""
282
+ return digests.digest(
283
+ domain_id, "parameters", _parameters_payload(operation, branch, parameters)
284
+ )
285
+
286
+
287
+ def parameters_hashes(
288
+ operation: str,
289
+ branch: str,
290
+ parameters: Mapping[str, JsonValue],
291
+ *,
292
+ domain_id: str,
293
+ digests: DigestKeys,
294
+ ) -> frozenset[str]:
295
+ """Every digest an earlier invocation of the same inputs may have stored: under the current
296
+ key and the earlier token keys (`DigestKeys.all_digests`)."""
297
+ payload = _parameters_payload(operation, branch, parameters)
298
+ return digests.all_digests(domain_id, "parameters", payload)
299
+
300
+
301
+ def _parameters_payload(operation: str, branch: str, parameters: Mapping[str, JsonValue]) -> bytes:
302
+ return json.dumps(
303
+ {"operation": operation, "branch": branch, "parameters": parameters},
304
+ sort_keys=True,
305
+ separators=(",", ":"),
306
+ ensure_ascii=False,
307
+ ).encode("utf-8")
308
+
309
+
310
+ def resolve(schema: Schema, name: str) -> _Resolved:
311
+ """A stored Operation of that api name, else a built-in; 404 otherwise."""
312
+ custom = schema.operation_by_api_name(name)
313
+ if custom is not None:
314
+ return _Resolved(name, custom, None)
315
+ builtin = builtin_definition(schema, name)
316
+ if builtin is None:
317
+ raise ProblemError(NOT_FOUND, f"No Operation {name!r} on this branch.")
318
+ return _Resolved(name, None, builtin)
319
+
320
+
321
+ EXPRESSIONS_DISABLED = "expressions are disabled by configuration (schema.expressions_enabled)"
322
+
323
+
324
+ def expressions_gate(operation: Operation, settings: OperationSettings) -> list[Finding]:
325
+ """Refuse what needs CEL unless expressions are enabled; returns warnings for skipped
326
+ preconditions."""
327
+ warnings: list[Finding] = []
328
+ if settings.expressions_enabled:
329
+ return warnings
330
+ if operation.preconditions:
331
+ if not settings.skip_expression_preconditions:
332
+ raise ProblemError(
333
+ EXPRESSIONS_NOT_ENABLED,
334
+ f"Operation {operation.api_name!r} has preconditions written in CEL, and "
335
+ f"{EXPRESSIONS_DISABLED}: nothing was evaluated or executed.",
336
+ extensions={"operation": operation.api_name},
337
+ )
338
+ warnings.append(
339
+ Finding(
340
+ PRECONDITIONS_SKIPPED,
341
+ f"{len(operation.preconditions)} precondition(s) skipped: {EXPRESSIONS_DISABLED}",
342
+ ("preconditions",),
343
+ severity="warning",
344
+ )
345
+ )
346
+ if operation.steps or operation.review_policy.mode is ReviewMode.EXPRESSION:
347
+ raise ProblemError(
348
+ EXPRESSIONS_NOT_ENABLED,
349
+ f"Operation {operation.api_name!r} computes its edits or review policy with CEL, "
350
+ f"and {EXPRESSIONS_DISABLED}: nothing was executed.",
351
+ extensions={"operation": operation.api_name},
352
+ )
353
+ return warnings
354
+
355
+
356
+ def review_required(resolved: _Resolved, *, protected: bool, settings: OperationSettings) -> bool:
357
+ """Whether the edits must go through a Proposal instead of a direct commit."""
358
+ if _policy_demands_review(resolved):
359
+ return True
360
+ return protected and not settings.direct_commit_on_protected
361
+
362
+
363
+ def _origin(resolved: _Resolved, invocation_id: str) -> Origin:
364
+ return Origin(type="import" if _is_import(resolved) else "operation", ref=invocation_id)
365
+
366
+
367
+ def _is_import(resolved: _Resolved) -> bool:
368
+ return resolved.builtin is not None and resolved.builtin.is_import
369
+
370
+
371
+ def _provenance(
372
+ resolved: _Resolved, invocation_id: str, principal: Principal, at: Any
373
+ ) -> ProvenanceEntry:
374
+ origin: ImportOrigin | OperationOrigin
375
+ if _is_import(resolved):
376
+ origin = ImportOrigin(batch_id=invocation_id)
377
+ else:
378
+ origin = OperationOrigin(
379
+ invocation_id=invocation_id,
380
+ operation=resolved.name,
381
+ operation_id=None if resolved.custom is None else resolved.custom.id,
382
+ principal=principal.id,
383
+ )
384
+ return ProvenanceEntry(id=new_id(IdPrefix.PROVENANCE), origin=origin, actor=principal.id, at=at)
385
+
386
+
387
+ def _permission(
388
+ resolved: _Resolved, branch: str, schema: Schema, parameters: Mapping[str, JsonValue]
389
+ ) -> tuple[Permission, AccessContext]:
390
+ if resolved.custom is not None:
391
+ return Permission.OPERATION_EXECUTE_CUSTOM, AccessContext(
392
+ operation=resolved.name, branch=branch
393
+ )
394
+ builtin = resolved.builtin
395
+ assert builtin is not None
396
+ kind = None if builtin.kind is None else builtin.kind.api_name
397
+ if builtin.action is Action.DELETE_RECORDS and isinstance(parameters.get("kind"), str):
398
+ # A bulk delete is scoped to its Kind, as `delete_{kind}` is.
399
+ named = schema.kind_by_api_name(str(parameters["kind"]))
400
+ kind = None if named is None else named.api_name
401
+ return Permission.OPERATION_EXECUTE, AccessContext(
402
+ kind=kind, operation=resolved.name, branch=branch
403
+ )
404
+
405
+
406
+ def _replay(
407
+ found: InvocationRow,
408
+ request: ExecutionRequest,
409
+ branch: str,
410
+ digest: str,
411
+ accepted: frozenset[str] = frozenset(),
412
+ ) -> ExecutionOutcome:
413
+ params = found.params
414
+ if params.get("parameters_hash") not in {digest, *accepted}:
415
+ raise ProblemError(
416
+ IDEMPOTENCY_CONFLICT,
417
+ "This Idempotency-Key was used with another Operation, branch or parameters.",
418
+ extensions={"invocation_id": found.id},
419
+ )
420
+ revisions = [from_hex(r) for r in params.get("revisions", [])]
421
+ return ExecutionOutcome(
422
+ status=ExecutionStatus.COMMITTED,
423
+ operation=request.operation,
424
+ branch=branch,
425
+ invocation_id=found.id,
426
+ parameters_hash=digest,
427
+ head=from_hex(params["head"]),
428
+ revisions=revisions,
429
+ edit_count=int(params.get("edit_count", 0)),
430
+ replayed=True,
431
+ )
432
+
433
+
434
+ def rejected(
435
+ findings: Sequence[Finding],
436
+ *,
437
+ limit: int,
438
+ edits: Sequence[dict[str, Any]] = (),
439
+ committed: Sequence[bytes] = (),
440
+ ) -> ProblemError:
441
+ """422 with the violations; `committed` lists Revisions of earlier import batches."""
442
+ return ProblemError(
443
+ VALIDATION_FAILED,
444
+ f"{len(findings)} violation(s); the edits were not committed.",
445
+ extensions={
446
+ "status": "rejected",
447
+ "issues": finding_documents(findings, limit),
448
+ "issue_count": len(findings),
449
+ "edits": list(edits),
450
+ "committed_revisions": [hex(r) for r in committed],
451
+ },
452
+ )
453
+
454
+
455
+ async def execute(
456
+ store: StoreAccess,
457
+ domain_id: str,
458
+ request: ExecutionRequest,
459
+ principal: Principal,
460
+ settings: OperationSettings,
461
+ *,
462
+ check: Check,
463
+ proposals_path: str,
464
+ reveal: Reveal | None = None,
465
+ ) -> ExecutionOutcome:
466
+ """Run an Operation on a branch (default: the Domain's default branch). Edits in the
467
+ outcome are masked for the caller's clearance (`reveal`; without it, as for a caller
468
+ without clearances), with vault references resolved as reads resolve them; validation
469
+ issues about values the caller may not read have a generic code (`generalize_issues`).
470
+
471
+ Raises `ProblemError` for refusals and violations and the commit pipeline's `CommitError`s
472
+ (`StaleHeadError`, `BranchLockTimeoutError`, ...), which the route maps.
473
+ """
474
+ started = time.monotonic()
475
+ async with store.sessions() as session:
476
+ await session.begin()
477
+ try:
478
+ return await _execute(
479
+ session,
480
+ store,
481
+ domain_id,
482
+ request,
483
+ principal,
484
+ settings,
485
+ check,
486
+ proposals_path,
487
+ started,
488
+ reveal,
489
+ )
490
+ finally:
491
+ if session.in_transaction():
492
+ await session.rollback()
493
+
494
+
495
+ async def _execute(
496
+ session: AsyncSession,
497
+ store: StoreAccess,
498
+ domain_id: str,
499
+ request: ExecutionRequest,
500
+ principal: Principal,
501
+ settings: OperationSettings,
502
+ check: Check,
503
+ proposals_path: str,
504
+ started: float,
505
+ reveal: Reveal | None,
506
+ ) -> ExecutionOutcome:
507
+ domain = await require_domain(session, domain_id)
508
+ branch = request.branch or domain.default_branch
509
+ ref = await require_branch(session, domain_id, branch)
510
+ writes = store.runtime.settings.writes
511
+ rows = MaterializedRepository(session, domain_id, branch, writes=writes)
512
+ compiled = await rows.schema()
513
+ if compiled is None:
514
+ raise BranchNotMaterializedError(f"branch {branch!r} has no materialized head")
515
+ # From the schema cache (by head Revision): the same object comes back, which keeps the
516
+ # effective tags of `reading.sensitivity` cached too.
517
+ cached = await head_schema(session, domain_id, branch, runtime=store.runtime)
518
+ schema = cached.schema if cached is not None else load_compiled(compiled.compiled)
519
+ resolved = resolve(schema, request.operation)
520
+ permission, context = _permission(resolved, branch, schema, request.parameters)
521
+ await check(permission, context)
522
+ if resolved.builtin is not None and import_rows_vault_attributes(
523
+ schema, resolved.builtin, request.parameters
524
+ ):
525
+ # Imports check vault values against erased ones: refuse before anything is committed,
526
+ # so the outcome does not tell whether a guessed value was erased.
527
+ await check(Permission.PII_IMPORT, AccessContext())
528
+ warnings: list[Finding] = []
529
+ if resolved.custom is not None:
530
+ _check_operation_permissions(resolved.custom, principal)
531
+ warnings = expressions_gate(resolved.custom, settings)
532
+ digests = await store.runtime.digests.ready(session)
533
+ digest = parameters_hash(
534
+ resolved.name, branch, request.parameters, domain_id=domain_id, digests=digests
535
+ )
536
+ accepted = parameters_hashes(
537
+ resolved.name, branch, request.parameters, domain_id=domain_id, digests=digests
538
+ )
539
+ invocations = InvocationRepository(session, domain_id)
540
+ if request.idempotency_key is not None and not request.dry_run:
541
+ found = await invocations.by_key(principal.id, request.idempotency_key)
542
+ if found is not None:
543
+ return _replay(found, request, branch, digest, accepted)
544
+ needs_review = (
545
+ review_required(resolved, protected=ref.protected, settings=settings)
546
+ and not request.reviewed_by_proposal
547
+ )
548
+ if needs_review and not request.dry_run:
549
+ raise _review_problem(
550
+ resolved.name,
551
+ branch,
552
+ proposals_path,
553
+ REVIEW_POLICY if _policy_demands_review(resolved) else REVIEW_PROTECTED,
554
+ )
555
+
556
+ refs = RefRepository(session, domain_id)
557
+ await refs.lock(branch, timeout_ms=store.runtime.settings.commit.lock_timeout_ms)
558
+ ref = await require_branch(session, domain_id, branch)
559
+ if request.expected_head is not None and request.expected_head != ref.head:
560
+ raise StaleHeadError(branch, request.expected_head, ref.head)
561
+
562
+ invocation_id = f"{INVOCATION_PREFIX}_{new_ulid()}"
563
+ if reveal is not None:
564
+ base = await reveal(session)
565
+ else:
566
+ base = execution_masking(None, await default_schema(session, domain_id, store.runtime))
567
+ masking = base.over(schema)
568
+ if resolved.custom is not None:
569
+ # The Operation may have been valid when the branch forked, before the default branch
570
+ # tagged what it reads: check it again with those tags.
571
+ stale = operation_sensitivity_issues(
572
+ with_tags(schema, masking.reference), resolved.custom, store.runtime.schema.rules()
573
+ )
574
+ if stale:
575
+ raise rejected(findings_of(stale), limit=settings.max_issues)
576
+ outcome = ExecutionOutcome(
577
+ status=ExecutionStatus.DRY_RUN if request.dry_run else ExecutionStatus.COMMITTED,
578
+ operation=resolved.name,
579
+ branch=branch,
580
+ invocation_id=invocation_id,
581
+ parameters_hash=digest,
582
+ head=ref.head,
583
+ warnings=warnings,
584
+ review_required=needs_review,
585
+ parameters=recorded_parameters(schema, resolved, request.parameters, masking.sensitive),
586
+ )
587
+ read_links = _link_reader(session, store, domain_id, branch, schema, rows)
588
+ planned = await _plan(
589
+ schema,
590
+ resolved,
591
+ request,
592
+ rows,
593
+ read_links,
594
+ settings,
595
+ principal,
596
+ store.clock(),
597
+ redact=_redactor(schema, masking, resolved.custom, settings.expression_limits),
598
+ )
599
+ changes, findings = planned.changes, planned.findings
600
+ outcome.warnings.extend(planned.warnings)
601
+ if planned.review and not needs_review and not request.reviewed_by_proposal:
602
+ outcome.review_required = True
603
+ if not request.dry_run:
604
+ raise _review_problem(resolved.name, branch, proposals_path, REVIEW_POLICY)
605
+ outcome.edit_count = len(changes)
606
+ outcome.edits = await _edits(
607
+ Disclosure(session, domain_id, (schema,), masking, store.runtime.vault),
608
+ schema,
609
+ masking,
610
+ changes[: settings.dry_run_max_edits],
611
+ )
612
+ if findings:
613
+ if request.dry_run:
614
+ outcome.violations = findings
615
+ outcome.preconditions_failed = planned.preconditions_failed
616
+ return outcome
617
+ if planned.preconditions_failed:
618
+ raise _precondition_problem(resolved.name, findings, limit=settings.max_issues)
619
+ raise rejected(findings, limit=settings.max_issues, edits=outcome.edits)
620
+
621
+ entry = _provenance(resolved, invocation_id, principal, store.clock())
622
+ origin = _origin(resolved, invocation_id)
623
+ message = request.message or f"{resolved.name} ({invocation_id})"
624
+ batch_size = settings.import_batch_rows if _is_import(resolved) else max(len(changes), 1)
625
+ planned_batches = batches(changes, planned.units, batch_size) or [[]]
626
+ head = ref.head
627
+ for position, batch in enumerate(planned_batches):
628
+ if position and not request.dry_run:
629
+ await session.commit()
630
+ await session.begin()
631
+ await require_domain(session, domain_id)
632
+ await refs.lock(branch, timeout_ms=store.runtime.settings.commit.lock_timeout_ms)
633
+ try:
634
+ result = await commit(
635
+ session,
636
+ domain_id,
637
+ branch,
638
+ [Change(c, provenance=entry.id) for c in batch],
639
+ author=author_of(principal),
640
+ message=message,
641
+ origin=origin,
642
+ provenance=[entry],
643
+ expected_head=head,
644
+ runtime=store.runtime,
645
+ clock=store.clock,
646
+ )
647
+ except ValidationFailedError as error:
648
+ issues = generalize_issues(error.issues, (schema,), masking)
649
+ located = _locate(findings_of(issues), position, batch_size, resolved)
650
+ if request.dry_run:
651
+ outcome.violations = located
652
+ return outcome
653
+ raise rejected(
654
+ located,
655
+ limit=settings.max_issues,
656
+ edits=outcome.edits,
657
+ committed=outcome.revisions,
658
+ ) from error
659
+ if not result.no_op:
660
+ outcome.revisions.append(result.revision_id)
661
+ head = result.revision_id
662
+ if request.dry_run:
663
+ outcome.revisions = []
664
+ return outcome
665
+ events: list[int] = []
666
+ try:
667
+ await _record(
668
+ session, domain_id, request, principal, outcome, resolved, store, started, head
669
+ )
670
+ if resolved.custom is not None:
671
+ events = await write_side_effects(
672
+ session,
673
+ domain_id,
674
+ resolved.custom,
675
+ schema=schema,
676
+ masking=masking,
677
+ rows=rows,
678
+ changes=changes,
679
+ execution=execution_fields(
680
+ branch=branch,
681
+ invocation_id=invocation_id,
682
+ principal=principal.id,
683
+ revisions=outcome.revisions,
684
+ head=head,
685
+ parameters=outcome.parameters,
686
+ ),
687
+ max_edits=settings.side_effect_max_edits,
688
+ defer=request.reviewed_by_proposal,
689
+ )
690
+ await session.commit()
691
+ except IntegrityError:
692
+ # A concurrent request with the same idempotency key won: answer with its result.
693
+ await session.rollback()
694
+ async with store.sessions() as fresh, fresh.begin():
695
+ await require_domain(fresh, domain_id)
696
+ assert request.idempotency_key is not None
697
+ found = await InvocationRepository(fresh, domain_id).by_key(
698
+ principal.id, request.idempotency_key
699
+ )
700
+ if found is None:
701
+ raise
702
+ return _replay(found, request, branch, digest, accepted)
703
+ counted(events)
704
+ return outcome
705
+
706
+
707
+ async def _edits(
708
+ disclosure: Disclosure, schema: Schema, masking: Masking, changes: Sequence[DataChange]
709
+ ) -> list[dict[str, Any]]:
710
+ """The edits shown for `changes`. An update carries the Record's current values: those of
711
+ vault Attributes are references, shown as on every read (the value, `erased_value` or the
712
+ mask). Only the whole value of a vault Attribute is resolved, and only when its vault row
713
+ belongs to that Kind and Attribute; the edits are copies, so nothing resolved here reaches
714
+ the changes that are committed."""
715
+ edits = [copy.deepcopy(edit_document(schema, change, masking)) for change in changes]
716
+ slots: list[tuple[Any, Any, str, str]] = []
717
+ for change, document in zip(changes, edits, strict=True):
718
+ if not isinstance(change, RecordUpsert):
719
+ continue
720
+ kind = schema.kinds.get(change.record.kind)
721
+ values = document.get("values")
722
+ if kind is None or not isinstance(values, dict):
723
+ continue
724
+ slots.extend(
725
+ (values, attribute.api_name, kind.id, attribute.id)
726
+ for attribute in kind.attributes.values()
727
+ if attribute.in_vault and attribute.api_name in values
728
+ )
729
+ await disclosure.slots(slots)
730
+ return edits
731
+
732
+
733
+ def _policy_demands_review(resolved: _Resolved) -> bool:
734
+ return resolved.custom is not None and resolved.custom.review_policy.mode is ReviewMode.ALWAYS
735
+
736
+
737
+ def refused(dry_run: ExecutionOutcome, *, limit: int) -> ProblemError:
738
+ """The 422 a dry run with violations stands for: what executing it would have raised."""
739
+ if dry_run.preconditions_failed:
740
+ return _precondition_problem(dry_run.operation, dry_run.violations, limit=limit)
741
+ return rejected(dry_run.violations, limit=limit, edits=dry_run.edits)
742
+
743
+
744
+ def _review_problem(operation: str, branch: str, proposals_path: str, reason: str) -> ProblemError:
745
+ return ProblemError(
746
+ REVIEW_REQUIRED,
747
+ f"Edits of {operation!r} on branch {branch!r} need review: run the Operation on "
748
+ "another branch and open a Proposal.",
749
+ extensions={
750
+ "branch": branch,
751
+ "operation": operation,
752
+ "proposals": proposals_path,
753
+ "reason": reason,
754
+ },
755
+ )
756
+
757
+
758
+ def _precondition_problem(
759
+ operation: str, findings: Sequence[Finding], *, limit: int
760
+ ) -> ProblemError:
761
+ """422 naming each failed precondition with its expression and position."""
762
+ return ProblemError(
763
+ PRECONDITION_PROBLEM,
764
+ f"{len(findings)} precondition(s) of {operation!r} failed; nothing was executed.",
765
+ extensions={
766
+ "status": "rejected",
767
+ "operation": operation,
768
+ "issues": finding_documents(findings, limit),
769
+ "issue_count": len(findings),
770
+ },
771
+ )
772
+
773
+
774
+ def _link_reader(
775
+ session: AsyncSession,
776
+ store: StoreAccess,
777
+ domain_id: str,
778
+ branch: str,
779
+ schema: Schema,
780
+ rows: MaterializedRepository,
781
+ ) -> LinkReader:
782
+ """`links()` for expressions: Links of a Record (stored or foreign-key backed, or of a
783
+ path Derivation, composed from the Links of its steps) at the materialized head, with
784
+ the Records at their far end."""
785
+ settings = store.runtime.settings
786
+ cap = settings.reads.max_page_rows
787
+
788
+ def too_many() -> ExprError:
789
+ return ExprError(
790
+ ExprErrorCode.LIMIT_EXCEEDED,
791
+ f"links() reached {cap} Links (store.reads.max_page_rows)",
792
+ )
793
+
794
+ async def read(relation_id: str, direction: LinkDirection, key: str) -> list[LinkedRecord]:
795
+ path = schema.path_relations().get(relation_id)
796
+ if path is not None:
797
+ try:
798
+ found_far = await path_links_of(
799
+ rows, path, direction, [key], settings.traverse.path_fanout, cap=cap
800
+ )
801
+ except PathCapExceededError:
802
+ raise too_many() from None
803
+ pairs = found_far.get(key, [])
804
+ if len(pairs) >= cap:
805
+ raise too_many()
806
+ far_kind = path.relation.far_endpoint(direction)
807
+ linked = await rows.records(far_kind, [far for far, _ in pairs])
808
+ return [LinkedRecord(far, linked[far].inputs, {}) for far, _ in pairs if far in linked]
809
+ relation = schema.relations.get(relation_id)
810
+ if relation is None:
811
+ return []
812
+ forward = direction is LinkDirection.FORWARD
813
+ found = await links_of_record(
814
+ session,
815
+ domain_id,
816
+ branch,
817
+ schema,
818
+ relation.endpoint(direction),
819
+ key,
820
+ direction=Direction.OUT if forward else Direction.IN,
821
+ relation_ids=[relation_id],
822
+ limit=cap,
823
+ runtime=store.runtime,
824
+ )
825
+ if len(found) >= cap:
826
+ raise too_many()
827
+ far = {(link.to_key if forward else link.from_key): link for link in found}
828
+ records = await rows.records(relation.far_endpoint(direction), list(far))
829
+ return [
830
+ LinkedRecord(far_key, records[far_key].inputs, link.values)
831
+ for far_key, link in far.items()
832
+ if far_key in records
833
+ ]
834
+
835
+ return read
836
+
837
+
838
+ def _check_operation_permissions(operation: Operation, principal: Principal) -> None:
839
+ """A stored Operation's `permissions` restrict it to principals or groups listed there."""
840
+ if not operation.permissions or principal.method == "none":
841
+ return
842
+ if set(operation.permissions) & set(principal.subjects):
843
+ return
844
+ raise ProblemError(
845
+ FORBIDDEN,
846
+ f"Operation {operation.api_name!r} is restricted to {', '.join(operation.permissions)}.",
847
+ extensions={"operation": operation.api_name},
848
+ )
849
+
850
+
851
+ async def _plan(
852
+ schema: Schema,
853
+ resolved: _Resolved,
854
+ request: ExecutionRequest,
855
+ rows: MaterializedRepository,
856
+ read_links: LinkReader,
857
+ settings: OperationSettings,
858
+ principal: Principal,
859
+ now: datetime,
860
+ *,
861
+ redact: Redact | None = None,
862
+ ) -> PlannedEdits:
863
+ async def read(kind_id: str, keys: Sequence[str]) -> dict[str, Mapping[str, JsonValue]]:
864
+ found = await rows.records(kind_id, keys)
865
+ return {key: row.values for key, row in found.items()}
866
+
867
+ async def read_inputs(kind_id: str, keys: Sequence[str]) -> dict[str, Mapping[str, JsonValue]]:
868
+ """Stored and derived values: what `record()` returns to expressions."""
869
+ found = await rows.records(kind_id, keys)
870
+ return {key: row.inputs for key, row in found.items()}
871
+
872
+ builtin = resolved.builtin
873
+ if builtin is None:
874
+ custom = resolved.custom
875
+ if custom is None or not settings.expressions_enabled:
876
+ return PlannedEdits() # without expressions a stored Operation edits nothing
877
+ return await plan_custom(
878
+ schema,
879
+ custom,
880
+ request.parameters,
881
+ read_inputs,
882
+ principal,
883
+ now,
884
+ settings,
885
+ read_links=read_links,
886
+ redact=redact,
887
+ )
888
+ bound = bind(builtin, request.parameters)
889
+ if bound.findings:
890
+ return PlannedEdits(findings=bound.findings)
891
+ if builtin.is_import:
892
+ plan = import_rows(schema, builtin, bound.values, max_rows=settings.max_bulk_rows)
893
+ elif builtin.action is Action.DELETE_RECORDS:
894
+ plan = await delete_keys(schema, bound.values, read, max_keys=settings.max_bulk_delete_keys)
895
+ else:
896
+ plan = await plan_edits(builtin, bound.values, read)
897
+ return PlannedEdits(
898
+ changes=plan.changes, findings=plan.findings, units=plan.units, warnings=plan.warnings
899
+ )
900
+
901
+
902
+ # Stored Operations with expressions ------------------------------------------------------------
903
+
904
+
905
+ @dataclass
906
+ class PlannedEdits:
907
+ """Edits of an execution, or the findings preventing them; `review` is true when an
908
+ expression review policy sends the edits to a Proposal, `preconditions_failed` when the
909
+ findings are the preconditions that did not hold; `units` are the runs of changes
910
+ committed together (see `operation_catalog.Plan`)."""
911
+
912
+ changes: list[DataChange] = field(default_factory=list)
913
+ findings: list[Finding] = field(default_factory=list)
914
+ review: bool = False
915
+ preconditions_failed: bool = False
916
+ units: list[int] = field(default_factory=list)
917
+ warnings: list[Finding] = field(default_factory=list)
918
+
919
+
920
+ def _false_precondition(program: CompiledExpression, index: int, message: str) -> Finding:
921
+ """A precondition that evaluated to false: its message, expression and position."""
922
+ position = program.root.pos
923
+ return Finding(
924
+ PRECONDITION_FAILED,
925
+ message,
926
+ ("preconditions", index),
927
+ details={
928
+ "expression": program.source,
929
+ "line": position.line,
930
+ "column": position.column,
931
+ },
932
+ )
933
+
934
+
935
+ def execution_masking(revealed: Masking | None, reference: Schema | None) -> Masking:
936
+ """The masking of an execution: the caller's (`reveal`), or without it (agent writes) no
937
+ clearance, the default branch's tags still deciding what is sensitive."""
938
+ if revealed is not None:
939
+ return revealed
940
+ return Masking(Clearance(), reference=MappingProxyType(reference_tags(reference)))
941
+
942
+
943
+ Redact = Callable[[ExprError], bool]
944
+ """Whether an evaluation error must not be shown as it is (it may quote a value)."""
945
+
946
+ REDACTED_ERROR = "Evaluating the expression failed on a value the caller may not read."
947
+
948
+
949
+ def _redactor(
950
+ schema: Schema,
951
+ masking: Masking,
952
+ operation: Operation | None = None,
953
+ limits: ExprLimits | None = None,
954
+ ) -> Redact:
955
+ """Errors of expressions that read values the caller is not cleared for are redacted: their
956
+ messages may quote the values (a cast, a missing Record's key). The caller must be cleared
957
+ for each Attribute the expression may read on the Kinds that hold it (both Kinds of a
958
+ Relation for a Link Attribute), as when the values are read.
959
+
960
+ An error of a step may quote a value the step computed (one that does not fit its
961
+ Attribute, the key of a Record it creates): it is redacted when any expression of the
962
+ step reads such an Attribute, or when the Attribute written is one. An expression that
963
+ cannot be parsed is redacted, since what it reads cannot be told."""
964
+ holders = _attribute_kinds(schema)
965
+ limits = limits or ExprLimits()
966
+
967
+ def hidden(attributes: Iterable[Attribute]) -> bool:
968
+ return any(masking.hidden(a, holders.get(a.id, ())) for a in attributes)
969
+
970
+ def reads(source: str, step: int | None) -> list[Attribute] | None:
971
+ """What `source` may read; `None` when it cannot be parsed."""
972
+ try:
973
+ parse(source, limits)
974
+ except ExprError:
975
+ return None
976
+ if operation is None:
977
+ return attributes_named(schema, expression_fields(source, limits))
978
+ return operation_reads(schema, operation, source, limits, step=step)
979
+
980
+ def redact(error: ExprError) -> bool:
981
+ step = _step_of(error)
982
+ sources = [] if error.source is None else [error.source]
983
+ written: list[Attribute] = []
984
+ if operation is not None and step is not None and step < len(operation.steps):
985
+ sources += _step_sources(operation.steps[step])
986
+ written = _step_writes(schema, operation.steps[step], tuple(error.path))
987
+ for source in sources:
988
+ read = reads(source, step)
989
+ if read is None or hidden(read):
990
+ return True
991
+ return hidden(written)
992
+
993
+ return redact
994
+
995
+
996
+ def _step_sources(step: OperationStep) -> list[str]:
997
+ """Every expression of an Operation step."""
998
+ sources = [getattr(step, name, None) for name in ("target", "from_", "to")]
999
+ found = [s for s in sources if isinstance(s, str)]
1000
+ found += list(getattr(step, "values", {}).values())
1001
+ return found
1002
+
1003
+
1004
+ def _step_writes(
1005
+ schema: Schema, step: OperationStep, path: tuple[PathPart, ...]
1006
+ ) -> list[Attribute]:
1007
+ """The Attributes an error of `step` at `path` may quote a written value of: the one at
1008
+ `("steps", n, "values", attribute)`, otherwise every Attribute the step writes."""
1009
+ if isinstance(step, CreateRecordStep | UpdateRecordStep):
1010
+ kind = schema.kinds.get(step.kind)
1011
+ attributes = {} if kind is None else kind.attributes
1012
+ elif isinstance(step, CreateLinkStep):
1013
+ relation = schema.relations.get(step.relation)
1014
+ attributes = {} if relation is None else relation.attributes
1015
+ else:
1016
+ return []
1017
+ if len(path) >= 4 and path[2] == "values":
1018
+ attribute = attributes.get(str(path[3]))
1019
+ return [] if attribute is None else [attribute]
1020
+ return [attributes[a] for a in step.values if a in attributes]
1021
+
1022
+
1023
+ def _attribute_kinds(schema: Schema) -> dict[str, tuple[str, ...]]:
1024
+ """Attribute id -> ids of the Kinds whose Records (or Links) hold its values."""
1025
+ holders: dict[str, tuple[str, ...]] = {}
1026
+ for kind in schema.kinds.values():
1027
+ for attribute_id in kind.attributes:
1028
+ holders[attribute_id] = (kind.id,)
1029
+ for relation in schema.relations.values():
1030
+ for attribute_id in relation.attributes:
1031
+ holders[attribute_id] = (relation.from_kind, relation.to_kind)
1032
+ return holders
1033
+
1034
+
1035
+ def _step_of(error: ExprError) -> int | None:
1036
+ """The step a located evaluation error comes from (`("steps", n, ...)`), if any."""
1037
+ path = tuple(error.path)
1038
+ if len(path) >= 2 and path[0] == "steps" and isinstance(path[1], int):
1039
+ return path[1]
1040
+ return None
1041
+
1042
+
1043
+ def _expression_finding(error: ExprError, redact: Redact | None = None) -> Finding:
1044
+ if redact is not None and redact(error):
1045
+ return Finding(error.code.value, REDACTED_ERROR, tuple(error.path))
1046
+ details: dict[str, Any] = {}
1047
+ if error.source is not None:
1048
+ details["expression"] = error.source
1049
+ if error.position is not None:
1050
+ details["line"] = error.position.line
1051
+ details["column"] = error.position.column
1052
+ return Finding(error.code.value, error.describe(), tuple(error.path), details=details)
1053
+
1054
+
1055
+ async def _with_records[T](
1056
+ compute: Callable[[], T],
1057
+ resolver: MemoryResolver,
1058
+ read: RecordReader,
1059
+ settings: OperationSettings,
1060
+ read_links: LinkReader | None = None,
1061
+ ) -> T:
1062
+ """Run a pure evaluation, loading each Record (or Link list) it asks for, then run it
1063
+ again; evaluation is deterministic, so a retry computes the same result."""
1064
+ for _ in range(settings.expression_limits.max_record_lookups + 1):
1065
+ try:
1066
+ return compute()
1067
+ except RecordNotLoaded as missing:
1068
+ request = missing.request
1069
+ if isinstance(request, RecordRequest):
1070
+ found = await read(request.kind, [request.key])
1071
+ resolver.records[(request.kind, request.key)] = found.get(request.key)
1072
+ continue
1073
+ assert isinstance(request, LinksRequest)
1074
+ if read_links is None:
1075
+ raise ExprError(
1076
+ ExprErrorCode.EVALUATION, "links() cannot be read in this evaluation"
1077
+ ) from None
1078
+ resolver.links[(request.relation, request.direction, request.key)] = await read_links(
1079
+ request.relation, request.direction, request.key
1080
+ )
1081
+ raise ExprError(
1082
+ ExprErrorCode.LIMIT_EXCEEDED,
1083
+ f"more than {settings.expression_limits.max_record_lookups} Record lookups",
1084
+ )
1085
+
1086
+
1087
+ async def plan_custom(
1088
+ schema: Schema,
1089
+ operation: Operation,
1090
+ parameters: Mapping[str, JsonValue],
1091
+ read: RecordReader,
1092
+ principal: Principal,
1093
+ now: datetime,
1094
+ settings: OperationSettings,
1095
+ *,
1096
+ mint_key: Callable[[], str] = new_ulid,
1097
+ read_links: LinkReader | None = None,
1098
+ redact: Redact | None = None,
1099
+ ) -> PlannedEdits:
1100
+ """Bind parameters, check preconditions, evaluate the review policy and plan the steps
1101
+ of a stored Operation with the expression engine (pure apart from `read` and
1102
+ `read_links`)."""
1103
+ definition = BuiltinOperation( # only the parameters matter to `bind`
1104
+ operation.api_name,
1105
+ Action.CREATE,
1106
+ operation.description,
1107
+ tuple(Parameter(p.name, p.type, p.required, p.description) for p in operation.parameters),
1108
+ )
1109
+ bound = bind(definition, parameters)
1110
+ if bound.findings:
1111
+ return PlannedEdits(findings=bound.findings)
1112
+ resolver = MemoryResolver()
1113
+ context = EvaluationContext(
1114
+ now=now,
1115
+ resolver=resolver,
1116
+ principal=ExpressionPrincipal(principal.id, principal.subjects),
1117
+ limits=settings.expression_limits,
1118
+ mint_key=mint_key,
1119
+ )
1120
+ try:
1121
+ compiled = compile_operation(schema, operation, settings.expression_limits)
1122
+ failures = await _with_records(
1123
+ lambda: check_preconditions(compiled, bound.values, context),
1124
+ resolver,
1125
+ read,
1126
+ settings,
1127
+ read_links,
1128
+ )
1129
+ if failures:
1130
+ return PlannedEdits(
1131
+ findings=[
1132
+ _expression_finding(f.error, redact)
1133
+ if f.error is not None
1134
+ else _false_precondition(compiled.preconditions[f.index], f.index, f.message)
1135
+ for f in failures
1136
+ ],
1137
+ preconditions_failed=True,
1138
+ )
1139
+ review = await _with_records(
1140
+ lambda: evaluate_review_policy(compiled, bound.values, context),
1141
+ resolver,
1142
+ read,
1143
+ settings,
1144
+ read_links,
1145
+ )
1146
+ steps = await _with_records(
1147
+ lambda: plan_steps(compiled, bound.values, context),
1148
+ resolver,
1149
+ read,
1150
+ settings,
1151
+ read_links,
1152
+ )
1153
+ except ExprError as error:
1154
+ return PlannedEdits(findings=[_expression_finding(error, redact)])
1155
+ return PlannedEdits(changes=steps.changes, review=review)
1156
+
1157
+
1158
+ def _locate(
1159
+ findings: list[Finding], batch: int, batch_size: int, resolved: _Resolved
1160
+ ) -> list[Finding]:
1161
+ """Findings of an import batch keep their Record keys; the batch is noted in details."""
1162
+ if not _is_import(resolved):
1163
+ return findings
1164
+ return [
1165
+ Finding(
1166
+ f.code,
1167
+ f.message,
1168
+ f.path,
1169
+ f.severity,
1170
+ f.kind,
1171
+ f.relation,
1172
+ f.key,
1173
+ f.attribute,
1174
+ {**f.details, "batch": batch, "batch_size": batch_size},
1175
+ )
1176
+ for f in findings
1177
+ ]
1178
+
1179
+
1180
+ async def _record(
1181
+ session: AsyncSession,
1182
+ domain_id: str,
1183
+ request: ExecutionRequest,
1184
+ principal: Principal,
1185
+ outcome: ExecutionOutcome,
1186
+ resolved: _Resolved,
1187
+ store: StoreAccess,
1188
+ started: float,
1189
+ head: bytes,
1190
+ ) -> None:
1191
+ """Invocation record and audit entry, in the transaction of the last Revision."""
1192
+ revisions = [hex(r) for r in outcome.revisions]
1193
+ await InvocationRepository(session, domain_id).insert(
1194
+ InvocationRow(
1195
+ id=outcome.invocation_id,
1196
+ operation_id=resolved.custom.id if resolved.custom else resolved.name,
1197
+ principal=principal.id,
1198
+ params={
1199
+ "operation": resolved.name,
1200
+ "branch": outcome.branch,
1201
+ "parameters": outcome.parameters,
1202
+ "parameters_hash": outcome.parameters_hash,
1203
+ "head": hex(outcome.head),
1204
+ "revisions": revisions,
1205
+ "edit_count": outcome.edit_count,
1206
+ },
1207
+ status=InvocationStatus.COMMITTED,
1208
+ revision_id=head,
1209
+ proposal_id=None,
1210
+ idempotency_key=request.idempotency_key,
1211
+ created_at=store.clock(),
1212
+ duration_ms=int((time.monotonic() - started) * 1000),
1213
+ )
1214
+ )
1215
+ await AuditRepository(session).append(
1216
+ NewAuditEntry(
1217
+ domain_id=domain_id,
1218
+ actor=principal.id,
1219
+ action=INVOKE_ACTION,
1220
+ target=f"{outcome.branch}@{hex(head)}",
1221
+ details={
1222
+ "invocation_id": outcome.invocation_id,
1223
+ "operation": resolved.name,
1224
+ "parameters_hash": outcome.parameters_hash,
1225
+ "revisions": revisions,
1226
+ "edits": outcome.edit_count,
1227
+ },
1228
+ )
1229
+ )