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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- 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
|
+
)
|