sediment-api 0.7.0__tar.gz → 0.7.1__tar.gz

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 (78) hide show
  1. {sediment_api-0.7.0 → sediment_api-0.7.1}/PKG-INFO +6 -6
  2. {sediment_api-0.7.0 → sediment_api-0.7.1}/pyproject.toml +6 -6
  3. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/__init__.py +1 -1
  4. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/deps.py +1 -2
  5. sediment_api-0.7.1/sediment_api/ingest_envelopes.py +55 -0
  6. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/ci_vendor.py +3 -4
  7. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/compute.py +3 -4
  8. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/forge.py +21 -22
  9. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/gateway.py +4 -3
  10. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/otlp.py +3 -3
  11. sediment_api-0.7.1/tests/test_ingest_envelopes.py +139 -0
  12. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_v1.py +1 -1
  13. {sediment_api-0.7.0 → sediment_api-0.7.1}/.gitignore +0 -0
  14. {sediment_api-0.7.0 → sediment_api-0.7.1}/LICENSE +0 -0
  15. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/config.py +0 -0
  16. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/database.py +0 -0
  17. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/main.py +0 -0
  18. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/mirror_gc.py +0 -0
  19. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/__init__.py +0 -0
  20. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/abandonment_report.py +0 -0
  21. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/attribution_share_report.py +0 -0
  22. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/compute_report.py +0 -0
  23. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/dataset_diagnostics.py +0 -0
  24. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/label_confidence_inspection.py +0 -0
  25. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/lifecycle_report.py +0 -0
  26. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/merge_retention_report.py +0 -0
  27. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/model_report.py +0 -0
  28. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/precision_report.py +0 -0
  29. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/reports/recovery_yield_report.py +0 -0
  30. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/__init__.py +0 -0
  31. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/query.py +0 -0
  32. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/reports.py +0 -0
  33. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/routers/v1.py +0 -0
  34. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/services/__init__.py +0 -0
  35. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/services/operational_reports.py +0 -0
  36. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/worker.py +0 -0
  37. {sediment_api-0.7.0 → sediment_api-0.7.1}/sediment_api/workers.py +0 -0
  38. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/conftest.py +0 -0
  39. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_attribution_share_report.py +0 -0
  40. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_baseline_gap_repro.py +0 -0
  41. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_bug_repro_offset.py +0 -0
  42. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_capture_record_isolation.py +0 -0
  43. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_ci_vendor.py +0 -0
  44. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_compute_ingest.py +0 -0
  45. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_compute_report_cli.py +0 -0
  46. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_config.py +0 -0
  47. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_context_discovery_authority.py +0 -0
  48. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_context_discovery_query.py +0 -0
  49. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_context_discovery_workers.py +0 -0
  50. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_context_evidence_query.py +0 -0
  51. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_context_query.py +0 -0
  52. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_credential_authorities.py +0 -0
  53. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_database_lifecycle.py +0 -0
  54. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_database_outage.py +0 -0
  55. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_database_role_startup.py +0 -0
  56. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_dataset_diagnostics_report.py +0 -0
  57. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_e2e_smoke.py +0 -0
  58. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_evidence_query.py +0 -0
  59. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_evidence_worker_imports.py +0 -0
  60. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_evidence_workers.py +0 -0
  61. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_gateway_capture_receipt.py +0 -0
  62. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_gateway_identity.py +0 -0
  63. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_lifecycle_report_cli.py +0 -0
  64. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_merge_retention_cli.py +0 -0
  65. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_one_shot_database.py +0 -0
  66. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_operational_reports.py +0 -0
  67. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_operational_reports_service.py +0 -0
  68. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_postgres_gateway_tracer.py +0 -0
  69. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_push_mirror.py +0 -0
  70. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_query.py +0 -0
  71. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_report_attachment_scope.py +0 -0
  72. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_report_routes.py +0 -0
  73. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_repository_identity_ingest.py +0 -0
  74. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_repository_identity_model_service.py +0 -0
  75. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_repository_identity_queries.py +0 -0
  76. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_retrieval_authority.py +0 -0
  77. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_worker_processes.py +0 -0
  78. {sediment_api-0.7.0 → sediment_api-0.7.1}/tests/test_worker_routes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sediment-api
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Sediment ingest API: facts in through three doors
5
5
  Project-URL: Source, https://github.com/sediment-ai/sediment
6
6
  Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
@@ -9,12 +9,12 @@ Project-URL: Changelog, https://github.com/sediment-ai/sediment/blob/main/CHANGE
9
9
  License-Expression: AGPL-3.0-or-later
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.12
12
- Requires-Dist: anyio>=4.15.1
12
+ Requires-Dist: anyio>=4.16.0
13
13
  Requires-Dist: fastapi>=0.142.2
14
14
  Requires-Dist: httpx>=0.27
15
15
  Requires-Dist: pydantic-settings>=2.16.2
16
- Requires-Dist: sediment-capture==0.7.0
17
- Requires-Dist: sediment-core==0.7.0
18
- Requires-Dist: sediment-derive==0.7.0
19
- Requires-Dist: sediment-export==0.7.0
16
+ Requires-Dist: sediment-capture==0.7.1
17
+ Requires-Dist: sediment-core==0.7.1
18
+ Requires-Dist: sediment-derive==0.7.1
19
+ Requires-Dist: sediment-export==0.7.1
20
20
  Requires-Dist: uvicorn>=0.54.0
@@ -1,21 +1,21 @@
1
1
  [project]
2
2
  name = "sediment-api"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  description = "Sediment ingest API: facts in through three doors"
5
5
  requires-python = ">=3.12"
6
6
  license = "AGPL-3.0-or-later"
7
7
  license-files = ["LICENSE"]
8
8
  dependencies = [
9
9
  # Security floor for HTTPX/Starlette, including installations from wheels.
10
- "anyio>=4.15.1",
10
+ "anyio>=4.16.0",
11
11
  "fastapi>=0.142.2",
12
12
  "httpx>=0.27",
13
13
  "uvicorn>=0.54.0",
14
14
  "pydantic-settings>=2.16.2",
15
- "sediment-core==0.7.0",
16
- "sediment-capture==0.7.0",
17
- "sediment-derive==0.7.0",
18
- "sediment-export==0.7.0",
15
+ "sediment-core==0.7.1",
16
+ "sediment-capture==0.7.1",
17
+ "sediment-derive==0.7.1",
18
+ "sediment-export==0.7.1",
19
19
  ]
20
20
 
21
21
  [project.urls]
@@ -3,4 +3,4 @@
3
3
 
4
4
  # Single source for the API version string — FastAPI's ``app.version``
5
5
  # (reported by /health) and GET /v1/me both read it, so the two cannot drift.
6
- __version__ = "0.7.0"
6
+ __version__ = "0.7.1"
@@ -14,8 +14,7 @@ from __future__ import annotations
14
14
  import json
15
15
  import secrets
16
16
  from dataclasses import dataclass
17
- from typing import Literal
18
- from typing import Any
17
+ from typing import Any, Literal
19
18
 
20
19
  from fastapi import Depends, Header, HTTPException, Request
21
20
  from sediment_capture import verify_signature
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Typed response envelopes for the ingest doors (AGENTS.md §API Conventions).
3
+
4
+ Routers return these instead of bare dicts so the generated OpenAPI names
5
+ each outcome. The wire bodies stay exactly the documented shapes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Annotated, Literal
12
+
13
+ from pydantic import ConfigDict, Field
14
+
15
+ _CLOSED = ConfigDict(extra="forbid")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class IngestReceipt:
20
+ """The retained Fact for one single-Fact ingest request."""
21
+
22
+ __pydantic_config__ = _CLOSED
23
+
24
+ fact_id: Annotated[
25
+ str,
26
+ Field(description="ID of the retained Fact, including on redelivery"),
27
+ ]
28
+ stored: Annotated[
29
+ bool,
30
+ Field(
31
+ description=(
32
+ "False when a redelivery collapsed on a UNIQUE index (ADR "
33
+ "0003); success, not an error"
34
+ )
35
+ ),
36
+ ]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class IngestSkipped:
41
+ """A declined delivery that the sender shouldn't retry."""
42
+
43
+ __pydantic_config__ = _CLOSED
44
+
45
+ skipped: Literal[True]
46
+ reason: Annotated[
47
+ str, Field(description="Why the route stored nothing for this delivery")
48
+ ]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class OTLPExportLogsResponse:
53
+ """The empty OTLP/HTTP JSON ExportLogsServiceResponse: full success."""
54
+
55
+ __pydantic_config__ = _CLOSED
@@ -16,8 +16,6 @@ collapses redeliveries; ``stored: false`` means success, not an error.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- from typing import Any
20
-
21
19
  from fastapi import APIRouter, Depends
22
20
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
23
21
  from sediment_core import (
@@ -40,6 +38,7 @@ from sediment_core import (
40
38
 
41
39
  from ..config import settings
42
40
  from ..deps import get_store, verify_ingest_token
41
+ from ..ingest_envelopes import IngestReceipt
43
42
 
44
43
  router = APIRouter(tags=["ci-vendor"])
45
44
 
@@ -121,7 +120,7 @@ def ingest_ci_vendor(
121
120
  body: VendorCIRequest,
122
121
  _: None = Depends(verify_ingest_token),
123
122
  store: FactStore = Depends(get_store),
124
- ) -> dict[str, Any]:
123
+ ) -> IngestReceipt:
125
124
  outcome = CIOutcome(
126
125
  org_id=settings.org_id,
127
126
  provider=body.provider,
@@ -146,4 +145,4 @@ def ingest_ci_vendor(
146
145
  source_event_id=body.source_event_id,
147
146
  )
148
147
  receipt = store.store_ci_outcome_receipt(outcome)
149
- return {"fact_id": receipt.fact_id, "stored": receipt.stored}
148
+ return IngestReceipt(fact_id=receipt.fact_id, stored=receipt.stored)
@@ -17,8 +17,6 @@ not an error.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- from typing import Any
21
-
22
20
  from fastapi import APIRouter, Depends
23
21
  from pydantic import BaseModel, ConfigDict, Field, model_validator
24
22
  from sediment_core import (
@@ -31,6 +29,7 @@ from sediment_core import (
31
29
 
32
30
  from ..config import settings
33
31
  from ..deps import get_store, verify_ingest_token
32
+ from ..ingest_envelopes import IngestReceipt
34
33
 
35
34
  router = APIRouter(tags=["compute"])
36
35
 
@@ -89,7 +88,7 @@ def ingest_compute_allocation(
89
88
  body: ComputeAllocationRequest,
90
89
  _: None = Depends(verify_ingest_token),
91
90
  store: FactStore = Depends(get_store),
92
- ) -> dict[str, Any]:
91
+ ) -> IngestReceipt:
93
92
  allocation = ComputeAllocation(
94
93
  org_id=settings.org_id,
95
94
  session_id=body.session_id,
@@ -99,4 +98,4 @@ def ingest_compute_allocation(
99
98
  occurred_at=body.occurred_at,
100
99
  )
101
100
  receipt = store.store_compute_allocation_receipt(allocation)
102
- return {"fact_id": receipt.fact_id, "stored": receipt.stored}
101
+ return IngestReceipt(fact_id=receipt.fact_id, stored=receipt.stored)
@@ -2,8 +2,8 @@
2
2
  """
3
3
  GitHub webhook ingest routes, mounted under /ingest/github in main.py.
4
4
 
5
- One module for both routes: they share the HMAC intake and differ only in
6
- which parser/store pair the payload walks through. The signature is
5
+ One module for all four routes: they share the HMAC intake and differ only
6
+ in which parser/store pair the payload walks through. The signature is
7
7
  verified BEFORE the event type is examined: a wrong-secret delivery must
8
8
  401 even for event types a route skips, or GitHub's setup ping would
9
9
  green-light a misconfigured webhook. The push route stores the Push fact,
@@ -19,7 +19,6 @@ from __future__ import annotations
19
19
  import logging
20
20
  from collections import Counter
21
21
  from datetime import UTC, datetime
22
- from typing import Any
23
22
 
24
23
  from fastapi import APIRouter, BackgroundTasks, Depends, Header, Request
25
24
  from sediment_capture import (
@@ -53,6 +52,7 @@ from starlette.concurrency import run_in_threadpool
53
52
 
54
53
  from ..config import settings
55
54
  from ..deps import get_store, read_verified_webhook
55
+ from ..ingest_envelopes import IngestReceipt, IngestSkipped
56
56
 
57
57
  logger = logging.getLogger("sediment.api.forge")
58
58
  router = APIRouter(tags=["forge"])
@@ -295,15 +295,15 @@ async def ingest_push(
295
295
  x_hub_signature_256: str | None = Header(None),
296
296
  x_github_event: str = Header(...),
297
297
  store: FactStore = Depends(get_store),
298
- ) -> dict[str, Any]:
298
+ ) -> IngestReceipt | IngestSkipped:
299
299
  payload = await read_verified_webhook(request, x_hub_signature_256)
300
300
  # GitHub delivers every subscribed event (incl. ping) to the one URL;
301
301
  # the X-GitHub-Event header is the discriminator.
302
302
  if x_github_event != "push":
303
- return {"skipped": True, "reason": "not a push event"}
303
+ return IngestSkipped(skipped=True, reason="not a push event")
304
304
  push = parse_push(payload, org_id=settings.org_id, github_host=settings.github_host)
305
305
  if push is None:
306
- return {"skipped": True, "reason": "not a storable push"}
306
+ return IngestSkipped(skipped=True, reason="not a storable push")
307
307
  receipt = await run_in_threadpool(store.store_push_receipt, push)
308
308
  fetch_repo, fetch_clone_url = push.repo, push.clone_url
309
309
  push = receipt.fact
@@ -325,12 +325,11 @@ async def ingest_push(
325
325
  push, fetch_repo=fetch_repo, fetch_clone_url=fetch_clone_url
326
326
  )
327
327
  background.add_task(request.app.state.workers.wait_background, task)
328
- return {"fact_id": push.push_id, "stored": stored}
328
+ return IngestReceipt(fact_id=push.push_id, stored=stored)
329
329
 
330
330
 
331
331
  @router.post(
332
332
  "/repository",
333
- response_model=dict[str, Any],
334
333
  responses={
335
334
  503: {
336
335
  "description": "Mirror work capacity, storage, or execution budget exceeded."
@@ -343,11 +342,11 @@ async def ingest_repository(
343
342
  x_github_event: str = Header(...),
344
343
  x_github_delivery: str | None = Header(None),
345
344
  store: FactStore = Depends(get_store),
346
- ) -> dict[str, Any]:
345
+ ) -> IngestReceipt | IngestSkipped:
347
346
  """Store an immutable rename receipt independently of mirror availability."""
348
347
  payload = await read_verified_webhook(request, x_hub_signature_256)
349
348
  if x_github_event != "repository":
350
- return {"skipped": True, "reason": "not a repository event"}
349
+ return IngestSkipped(skipped=True, reason="not a repository event")
351
350
  rename, reason = parse_repository_rename(
352
351
  payload,
353
352
  org_id=settings.org_id,
@@ -356,12 +355,12 @@ async def ingest_repository(
356
355
  )
357
356
  if rename is None:
358
357
  assert reason is not None
359
- return {"skipped": True, "reason": reason.value}
358
+ return IngestSkipped(skipped=True, reason=reason.value)
360
359
  receipt = await run_in_threadpool(store.store_repository_rename_receipt, rename)
361
360
  logger.info(
362
361
  "repository_rename_stored fact_id=%s stored=%s", receipt.fact_id, receipt.stored
363
362
  )
364
- return {"fact_id": receipt.fact_id, "stored": receipt.stored}
363
+ return IngestReceipt(fact_id=receipt.fact_id, stored=receipt.stored)
365
364
 
366
365
 
367
366
  @router.post("/ci")
@@ -371,10 +370,10 @@ async def ingest_ci(
371
370
  x_github_event: str = Header(...),
372
371
  x_github_delivery: str | None = Header(None),
373
372
  store: FactStore = Depends(get_store),
374
- ) -> dict[str, Any]:
373
+ ) -> IngestReceipt | IngestSkipped:
375
374
  payload = await read_verified_webhook(request, x_hub_signature_256)
376
375
  if x_github_event != "workflow_run":
377
- return {"skipped": True, "reason": "not a workflow_run event"}
376
+ return IngestSkipped(skipped=True, reason="not a workflow_run event")
378
377
  outcome = parse_workflow_run(
379
378
  payload,
380
379
  org_id=settings.org_id,
@@ -382,7 +381,7 @@ async def ingest_ci(
382
381
  github_host=settings.github_host,
383
382
  )
384
383
  if outcome is None:
385
- return {"skipped": True, "reason": "not a completed workflow_run"}
384
+ return IngestSkipped(skipped=True, reason="not a completed workflow_run")
386
385
  receipt = await run_in_threadpool(store.store_ci_outcome_receipt, outcome)
387
386
  outcome = receipt.fact
388
387
  stored = receipt.stored
@@ -393,7 +392,7 @@ async def ingest_ci(
393
392
  outcome.repo,
394
393
  outcome.result,
395
394
  )
396
- return {"fact_id": outcome.outcome_id, "stored": stored}
395
+ return IngestReceipt(fact_id=outcome.outcome_id, stored=stored)
397
396
 
398
397
 
399
398
  @router.post("/pull-request")
@@ -403,10 +402,10 @@ async def ingest_pull_request(
403
402
  x_github_event: str = Header(...),
404
403
  x_github_delivery: str | None = Header(None),
405
404
  store: FactStore = Depends(get_store),
406
- ) -> dict[str, Any]:
405
+ ) -> IngestReceipt | IngestSkipped:
407
406
  payload = await read_verified_webhook(request, x_hub_signature_256)
408
407
  if x_github_event != "pull_request":
409
- return {"skipped": True, "reason": "not a pull_request event"}
408
+ return IngestSkipped(skipped=True, reason="not a pull_request event")
410
409
  if payload.get("action") == "closed":
411
410
  merge = parse_pull_request_merge(
412
411
  payload,
@@ -415,7 +414,7 @@ async def ingest_pull_request(
415
414
  github_host=settings.github_host,
416
415
  )
417
416
  if merge is None:
418
- return {"skipped": True, "reason": "not a merged pull_request"}
417
+ return IngestSkipped(skipped=True, reason="not a merged pull_request")
419
418
  receipt = await run_in_threadpool(store.store_pull_request_merge_receipt, merge)
420
419
  merge = receipt.fact
421
420
  stored = receipt.stored
@@ -426,7 +425,7 @@ async def ingest_pull_request(
426
425
  merge.repo,
427
426
  merge.pr_number,
428
427
  )
429
- return {"fact_id": merge.merge_id, "stored": stored}
428
+ return IngestReceipt(fact_id=merge.merge_id, stored=stored)
430
429
 
431
430
  revision, skip_reason = parse_pull_request_revision(
432
431
  payload,
@@ -436,7 +435,7 @@ async def ingest_pull_request(
436
435
  )
437
436
  if revision is None:
438
437
  assert skip_reason is not None
439
- return {"skipped": True, "reason": skip_reason.value}
438
+ return IngestSkipped(skipped=True, reason=skip_reason.value)
440
439
  receipt = await run_in_threadpool(
441
440
  store.store_pull_request_revision_receipt, revision
442
441
  )
@@ -449,4 +448,4 @@ async def ingest_pull_request(
449
448
  revision.repo,
450
449
  revision.pr_number,
451
450
  )
452
- return {"fact_id": revision.revision_id, "stored": stored}
451
+ return IngestReceipt(fact_id=revision.revision_id, stored=stored)
@@ -20,6 +20,7 @@ from sediment_core.models import AwareDatetime
20
20
 
21
21
  from ..config import settings
22
22
  from ..deps import get_store, verify_ingest_token
23
+ from ..ingest_envelopes import IngestReceipt, IngestSkipped
23
24
 
24
25
  logger = logging.getLogger("sediment.api.gateway")
25
26
  router = APIRouter(tags=["gateway"])
@@ -57,7 +58,7 @@ def ingest_gateway(
57
58
  body: GatewayIngestRequest,
58
59
  _: None = Depends(verify_ingest_token),
59
60
  store: FactStore = Depends(get_store),
60
- ) -> dict[str, Any]:
61
+ ) -> IngestReceipt | IngestSkipped:
61
62
  adapter = ADAPTERS.get(body.provider)
62
63
  if adapter is None:
63
64
  raise HTTPException(
@@ -85,7 +86,7 @@ def ingest_gateway(
85
86
  body.provider,
86
87
  body.payload.get("litellm_call_id"),
87
88
  )
88
- return {"skipped": True, "reason": "no_session"}
89
+ return IngestSkipped(skipped=True, reason="no_session")
89
90
 
90
91
  try:
91
92
  inference_call = adapter.normalize(
@@ -134,4 +135,4 @@ def ingest_gateway(
134
135
  receipt.fact_id,
135
136
  receipt.stored,
136
137
  )
137
- return {"fact_id": receipt.fact_id, "stored": receipt.stored}
138
+ return IngestReceipt(fact_id=receipt.fact_id, stored=receipt.stored)
@@ -12,7 +12,6 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  import logging
15
- from typing import Any
16
15
 
17
16
  from fastapi import APIRouter, Depends, HTTPException, Request
18
17
  from sediment_capture import parse_otlp_logs
@@ -21,6 +20,7 @@ from starlette.concurrency import run_in_threadpool
21
20
 
22
21
  from ..config import settings
23
22
  from ..deps import get_store, verify_ingest_token
23
+ from ..ingest_envelopes import OTLPExportLogsResponse
24
24
 
25
25
  logger = logging.getLogger("sediment.api.otlp")
26
26
  router = APIRouter(tags=["otlp"])
@@ -31,7 +31,7 @@ async def ingest_otlp_logs(
31
31
  request: Request,
32
32
  _: None = Depends(verify_ingest_token),
33
33
  store: FactStore = Depends(get_store),
34
- ) -> dict[str, Any]:
34
+ ) -> OTLPExportLogsResponse:
35
35
  try:
36
36
  payload = await request.json()
37
37
  except HTTPException:
@@ -98,7 +98,7 @@ async def ingest_otlp_logs(
98
98
  },
99
99
  )
100
100
  # Empty ExportLogsServiceResponse = full success (OTLP/HTTP JSON).
101
- return {}
101
+ return OTLPExportLogsResponse()
102
102
 
103
103
 
104
104
  def _store_translated_facts(
@@ -0,0 +1,139 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Ingest response envelopes: the committed OpenAPI names each outcome, and
3
+ representative stored, duplicate, and skipped bodies validate against it
4
+ while keeping their exact wire shapes (AGENTS.md §API Conventions)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+ import pytest
14
+ import yaml
15
+ from fastapi.testclient import TestClient
16
+ from jsonschema import Draft202012Validator
17
+ from sediment_capture import sign_payload
18
+
19
+ from sediment_api.config import settings
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[3]
22
+ FIXTURES = REPO_ROOT / "packages/capture/tests/fixtures"
23
+ SPEC = yaml.safe_load((REPO_ROOT / "openapi.yaml").read_text(encoding="utf-8"))
24
+ RECEIPT = "#/components/schemas/IngestReceipt"
25
+ SKIPPED = "#/components/schemas/IngestSkipped"
26
+ OTLP = "#/components/schemas/OTLPExportLogsResponse"
27
+
28
+
29
+ def _ok_schema(path: str) -> dict[str, Any]:
30
+ response = SPEC["paths"][path]["post"]["responses"]["200"]
31
+ return response["content"]["application/json"]["schema"]
32
+
33
+
34
+ def _assert_matches_spec(path: str, response: httpx.Response) -> None:
35
+ assert response.status_code == 200
36
+ schema = {**_ok_schema(path), "components": SPEC["components"]}
37
+ Draft202012Validator(schema).validate(response.json())
38
+
39
+
40
+ @pytest.mark.parametrize(
41
+ ("path", "refs"),
42
+ [
43
+ ("/ingest/gateway", [RECEIPT, SKIPPED]),
44
+ ("/ingest/github/push", [RECEIPT, SKIPPED]),
45
+ ("/ingest/github/repository", [RECEIPT, SKIPPED]),
46
+ ("/ingest/github/ci", [RECEIPT, SKIPPED]),
47
+ ("/ingest/github/pull-request", [RECEIPT, SKIPPED]),
48
+ ("/ingest/ci", [RECEIPT]),
49
+ ("/ingest/compute", [RECEIPT]),
50
+ ("/v1/logs", [OTLP]),
51
+ ],
52
+ )
53
+ def test_each_ingest_route_names_its_envelopes(path: str, refs: list[str]) -> None:
54
+ schema = _ok_schema(path)
55
+ options = schema.get("anyOf", [schema])
56
+ assert [option["$ref"] for option in options] == refs
57
+
58
+
59
+ def test_envelope_schemas_are_closed() -> None:
60
+ schemas = SPEC["components"]["schemas"]
61
+ receipt, skipped, otlp = (
62
+ schemas[name]
63
+ for name in ("IngestReceipt", "IngestSkipped", "OTLPExportLogsResponse")
64
+ )
65
+ assert receipt["required"] == ["fact_id", "stored"]
66
+ assert receipt["properties"]["fact_id"]["type"] == "string"
67
+ assert receipt["properties"]["stored"]["type"] == "boolean"
68
+ assert skipped["required"] == ["skipped", "reason"]
69
+ assert skipped["properties"]["skipped"]["const"] is True
70
+ assert otlp["properties"] == {}
71
+ for schema in (receipt, skipped, otlp):
72
+ assert schema["additionalProperties"] is False
73
+
74
+
75
+ def _bearer() -> dict[str, str]:
76
+ return {"Authorization": f"Bearer {settings.api_bearer_token}"}
77
+
78
+
79
+ def _webhook(
80
+ client: TestClient, path: str, event: str, payload: dict[str, Any]
81
+ ) -> httpx.Response:
82
+ body = json.dumps(payload).encode()
83
+ return client.post(
84
+ path,
85
+ content=body,
86
+ headers={
87
+ "X-Hub-Signature-256": sign_payload(body, settings.github_webhook_secret),
88
+ "X-GitHub-Event": event,
89
+ "X-GitHub-Delivery": "delivery-envelopes",
90
+ "Content-Type": "application/json",
91
+ },
92
+ )
93
+
94
+
95
+ def test_gateway_stored_duplicate_and_skipped_bodies(client: TestClient) -> None:
96
+ payload = json.loads(
97
+ (FIXTURES / "litellm_standard_logging_object.json").read_text()
98
+ )
99
+ body = {"provider": "litellm", "session_id": "sess-envelope", "payload": payload}
100
+
101
+ first = client.post("/ingest/gateway", json=body, headers=_bearer())
102
+ again = client.post("/ingest/gateway", json=body, headers=_bearer())
103
+ skipped = client.post(
104
+ "/ingest/gateway",
105
+ json={"provider": "litellm", "payload": {"litellm_call_id": "no-session"}},
106
+ headers=_bearer(),
107
+ )
108
+
109
+ for response in (first, again, skipped):
110
+ _assert_matches_spec("/ingest/gateway", response)
111
+ fact_id = first.json()["fact_id"]
112
+ assert first.json() == {"fact_id": fact_id, "stored": True}
113
+ assert again.json() == {"fact_id": fact_id, "stored": False}
114
+ assert skipped.json() == {"skipped": True, "reason": "no_session"}
115
+
116
+
117
+ def test_webhook_stored_duplicate_and_skipped_bodies(client: TestClient) -> None:
118
+ push = json.loads((FIXTURES / "github_push.json").read_text())
119
+
120
+ first = _webhook(client, "/ingest/github/push", "push", push)
121
+ again = _webhook(client, "/ingest/github/push", "push", push)
122
+ skipped = _webhook(client, "/ingest/github/push", "ping", {"zen": "ok"})
123
+
124
+ for response in (first, again, skipped):
125
+ _assert_matches_spec("/ingest/github/push", response)
126
+ fact_id = first.json()["fact_id"]
127
+ assert first.json() == {"fact_id": fact_id, "stored": True}
128
+ assert again.json() == {"fact_id": fact_id, "stored": False}
129
+ assert skipped.json() == {"skipped": True, "reason": "not a push event"}
130
+
131
+
132
+ def test_otlp_success_stays_empty(client: TestClient) -> None:
133
+ [fixture] = sorted(FIXTURES.glob("otlp/claude_code/tool_decision_accept_user.json"))
134
+ payload = json.loads(fixture.read_text())
135
+
136
+ for _ in range(2):
137
+ response = client.post("/v1/logs", json=payload, headers=_bearer())
138
+ _assert_matches_spec("/v1/logs", response)
139
+ assert response.content == b"{}"
@@ -101,7 +101,7 @@ def test_me_happy_path(client: TestClient) -> None:
101
101
  assert resp.status_code == 200
102
102
  assert resp.json() == {
103
103
  "org_id": ORG,
104
- "version": "0.7.0",
104
+ "version": "0.7.1",
105
105
  "authority": "operator",
106
106
  "client_id": "operator",
107
107
  }
File without changes
File without changes