sediment-api 0.3.0__tar.gz → 0.4.0__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 (72) hide show
  1. {sediment_api-0.3.0 → sediment_api-0.4.0}/.gitignore +1 -3
  2. {sediment_api-0.3.0 → sediment_api-0.4.0}/PKG-INFO +6 -6
  3. {sediment_api-0.3.0 → sediment_api-0.4.0}/pyproject.toml +6 -6
  4. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/__init__.py +1 -1
  5. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/config.py +5 -0
  6. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/main.py +6 -2
  7. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/ci_vendor.py +2 -2
  8. sediment_api-0.4.0/tests/test_database_role_startup.py +136 -0
  9. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_v1.py +1 -1
  10. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_worker_processes.py +10 -6
  11. {sediment_api-0.3.0 → sediment_api-0.4.0}/LICENSE +0 -0
  12. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/database.py +0 -0
  13. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/deps.py +0 -0
  14. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/mirror_gc.py +0 -0
  15. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/__init__.py +0 -0
  16. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/abandonment_report.py +0 -0
  17. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/attribution_share_report.py +0 -0
  18. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/dataset_diagnostics.py +0 -0
  19. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/label_confidence_inspection.py +0 -0
  20. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/lifecycle_report.py +0 -0
  21. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/merge_retention_report.py +0 -0
  22. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/model_report.py +0 -0
  23. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/precision_report.py +0 -0
  24. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/reports/recovery_yield_report.py +0 -0
  25. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/__init__.py +0 -0
  26. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/forge.py +0 -0
  27. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/gateway.py +0 -0
  28. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/otlp.py +0 -0
  29. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/query.py +0 -0
  30. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/reports.py +0 -0
  31. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/routers/v1.py +0 -0
  32. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/services/__init__.py +0 -0
  33. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/services/operational_reports.py +0 -0
  34. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/worker.py +0 -0
  35. {sediment_api-0.3.0 → sediment_api-0.4.0}/sediment_api/workers.py +0 -0
  36. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/conftest.py +0 -0
  37. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_attribution_share_report.py +0 -0
  38. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_baseline_gap_repro.py +0 -0
  39. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_bug_repro_offset.py +0 -0
  40. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_capture_record_isolation.py +0 -0
  41. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_ci_vendor.py +0 -0
  42. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_config.py +0 -0
  43. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_context_discovery_authority.py +0 -0
  44. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_context_discovery_query.py +0 -0
  45. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_context_discovery_workers.py +0 -0
  46. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_context_evidence_query.py +0 -0
  47. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_context_query.py +0 -0
  48. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_credential_authorities.py +0 -0
  49. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_database_lifecycle.py +0 -0
  50. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_database_outage.py +0 -0
  51. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_dataset_diagnostics_report.py +0 -0
  52. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_e2e_smoke.py +0 -0
  53. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_evidence_query.py +0 -0
  54. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_evidence_worker_imports.py +0 -0
  55. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_evidence_workers.py +0 -0
  56. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_gateway_capture_receipt.py +0 -0
  57. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_gateway_identity.py +0 -0
  58. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_lifecycle_report_cli.py +0 -0
  59. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_merge_retention_cli.py +0 -0
  60. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_one_shot_database.py +0 -0
  61. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_operational_reports.py +0 -0
  62. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_operational_reports_service.py +0 -0
  63. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_postgres_gateway_tracer.py +0 -0
  64. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_push_mirror.py +0 -0
  65. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_query.py +0 -0
  66. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_report_attachment_scope.py +0 -0
  67. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_report_routes.py +0 -0
  68. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_repository_identity_ingest.py +0 -0
  69. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_repository_identity_model_service.py +0 -0
  70. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_repository_identity_queries.py +0 -0
  71. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_retrieval_authority.py +0 -0
  72. {sediment_api-0.3.0 → sediment_api-0.4.0}/tests/test_worker_routes.py +0 -0
@@ -13,8 +13,7 @@ exports/
13
13
  !docs/exports/
14
14
  .env
15
15
  .DS_Store
16
- .claude/settings.local.*
17
- .claude/worktrees/
16
+ .claude/
18
17
 
19
18
  # Deployment-local compose overrides (mirror credentials etc.) — never in PRs
20
19
  compose.override.yml
@@ -29,4 +28,3 @@ litellm/config.local.yaml
29
28
  !.env.example
30
29
  .agents/
31
30
  skills-lock.json
32
- .claude/skills/asd-ste100
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sediment-api
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -13,8 +13,8 @@ Requires-Dist: anyio>=4.15.1
13
13
  Requires-Dist: fastapi>=0.141.1
14
14
  Requires-Dist: httpx>=0.27
