backbone-conductor 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """Backbone Conductor: auditable coordination, independent of agent runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from backbone_conductor.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,651 @@
1
+ """HTTP API with optional bearer authentication and member authorization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from contextlib import asynccontextmanager
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from fastapi import FastAPI, Query, Request
12
+ from fastapi.responses import FileResponse, JSONResponse
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from . import __version__
16
+ from .audit import bind_http_actor, reset_http_actor
17
+ from .auth import Principal, TokenAuth
18
+ from .service import Conductor
19
+
20
+ _DECISION_MAP_FILES = {
21
+ "/decision-map": "decision_map.html",
22
+ "/decision-map.css": "decision_map.css",
23
+ "/decision-map.js": "decision_map.js",
24
+ "/lifecycle-map": "lifecycle_map.html",
25
+ "/lifecycle-map.js": "lifecycle_map.js",
26
+ }
27
+ _DECISION_MAP_CSP = (
28
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
29
+ "base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'"
30
+ )
31
+
32
+
33
+ def _member_route(method: str, path: str) -> bool:
34
+ if (method, path) in {
35
+ ("GET", "/schema"),
36
+ ("GET", "/whoami"),
37
+ ("GET", "/decision-map/data"),
38
+ ("GET", "/lifecycle-map/data"),
39
+ ("GET", "/tasks"),
40
+ ("GET", "/sync"),
41
+ ("POST", "/intents"),
42
+ ("POST", "/decisions"),
43
+ ("POST", "/artifacts"),
44
+ }:
45
+ return True
46
+ if method == "GET":
47
+ return re.fullmatch(r"/tasks/[^/]+", path) is not None
48
+ if method == "POST":
49
+ return re.fullmatch(r"/tasks/[^/]+/(start|rebase|submit|fetch)", path) is not None
50
+ return False
51
+
52
+
53
+ def _reviewer_route(method: str, path: str) -> bool:
54
+ if method == "GET":
55
+ if path in {
56
+ "/state",
57
+ "/schema",
58
+ "/whoami",
59
+ "/intents",
60
+ "/decisions",
61
+ "/decision-map/data",
62
+ "/lifecycle-map/data",
63
+ "/tasks",
64
+ "/conflicts",
65
+ "/timeline",
66
+ "/audit/verify",
67
+ "/audit/snapshot",
68
+ "/audit/history",
69
+ "/sync",
70
+ "/docs",
71
+ "/redoc",
72
+ "/openapi.json",
73
+ }:
74
+ return True
75
+ return (
76
+ re.fullmatch(r"/(intents|decisions|tasks)/[^/]+", path) is not None
77
+ or re.fullmatch(r"/tasks/[^/]+/inspection", path) is not None
78
+ )
79
+ if method == "POST":
80
+ return (
81
+ re.fullmatch(r"/intents/[^/]+/review", path) is not None
82
+ or re.fullmatch(r"/decisions/[^/]+/revert", path) is not None
83
+ or re.fullmatch(r"/tasks/[^/]+/merge", path) is not None
84
+ or re.fullmatch(r"/conflicts/[^/]+/resolve", path) is not None
85
+ )
86
+ return False
87
+
88
+
89
+ class Action(BaseModel):
90
+ model_config = ConfigDict(extra="forbid")
91
+
92
+
93
+ class Transition(Action):
94
+ status: str = Field(min_length=1)
95
+
96
+
97
+ class Dispatch(Action):
98
+ intent_id: str = Field(min_length=1)
99
+ member_id: str = Field(min_length=1)
100
+ spec: str = ""
101
+ forbidden_paths: list[str] = Field(default_factory=list)
102
+
103
+
104
+ class Member(Action):
105
+ member_id: str = Field(min_length=1)
106
+
107
+
108
+ class Submission(Member):
109
+ artifact: dict[str, Any]
110
+
111
+
112
+ class BranchFetch(Member):
113
+ branch: str = Field(min_length=1)
114
+ expected_sha: str = Field(min_length=1)
115
+ remote: str = "origin"
116
+
117
+
118
+ class Approval(Action):
119
+ author: str = Field(min_length=1)
120
+ rationale: str | None = None
121
+
122
+
123
+ class MergeApproval(Approval):
124
+ expected_version: str = Field(min_length=1)
125
+ expected_target_sha: str = Field(min_length=1)
126
+
127
+
128
+ class IntentRevision(Approval):
129
+ patch: dict[str, Any]
130
+ expected_version: str = Field(min_length=1)
131
+
132
+
133
+ class IntentReplacement(IntentRevision):
134
+ reason: str = Field(min_length=1)
135
+
136
+
137
+ class IntentReviewAction(Approval):
138
+ outcome: str = Field(min_length=1)
139
+ rationale: str = Field(min_length=1)
140
+ expected_version: str = Field(min_length=1)
141
+
142
+
143
+ class DecisionRevertAction(Approval):
144
+ rationale: str = Field(min_length=1)
145
+ expected_version: str = Field(min_length=1)
146
+
147
+
148
+ class Cancellation(Approval):
149
+ reason: str = Field(min_length=1)
150
+
151
+
152
+ class TaskRebase(Member):
153
+ expected_version: str = Field(min_length=1)
154
+
155
+
156
+ class Resolution(Approval):
157
+ action: str = Field(min_length=1)
158
+ rationale: str = Field(min_length=1)
159
+ expected_version: str = Field(min_length=1)
160
+
161
+
162
+ class Sync(Action):
163
+ remote: str = "origin"
164
+ branch: str | None = None
165
+
166
+
167
+ class Reconciliation(Sync):
168
+ local_head: str = Field(min_length=1)
169
+ remote_head: str = Field(min_length=1)
170
+ author: str = Field(min_length=1)
171
+ rationale: str = Field(min_length=1)
172
+ resolutions: dict[str, dict[str, dict[str, Any]]] | None = None
173
+
174
+
175
+ def create_app(
176
+ repo: str | Path,
177
+ *,
178
+ auth_file: str | Path | None = None,
179
+ ledger_branch: str | None = None,
180
+ mcp_http: bool = False,
181
+ mcp_allowed_hosts: tuple[str, ...] = (),
182
+ ) -> FastAPI:
183
+ """Create a local admin API or an authenticated admin/member/reviewer API."""
184
+ if mcp_http and auth_file is None:
185
+ raise ValueError("Streamable HTTP MCP requires --auth-file")
186
+ if mcp_allowed_hosts and not mcp_http:
187
+ raise ValueError("MCP allowed hosts require Streamable HTTP MCP")
188
+ conductor = Conductor(repo, ledger_branch=ledger_branch)
189
+ auth = TokenAuth(auth_file, conductor.code_store.root) if auth_file is not None else None
190
+ member_mcp = None
191
+ member_mcp_app = None
192
+ if mcp_http:
193
+ from .mcp_http import create_member_http_app
194
+
195
+ assert auth is not None
196
+ member_mcp, member_mcp_app = create_member_http_app(
197
+ repo, auth, ledger_branch=ledger_branch, allowed_hosts=mcp_allowed_hosts
198
+ )
199
+
200
+ @asynccontextmanager
201
+ async def lifespan(_app: FastAPI):
202
+ if member_mcp is None:
203
+ yield
204
+ else:
205
+ async with member_mcp.session_manager.run():
206
+ yield
207
+
208
+ app = FastAPI(
209
+ title="Backbone Conductor",
210
+ version=__version__,
211
+ description=(
212
+ "Without --auth-file, bind to loopback for trusted local administrators. "
213
+ "With --auth-file, bearer tokens authorize admin, member and reviewer operations. "
214
+ "Use TLS at a trusted reverse proxy or configure direct HTTPS for remote access. "
215
+ "Merge approval records require an actual Git merge and human semantic review."
216
+ ),
217
+ lifespan=lifespan,
218
+ )
219
+ app.state.conductor = conductor
220
+ app.state.member_mcp = member_mcp
221
+
222
+ @app.middleware("http")
223
+ async def authenticate(request: Request, call_next):
224
+ if mcp_http and request.url.path == "/mcp":
225
+ # The mounted MCP app authenticates every request, including initialize.
226
+ return await call_next(request)
227
+ if request.method == "GET" and request.url.path in _DECISION_MAP_FILES:
228
+ # The shell contains no ledger data. Browser clients provide a bearer
229
+ # token in memory when fetching the role-protected projection below.
230
+ return await call_next(request)
231
+ if auth is None:
232
+ request.state.principal = Principal("local", "admin")
233
+ elif request.url.path == "/health":
234
+ request.state.principal = None
235
+ else:
236
+ try:
237
+ principal = auth.authenticate(request.headers.get("authorization"))
238
+ except ValueError:
239
+ return JSONResponse(
240
+ status_code=503,
241
+ content={"detail": "HTTP credentials are unavailable or invalid"},
242
+ )
243
+ if principal is None:
244
+ return JSONResponse(
245
+ status_code=401,
246
+ content={"detail": "Valid bearer token required"},
247
+ headers={"WWW-Authenticate": "Bearer"},
248
+ )
249
+ request.state.principal = principal
250
+ if principal.role == "member" and not _member_route(request.method, request.url.path):
251
+ return JSONResponse(status_code=403, content={"detail": "Admin role required"})
252
+ if principal.role == "reviewer" and not _reviewer_route(
253
+ request.method, request.url.path
254
+ ):
255
+ return JSONResponse(
256
+ status_code=403, content={"detail": "Reviewer role cannot access this endpoint"}
257
+ )
258
+ if auth is None or request.state.principal is None:
259
+ return await call_next(request)
260
+ token = bind_http_actor(request.state.principal.name, request.state.principal.role)
261
+ try:
262
+ return await call_next(request)
263
+ finally:
264
+ reset_http_actor(token)
265
+
266
+ def bind_member(request: Request, member_id: str | None) -> str | None:
267
+ principal = request.state.principal
268
+ if principal.role == "member":
269
+ if member_id is not None and member_id != principal.name:
270
+ raise PermissionError("Member identity is bound to the bearer token")
271
+ return principal.name
272
+ return member_id
273
+
274
+ def bind_author(request: Request, data: dict[str, Any], required_status: str) -> dict:
275
+ principal = request.state.principal
276
+ if auth is None:
277
+ return data
278
+ if data.get("author", principal.name) != principal.name:
279
+ raise PermissionError("Author identity is bound to the bearer token")
280
+ if principal.role == "member" and data.get("status", required_status) != required_status:
281
+ raise PermissionError(f"Members may create {required_status} records only")
282
+ return {**data, "author": principal.name}
283
+
284
+ def actor(request: Request, claimed: str) -> str:
285
+ if auth is not None and claimed != request.state.principal.name:
286
+ raise PermissionError("Author identity is bound to the bearer token")
287
+ return claimed
288
+
289
+ def visible_task(request: Request, task_id: str) -> dict:
290
+ result = conductor.state()["tasks"][task_id]
291
+ bind_member(request, result["member_id"])
292
+ return result
293
+
294
+ def member_projection(snapshot: dict, name: str) -> tuple[list[dict], list[dict], list[dict]]:
295
+ tasks = [task for task in snapshot["tasks"].values() if task["member_id"] == name]
296
+ visible_intents = {task["intent_id"] for task in tasks}
297
+ visible_intents.update(
298
+ intent["id"] for intent in snapshot["intents"].values() if intent["author"] == name
299
+ )
300
+ intents = [
301
+ intent for intent in snapshot["intents"].values() if intent["id"] in visible_intents
302
+ ]
303
+ visible_decisions = {
304
+ decision_id for task in tasks for decision_id in task["decisions_at_fork"]
305
+ }
306
+ visible_decisions.update(
307
+ task["approval"]["decision_id"] for task in tasks if task["approval"] is not None
308
+ )
309
+ decisions = [
310
+ decision
311
+ for decision in snapshot["decisions"].values()
312
+ if decision["id"] in visible_decisions
313
+ or visible_intents.intersection(decision["related_intents"])
314
+ ]
315
+ return intents, tasks, decisions
316
+
317
+ async def domain_error(_request: Request, exc: Exception) -> JSONResponse:
318
+ if isinstance(exc, PermissionError):
319
+ status = 403
320
+ elif isinstance(exc, (KeyError, FileNotFoundError)):
321
+ status = 404
322
+ elif isinstance(exc, ValueError):
323
+ status = 422
324
+ else:
325
+ status = 409
326
+ message = str(exc.args[0]) if isinstance(exc, KeyError) else str(exc)
327
+ return JSONResponse(status_code=status, content={"detail": message})
328
+
329
+ for error in (ValueError, KeyError, PermissionError, FileNotFoundError, RuntimeError):
330
+ app.add_exception_handler(error, domain_error)
331
+
332
+ @app.get("/health")
333
+ def health() -> Any:
334
+ if auth is not None:
335
+ try:
336
+ auth.check_available()
337
+ except ValueError:
338
+ return JSONResponse(status_code=503, content={"status": "unavailable"})
339
+ return {"status": "ok"}
340
+
341
+ def map_file(path: str) -> FileResponse:
342
+ return FileResponse(
343
+ Path(__file__).with_name("web") / _DECISION_MAP_FILES[path],
344
+ headers={
345
+ "Cache-Control": "no-store",
346
+ "Content-Security-Policy": _DECISION_MAP_CSP,
347
+ "X-Content-Type-Options": "nosniff",
348
+ },
349
+ )
350
+
351
+ @app.get("/decision-map", include_in_schema=False)
352
+ def decision_map() -> FileResponse:
353
+ return map_file("/decision-map")
354
+
355
+ @app.get("/decision-map.css", include_in_schema=False)
356
+ def decision_map_css() -> FileResponse:
357
+ return map_file("/decision-map.css")
358
+
359
+ @app.get("/decision-map.js", include_in_schema=False)
360
+ def decision_map_js() -> FileResponse:
361
+ return map_file("/decision-map.js")
362
+
363
+ @app.get("/decision-map/data")
364
+ def decision_map_data(request: Request) -> JSONResponse:
365
+ snapshot = conductor.state()
366
+ decisions = list(snapshot["decisions"].values())
367
+ principal = request.state.principal
368
+ if principal.role == "member":
369
+ _, _, decisions = member_projection(snapshot, principal.name)
370
+ return JSONResponse(
371
+ content={
372
+ "version": snapshot["version"],
373
+ "decisions": decisions,
374
+ },
375
+ headers={"Cache-Control": "no-store"},
376
+ )
377
+
378
+ @app.get("/lifecycle-map", include_in_schema=False)
379
+ def lifecycle_map() -> FileResponse:
380
+ return map_file("/lifecycle-map")
381
+
382
+ @app.get("/lifecycle-map.js", include_in_schema=False)
383
+ def lifecycle_map_js() -> FileResponse:
384
+ return map_file("/lifecycle-map.js")
385
+
386
+ @app.get("/lifecycle-map/data")
387
+ def lifecycle_map_data(request: Request) -> JSONResponse:
388
+ snapshot = conductor.state()
389
+ intents = list(snapshot["intents"].values())
390
+ tasks = list(snapshot["tasks"].values())
391
+ decisions = list(snapshot["decisions"].values())
392
+ principal = request.state.principal
393
+ if principal.role == "member":
394
+ intents, tasks, decisions = member_projection(snapshot, principal.name)
395
+ return JSONResponse(
396
+ content={
397
+ "version": snapshot["version"],
398
+ "intents": intents,
399
+ "tasks": tasks,
400
+ "decisions": decisions,
401
+ },
402
+ headers={"Cache-Control": "no-store"},
403
+ )
404
+
405
+ @app.post("/initialize")
406
+ def initialize() -> dict:
407
+ return conductor.initialize()
408
+
409
+ @app.get("/state")
410
+ def state() -> dict:
411
+ return conductor.state()
412
+
413
+ @app.get("/whoami")
414
+ def whoami(request: Request) -> dict[str, str]:
415
+ principal = request.state.principal
416
+ return {"name": principal.name, "role": principal.role}
417
+
418
+ @app.get("/schema")
419
+ def schema() -> dict:
420
+ from .models import BackboneState
421
+
422
+ return BackboneState.model_json_schema()
423
+
424
+ @app.get("/intents")
425
+ def intents() -> list[dict]:
426
+ return list(conductor.state()["intents"].values())
427
+
428
+ @app.post("/intents", status_code=201)
429
+ def create_intent(data: dict[str, Any], request: Request) -> dict:
430
+ return conductor.create_intent(bind_author(request, data, "draft"))
431
+
432
+ @app.get("/intents/{intent_id}")
433
+ def intent(intent_id: str) -> dict:
434
+ return conductor.state()["intents"][intent_id]
435
+
436
+ @app.post("/intents/{intent_id}/transition")
437
+ def transition_intent(intent_id: str, data: Transition) -> dict:
438
+ return conductor.transition_intent(intent_id, data.status)
439
+
440
+ @app.post("/intents/{intent_id}/review")
441
+ def review_intent(intent_id: str, data: IntentReviewAction, request: Request) -> dict:
442
+ return conductor.review_intent(
443
+ intent_id,
444
+ data.outcome,
445
+ actor(request, data.author),
446
+ data.rationale,
447
+ data.expected_version,
448
+ )
449
+
450
+ @app.post("/intents/{intent_id}/revise")
451
+ def revise_intent(intent_id: str, data: IntentRevision, request: Request) -> dict:
452
+ return conductor.revise_intent(
453
+ intent_id, data.patch, actor(request, data.author), data.expected_version
454
+ )
455
+
456
+ @app.post("/intents/{intent_id}/replace", status_code=201)
457
+ def replace_intent(intent_id: str, data: IntentReplacement, request: Request) -> dict:
458
+ return conductor.replace_intent(
459
+ intent_id, data.patch, actor(request, data.author), data.reason, data.expected_version
460
+ )
461
+
462
+ @app.get("/decisions")
463
+ def decisions() -> list[dict]:
464
+ return list(conductor.state()["decisions"].values())
465
+
466
+ @app.post("/decisions", status_code=201)
467
+ def create_decision(data: dict[str, Any], request: Request) -> dict:
468
+ return conductor.log_decision(bind_author(request, data, "proposed"))
469
+
470
+ @app.get("/decisions/{decision_id}")
471
+ def decision(decision_id: str) -> dict:
472
+ return conductor.state()["decisions"][decision_id]
473
+
474
+ @app.post("/decisions/{decision_id}/transition")
475
+ def transition_decision(decision_id: str, data: Transition) -> dict:
476
+ return conductor.transition_decision(decision_id, data.status)
477
+
478
+ @app.post("/decisions/{decision_id}/revert")
479
+ def revert_decision(decision_id: str, data: DecisionRevertAction, request: Request) -> dict:
480
+ return conductor.revert_decision(
481
+ decision_id, actor(request, data.author), data.rationale, data.expected_version
482
+ )
483
+
484
+ @app.get("/tasks")
485
+ def tasks(request: Request, member_id: str | None = None) -> dict:
486
+ member_id = bind_member(request, member_id)
487
+ if member_id is not None:
488
+ return conductor.get_my_task(member_id)
489
+ return {"tasks": list(conductor.state()["tasks"].values())}
490
+
491
+ @app.post("/tasks", status_code=201)
492
+ def dispatch_task(data: Dispatch) -> dict:
493
+ return conductor.dispatch_task(
494
+ data.intent_id, data.member_id, data.spec, data.forbidden_paths
495
+ )
496
+
497
+ @app.get("/tasks/{task_id}")
498
+ def task(task_id: str, request: Request) -> dict:
499
+ return visible_task(request, task_id)
500
+
501
+ @app.get("/tasks/{task_id}/inspection")
502
+ def task_inspection(task_id: str, full_patch: bool = False) -> dict:
503
+ return conductor.inspect_task(task_id, full_patch=full_patch)
504
+
505
+ @app.post("/tasks/{task_id}/start")
506
+ def start_task(task_id: str, data: Member, request: Request) -> dict:
507
+ return conductor.start_task(task_id, bind_member(request, data.member_id))
508
+
509
+ @app.post("/tasks/{task_id}/rebase")
510
+ def rebase_task(task_id: str, data: TaskRebase, request: Request) -> dict:
511
+ return conductor.rebase_task(
512
+ task_id, bind_member(request, data.member_id), data.expected_version
513
+ )
514
+
515
+ @app.post("/tasks/{task_id}/fetch")
516
+ def fetch_task_branch(task_id: str, data: BranchFetch, request: Request) -> dict:
517
+ return conductor.fetch_artifact_branch(
518
+ task_id,
519
+ bind_member(request, data.member_id),
520
+ data.branch,
521
+ data.expected_sha,
522
+ data.remote,
523
+ )
524
+
525
+ @app.post("/tasks/{task_id}/cancel")
526
+ def cancel_task(task_id: str, data: Cancellation, request: Request) -> dict:
527
+ return conductor.cancel_task(task_id, actor(request, data.author), data.reason)
528
+
529
+ @app.post("/artifacts", status_code=201)
530
+ def submit_artifact(data: Submission, request: Request) -> dict:
531
+ return conductor.submit_artifact(bind_member(request, data.member_id), data.artifact)
532
+
533
+ @app.post("/tasks/{task_id}/submit")
534
+ def submit_task(task_id: str, data: Submission, request: Request) -> dict:
535
+ assigned = visible_task(request, task_id)
536
+ bind_member(request, data.member_id)
537
+ if assigned["member_id"] != data.member_id:
538
+ raise PermissionError("Task belongs to another member")
539
+ artifact = dict(data.artifact)
540
+ if artifact.get("intent_id", assigned["intent_id"]) != assigned["intent_id"]:
541
+ raise ValueError("artifact.intent_id must match the task in the request path")
542
+ artifact["intent_id"] = assigned["intent_id"]
543
+ return conductor.submit_artifact(data.member_id, artifact)
544
+
545
+ @app.post("/tasks/{task_id}/merge")
546
+ def merge_task(task_id: str, data: MergeApproval, request: Request) -> dict:
547
+ """Record human approval after performing the actual Git merge externally."""
548
+ principal = request.state.principal
549
+ if principal.role == "reviewer":
550
+ if not data.rationale or not data.rationale.strip():
551
+ raise ValueError("Reviewer approval requires a rationale")
552
+ if conductor.state()["tasks"][task_id]["member_id"] == principal.name:
553
+ raise PermissionError("Reviewers cannot approve their own assigned task")
554
+ return conductor.merge_task(
555
+ task_id,
556
+ actor(request, data.author),
557
+ data.rationale,
558
+ expected_version=data.expected_version,
559
+ expected_target_sha=data.expected_target_sha,
560
+ )
561
+
562
+ @app.get("/conflicts")
563
+ def conflicts() -> list[dict]:
564
+ return list(conductor.state()["conflicts"].values())
565
+
566
+ @app.post("/conflicts/check")
567
+ def detect_conflicts() -> dict:
568
+ return conductor.detect_conflicts()
569
+
570
+ @app.post("/conflicts/{conflict_id}/resolve")
571
+ def resolve_conflict(conflict_id: str, data: Resolution, request: Request) -> dict:
572
+ return conductor.resolve_conflict(
573
+ conflict_id,
574
+ actor(request, data.author),
575
+ data.action,
576
+ data.rationale,
577
+ data.expected_version,
578
+ )
579
+
580
+ @app.get("/sync")
581
+ def check_sync(
582
+ request: Request, member_id: str | None = None, since_version: str | None = None
583
+ ) -> dict:
584
+ return conductor.check_backbone_sync(bind_member(request, member_id), since_version)
585
+
586
+ @app.post("/sync")
587
+ def sync(data: Sync) -> dict:
588
+ return conductor.sync(data.remote, data.branch)
589
+
590
+ @app.post("/refresh")
591
+ def refresh(data: Sync) -> dict:
592
+ return conductor.refresh(data.remote, data.branch)
593
+
594
+ @app.post("/reconcile")
595
+ def reconcile(data: Reconciliation, request: Request) -> dict:
596
+ return conductor.reconcile(
597
+ data.local_head,
598
+ data.remote_head,
599
+ actor(request, data.author),
600
+ data.rationale,
601
+ data.remote,
602
+ data.branch,
603
+ data.resolutions,
604
+ )
605
+
606
+ @app.get("/timeline")
607
+ def timeline(
608
+ limit: int = Query(default=50, ge=1, le=1000),
609
+ author: str | None = None,
610
+ http_principal: str | None = None,
611
+ event_type: str | None = Query(
612
+ default=None,
613
+ pattern="^(initialize|intent|decision|task|artifact|conflict|reconcile|migrate|other)$",
614
+ ),
615
+ since: datetime | None = None,
616
+ until: datetime | None = None,
617
+ ) -> list[dict]:
618
+ if (since is not None and since.tzinfo is None) or (
619
+ until is not None and until.tzinfo is None
620
+ ):
621
+ raise ValueError("Timeline dates must include a timezone")
622
+ if since is not None and until is not None and since > until:
623
+ raise ValueError("since must not be after until")
624
+ return conductor.log(
625
+ limit,
626
+ author=author,
627
+ http_principal=http_principal,
628
+ event_type=event_type,
629
+ since=since.isoformat() if since is not None else None,
630
+ until=until.isoformat() if until is not None else None,
631
+ )
632
+
633
+ @app.get("/audit/verify")
634
+ def verify_audit(limit: int = Query(default=50, ge=1, le=1000)) -> dict:
635
+ return conductor.verify_audit_signatures(limit)
636
+
637
+ @app.get("/audit/snapshot")
638
+ def verify_snapshot() -> dict:
639
+ return conductor.verify_current_snapshot()
640
+
641
+ @app.get("/audit/history")
642
+ def verify_history(
643
+ limit: int = Query(default=50, ge=1, le=1000),
644
+ offset: int = Query(default=0, ge=0),
645
+ expected_head: str | None = None,
646
+ ) -> dict:
647
+ return conductor.verify_audit_history(limit, offset, expected_head)
648
+
649
+ if member_mcp_app is not None:
650
+ app.mount("/", member_mcp_app)
651
+ return app
@@ -0,0 +1,27 @@
1
+ """Request-scoped attribution for commits made through authenticated HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar, Token
6
+
7
+ _HTTP_ACTOR: ContextVar[tuple[str, str] | None] = ContextVar("backbone_http_actor", default=None)
8
+
9
+
10
+ def bind_http_actor(name: str, role: str) -> Token[tuple[str, str] | None]:
11
+ """Bind the already authenticated request principal until its response completes."""
12
+ return _HTTP_ACTOR.set((name, role))
13
+
14
+
15
+ def reset_http_actor(token: Token[tuple[str, str] | None]) -> None:
16
+ _HTTP_ACTOR.reset(token)
17
+
18
+
19
+ def attributed_message(message: str) -> str:
20
+ """Append a Git trailer without claiming that Git itself verified the token."""
21
+ actor = _HTTP_ACTOR.get()
22
+ if actor is None:
23
+ return message.rstrip("\n") + "\n"
24
+ name, role = actor
25
+ return (
26
+ message.rstrip("\n") + f"\n\nBackbone-HTTP-Principal: {name}\nBackbone-HTTP-Role: {role}\n"
27
+ )