data-olympus 0.3.1__tar.gz → 0.3.3__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 (81) hide show
  1. {data_olympus-0.3.1 → data_olympus-0.3.3}/CHANGELOG.md +40 -1
  2. {data_olympus-0.3.1 → data_olympus-0.3.3}/PKG-INFO +1 -1
  3. {data_olympus-0.3.1 → data_olympus-0.3.3}/pyproject.toml +1 -1
  4. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/config.py +21 -2
  5. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/rest_api.py +30 -13
  6. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/server.py +52 -12
  7. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/session_metrics.py +51 -9
  8. {data_olympus-0.3.1 → data_olympus-0.3.3}/.gitignore +0 -0
  9. {data_olympus-0.3.1 → data_olympus-0.3.3}/LICENSE +0 -0
  10. {data_olympus-0.3.1 → data_olympus-0.3.3}/NOTICE +0 -0
  11. {data_olympus-0.3.1 → data_olympus-0.3.3}/README.md +0 -0
  12. {data_olympus-0.3.1 → data_olympus-0.3.3}/SPEC.md +0 -0
  13. {data_olympus-0.3.1 → data_olympus-0.3.3}/benchmarks/README.md +0 -0
  14. {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/_kb_detect_workspace.sh +0 -0
  15. {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/_kb_enforce.py +0 -0
  16. {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/_kb_fallback.py +0 -0
  17. {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/kb +0 -0
  18. {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/kb-enforce-hook +0 -0
  19. {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/opencode/data-olympus-gate.ts +0 -0
  20. {data_olympus-0.3.1 → data_olympus-0.3.3}/deploy/k8s/README.md +0 -0
  21. {data_olympus-0.3.1 → data_olympus-0.3.3}/deploy/k8s/read-replica/README.md +0 -0
  22. {data_olympus-0.3.1 → data_olympus-0.3.3}/docs/releases/README.md +0 -0
  23. {data_olympus-0.3.1 → data_olympus-0.3.3}/example-bundle/projects/acme-app/README.md +0 -0
  24. {data_olympus-0.3.1 → data_olympus-0.3.3}/example-bundle/projects/acme-app/components/api/README.md +0 -0
  25. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/__init__.py +0 -0
  26. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/audit_log.py +0 -0
  27. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/audit_trailers.py +0 -0
  28. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/auth.py +0 -0
  29. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/__init__.py +0 -0
  30. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/import_cmd.py +0 -0
  31. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/indexgen.py +0 -0
  32. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/main.py +0 -0
  33. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/report_cmd.py +0 -0
  34. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cooccurrence.py +0 -0
  35. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/dedup.py +0 -0
  36. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/durable.py +0 -0
  37. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/embeddings.py +0 -0
  38. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/enforce_policy.py +0 -0
  39. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/__init__.py +0 -0
  40. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/document.py +0 -0
  41. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/frontmatter.py +0 -0
  42. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/lint.py +0 -0
  43. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/validate.py +0 -0
  44. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/git_ops.py +0 -0
  45. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/health.py +0 -0
  46. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/__init__.py +0 -0
  47. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/adr.py +0 -0
  48. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/flat.py +0 -0
  49. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/model.py +0 -0
  50. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/okf.py +0 -0
  51. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/run.py +0 -0
  52. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/stamp.py +0 -0
  53. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/index.py +0 -0
  54. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/markdown_parse.py +0 -0
  55. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/models.py +0 -0
  56. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/onboarding.py +0 -0
  57. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/onboarding_inflight.py +0 -0
  58. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/onboarding_playbook.py +0 -0
  59. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/pending.py +0 -0
  60. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/principals.py +0 -0
  61. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/prompts.py +0 -0
  62. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/push_queue.py +0 -0
  63. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/query_expansion.py +0 -0
  64. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/rate_limit.py +0 -0
  65. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/refresh.py +0 -0
  66. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/report.py +0 -0
  67. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/safe_id.py +0 -0
  68. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/search_gate.py +0 -0
  69. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/search_shortcut.py +0 -0
  70. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/setup_wizard.py +0 -0
  71. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/thin_pointer.py +0 -0
  72. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_audit.py +0 -0
  73. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_enforce.py +0 -0
  74. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_onboarding.py +0 -0
  75. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_read.py +0 -0
  76. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_write.py +0 -0
  77. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/trigram.py +0 -0
  78. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/viewer/__init__.py +0 -0
  79. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/viewer/generator.py +0 -0
  80. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/worktrees.py +0 -0
  81. {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/write_gate.py +0 -0
@@ -12,6 +12,43 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.3.3] - 2026-07-05
16
+
17
+ ### Fixed
18
+
19
+ - **MCP streamable-HTTP session churn under long-lived clients.** A persistent
20
+ client (notably `opencode serve`) that holds the `GET /mcp` SSE stream open was
21
+ being forced to reconnect on a cycle, piling up abandoned transports on the
22
+ server and leaking event listeners on the client until it degraded. Two causes
23
+ addressed:
24
+ - The activity middleware now keeps a session non-idle for as long as its SSE
25
+ stream is actually open (re-stamping every `KB_SESSION_TOUCH_INTERVAL_SEC`,
26
+ default 30s, clamped to a third of the idle window), so the reaper evicts a
27
+ session only after its stream has genuinely closed. With that safety in
28
+ place, the idle-reap default (`KB_SESSION_IDLE_TIMEOUT_SEC`) drops from 1800s
29
+ to 300s, so an abandoned handshake is cleared in minutes instead of half an
30
+ hour.
31
+ - The shipped ingress manifest (`deploy/k8s/ingress.yaml`) now sets a long
32
+ `proxy-read-timeout`/`proxy-send-timeout` and disables buffering, so a proxy
33
+ with a short default idle timeout (nginx: 60s) no longer closes the SSE
34
+ stream and triggers the reconnect loop. See `docs/serving.md`.
35
+
36
+ ## [0.3.2] - 2026-07-05
37
+
38
+ ### Fixed
39
+
40
+ - **`gate/check` no longer self-DoSes multi-agent fleets with 429s.** The
41
+ enforcement hook calls `POST /api/v1/gate/check` (and `kb_gate_check`) once per
42
+ gated tool action for every agent, but it shared the write/consult quota
43
+ (`KB_RATE_LIMIT_PER_HOUR`, default 100). Behind an ingress with no auth, all
44
+ clients collapse to a single limiter bucket, so a few active agents exhausted
45
+ the hourly quota in minutes and everything after got `429 Too Many Requests`.
46
+ gate-check now has its own ceiling, `KB_GATE_CHECK_RATE_LIMIT_PER_HOUR`,
47
+ defaulting to `0` (unthrottled, consistent with the read routes) since it is a
48
+ mandatory per-action probe that does only cheap classification and no writes.
49
+ Set a positive value for an explicit backstop. `consult` and
50
+ `onboarding/cleanup-plan` stay throttled by `KB_RATE_LIMIT_PER_HOUR`.
51
+
15
52
  ## [0.3.1] - 2026-07-05
16
53
 
17
54
  ### Fixed
@@ -1013,7 +1050,9 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1013
1050
  - `docs/adoption.md`: bring-your-own-KB guide (author, lint, index, serve, wire an agent).
1014
1051
  - `docs/comparison.md`: how data-olympus relates to OKF, enterprise catalogs, markdown KB tools, agent-context conventions, RAG, and ADR tooling.
1015
1052
 
1016
- [Unreleased]: https://github.com/knaisoma/data-olympus/compare/v0.3.1...HEAD
1053
+ [Unreleased]: https://github.com/knaisoma/data-olympus/compare/v0.3.3...HEAD
1054
+ [0.3.3]: https://github.com/knaisoma/data-olympus/compare/v0.3.2...v0.3.3
1055
+ [0.3.2]: https://github.com/knaisoma/data-olympus/compare/v0.3.1...v0.3.2
1017
1056
  [0.3.1]: https://github.com/knaisoma/data-olympus/compare/v0.3.0...v0.3.1
1018
1057
  [0.3.0]: https://github.com/knaisoma/data-olympus/compare/v0.2.0...v0.3.0
1019
1058
  [0.2.0]: https://github.com/knaisoma/data-olympus/compare/v0.1.1...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-olympus
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Governance-grade knowledge-base format (OKF-compatible) plus CLI and single-writer MCP server
5
5
  Project-URL: Homepage, https://github.com/knaisoma/data-olympus
6
6
  Project-URL: Repository, https://github.com/knaisoma/data-olympus
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "data-olympus"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "Governance-grade knowledge-base format (OKF-compatible) plus CLI and single-writer MCP server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -28,6 +28,14 @@ class Config:
28
28
  write_block_paths: list[str] = field(default_factory=list)
29
29
  rate_limit_per_hour: int = 100
30
30
  rate_limit_per_ip_per_hour: int = 0
31
+ # Separate ceiling for the high-frequency /api/v1/gate/check route (the
32
+ # enforcement hook's per-tool-action freshness probe). 0 (default) disables
33
+ # throttling for that route: it is a mandatory, once-per-tool-call, read-only
34
+ # probe, so any fixed hourly quota self-DoSes an active multi-agent fleet
35
+ # (behind ingress all clients collapse to one limiter bucket). Reads are
36
+ # already unthrottled; this keeps gate/check consistent with them. Set a
37
+ # positive value only if you want an explicit backstop.
38
+ gate_check_rate_limit_per_hour: int = 0
31
39
  max_text_bytes: int = 262144
32
40
  max_postimage_bytes: int = 1048576
33
41
  max_body_bytes: int = 2097152
@@ -57,8 +65,15 @@ class Config:
57
65
  # Streamable-http session reaping: terminate transports idle beyond this many
58
66
  # seconds to bound _server_instances (see session_metrics). 0 disables the
59
67
  # reaper (observability-only). The scan runs every session_reap_interval_sec.
60
- session_idle_timeout_sec: int = 1800
68
+ # An in-flight GET SSE stream is kept non-idle by the activity middleware, so
69
+ # this window only clears sessions whose stream has actually closed (an
70
+ # abandoned handshake); 5 minutes keeps those from piling up.
71
+ session_idle_timeout_sec: int = 300
61
72
  session_reap_interval_sec: int = 60
73
+ # How often the activity middleware re-stamps a session while its GET SSE
74
+ # stream is open, so a quiet-but-connected client is never reaped. Clamped
75
+ # below session_idle_timeout_sec at wiring time.
76
+ session_touch_interval_sec: int = 30
62
77
  # Optional override for the status-aware reranker's status->weight map
63
78
  # (issue #37). None means "use the Index built-in default map". A negative
64
79
  # weight boosts an in-force status, a positive one penalizes a retired one.
@@ -155,6 +170,7 @@ def load_config() -> Config:
155
170
  write_block_paths = _split_csv(os.getenv("KB_WRITE_BLOCK_PATHS", ""))
156
171
  rate_limit_per_hour = int(os.getenv("KB_RATE_LIMIT_PER_HOUR", "100"))
157
172
  rate_limit_per_ip_per_hour = int(os.getenv("KB_RATE_LIMIT_PER_IP_PER_HOUR", "0"))
173
+ gate_check_rate_limit_per_hour = int(os.getenv("KB_GATE_CHECK_RATE_LIMIT_PER_HOUR", "0"))
158
174
  max_text_bytes = int(os.getenv("KB_MAX_TEXT_BYTES", "262144"))
159
175
  max_postimage_bytes = int(os.getenv("KB_MAX_POSTIMAGE_BYTES", "1048576"))
160
176
  max_body_bytes = int(os.getenv("KB_MAX_BODY_BYTES", "2097152"))
@@ -182,8 +198,9 @@ def load_config() -> Config:
182
198
  auth_principals = parse_principals_env(os.getenv("KB_AUTH_PRINCIPALS", ""))
183
199
  consult_ttl_sec = int(os.getenv("KB_CONSULT_TTL_SEC", "300"))
184
200
  ledger_path = os.getenv("KB_LEDGER_PATH", "/state/ledger.json")
185
- session_idle_timeout_sec = int(os.getenv("KB_SESSION_IDLE_TIMEOUT_SEC", "1800"))
201
+ session_idle_timeout_sec = int(os.getenv("KB_SESSION_IDLE_TIMEOUT_SEC", "300"))
186
202
  session_reap_interval_sec = int(os.getenv("KB_SESSION_REAP_INTERVAL_SEC", "60"))
203
+ session_touch_interval_sec = int(os.getenv("KB_SESSION_TOUCH_INTERVAL_SEC", "30"))
187
204
  status_weights = _load_status_weights(os.getenv("KB_STATUS_WEIGHTS", ""))
188
205
  read_only = _env_bool(os.getenv("KB_READ_ONLY", ""))
189
206
  from data_olympus.cooccurrence import (
@@ -226,6 +243,7 @@ def load_config() -> Config:
226
243
  write_block_paths=write_block_paths,
227
244
  rate_limit_per_hour=rate_limit_per_hour,
228
245
  rate_limit_per_ip_per_hour=rate_limit_per_ip_per_hour,
246
+ gate_check_rate_limit_per_hour=gate_check_rate_limit_per_hour,
229
247
  max_text_bytes=max_text_bytes,
230
248
  max_postimage_bytes=max_postimage_bytes,
231
249
  max_body_bytes=max_body_bytes,
@@ -244,6 +262,7 @@ def load_config() -> Config:
244
262
  ledger_path=ledger_path,
245
263
  session_idle_timeout_sec=session_idle_timeout_sec,
246
264
  session_reap_interval_sec=session_reap_interval_sec,
265
+ session_touch_interval_sec=session_touch_interval_sec,
247
266
  status_weights=status_weights,
248
267
  read_only=read_only,
249
268
  cooccurrence_enabled=cooc_enabled,
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
32
32
  from starlette.requests import Request
33
33
 
34
34
  from data_olympus.models import HealthResponse
35
+ from data_olympus.rate_limit import SlidingWindowLimiter
35
36
  from data_olympus.server import ServerState
36
37
 
37
38
 
@@ -285,22 +286,33 @@ async def _read_json_capped(
285
286
  )
286
287
 
287
288
 
289
+ _USE_WRITE_LIMITER: Any = object()
290
+
291
+
288
292
  def _rate_limited(
289
293
  state: ServerState, request: Request, principal: Principal,
294
+ *, limiter: SlidingWindowLimiter | None = _USE_WRITE_LIMITER,
290
295
  ) -> JSONResponse | None:
291
- """Apply the shared sliding-window limiter to an enforcement-plane route.
292
-
293
- The consult / gate / cleanup-plan routes were previously unthrottled (item 6),
294
- so an anonymous or authenticated caller could hammer the classifier and ledger
295
- without bound. Reuse the same limiter the write routes use, keyed by
296
- (remote_addr, principal name) so a per-principal quota applies. Returns a 429
297
- JSONResponse when over quota, else None.
298
-
299
- When the limiter is absent (a read-only deployment with no write pipeline
300
- configured) throttling is skipped: there is no shared limiter object to
301
- consult and these routes are read-mostly there.
296
+ """Apply a sliding-window limiter to an enforcement-plane route.
297
+
298
+ The consult / cleanup-plan routes were previously unthrottled (item 6), so an
299
+ anonymous or authenticated caller could hammer the classifier and ledger
300
+ without bound. They reuse the write limiter (``state.rate_limiter``), keyed by
301
+ (remote_addr, principal name). Returns a 429 JSONResponse when over quota, else
302
+ None.
303
+
304
+ ``limiter`` overrides which limiter to consult. The high-frequency
305
+ /api/v1/gate/check route passes ``state.gate_rate_limiter`` (None by default),
306
+ so it is unthrottled unless an operator sets an explicit ceiling: it is a
307
+ mandatory once-per-tool-action probe, and any fixed quota shared across a
308
+ fleet (all clients collapse to one bucket behind ingress) self-DoSes with 429s.
309
+
310
+ When the resolved limiter is absent (a read-only deployment, or gate/check with
311
+ no ceiling configured) throttling is skipped.
302
312
  """
303
- limiter = state.rate_limiter
313
+ # Distinguish "no limiter arg" (use the write limiter) from an explicit None
314
+ # (the gate-check limiter with no ceiling: throttling is genuinely off).
315
+ limiter = state.rate_limiter if limiter is _USE_WRITE_LIMITER else limiter
304
316
  if limiter is None:
305
317
  return None
306
318
  remote_addr = request.client.host if request.client else "unknown"
@@ -662,7 +674,12 @@ def register_routes(
662
674
  principal, denied = _authorize(request, registry)
663
675
  if denied is not None:
664
676
  return denied
665
- if (throttled := _rate_limited(state, request, principal)) is not None:
677
+ # gate/check uses its own limiter (unthrottled by default): it is the
678
+ # hook's once-per-tool-action probe, so it must not share the write /
679
+ # consult quota (that self-DoSes a fleet, see _rate_limited).
680
+ if (throttled := _rate_limited(
681
+ state, request, principal, limiter=state.gate_rate_limiter
682
+ )) is not None:
666
683
  return throttled
667
684
  import time as _time
668
685
 
@@ -123,6 +123,7 @@ class ServerState:
123
123
  push_queue: PushQueue | None = None,
124
124
  pending: PendingQueue | None = None,
125
125
  rate_limiter: SlidingWindowLimiter | None = None,
126
+ gate_rate_limiter: SlidingWindowLimiter | None = None,
126
127
  blocklist: PathBlocklist | None = None,
127
128
  audit_log: AuditLog | None = None,
128
129
  classifier: IntentClassifier | None = None,
@@ -147,6 +148,10 @@ class ServerState:
147
148
  self.push_queue: PushQueue | None = push_queue
148
149
  self.pending: PendingQueue | None = pending
149
150
  self.rate_limiter: SlidingWindowLimiter | None = rate_limiter
151
+ # Separate limiter for /api/v1/gate/check (None = unthrottled, the default).
152
+ # Kept distinct from `rate_limiter` so gate-check traffic and the write /
153
+ # consult / cleanup-plan quota never share a bucket.
154
+ self.gate_rate_limiter: SlidingWindowLimiter | None = gate_rate_limiter
150
155
  self.blocklist: PathBlocklist | None = blocklist
151
156
  self.audit_log: AuditLog | None = audit_log
152
157
  # Process-wide write serializer (scope item 1): one instance shared by
@@ -232,6 +237,7 @@ def build_app(
232
237
  confidence_threshold: float = 0.85,
233
238
  rate_limit_per_hour: int = 100,
234
239
  rate_limit_per_ip_per_hour: int = 0,
240
+ gate_check_rate_limit_per_hour: int = 0,
235
241
  max_text_bytes: int = 262144,
236
242
  max_postimage_bytes: int = 1048576,
237
243
  max_body_bytes: int = 2097152,
@@ -245,8 +251,9 @@ def build_app(
245
251
  audit_hmac_key: str = "",
246
252
  audit_max_bytes: int = 0,
247
253
  ledger_path: str | None = None,
248
- session_idle_timeout_sec: int = 1800,
254
+ session_idle_timeout_sec: int = 300,
249
255
  session_reap_interval_sec: int = 60,
256
+ session_touch_interval_sec: int = 30,
250
257
  status_weights: dict[str, float] | None = None,
251
258
  read_only: bool = False,
252
259
  embeddings_enabled: bool = False,
@@ -279,6 +286,7 @@ def build_app(
279
286
  write_block_paths=write_block_paths or [],
280
287
  rate_limit_per_hour=rate_limit_per_hour,
281
288
  rate_limit_per_ip_per_hour=rate_limit_per_ip_per_hour,
289
+ gate_check_rate_limit_per_hour=gate_check_rate_limit_per_hour,
282
290
  max_text_bytes=max_text_bytes,
283
291
  max_postimage_bytes=max_postimage_bytes,
284
292
  max_body_bytes=max_body_bytes,
@@ -293,6 +301,7 @@ def build_app(
293
301
  audit_max_bytes=audit_max_bytes,
294
302
  session_idle_timeout_sec=session_idle_timeout_sec,
295
303
  session_reap_interval_sec=session_reap_interval_sec,
304
+ session_touch_interval_sec=session_touch_interval_sec,
296
305
  status_weights=status_weights,
297
306
  read_only=read_only,
298
307
  embeddings_enabled=embeddings_enabled,
@@ -393,6 +402,13 @@ def build_app(
393
402
  max_per_hour=config.rate_limit_per_hour,
394
403
  max_per_ip_per_hour=config.rate_limit_per_ip_per_hour,
395
404
  )
405
+ # gate/check gets its own limiter only when a positive ceiling is set;
406
+ # 0 (default) leaves it unthrottled (see Config.gate_check_rate_limit_per_hour).
407
+ gate_rate_limiter = (
408
+ SlidingWindowLimiter(max_per_hour=config.gate_check_rate_limit_per_hour)
409
+ if config.gate_check_rate_limit_per_hour > 0
410
+ else None
411
+ )
396
412
  blocklist = PathBlocklist(
397
413
  tier_blocks=config.write_block_tiers,
398
414
  path_blocks=config.write_block_paths,
@@ -401,6 +417,7 @@ def build_app(
401
417
  state.push_queue = push_queue
402
418
  state.pending = pending
403
419
  state.rate_limiter = rate_limiter
420
+ state.gate_rate_limiter = gate_rate_limiter
404
421
  state.blocklist = blocklist
405
422
 
406
423
  if config.kb_remote_url and not config.read_only:
@@ -545,17 +562,22 @@ def build_app(
545
562
  )
546
563
  return resp.model_dump()
547
564
 
548
- def _mcp_rate_limited() -> dict[str, object] | None:
549
- """Apply the shared sliding-window limiter to an MCP enforcement-plane tool
565
+ def _mcp_rate_limited(*, gate: bool = False) -> dict[str, object] | None:
566
+ """Apply a sliding-window limiter to an MCP enforcement-plane tool
550
567
  (WP0b item (b)).
551
568
 
552
- The REST consult / gate-check / cleanup-plan routes throttle via
553
- ``_rate_limited``; the MCP tool paths did not, so an agent could hammer the
554
- classifier/ledger unbounded. Key on the resolved principal (there is no
555
- per-request remote addr on the MCP transport, so the limiter's per-agent
556
- quota is the meaningful dimension). Returns a rejection dict when over
557
- quota, else None. Skipped when no limiter is wired (read-only deploy)."""
558
- limiter = state.rate_limiter
569
+ The REST consult / cleanup-plan routes throttle via ``_rate_limited``; the
570
+ MCP tool paths did not, so an agent could hammer the classifier/ledger
571
+ unbounded. Key on the resolved principal (there is no per-request remote
572
+ addr on the MCP transport, so the limiter's per-agent quota is the
573
+ meaningful dimension). Returns a rejection dict when over quota, else None.
574
+
575
+ ``gate=True`` (kb_gate_check) consults ``state.gate_rate_limiter`` instead
576
+ of the write limiter; that limiter is None by default, so the
577
+ high-frequency freshness probe is unthrottled unless explicitly capped,
578
+ matching the REST /api/v1/gate/check route. Skipped when the resolved
579
+ limiter is None (read-only deploy, or gate-check with no ceiling set)."""
580
+ limiter = state.gate_rate_limiter if gate else state.rate_limiter
559
581
  if limiter is None:
560
582
  return None
561
583
  principal_name = _current_principal.get().name
@@ -768,7 +790,7 @@ def build_app(
768
790
  ) -> dict[str, object]:
769
791
  """Return a verdict (allow | consult_required) for a pending code action.
770
792
  Governed actions require a fresh consultation on record."""
771
- if (throttled := _mcp_rate_limited()) is not None:
793
+ if (throttled := _mcp_rate_limited(gate=True)) is not None:
772
794
  return throttled
773
795
  import time as _time
774
796
 
@@ -852,6 +874,7 @@ def build_app_from_config(config: Config, *, bootstrap_now: bool = True) -> Fast
852
874
  confidence_threshold=config.confidence_threshold,
853
875
  rate_limit_per_hour=config.rate_limit_per_hour,
854
876
  rate_limit_per_ip_per_hour=config.rate_limit_per_ip_per_hour,
877
+ gate_check_rate_limit_per_hour=config.gate_check_rate_limit_per_hour,
855
878
  max_text_bytes=config.max_text_bytes,
856
879
  max_postimage_bytes=config.max_postimage_bytes,
857
880
  max_body_bytes=config.max_body_bytes,
@@ -867,6 +890,7 @@ def build_app_from_config(config: Config, *, bootstrap_now: bool = True) -> Fast
867
890
  ledger_path=config.ledger_path,
868
891
  session_idle_timeout_sec=config.session_idle_timeout_sec,
869
892
  session_reap_interval_sec=config.session_reap_interval_sec,
893
+ session_touch_interval_sec=config.session_touch_interval_sec,
870
894
  status_weights=config.status_weights,
871
895
  read_only=config.read_only,
872
896
  embeddings_enabled=config.embeddings_enabled,
@@ -962,9 +986,25 @@ def main() -> None:
962
986
  )
963
987
 
964
988
  tracker = SessionActivityTracker()
989
+ # Re-stamp an in-flight SSE session at least 3x per idle window, so a
990
+ # quiet-but-connected client is never reaped even if an operator sets a short
991
+ # idle timeout. Falls back to the configured interval when the idle window is
992
+ # generous.
993
+ _idle = config.session_idle_timeout_sec
994
+ _touch_interval = float(config.session_touch_interval_sec)
995
+ if _idle > 0:
996
+ # Always strictly below the idle window (>= 3 touches per window) even for
997
+ # a tiny idle value; a small positive floor avoids a zero/negative sleep.
998
+ _touch_interval = max(0.1, min(_touch_interval, _idle / 3))
965
999
  http_app = app.http_app(
966
1000
  transport="streamable-http",
967
- middleware=[StarletteMiddleware(SessionActivityMiddleware, tracker=tracker)],
1001
+ middleware=[
1002
+ StarletteMiddleware(
1003
+ SessionActivityMiddleware,
1004
+ tracker=tracker,
1005
+ touch_interval_sec=_touch_interval,
1006
+ )
1007
+ ],
968
1008
  )
969
1009
  # Health surfaces the live session count; the manager is only reachable once
970
1010
  # the lifespan has started, so this returns None before then (never raises).
@@ -45,6 +45,7 @@ these internals, discovery returns ``None`` and the count is reported as
45
45
  from __future__ import annotations
46
46
 
47
47
  import asyncio
48
+ import contextlib
48
49
  import logging
49
50
  import time
50
51
  from typing import TYPE_CHECKING, Any, Protocol
@@ -195,20 +196,61 @@ class SessionActivityTracker:
195
196
 
196
197
  class SessionActivityMiddleware:
197
198
  """ASGI middleware that stamps :class:`SessionActivityTracker` on each request
198
- carrying an ``mcp-session-id`` header. Pure pass-through otherwise; adds no
199
- latency beyond a header scan."""
199
+ carrying an ``mcp-session-id`` header.
200
+
201
+ The ``GET /mcp`` Server-Sent-Events stream is a *single* long-lived request
202
+ that emits no further ASGI events while the session is quiet. Stamping only
203
+ at request start would let the idle reaper evict a still-connected client
204
+ after the idle window (forcing a reconnect, the exact churn that piles up
205
+ transports server-side and leaks listeners in a persistent client like
206
+ ``opencode serve``). So for an in-flight GET stream this middleware re-stamps
207
+ the session every ``touch_interval_sec`` for as long as the stream stays open;
208
+ when the client disconnects the request completes, re-stamping stops, and only
209
+ then does the session go idle and become reap-eligible. Short GET/POST
210
+ requests finish before the first tick, so they pay only the initial stamp.
211
+ """
200
212
 
201
- def __init__(self, app: ASGIApp, tracker: SessionActivityTracker) -> None:
213
+ def __init__(
214
+ self,
215
+ app: ASGIApp,
216
+ tracker: SessionActivityTracker,
217
+ *,
218
+ touch_interval_sec: float = 30.0,
219
+ ) -> None:
202
220
  self.app = app
203
221
  self._tracker = tracker
222
+ self._touch_interval = touch_interval_sec
204
223
 
205
224
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
206
- if scope.get("type") == "http":
207
- for key, value in scope.get("headers", []):
208
- if key.lower() == _MCP_SESSION_ID_HEADER and value:
209
- self._tracker.touch(value.decode("latin-1"))
210
- break
211
- await self.app(scope, receive, send)
225
+ if scope.get("type") != "http":
226
+ await self.app(scope, receive, send)
227
+ return
228
+ session_id = ""
229
+ for key, value in scope.get("headers", []):
230
+ if key.lower() == _MCP_SESSION_ID_HEADER and value:
231
+ session_id = value.decode("latin-1")
232
+ break
233
+ if not session_id:
234
+ await self.app(scope, receive, send)
235
+ return
236
+ self._tracker.touch(session_id)
237
+ # Only the long-lived GET SSE stream needs keep-alive re-stamping.
238
+ if scope.get("method") != "GET":
239
+ await self.app(scope, receive, send)
240
+ return
241
+
242
+ async def _keep_active() -> None:
243
+ while True:
244
+ await asyncio.sleep(self._touch_interval)
245
+ self._tracker.touch(session_id)
246
+
247
+ task = asyncio.create_task(_keep_active())
248
+ try:
249
+ await self.app(scope, receive, send)
250
+ finally:
251
+ task.cancel()
252
+ with contextlib.suppress(asyncio.CancelledError, Exception):
253
+ await task
212
254
 
213
255
 
214
256
  async def reap_idle_sessions(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes