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,1107 @@
1
+ """Proposals, reviews, merges and Conflict resolution (design 6.8 to 6.10, 10.3, 11.1).
2
+
3
+ | Method and path | Permission | Notes |
4
+ |-----------------|------------|-------|
5
+ | `POST .../proposals` | `proposal.create` (source), `data.read` (target) | 201; key replays 200 |
6
+ | `GET .../proposals` | `data.read` | filters `status`, `origin`, `source`, `target`, `author` |
7
+ | `GET .../proposals/{p}` | `data.read` (both branches) | reviews, approvals, latest attempt |
8
+ | `PATCH .../proposals/{p}` | author, else `proposal.merge` | title, description |
9
+ | `POST .../proposals/{p}/close` | author, else `proposal.merge` | attempts abandoned |
10
+ | `POST .../proposals/{p}/reviews` | `proposal.review` (target) | 201 |
11
+ | `POST .../proposals/{p}/merge` | `proposal.merge` (target), read | 200; 409 `merge-conflicts` |
12
+ | `GET .../proposals/{p}/conflicts` | `data.read` (both branches) | attempt, page of Conflicts |
13
+ | `POST .../proposals/{p}/conflicts/{c}/resolution` | `conflict.resolve` (target), read | stores |
14
+ | `POST .../proposals/{p}/conflicts:resolve` | `conflict.resolve` (target), read | stores, re-runs |
15
+
16
+ The merge answers 200 with the merge outcome when the target moved (merge Revision,
17
+ fast-forward or already up to date) and 409 `merge-conflicts` when Conflicts remain; that
18
+ problem carries the stored attempt (`attempt`, same shape as the Conflict listing, open
19
+ Conflicts first page). Conflicts are identified by their fingerprint. Reading the Conflicts of
20
+ an open Proposal recomputes its attempt when a head moved since (6.10, stale attempts).
21
+
22
+ Reads are authorized against the source and the target branch (`services.proposal_reads`):
23
+ listings keep the Proposals the caller may read, so a page may hold fewer items than asked
24
+ while `next_cursor` is set; callers limited to Kinds see the Conflicts of those Kinds only.
25
+ Writes keep that scope ("read" above): merging and resolving need the read access of the
26
+ Conflict listing, a resolution naming a Conflict outside it is refused (403), and the
27
+ attempts they return list only the Conflicts the caller may read. Proposal details count only
28
+ those Conflicts too, and the merge check of a caller who does not see every Conflict states no
29
+ count.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from collections.abc import Awaitable, Callable, Mapping
36
+ from dataclasses import dataclass
37
+ from datetime import datetime
38
+ from typing import Annotated, Any, Literal
39
+
40
+ from fastapi import APIRouter, Depends, Query, Request, Response
41
+ from pydantic import BaseModel, Field, JsonValue
42
+
43
+ from lorebase_core.canonical import hex
44
+ from lorebase_core.merge import ConflictKind
45
+ from lorebase_core.model import Schema, Sensitivity
46
+ from lorebase_keeper.auth.audit import annotate
47
+ from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, authorize, require
48
+ from lorebase_keeper.auth.permissions import Permission
49
+ from lorebase_keeper.auth.rbac import AccessContext
50
+ from lorebase_keeper.context import StoreDep
51
+ from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep
52
+ from lorebase_keeper.errors import VALIDATION_FAILED, Problem, ProblemError, ValidationProblem
53
+ from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
54
+ from lorebase_keeper.reading.points import read_guard
55
+ from lorebase_keeper.reading.sensitivity import (
56
+ Disclosure,
57
+ Masking,
58
+ collapse_issues,
59
+ conflict_hidden,
60
+ default_schema,
61
+ mask_conflict,
62
+ masking_for,
63
+ )
64
+ from lorebase_keeper.reading.visibility import projection
65
+ from lorebase_keeper.routes.domains import REPLAYED_HEADER
66
+ from lorebase_keeper.services import agent_writes
67
+ from lorebase_keeper.services import proposals as service
68
+ from lorebase_keeper.services.errors import commit_problem, findings_of
69
+ from lorebase_keeper.services.pii_import import require_schema_change
70
+ from lorebase_keeper.services.proposal_reads import (
71
+ ConflictScope,
72
+ conflict_scope,
73
+ readable_proposals,
74
+ require_target_read,
75
+ visible_scope,
76
+ )
77
+ from lorebase_keeper.services.reads import ReadGuard
78
+ from lorebase_keeper.settings import KeeperSettings
79
+ from lorebase_store.commit import CommitError, ValidationFailedError
80
+ from lorebase_store.commit.schema import load_compiled
81
+ from lorebase_store.models.enums import ProposalOrigin, ProposalStatus, ReviewVerdict
82
+ from lorebase_store.repositories import (
83
+ ConflictRow,
84
+ MaterializedRepository,
85
+ MergeAttemptRow,
86
+ ProposalFilter,
87
+ ProposalRow,
88
+ ResolutionRow,
89
+ ReviewRow,
90
+ scope_to_domain,
91
+ )
92
+
93
+ router = APIRouter(tags=["proposals"])
94
+
95
+ PROPOSALS = "/domains/{domain_id}/proposals"
96
+ PROPOSAL = PROPOSALS + "/{proposal_id}"
97
+
98
+ DataRead = Annotated[ReadGuard, Depends(read_guard())]
99
+
100
+ _ERRORS: dict[int | str, dict[str, Any]] = {
101
+ 403: {"model": Problem, "description": "Missing permission, or self-approval refused."},
102
+ 404: {"model": Problem, "description": "Domain, Proposal, branch or Conflict not found."},
103
+ 409: {
104
+ "model": Problem,
105
+ "description": "Proposal not open (`proposal-not-open`), approvals missing "
106
+ "(`approvals-required`), Conflicts remain (`merge-conflicts`, with `attempt`), a "
107
+ "branch is gone (`proposal-branch-missing`), duplicate or idempotency conflict.",
108
+ },
109
+ 422: {"model": ValidationProblem, "description": "Invalid request or resolution."},
110
+ 503: {"model": Problem, "description": "The target branch is locked by another writer."},
111
+ }
112
+
113
+ _CODES = frozenset(kind.value for kind in ConflictKind)
114
+
115
+
116
+ # Models -----------------------------------------------------------------------------------
117
+
118
+
119
+ class ProposalCreate(BaseModel):
120
+ """A new Proposal: merge `source_ref` into `target_ref`."""
121
+
122
+ source_ref: str = Field(min_length=1, max_length=200)
123
+ target_ref: str = Field(min_length=1, max_length=200)
124
+ title: str = Field(min_length=1, max_length=300)
125
+ description: str | None = Field(default=None, max_length=20000)
126
+
127
+
128
+ class ProposalUpdate(BaseModel):
129
+ """Title and description; omitted members are left unchanged."""
130
+
131
+ title: str | None = Field(default=None, min_length=1, max_length=300)
132
+ description: str | None = Field(default=None, max_length=20000)
133
+
134
+
135
+ class CheckOut(BaseModel):
136
+ """One Proposal check (design 10.3)."""
137
+
138
+ name: str
139
+ status: Literal["passed", "failed", "pending"]
140
+ message: str | None = None
141
+
142
+
143
+ class ProposalOut(BaseModel):
144
+ id: str
145
+ title: str
146
+ description: str | None
147
+ source_ref: str
148
+ target_ref: str
149
+ origin: ProposalOrigin
150
+ status: ProposalStatus
151
+ author: str
152
+ created_at: datetime
153
+ updated_at: datetime | None
154
+ merged_revision: str | None
155
+ policy: dict[str, Any] = Field(description="Merge policy options differing from defaults.")
156
+ checks: list[CheckOut]
157
+
158
+
159
+ class ReviewIn(BaseModel):
160
+ verdict: ReviewVerdict
161
+ comment: str | None = Field(default=None, max_length=20000)
162
+
163
+
164
+ class ReviewOut(BaseModel):
165
+ id: str
166
+ reviewer: str
167
+ verdict: ReviewVerdict
168
+ comment: str | None
169
+ at: datetime
170
+
171
+
172
+ class ApprovalOut(BaseModel):
173
+ """Review state against the target branch's protection."""
174
+
175
+ protected: bool
176
+ required: int
177
+ approvals: int
178
+ approvers: list[str]
179
+ changes_requested: list[str]
180
+ satisfied: bool
181
+
182
+
183
+ class ResolutionIn(BaseModel):
184
+ """A decision for one Conflict (`value` or per-member `values` for `custom` only)."""
185
+
186
+ choice: str
187
+ value: JsonValue = None
188
+ values: dict[str, JsonValue] | None = None
189
+
190
+
191
+ class FingerprintedResolutionIn(ResolutionIn):
192
+ fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
193
+
194
+
195
+ class ResolveManyIn(BaseModel):
196
+ resolutions: list[FingerprintedResolutionIn] = Field(min_length=1)
197
+
198
+
199
+ class ResolutionOut(BaseModel):
200
+ """A stored resolution; `matched` is false when its Conflict is gone from the attempt."""
201
+
202
+ fingerprint: str
203
+ choice: str
204
+ value: JsonValue = None
205
+ values: dict[str, JsonValue] | None = None
206
+ resolver: str
207
+ at: datetime
208
+ matched: bool = True
209
+
210
+
211
+ class AttemptOut(BaseModel):
212
+ """A merge attempt (design 6.8 step 5) and the resolutions of its Proposal."""
213
+
214
+ id: str
215
+ proposal: str
216
+ status: str
217
+ result: str | None = Field(description="`conflicts`, `merged`, `fast_forward` or `up_to_date`.")
218
+ base: str | None
219
+ ours: str
220
+ theirs: str
221
+ virtual_base: bool
222
+ created_at: datetime
223
+ created_by: str | None
224
+ revision: str | None = Field(description="Target head after the merge committed.")
225
+ policy: dict[str, Any]
226
+ open_conflicts: int
227
+ resolved_conflicts: int
228
+ counts: dict[str, dict[str, int]] = Field(description="Per Conflict code: open, resolved.")
229
+ recomputed: bool = Field(description="This request ran the merge.")
230
+ stale: bool = Field(description="A head moved since the previous attempt.")
231
+ resolutions: list[ResolutionOut]
232
+ unmatched_resolutions: list[str]
233
+ auto_resolutions: list[dict[str, Any]]
234
+
235
+
236
+ class ConflictOut(BaseModel):
237
+ """A Conflict (core `Conflict`) with its id, state and members."""
238
+
239
+ model_config = {"extra": "allow"}
240
+
241
+ id: str
242
+ status: Literal["open", "resolved"]
243
+ resolution: dict[str, Any] | None = None
244
+
245
+
246
+ class ConflictsOut(AttemptOut):
247
+ """An attempt with one page of its Conflicts."""
248
+
249
+ conflicts: list[ConflictOut]
250
+ next_cursor: str | None = None
251
+
252
+
253
+ class ProposalDetailOut(ProposalOut):
254
+ reviews: list[ReviewOut]
255
+ approval: ApprovalOut
256
+ attempt: AttemptOut | None
257
+
258
+
259
+ class ReviewResult(BaseModel):
260
+ review: ReviewOut
261
+ approval: ApprovalOut
262
+
263
+
264
+ class MergeOut(BaseModel):
265
+ """A merged Proposal."""
266
+
267
+ status: Literal["merged"] = "merged"
268
+ result: str = Field(description="`merged`, `fast_forward` or `up_to_date`.")
269
+ revision: str = Field(description="Head of the target branch after the merge.")
270
+ proposal: ProposalOut
271
+ attempt: ConflictsOut
272
+ source_deleted: bool
273
+ replayed: bool
274
+
275
+
276
+ # Mapping ----------------------------------------------------------------------------------
277
+
278
+
279
+ def _checks(
280
+ row: ProposalRow, approval: service.Approval | None = None, *, counted: bool = True
281
+ ) -> list[CheckOut]:
282
+ """The checks of `row`; `counted` false leaves the number of open Conflicts and of
283
+ extraction items out (callers who do not see every Conflict: items may be about Kinds
284
+ they may not read)."""
285
+ checks: list[CheckOut] = []
286
+ merge = (row.checks or {}).get("merge")
287
+ if isinstance(merge, dict):
288
+ open_count = int(merge.get("open_conflicts") or 0)
289
+ status: Literal["passed", "failed", "pending"] = "passed" if open_count == 0 else "failed"
290
+ if open_count == 0:
291
+ message = "No Conflicts."
292
+ elif counted:
293
+ message = f"{open_count} open Conflict(s) at {str(merge.get('ours'))[:12]}."
294
+ else:
295
+ message = f"Open Conflicts at {str(merge.get('ours'))[:12]}."
296
+ checks.append(CheckOut(name="merge", status=status, message=message))
297
+ else:
298
+ checks.append(CheckOut(name="merge", status="pending", message="Not computed yet."))
299
+ extraction = (row.checks or {}).get("extraction")
300
+ if isinstance(extraction, dict):
301
+ decisions = extraction.get("decisions") or {}
302
+ pending = int(decisions.get("pending") or 0)
303
+ checks.append(
304
+ CheckOut(
305
+ name="extraction",
306
+ status="pending" if pending else "passed",
307
+ message=(
308
+ f"{extraction.get('items', 0)} item(s): "
309
+ f"{decisions.get('accepted', 0)} accepted, {pending} pending, "
310
+ f"{decisions.get('rejected', 0)} rejected."
311
+ if counted
312
+ else "Items pending review."
313
+ if pending
314
+ else "Items reviewed."
315
+ ),
316
+ )
317
+ )
318
+ if approval is not None and approval.protected:
319
+ checks.append(
320
+ CheckOut(
321
+ name="approvals",
322
+ status="passed" if approval.satisfied else "pending",
323
+ message=f"{len(approval.approvers)} of {approval.required} approval(s)"
324
+ + (
325
+ f"; changes requested by {', '.join(approval.changes_requested)}"
326
+ if approval.changes_requested
327
+ else ""
328
+ ),
329
+ )
330
+ )
331
+ return checks
332
+
333
+
334
+ def _proposal(
335
+ row: ProposalRow, approval: service.Approval | None = None, *, counted: bool = True
336
+ ) -> ProposalOut:
337
+ return ProposalOut(
338
+ id=row.id,
339
+ title=row.title,
340
+ description=row.description,
341
+ source_ref=row.source_ref,
342
+ target_ref=row.target_ref,
343
+ origin=row.origin,
344
+ status=row.status,
345
+ author=row.author,
346
+ created_at=row.created_at,
347
+ updated_at=row.updated_at,
348
+ merged_revision=None if row.merged_revision is None else hex(row.merged_revision),
349
+ policy=dict((row.policies or {}).get(service.POLICY_KEY) or {}),
350
+ checks=_checks(row, approval, counted=counted),
351
+ )
352
+
353
+
354
+ def _review(row: ReviewRow) -> ReviewOut:
355
+ return ReviewOut(
356
+ id=row.id, reviewer=row.reviewer, verdict=row.verdict, comment=row.comment, at=row.at
357
+ )
358
+
359
+
360
+ def _approval(approval: service.Approval) -> ApprovalOut:
361
+ return ApprovalOut(
362
+ protected=approval.protected,
363
+ required=approval.required,
364
+ approvals=len(approval.approvers),
365
+ approvers=list(approval.approvers),
366
+ changes_requested=list(approval.changes_requested),
367
+ satisfied=approval.satisfied,
368
+ )
369
+
370
+
371
+ def _resolution(
372
+ row: ResolutionRow, unmatched: frozenset[str], masked: str | None = None
373
+ ) -> ResolutionOut:
374
+ """A stored resolution; with `masked`, its custom values are replaced by it."""
375
+ body = row.resolution
376
+ value, values = body.get("value"), body.get("values")
377
+ if masked is not None:
378
+ value = None if value is None else masked
379
+ values = None if values is None else dict.fromkeys(values, masked)
380
+ return ResolutionOut(
381
+ fingerprint=row.fingerprint,
382
+ choice=str(body.get("choice")),
383
+ value=value,
384
+ values=values,
385
+ resolver=row.resolved_by,
386
+ at=row.at,
387
+ matched=row.fingerprint not in unmatched,
388
+ )
389
+
390
+
391
+ def _attempt_fields(
392
+ attempt: MergeAttemptRow,
393
+ counts: dict[tuple[str, bool], int],
394
+ resolutions: list[ResolutionRow],
395
+ *,
396
+ recomputed: bool,
397
+ stale: bool,
398
+ masked: Callable[[str], str | None] = lambda _fingerprint: None,
399
+ ) -> dict[str, Any]:
400
+ outcome = attempt.outcome or {}
401
+ unmatched = [str(f) for f in outcome.get("unmatched_resolutions") or []]
402
+ per_code: dict[str, dict[str, int]] = {}
403
+ for (code, resolved), count in sorted(counts.items()):
404
+ entry = per_code.setdefault(code, {"open": 0, "resolved": 0})
405
+ entry["resolved" if resolved else "open"] += count
406
+ return {
407
+ "id": attempt.id,
408
+ "proposal": attempt.proposal_id,
409
+ "status": attempt.status.value,
410
+ "result": attempt.result,
411
+ "base": None if attempt.base is None else hex(attempt.base),
412
+ "ours": hex(attempt.ours),
413
+ "theirs": hex(attempt.theirs),
414
+ "virtual_base": attempt.virtual_base,
415
+ "created_at": attempt.created_at,
416
+ "created_by": attempt.created_by,
417
+ "revision": None if attempt.revision is None else hex(attempt.revision),
418
+ "policy": attempt.policy or {},
419
+ "open_conflicts": sum(c["open"] for c in per_code.values()),
420
+ "resolved_conflicts": sum(c["resolved"] for c in per_code.values()),
421
+ "counts": per_code,
422
+ "recomputed": recomputed,
423
+ "stale": stale,
424
+ "resolutions": [
425
+ _resolution(r, frozenset(unmatched), masked(r.fingerprint)) for r in resolutions
426
+ ],
427
+ "unmatched_resolutions": unmatched,
428
+ "auto_resolutions": list(outcome.get("auto_resolutions") or []),
429
+ }
430
+
431
+
432
+ def _attempt_summary(attempt: MergeAttemptRow) -> AttemptOut:
433
+ """Attempt without per-code counts and resolutions (Proposal detail)."""
434
+ outcome = attempt.outcome or {}
435
+ fields = _attempt_fields(attempt, {}, [], recomputed=False, stale=False)
436
+ fields["open_conflicts"] = int(outcome.get("open") or 0)
437
+ fields["resolved_conflicts"] = int(outcome.get("resolved") or 0)
438
+ return AttemptOut(**fields)
439
+
440
+
441
+ @dataclass(frozen=True)
442
+ class _Visibility:
443
+ """What the caller may see of Conflict values: the target branch's schema (None when it
444
+ is not materialized) and the caller's masking."""
445
+
446
+ schema: Schema | None
447
+ masking: Masking
448
+ left_out: frozenset[str] = frozenset()
449
+ """Ids the caller's projection of the schema leaves out (Kinds invisible to it, design
450
+ 13.3): Conflicts and automatic resolutions never show them."""
451
+
452
+ def hidden(self, payload: Mapping[str, Any]) -> bool:
453
+ return conflict_hidden(payload, self.schema, self.masking)
454
+
455
+ def names_left_out(self, value: Any) -> bool:
456
+ if not self.left_out:
457
+ return False
458
+ text = json.dumps(value, default=str)
459
+ return any(i in text for i in self.left_out)
460
+
461
+ def scrub(self, payload: dict[str, Any]) -> dict[str, Any]:
462
+ """`payload` without the values of Attributes left out, and without a message or
463
+ details naming something left out."""
464
+ if not self.left_out:
465
+ return payload
466
+ out = dict(payload)
467
+ for name in (*_CONFLICT_SIDES, "values"):
468
+ out[name] = _without(out.get(name), self.left_out)
469
+ if self.names_left_out([out.get("message"), out.get("details")]):
470
+ out["message"] = "The versions differ."
471
+ out["details"] = {}
472
+ return out
473
+
474
+
475
+ _CONFLICT_SIDES = ("base", "ours", "theirs")
476
+
477
+
478
+ def _without(value: Any, ids: frozenset[str]) -> Any:
479
+ """A value map (or an element body holding `values`) without the entries of `ids`."""
480
+ if not isinstance(value, dict):
481
+ return value
482
+ if isinstance(value.get("values"), dict):
483
+ return {**value, "values": _without(value["values"], ids)}
484
+ return {k: v for k, v in value.items() if k not in ids}
485
+
486
+
487
+ async def _visibility(
488
+ store: StoreDep,
489
+ guard: ReadGuard,
490
+ domain_id: str,
491
+ target: str,
492
+ keeper: KeeperSettings,
493
+ scope: ConflictScope | None = None,
494
+ ) -> _Visibility:
495
+ writes = store.runtime.settings.writes
496
+ async with store.sessions() as session, session.begin():
497
+ await scope_to_domain(session, domain_id)
498
+ compiled = await MaterializedRepository(session, domain_id, target, writes=writes).schema()
499
+ reference = await default_schema(session, domain_id, store.runtime)
500
+ schema = None if compiled is None else load_compiled(compiled.compiled)
501
+ masking = await masking_for(
502
+ guard.authorizer, guard.principal, domain_id, reference, keeper.reads.sensitivity
503
+ )
504
+ left_out: frozenset[str] = frozenset()
505
+ if schema is not None and scope is not None and scope.invisible and scope.kinds is not None:
506
+ left_out = projection(schema, scope.kinds).hidden
507
+ return _Visibility(schema, masking if schema is None else masking.over(schema), left_out)
508
+
509
+
510
+ def _conflict(row: ConflictRow, seen: _Visibility) -> ConflictOut:
511
+ payload = seen.scrub(mask_conflict(row.payload, seen.schema, seen.masking))
512
+ resolution = None if row.resolution is None else seen.scrub(row.resolution)
513
+ if resolution is not None and seen.hidden(row.payload):
514
+ resolution = {
515
+ **resolution,
516
+ **{k: seen.masking.masked for k in ("value", "values") if resolution.get(k)},
517
+ }
518
+ return ConflictOut(
519
+ id=row.fingerprint,
520
+ status="resolved" if row.resolved else "open",
521
+ resolution=resolution,
522
+ **payload,
523
+ )
524
+
525
+
526
+ async def _attempt(
527
+ view: service.AttemptView,
528
+ store: StoreDep,
529
+ guard: ReadGuard,
530
+ domain_id: str,
531
+ target: str,
532
+ keeper: KeeperSettings,
533
+ scope: ConflictScope | None = None,
534
+ ) -> ConflictsOut:
535
+ """The attempt as the caller may see it: values masked by schema (`_conflicts`), then
536
+ the vault references left in it resolved as on every read (`Disclosure`)."""
537
+ seen = await _visibility(store, guard, domain_id, target, keeper, scope)
538
+ attempt = _conflicts(view, seen)
539
+ schemas = () if seen.schema is None else (seen.schema,)
540
+ async with store.sessions() as session, session.begin():
541
+ await scope_to_domain(session, domain_id)
542
+ disclosure = Disclosure(session, domain_id, schemas, seen.masking, store.runtime.vault)
543
+ body = await disclosure.model(attempt)
544
+ return ConflictsOut.model_validate_json(body)
545
+
546
+
547
+ def _conflicts(view: service.AttemptView, seen: _Visibility) -> ConflictsOut:
548
+ hidden = {row.fingerprint for row in view.conflicts if seen.hidden(row.payload)}
549
+ shown = {row.fingerprint for row in view.conflicts} - hidden
550
+ full = seen.masking.clearance.covers(Sensitivity, ())
551
+
552
+ def masked(fingerprint: str) -> str | None:
553
+ # Resolutions of Conflicts outside this page are masked unless the caller is cleared
554
+ # for everything: their Attribute is not known here.
555
+ if fingerprint in shown or (fingerprint not in hidden and full):
556
+ return None
557
+ return seen.masking.masked
558
+
559
+ fields = _attempt_fields(
560
+ view.attempt,
561
+ view.counts,
562
+ view.resolutions,
563
+ recomputed=view.recomputed,
564
+ stale=view.stale,
565
+ masked=masked,
566
+ )
567
+ fields["auto_resolutions"] = [
568
+ a for a in fields["auto_resolutions"] if not seen.names_left_out(a)
569
+ ]
570
+ return ConflictsOut(
571
+ **fields,
572
+ conflicts=[_conflict(row, seen) for row in view.conflicts],
573
+ next_cursor=None if view.next_after is None else encode_cursor({"after": view.next_after}),
574
+ )
575
+
576
+
577
+ def _detail(view: service.ProposalView, scope: ConflictScope) -> ProposalDetailOut:
578
+ """`view`, whose attempt `service.scoped` limited to `scope`."""
579
+ base = _proposal(view.proposal, view.approval, counted=scope.everything)
580
+ return ProposalDetailOut(
581
+ **base.model_dump(),
582
+ reviews=[_review(r) for r in view.reviews],
583
+ approval=_approval(view.approval),
584
+ attempt=None if view.attempt is None else _attempt_summary(view.attempt),
585
+ )
586
+
587
+
588
+ def _codes(kind: str | None) -> list[str]:
589
+ if not kind:
590
+ return []
591
+ codes = [code.strip() for code in kind.split(",") if code.strip()]
592
+ unknown = [code for code in codes if code not in _CODES]
593
+ if unknown:
594
+ raise ProblemError(
595
+ VALIDATION_FAILED,
596
+ "One or more request members are invalid.",
597
+ extensions={
598
+ "errors": [
599
+ {
600
+ "pointer": "/query/kind",
601
+ "message": f"unknown Conflict code {unknown[0]!r}",
602
+ "code": "invalid",
603
+ }
604
+ ]
605
+ },
606
+ )
607
+ return codes
608
+
609
+
610
+ async def _check(
611
+ request: Request,
612
+ auth: AuthDep,
613
+ principal: PrincipalDep,
614
+ permission: Permission,
615
+ domain_id: str,
616
+ branch: str,
617
+ ) -> None:
618
+ await authorize(request, auth, principal, permission, domain_id, AccessContext(branch=branch))
619
+
620
+
621
+ async def _readable(
622
+ store: StoreDep, guard: ReadGuard, domain_id: str, proposal_id: str
623
+ ) -> ConflictScope:
624
+ """What the caller may read of the Proposal (403 when nothing; unknown Proposals are
625
+ 403 for callers whose grants are narrowed)."""
626
+ await guard.precheck()
627
+ try:
628
+ proposal = await service.target_of(store, domain_id, proposal_id)
629
+ except ProblemError as error:
630
+ raise guard.unknown(error, wide=await guard.wide()) from None
631
+ return await conflict_scope(guard, proposal)
632
+
633
+
634
+ async def _store_problem(
635
+ error: CommitError, retry_after_s: int, seen: Callable[[], Awaitable[_Visibility]]
636
+ ) -> ProblemError:
637
+ """The problem answering a store failure; validation issues about values the caller may
638
+ not read (`seen`) have a generic code."""
639
+ if not isinstance(error, ValidationFailedError):
640
+ return commit_problem(error, max_issues=1, retry_after_s=retry_after_s)
641
+ visible = await seen()
642
+ schemas = () if visible.schema is None else (visible.schema,)
643
+ return commit_problem(
644
+ error,
645
+ max_issues=1,
646
+ retry_after_s=retry_after_s,
647
+ locate=lambda issues: findings_of(collapse_issues(issues, schemas, visible.masking)),
648
+ )
649
+
650
+
651
+ # Routes -----------------------------------------------------------------------------------
652
+
653
+
654
+ @router.post(PROPOSALS, status_code=201, summary="Create a Proposal", responses=_ERRORS)
655
+ async def create_proposal(
656
+ domain_id: str,
657
+ body: ProposalCreate,
658
+ request: Request,
659
+ response: Response,
660
+ principal: PrincipalDep,
661
+ auth: AuthDep,
662
+ store: StoreDep,
663
+ keeper: KeeperSettingsDep,
664
+ idempotency_key: IdempotencyKeyDep,
665
+ guard: DataRead,
666
+ ) -> ProposalDetailOut:
667
+ """Open a Proposal and compute its merge preview (Conflict count).
668
+
669
+ The merge policy comes from the Domain's `merge_policy` setting. With an
670
+ `Idempotency-Key`, a retried create returns the Proposal with status 200 and
671
+ `Idempotent-Replayed: true`.
672
+ """
673
+ await _check(request, auth, principal, Permission.PROPOSAL_CREATE, domain_id, body.source_ref)
674
+ await require_target_read(guard, body.target_ref)
675
+ created = await service.create(
676
+ store,
677
+ domain_id,
678
+ service.NewProposal(
679
+ source_ref=body.source_ref,
680
+ target_ref=body.target_ref,
681
+ title=body.title,
682
+ description=body.description,
683
+ ),
684
+ principal,
685
+ keeper.proposals,
686
+ idempotency_key=idempotency_key,
687
+ )
688
+ proposal = created.view.proposal
689
+ annotate(
690
+ request,
691
+ action="proposal.create",
692
+ target=proposal.id,
693
+ source=proposal.source_ref,
694
+ target_branch=proposal.target_ref,
695
+ replayed=created.replayed,
696
+ )
697
+ if created.replayed:
698
+ response.status_code = 200
699
+ response.headers[REPLAYED_HEADER] = "true"
700
+ response.headers["Location"] = (
701
+ f"{keeper.api_prefix}/domains/{domain_id}/proposals/{proposal.id}"
702
+ )
703
+ scope = await visible_scope(guard, proposal)
704
+ return _detail(await service.scoped(store, domain_id, created.view, scope), scope)
705
+
706
+
707
+ @router.get(PROPOSALS, summary="List Proposals", responses=_ERRORS)
708
+ async def list_proposals(
709
+ domain_id: str,
710
+ store: StoreDep,
711
+ params: PageParamsDep,
712
+ guard: DataRead,
713
+ status: ProposalStatus | None = None,
714
+ origin: ProposalOrigin | None = None,
715
+ source: Annotated[str | None, Query(description="Source branch.")] = None,
716
+ target: Annotated[str | None, Query(description="Target branch.")] = None,
717
+ author: str | None = None,
718
+ ) -> Page[ProposalOut]:
719
+ """Proposals the caller may read, newest first."""
720
+ await guard.precheck()
721
+ after = None
722
+ if params.position is not None:
723
+ try:
724
+ after = (
725
+ datetime.fromisoformat(str(params.position["at"])),
726
+ str(params.position["id"]),
727
+ )
728
+ except (KeyError, ValueError):
729
+ raise ProblemError(VALIDATION_FAILED, "The cursor is not a Proposal cursor.") from None
730
+ rows = await service.page(
731
+ store,
732
+ domain_id,
733
+ ProposalFilter(
734
+ status=status, origin=origin, source_ref=source, target_ref=target, author=author
735
+ ),
736
+ limit=params.limit + 1,
737
+ after=after,
738
+ )
739
+ more = len(rows) > params.limit
740
+ rows = rows[: params.limit]
741
+ next_cursor = (
742
+ encode_cursor({"at": rows[-1].created_at.isoformat(), "id": rows[-1].id})
743
+ if more and rows
744
+ else None
745
+ )
746
+ items = [
747
+ _proposal(r, counted=(await visible_scope(guard, r)).everything)
748
+ for r in await readable_proposals(guard, rows)
749
+ ]
750
+ return Page[ProposalOut](items=items, next_cursor=next_cursor)
751
+
752
+
753
+ @router.get(PROPOSAL, summary="Get a Proposal", responses=_ERRORS)
754
+ async def get_proposal(
755
+ domain_id: str,
756
+ proposal_id: str,
757
+ store: StoreDep,
758
+ keeper: KeeperSettingsDep,
759
+ guard: DataRead,
760
+ ) -> ProposalDetailOut:
761
+ """The Proposal with its reviews, approval state and latest merge attempt."""
762
+ scope = await _readable(store, guard, domain_id, proposal_id)
763
+ view = await service.get(store, domain_id, proposal_id, keeper.proposals)
764
+ return _detail(await service.scoped(store, domain_id, view, scope), scope)
765
+
766
+
767
+ async def _author_or_merger(
768
+ request: Request,
769
+ auth: AuthDep,
770
+ principal: PrincipalDep,
771
+ store: StoreDep,
772
+ domain_id: str,
773
+ proposal_id: str,
774
+ ) -> None:
775
+ proposal = await service.target_of(store, domain_id, proposal_id)
776
+ if principal.id != proposal.author:
777
+ await _check(
778
+ request, auth, principal, Permission.PROPOSAL_MERGE, domain_id, proposal.target_ref
779
+ )
780
+
781
+
782
+ @router.patch(PROPOSAL, summary="Update a Proposal", responses=_ERRORS)
783
+ async def update_proposal(
784
+ domain_id: str,
785
+ proposal_id: str,
786
+ body: ProposalUpdate,
787
+ request: Request,
788
+ principal: PrincipalDep,
789
+ auth: AuthDep,
790
+ store: StoreDep,
791
+ keeper: KeeperSettingsDep,
792
+ guard: DataRead,
793
+ _: Annotated[Access, Depends(require(Permission.DATA_READ))],
794
+ ) -> ProposalDetailOut:
795
+ """Change the title or description of an open Proposal (its author or a maintainer)."""
796
+ await _author_or_merger(request, auth, principal, store, domain_id, proposal_id)
797
+ view = await service.update(
798
+ store,
799
+ domain_id,
800
+ proposal_id,
801
+ keeper.proposals,
802
+ title=body.title,
803
+ description=body.description,
804
+ )
805
+ annotate(
806
+ request,
807
+ action="proposal.update",
808
+ target=proposal_id,
809
+ fields=sorted(body.model_dump(exclude_unset=True)),
810
+ )
811
+ scope = await visible_scope(guard, view.proposal)
812
+ return _detail(await service.scoped(store, domain_id, view, scope), scope)
813
+
814
+
815
+ @router.post(PROPOSAL + "/close", summary="Close a Proposal", responses=_ERRORS)
816
+ async def close_proposal(
817
+ domain_id: str,
818
+ proposal_id: str,
819
+ request: Request,
820
+ principal: PrincipalDep,
821
+ auth: AuthDep,
822
+ store: StoreDep,
823
+ keeper: KeeperSettingsDep,
824
+ guard: DataRead,
825
+ _: Annotated[Access, Depends(require(Permission.DATA_READ))],
826
+ ) -> ProposalDetailOut:
827
+ """Close without merging; the merge attempt is discarded and the target is unchanged."""
828
+ await _author_or_merger(request, auth, principal, store, domain_id, proposal_id)
829
+ view = await service.close(store, domain_id, proposal_id, keeper.proposals)
830
+ released = await agent_writes.release_branch(store, domain_id, view.proposal, keeper)
831
+ annotate(request, action="proposal.close", target=proposal_id, branch_deleted=released)
832
+ scope = await visible_scope(guard, view.proposal)
833
+ return _detail(await service.scoped(store, domain_id, view, scope), scope)
834
+
835
+
836
+ @router.post(PROPOSAL + "/reviews", status_code=201, summary="Review a Proposal", responses=_ERRORS)
837
+ async def review_proposal(
838
+ domain_id: str,
839
+ proposal_id: str,
840
+ body: ReviewIn,
841
+ request: Request,
842
+ principal: PrincipalDep,
843
+ auth: AuthDep,
844
+ store: StoreDep,
845
+ keeper: KeeperSettingsDep,
846
+ ) -> ReviewResult:
847
+ """Approve, request changes or comment."""
848
+ proposal = await service.target_of(store, domain_id, proposal_id)
849
+ await _check(
850
+ request, auth, principal, Permission.PROPOSAL_REVIEW, domain_id, proposal.target_ref
851
+ )
852
+ row, view = await service.review(
853
+ store,
854
+ domain_id,
855
+ proposal_id,
856
+ principal,
857
+ keeper.proposals,
858
+ verdict=body.verdict,
859
+ comment=body.comment,
860
+ )
861
+ annotate(request, action="proposal.review", target=proposal_id, verdict=body.verdict.value)
862
+ return ReviewResult(review=_review(row), approval=_approval(view.approval))
863
+
864
+
865
+ class MergeIn(BaseModel):
866
+ message: str | None = Field(default=None, max_length=2000)
867
+
868
+
869
+ @router.post(PROPOSAL + "/merge", summary="Merge a Proposal", responses=_ERRORS)
870
+ async def merge_proposal(
871
+ domain_id: str,
872
+ proposal_id: str,
873
+ request: Request,
874
+ response: Response,
875
+ principal: PrincipalDep,
876
+ auth: AuthDep,
877
+ store: StoreDep,
878
+ keeper: KeeperSettingsDep,
879
+ idempotency_key: IdempotencyKeyDep,
880
+ guard: DataRead,
881
+ body: MergeIn | None = None,
882
+ ) -> MergeOut:
883
+ """Run or re-run the merge with the stored resolutions.
884
+
885
+ 200 with the new target head when the merge committed (merge Revision with parents
886
+ `[target head, source head]`, fast-forward, or nothing to do); 409 `merge-conflicts`
887
+ with the attempt when Conflicts remain. With an `Idempotency-Key`, retrying a merge that
888
+ committed returns it again (`Idempotent-Replayed: true`).
889
+ """
890
+ proposal = await service.target_of(store, domain_id, proposal_id)
891
+ await _check(
892
+ request, auth, principal, Permission.PROPOSAL_MERGE, domain_id, proposal.target_ref
893
+ )
894
+ scope = await conflict_scope(guard, proposal)
895
+ try:
896
+ outcome = await service.merge(
897
+ store,
898
+ domain_id,
899
+ proposal_id,
900
+ principal,
901
+ keeper.proposals,
902
+ idempotency_key=idempotency_key,
903
+ message=None if body is None else body.message,
904
+ page_limit=keeper.pagination.default_limit,
905
+ scope=scope,
906
+ schema_guard=lambda old, new: require_schema_change(
907
+ auth.authorizer, principal, domain_id, old, new
908
+ ),
909
+ )
910
+ except CommitError as error:
911
+ raise await _store_problem(
912
+ error,
913
+ keeper.branches.lock_retry_after_s,
914
+ lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
915
+ ) from None
916
+ attempt = await _attempt(
917
+ outcome.view, store, guard, domain_id, proposal.target_ref, keeper, scope
918
+ )
919
+ annotate(
920
+ request,
921
+ action="proposal.merge",
922
+ target=proposal_id,
923
+ attempt=outcome.view.attempt.id,
924
+ result=outcome.view.attempt.result,
925
+ revision=None if outcome.head is None else hex(outcome.head),
926
+ open_conflicts=attempt.open_conflicts,
927
+ replayed=outcome.replayed,
928
+ )
929
+ if outcome.head is None:
930
+ raise ProblemError(
931
+ service.MERGE_CONFLICTS,
932
+ f"{attempt.open_conflicts} Conflict(s) remain; resolve them and merge again.",
933
+ extensions={"attempt": attempt.model_dump(mode="json")},
934
+ )
935
+ source_deleted = outcome.source_deleted
936
+ if not outcome.replayed and not source_deleted:
937
+ source_deleted = await agent_writes.release_branch(
938
+ store, domain_id, outcome.proposal, keeper
939
+ )
940
+ annotate(request, branch_deleted=source_deleted)
941
+ if outcome.replayed:
942
+ response.headers[REPLAYED_HEADER] = "true"
943
+ return MergeOut(
944
+ result=outcome.view.attempt.result or "merged",
945
+ revision=hex(outcome.head),
946
+ proposal=_proposal(outcome.proposal, counted=scope.everything),
947
+ attempt=attempt,
948
+ source_deleted=source_deleted,
949
+ replayed=outcome.replayed,
950
+ )
951
+
952
+
953
+ @router.get(PROPOSAL + "/conflicts", summary="List the Conflicts of a Proposal", responses=_ERRORS)
954
+ async def list_conflicts(
955
+ domain_id: str,
956
+ proposal_id: str,
957
+ principal: PrincipalDep,
958
+ store: StoreDep,
959
+ keeper: KeeperSettingsDep,
960
+ params: PageParamsDep,
961
+ guard: DataRead,
962
+ kind: Annotated[
963
+ str | None, Query(description="Conflict codes, comma-separated (`DV1,XS8`).")
964
+ ] = None,
965
+ status: Annotated[Literal["open", "resolved"] | None, Query()] = None,
966
+ ) -> ConflictsOut:
967
+ """The current merge attempt with one page of its Conflicts, ordered by id."""
968
+ scope = await _readable(store, guard, domain_id, proposal_id)
969
+ proposal = await service.target_of(store, domain_id, proposal_id)
970
+ after = None if params.position is None else str(params.position.get("after", "")) or None
971
+ try:
972
+ view = await service.conflicts(
973
+ store,
974
+ domain_id,
975
+ proposal_id,
976
+ principal,
977
+ keeper.proposals,
978
+ service.ConflictQuery(
979
+ codes=_codes(kind),
980
+ resolved=None if status is None else status == "resolved",
981
+ limit=params.limit,
982
+ after=after,
983
+ scope=scope,
984
+ ),
985
+ )
986
+ except CommitError as error:
987
+ raise await _store_problem(
988
+ error,
989
+ keeper.branches.lock_retry_after_s,
990
+ lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
991
+ ) from None
992
+ return await _attempt(view, store, guard, domain_id, proposal.target_ref, keeper, scope)
993
+
994
+
995
+ @router.post(
996
+ PROPOSAL + "/conflicts/{conflict_id}/resolution",
997
+ summary="Resolve one Conflict",
998
+ responses=_ERRORS,
999
+ )
1000
+ async def resolve_conflict(
1001
+ domain_id: str,
1002
+ proposal_id: str,
1003
+ conflict_id: str,
1004
+ body: ResolutionIn,
1005
+ request: Request,
1006
+ principal: PrincipalDep,
1007
+ auth: AuthDep,
1008
+ store: StoreDep,
1009
+ keeper: KeeperSettingsDep,
1010
+ guard: DataRead,
1011
+ ) -> ResolutionOut:
1012
+ """Store the resolution of one Conflict of the current attempt (no merge run)."""
1013
+ proposal = await service.target_of(store, domain_id, proposal_id)
1014
+ await _check(
1015
+ request, auth, principal, Permission.CONFLICT_RESOLVE, domain_id, proposal.target_ref
1016
+ )
1017
+ scope = await conflict_scope(guard, proposal)
1018
+ try:
1019
+ rows, _view = await service.resolve(
1020
+ store,
1021
+ domain_id,
1022
+ proposal_id,
1023
+ principal,
1024
+ keeper.proposals,
1025
+ [
1026
+ service.ResolutionInput(
1027
+ fingerprint=conflict_id,
1028
+ choice=body.choice,
1029
+ value=body.value,
1030
+ values=body.values,
1031
+ )
1032
+ ],
1033
+ rerun=False,
1034
+ page_limit=keeper.pagination.default_limit,
1035
+ scope=scope,
1036
+ )
1037
+ except CommitError as error:
1038
+ raise await _store_problem(
1039
+ error,
1040
+ keeper.branches.lock_retry_after_s,
1041
+ lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
1042
+ ) from None
1043
+ annotate(request, action="proposal.resolve", target=proposal_id, conflicts=[conflict_id])
1044
+ return _resolution(rows[0], frozenset())
1045
+
1046
+
1047
+ @router.post(
1048
+ PROPOSAL + "/conflicts:resolve", summary="Resolve Conflicts in bulk", responses=_ERRORS
1049
+ )
1050
+ async def resolve_conflicts(
1051
+ domain_id: str,
1052
+ proposal_id: str,
1053
+ body: ResolveManyIn,
1054
+ request: Request,
1055
+ principal: PrincipalDep,
1056
+ auth: AuthDep,
1057
+ store: StoreDep,
1058
+ keeper: KeeperSettingsDep,
1059
+ guard: DataRead,
1060
+ ) -> ConflictsOut:
1061
+ """Store several resolutions, re-run the merge and return the new attempt."""
1062
+ if len(body.resolutions) > keeper.proposals.max_resolutions:
1063
+ raise ProblemError(
1064
+ VALIDATION_FAILED,
1065
+ f"At most {keeper.proposals.max_resolutions} resolutions per request.",
1066
+ )
1067
+ proposal = await service.target_of(store, domain_id, proposal_id)
1068
+ await _check(
1069
+ request, auth, principal, Permission.CONFLICT_RESOLVE, domain_id, proposal.target_ref
1070
+ )
1071
+ scope = await conflict_scope(guard, proposal)
1072
+ try:
1073
+ _rows, view = await service.resolve(
1074
+ store,
1075
+ domain_id,
1076
+ proposal_id,
1077
+ principal,
1078
+ keeper.proposals,
1079
+ [
1080
+ service.ResolutionInput(
1081
+ fingerprint=item.fingerprint,
1082
+ choice=item.choice,
1083
+ value=item.value,
1084
+ values=item.values,
1085
+ pointer=f"/body/resolutions/{index}",
1086
+ )
1087
+ for index, item in enumerate(body.resolutions)
1088
+ ],
1089
+ rerun=True,
1090
+ page_limit=keeper.pagination.default_limit,
1091
+ scope=scope,
1092
+ )
1093
+ except CommitError as error:
1094
+ raise await _store_problem(
1095
+ error,
1096
+ keeper.branches.lock_retry_after_s,
1097
+ lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
1098
+ ) from None
1099
+ assert view is not None
1100
+ annotate(
1101
+ request,
1102
+ action="proposal.resolve",
1103
+ target=proposal_id,
1104
+ conflicts=[item.fingerprint for item in body.resolutions],
1105
+ attempt=view.attempt.id,
1106
+ )
1107
+ return await _attempt(view, store, guard, domain_id, proposal.target_ref, keeper, scope)