15
15
  Requires-Dist: pydantic-settings>=2.15.0
16
- Requires-Dist: sediment-capture==0.3.0
17
- Requires-Dist: sediment-core==0.3.0
18
- Requires-Dist: sediment-derive==0.3.0
19
- Requires-Dist: sediment-export==0.3.0
20
- Requires-Dist: uvicorn>=0.53.0
16
+ Requires-Dist: sediment-capture==0.4.0
17
+ Requires-Dist: sediment-core==0.4.0
18
+ Requires-Dist: sediment-derive==0.4.0
19
+ Requires-Dist: sediment-export==0.4.0
20
+ Requires-Dist: uvicorn>=0.54.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sediment-api"
3
- version = "0.3.0"
3
+ version = "0.4.0"
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"
@@ -10,12 +10,12 @@ dependencies = [
10
10
  "anyio>=4.15.1",
11
11
  "fastapi>=0.141.1",
12
12
  "httpx>=0.27",
13
- "uvicorn>=0.53.0",
13
+ "uvicorn>=0.54.0",
14
14
  "pydantic-settings>=2.15.0",
15
- "sediment-core==0.3.0",
16
- "sediment-capture==0.3.0",
17
- "sediment-derive==0.3.0",
18
- "sediment-export==0.3.0",
15
+ "sediment-core==0.4.0",
16
+ "sediment-capture==0.4.0",
17
+ "sediment-derive==0.4.0",
18
+ "sediment-export==0.4.0",
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.3.0"
6
+ __version__ = "0.4.0"
@@ -51,6 +51,11 @@ class Settings(BaseSettings):
51
51
  # PostgreSQL is the sole active fact store. SecretStr prevents settings
52
52
  # representations and validation diagnostics from echoing credentials.
53
53
  database_url: SecretStr
54
+ # Operator-named database roles (ADR 0027); empty means the default name.
55
+ # The lifespan validates them, keeping postgres_roles out of workers.
56
+ migrator_role: str = ""
57
+ runtime_role: str = ""
58
+ operator_role: str = ""
54
59
 
55
60
  # Raw git substrate: base dir for the per-(org, repo) bare mirrors.
56
61
  # None (unset) disables mirror refresh entirely — facts still flow;
@@ -22,7 +22,7 @@ from sediment_core.postgres_migrations import (
22
22
  RevisionState,
23
23
  inspect_engine_revision,
24
24
  )
25
- from sediment_core.postgres_roles import validate_runtime_privileges
25
+ from sediment_core.postgres_roles import RoleNames, validate_runtime_privileges
26
26
  from sqlalchemy.exc import (
27
27
  DisconnectionError,
28
28
  InterfaceError,
@@ -67,6 +67,10 @@ for _warning in settings.config_warnings():
67
67
  async def lifespan(application: FastAPI):
68
68
  """Own one PostgreSQL engine and verify the exact schema before traffic."""
69
69
  database_url = settings.database_url.get_secret_value()
70
+ # Invalid role names fail here, with their own message, before any I/O.
71
+ roles = RoleNames.configured(
72
+ settings.migrator_role, settings.runtime_role, settings.operator_role
73
+ )
70
74
  engine = None
71
75
  workers = None
72
76
  try:
@@ -82,7 +86,7 @@ async def lifespan(application: FastAPI):
82
86
  f"{HEAD_REVISION}, found {inspection.state.value}"
83
87
  )
84
88
  if not settings.dev_mode:
85
- validate_runtime_privileges(engine)
89
+ validate_runtime_privileges(engine, roles)
86
90
  application.state.database_engine = engine
87
91
  application.state.fact_store = FactStore(engine)
88
92
  application.state.database_target = sanitized_database_target(database_url)
@@ -68,8 +68,8 @@ class VendorCIRequest(BaseModel):
68
68
  )
69
69
  run_id: NonEmptyId = Field(
70
70
  description=(
71
- "The provider-issued pipeline-run id, unique within the deployment "
72
- "organization and provider namespace"
71
+ "The CI provider's pipeline-run ID. Identified runs also carry a "
72
+ "forge provider and host; run_attempt distinguishes attempts."
73
73
  )
74
74
  )
75
75
  run_attempt: int | None = Field(default=None, ge=1, le=2**63 - 1, strict=True)
@@ -0,0 +1,136 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Production startup against operator-named roles provisioned without Sediment."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from uuid import uuid4
7
+
8
+ import pytest
9
+ from psycopg import sql
10
+ from pydantic import SecretStr
11
+ from sqlalchemy import create_engine
12
+ from sqlalchemy.engine import make_url
13
+ from sqlalchemy.pool import NullPool
14
+
15
+ from sediment_core.postgres_engine import DatabaseOperationError
16
+ from sediment_core.postgres_roles import (
17
+ DatabasePrivilegeError,
18
+ RoleNames,
19
+ administrator_sql,
20
+ migrate_database,
21
+ )
22
+
23
+ # Creates cluster-wide roles under names no other module uses; see
24
+ # packages/core/tests/test_postgres_roles.py for the default-name contract.
25
+ pytestmark = pytest.mark.cluster_roles
26
+ ROLES = RoleNames("sediment_test_api_m", "sediment_test_api_r", "sediment_test_api_o")
27
+ ADMIN, ADMIN_PASSWORD = "sediment_test_api_admin", "api-admin-test-secret"
28
+ PASSWORDS = {role: f"{role}-secret" for role in ROLES}
29
+
30
+
31
+ @pytest.fixture
32
+ def provisioned(postgres_admin_url):
33
+ """The administrator's printed SQL applied by a non-superuser owner."""
34
+ superuser = create_engine(
35
+ postgres_admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
36
+ )
37
+ database = f"sediment_api_role_test_{uuid4().hex}"
38
+ engines = []
39
+
40
+ def url(role, password=None):
41
+ return (
42
+ make_url(postgres_admin_url)
43
+ .set(
44
+ database=database,
45
+ username=role,
46
+ password=password or PASSWORDS.get(role, ADMIN_PASSWORD),
47
+ )
48
+ .render_as_string(hide_password=False)
49
+ )
50
+
51
+ def connect(role, password=None):
52
+ engine = create_engine(url(role, password), poolclass=NullPool)
53
+ engines.append(engine)
54
+ return engine
55
+
56
+ with superuser.connect() as connection:
57
+ connection.exec_driver_sql(
58
+ f"CREATE ROLE {ADMIN} LOGIN CREATEROLE CREATEDB PASSWORD '{ADMIN_PASSWORD}'"
59
+ )
60
+ connection.exec_driver_sql(f'CREATE DATABASE "{database}" OWNER {ADMIN}')
61
+ try:
62
+ with connect(ADMIN).connect() as connection:
63
+ driver = connection.connection.driver_connection
64
+ driver.execute(administrator_sql(database, ROLES))
65
+ for role, secret in PASSWORDS.items():
66
+ driver.execute(
67
+ sql.SQL("ALTER ROLE {} PASSWORD {}").format(
68
+ sql.Identifier(role), sql.Literal(secret)
69
+ )
70
+ )
71
+ driver.commit()
72
+ yield url, connect
73
+ finally:
74
+ for engine in engines:
75
+ engine.dispose()
76
+ with superuser.connect() as connection:
77
+ connection.exec_driver_sql(f'DROP DATABASE "{database}" WITH (FORCE)')
78
+ for role in (*ROLES, ADMIN):
79
+ connection.exec_driver_sql(f'DROP ROLE IF EXISTS "{role}"')
80
+ superuser.dispose()
81
+
82
+
83
+ def start(monkeypatch, database_url):
84
+ from fastapi.testclient import TestClient
85
+
86
+ from sediment_api.config import settings
87
+ from sediment_api.main import app
88
+
89
+ monkeypatch.setattr(settings, "database_url", SecretStr(database_url))
90
+ monkeypatch.setattr(settings, "dev_mode", False)
91
+ monkeypatch.setattr(settings, "migrator_role", ROLES.migrator)
92
+ monkeypatch.setattr(settings, "runtime_role", ROLES.runtime)
93
+ monkeypatch.setattr(settings, "operator_role", ROLES.operator)
94
+ with TestClient(app) as client:
95
+ assert client.get("/health").status_code == 200
96
+
97
+
98
+ def test_startup_refuses_until_the_migrate_step_grants_then_serves(
99
+ provisioned, monkeypatch
100
+ ):
101
+ url, connect = provisioned
102
+ with pytest.raises(DatabaseOperationError, match="found absent"):
103
+ start(monkeypatch, url(ROLES.runtime))
104
+ assert migrate_database(url(ROLES.migrator), ROLES)
105
+ start(monkeypatch, url(ROLES.runtime))
106
+ # A table that Alembic created without grants blocks startup again.
107
+ with connect(ROLES.migrator).begin() as connection:
108
+ connection.exec_driver_sql(
109
+ f"REVOKE ALL ON inference_call_aliases FROM {ROLES.runtime}"
110
+ )
111
+ with pytest.raises(DatabasePrivilegeError, match="inference_call_aliases"):
112
+ start(monkeypatch, url(ROLES.runtime))
113
+ assert migrate_database(url(ROLES.migrator), ROLES)
114
+ start(monkeypatch, url(ROLES.runtime))
115
+
116
+
117
+ def test_startup_uses_a_runtime_password_the_role_rotated_itself(
118
+ provisioned, monkeypatch
119
+ ):
120
+ url, connect = provisioned
121
+ assert migrate_database(url(ROLES.migrator), ROLES)
122
+ with connect(ROLES.runtime).connect() as connection:
123
+ driver = connection.connection.driver_connection
124
+ driver.execute("ALTER ROLE CURRENT_USER PASSWORD 'rotated-runtime-secret'")
125
+ driver.commit()
126
+ start(monkeypatch, url(ROLES.runtime, "rotated-runtime-secret"))
127
+
128
+
129
+ def test_startup_refuses_the_migrator_and_the_administrator(provisioned, monkeypatch):
130
+ url, _ = provisioned
131
+ assert migrate_database(url(ROLES.migrator), ROLES)
132
+ with pytest.raises(DatabasePrivilegeError, match=ROLES.runtime):
133
+ start(monkeypatch, url(ROLES.migrator))
134
+ # The administrator can't even read the schema revision.
135
+ with pytest.raises(DatabaseOperationError, match="verify database startup"):
136
+ start(monkeypatch, url(ADMIN))
@@ -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.3.0",
104
+ "version": "0.4.0",
105
105
  "authority": "operator",
106
106
  "client_id": "operator",
107
107
  }
@@ -42,10 +42,13 @@ def test_deadline_kills_process_group_before_slot_reuse(monkeypatch, tmp_path):
42
42
  "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
43
43
  "child = subprocess.Popen([sys.executable, '-c', "
44
44
  "'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)'])\n"
45
- f"open({str(marker)!r}, 'w').write(f'{{os.getpid()}} {{child.pid}}')\n"
45
+ f"from pathlib import Path; t = Path({str(marker)!r} + '.tmp')\n"
46
+ "t.write_text(f'{os.getpid()} {child.pid}')\n"
47
+ f"t.replace({str(marker)!r})\n"
46
48
  "time.sleep(60)\n",
47
49
  )
48
- monkeypatch.setattr(workers, "QUERY_BUDGET_SECONDS", 0.3)
50
+ # The budget outlasts startup, so the marker exists before the kill.
51
+ monkeypatch.setattr(workers, "QUERY_BUDGET_SECONDS", 1.0)
49
52
  monkeypatch.setattr(workers, "TERMINATION_GRACE_SECONDS", 0.1)
50
53
 
51
54
  async def check():
@@ -98,7 +101,7 @@ def test_cancellation_and_shutdown_reap_child(monkeypatch, tmp_path, operation):
98
101
  marker = tmp_path / "pid"
99
102
  _command(
100
103
  monkeypatch,
101
- f"import os,time; open({str(marker)!r},'w').write(str(os.getpid())); time.sleep(60)",
104
+ f"import os,time; from pathlib import Path; t=Path({str(marker)!r}+'.tmp'); t.write_text(str(os.getpid())); t.replace({str(marker)!r}); time.sleep(60)",
102
105
  )
103
106
 
104
107
  async def check():
@@ -234,7 +237,7 @@ def test_cancellation_during_spawn_does_not_orphan_the_started_process(
234
237
  marker = tmp_path / "pid"
235
238
  _command(
236
239
  monkeypatch,
237
- f"import os,time; open({str(marker)!r},'w').write(str(os.getpid())); time.sleep(60)",
240
+ f"import os,time; from pathlib import Path; t=Path({str(marker)!r}+'.tmp'); t.write_text(str(os.getpid())); t.replace({str(marker)!r}); time.sleep(60)",
238
241
  )
239
242
  spawn = asyncio.create_subprocess_exec
240
243
 
@@ -335,9 +338,10 @@ def test_dying_process_group_permission_race_is_rechecked(monkeypatch, tmp_path)
335
338
  marker = tmp_path / "pid"
336
339
  _command(
337
340
  monkeypatch,
338
- f"import os,time; open({str(marker)!r},'w').write(str(os.getpid())); time.sleep(60)",
341
+ f"import os,time; from pathlib import Path; t=Path({str(marker)!r}+'.tmp'); t.write_text(str(os.getpid())); t.replace({str(marker)!r}); time.sleep(60)",
339
342
  )
340
- monkeypatch.setattr(workers, "QUERY_BUDGET_SECONDS", 0.1)
343
+ # The budget outlasts startup, so the marker exists before the kill.
344
+ monkeypatch.setattr(workers, "QUERY_BUDGET_SECONDS", 1.0)
341
345
  signal_group = os.killpg
342
346
  raced = False
343
347
 
File without changes