org-knowledge-layer 0.2.0__tar.gz → 0.3.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 (116) hide show
  1. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.claude/settings.local.json +41 -1
  2. org_knowledge_layer-0.3.1/CHANGELOG.md +69 -0
  3. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/PKG-INFO +12 -10
  4. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/README.md +10 -8
  5. org_knowledge_layer-0.3.1/docs/DEPLOY.md +171 -0
  6. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/pyproject.toml +1 -1
  7. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/cli.py +26 -2
  8. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/client.py +29 -2
  9. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/mcp_server.py +35 -8
  10. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/service.py +52 -12
  11. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/tests/test_okl.py +323 -0
  12. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.claude/hooks/stop-okl-encode.sh +0 -0
  13. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.claude/hooks/userpromptsubmit-okl-check.sh +0 -0
  14. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.claude/settings.json +0 -0
  15. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.github/pull_request_template.md +0 -0
  18. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.github/workflows/ci.yml +0 -0
  19. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.github/workflows/okl-verify.yml +0 -0
  20. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/.gitignore +0 -0
  21. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/AGENTS.md +0 -0
  22. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/CLAUDE.md +0 -0
  23. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/CONTRIBUTING.md +0 -0
  24. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/LICENSE +0 -0
  25. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/SECURITY.md +0 -0
  26. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/ci/okl-verify.yml +0 -0
  27. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/ab-results-chart.png +0 -0
  28. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/ab-results-chart.svg +0 -0
  29. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/decisions/2026-07-17-flat-retrieval-until-scale.md +0 -0
  30. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/decisions/2026-07-21-subject-tags-controlled-vocabulary.md +0 -0
  31. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/okl-sixth-surface.excalidraw +0 -0
  32. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/okl-sixth-surface.svg +0 -0
  33. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/posts/01-memory-that-outlives-the-run.md +0 -0
  34. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/posts/02-dont-let-a-step-grade-itself.md +0 -0
  35. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/docs/posts/03-enforcement-or-good-intentions.md +0 -0
  36. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/README.md +0 -0
  37. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/REPORT.md +0 -0
  38. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/ab_harness.py +0 -0
  39. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/ab-20260829-2300.json +0 -0
  40. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/ab-20260829-2315.json +0 -0
  41. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/ab-20260830-0003.json +0 -0
  42. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/ab-20260830-0148.json +0 -0
  43. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/ab-20260901-0133.json +0 -0
  44. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/README.md +0 -0
  45. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/briefed-userpromptsubmit-lint.yml +0 -0
  46. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/control-lint.yml +0 -0
  47. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/hook.log +0 -0
  48. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/service-record-500.log +0 -0
  49. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/session-briefed-pretooluse.txt +0 -0
  50. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/session-briefed-userpromptsubmit.txt +0 -0
  51. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/results/e2e-20260830/session-control.txt +0 -0
  52. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/evals/tasks.jsonl +0 -0
  53. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/hooks/stop-okl-encode.sh +0 -0
  54. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/hooks/userpromptsubmit-okl-check.sh +0 -0
  55. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/dotnet-canon.json +0 -0
  56. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/dotnet-decisions.json +0 -0
  57. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/dotnet-defects.json +0 -0
  58. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/dotnet-review-surfaces.json +0 -0
  59. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/frontend-canon.json +0 -0
  60. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/geospatial-deeptime-defects.json +0 -0
  61. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/geospatial-defects.json +0 -0
  62. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/geospatial-enforcement-defects.json +0 -0
  63. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/geospatial-eval-defects.json +0 -0
  64. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/rag-defects.json +0 -0
  65. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/seed/react-defects.json +0 -0
  66. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/__init__.py +0 -0
  67. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/__main__.py +0 -0
  68. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/bootstrap.py +0 -0
  69. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/core.py +0 -0
  70. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/drift.py +0 -0
  71. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/MANIFEST.md +0 -0
  72. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/ci/method-gates.yml +0 -0
  73. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/ci/okl-verify.yml +0 -0
  74. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/agents/architecture-reviewer.md +0 -0
  75. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/commands/check-rules.md +0 -0
  76. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/commands/feature-spec.md +0 -0
  77. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/commands/seed-from-codebase.md +0 -0
  78. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/rules/example-area.md +0 -0
  79. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/skills/RECOMMENDED-COMPANIONS.md +0 -0
  80. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/skills/encoding-loop/SKILL.md +0 -0
  81. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/claude/skills/verify-before-claiming/SKILL.md +0 -0
  82. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/evals/README.md +0 -0
  83. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/evals/cases.jsonl +0 -0
  84. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/evals/run_evals.py +0 -0
  85. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/check-canon-size.sh +0 -0
  86. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/check-diagram-pairs.sh +0 -0
  87. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/check-doc-orphans.sh +0 -0
  88. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/check-links.sh +0 -0
  89. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/check-retractions.sh +0 -0
  90. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/check-tombstones.sh +0 -0
  91. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/gates/run-gates.sh +0 -0
  92. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/hooks/hooks.json +0 -0
  93. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/hooks/stop-okl-encode.sh +0 -0
  94. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/hooks/userpromptsubmit-okl-check.sh +0 -0
  95. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/plugin/plugin.json +0 -0
  96. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/dotnet/README.md +0 -0
  97. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/dotnet/rules/architecture.md +0 -0
  98. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/dotnet/rules/messaging.md +0 -0
  99. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/dotnet/rules/performance-and-data.md +0 -0
  100. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/dotnet/rules/security.md +0 -0
  101. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/geospatial/README.md +0 -0
  102. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/geospatial/rules/geospatial-ml.md +0 -0
  103. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/python-rag/README.md +0 -0
  104. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/python-rag/rules/fastapi-backend.md +0 -0
  105. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/python-rag/rules/project-structure.md +0 -0
  106. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/python-rag/rules/rag-pipeline.md +0 -0
  107. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/react/README.md +0 -0
  108. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/profiles/react/rules/frontend.md +0 -0
  109. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/registries/RETRACTIONS.md +0 -0
  110. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/registries/tombstones.txt +0 -0
  111. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/root/CLAUDE.md +0 -0
  112. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold/root/METHOD.md +0 -0
  113. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/scaffold_cmd.py +0 -0
  114. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/seed.py +0 -0
  115. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/src/okl/store.py +0 -0
  116. {org_knowledge_layer-0.2.0 → org_knowledge_layer-0.3.1}/tests/test_scaffold.py +0 -0
@@ -130,7 +130,47 @@
130
130
  "Bash(check)",
131
131
  "Bash(okl bootstrap *)",
132
132
  "Bash(git -c core.hooksPath=/dev/null commit -qm 'First-run experience: seeding is a choice, briefings are capped, empty states are honest *)",
133
- "Bash(git -c core.hooksPath=/dev/null commit -q --amend -F /tmp/msg.txt)"
133
+ "Bash(git -c core.hooksPath=/dev/null commit -q --amend -F /tmp/msg.txt)",
134
+ "Bash(git -c core.hooksPath=/dev/null commit -qm 'v0.2.0 *)",
135
+ "Bash(command -v postgres pg_ctl initdb)",
136
+ "Read(//opt/homebrew/opt/**)",
137
+ "Bash(python3 -c \"import mcp; print\\(mcp.__version__ if hasattr\\(mcp,'__version__'\\) else 'installed'\\)\")",
138
+ "Bash(python3 -m pip install -q \"psycopg[binary]>=3.1\")",
139
+ "Bash(python3 -m pip install -q \"mcp>=1.2\")",
140
+ "Bash(python3 -c \"import mcp; print\\('mcp installed'\\)\")",
141
+ "Bash(python3 -c \"import mcp, importlib.metadata as md; print\\('installed mcp version:', md.version\\('mcp'\\)\\)\")",
142
+ "Bash(python3 -c \"from mcp.server.mcpserver import MCPServer; print\\('MCPServer exists'\\); import inspect; print\\('has .tool\\(\\):', hasattr\\(MCPServer, 'tool'\\)\\); print\\('has .list_tools\\(\\):', hasattr\\(MCPServer,'list_tools'\\)\\); print\\('has .run\\(\\):', hasattr\\(MCPServer,'run'\\)\\)\")",
143
+ "Bash(python3 -m pytest -q -k \"mcp_server_builds or postgres_matches\" -v)",
144
+ "Bash(OKL_TEST_POSTGRES_URL=\"postgresql://joshuadell@/postgres?host=/tmp/oklpg&port=55432\" python3 -m pytest -q -k \"postgres_matches\" -v)",
145
+ "Bash(python3 -c \"import importlib.metadata as m; print\\(m.version\\('mcp'\\)\\)\")",
146
+ "Bash(OKL_TEST_POSTGRES_URL=\"postgresql://joshuadell@/postgres?host=/tmp/oklpg&port=55432\" python3 -m pytest -k \"postgres_matches\")",
147
+ "Bash(export OKL_TEST_POSTGRES_URL=\"postgresql://okl@/okltest?host=/tmp/oklpg&port=55432\")",
148
+ "Bash(python3 -m pytest -q -k \"postgres_matches\")",
149
+ "Bash(export OKL_DATABASE_URL=\"postgresql://okl@/okltest?host=/tmp/oklpg&port=55432\")",
150
+ "Bash(export OKL_TOKEN=\"deploy-test-secret\")",
151
+ "Bash(python3 -m uvicorn --factory okl.service:create_app --host 127.0.0.1 --port 8791)",
152
+ "Bash(echo \"started pid $!\")",
153
+ "Bash(curl -s http://127.0.0.1:8791/health)",
154
+ "Bash(OKL_TEST_POSTGRES_URL=\"postgresql://okl@/okltest?host=/tmp/oklpg&port=55432\" python3 -m pytest -q)",
155
+ "Bash(pkill -f uvicorn)",
156
+ "Bash(pg_ctl -D /tmp/oklpg/data stop -m immediate)",
157
+ "Bash(tmutil listlocalsnapshots *)",
158
+ "Bash(python3 -c \"import json,sys; print\\(', '.join\\(sorted\\(json.load\\(sys.stdin\\)['releases']\\)\\)\\)\")",
159
+ "Bash(awk 'NR==1 || $2 ~ /Gi|Ti/')",
160
+ "Bash(rm -rf /tmp/vt2)",
161
+ "Bash(python3 -m venv /tmp/vt2)",
162
+ "Bash(/tmp/vt2/bin/pip install *)",
163
+ "Bash(/tmp/vt2/bin/python -c ' *)",
164
+ "Bash(python3 -m pytest -q --collect-only)",
165
+ "Bash(curl -s https://pypi.org/simple/org-knowledge-layer/)",
166
+ "Bash(rm -rf /tmp/pv)",
167
+ "Bash(python3 -m venv /tmp/pv)",
168
+ "Bash(/tmp/pv/bin/pip install *)",
169
+ "Bash(/tmp/pv/bin/python -c \"import importlib.metadata as m; print\\(m.version\\('org-knowledge-layer'\\)\\)\")",
170
+ "Bash(OKL_DATABASE_URL=sqlite:///:memory: OKL_TOKEN=t /tmp/pv/bin/python -c ' *)",
171
+ "Bash(OKL_DATABASE_URL=sqlite:///:memory: /tmp/pv/bin/python -c ' *)",
172
+ "Bash(OKL_DATABASE_URL=sqlite:///:memory: OKL_TOKEN=t python3 -c ' *)",
173
+ "Bash(python3 -m pytest -q -k \"openapi_spec_and_docs\")"
134
174
  ],
135
175
  "additionalDirectories": [
136
176
  "/Users/joshuadell/Dev/okl/e2e/scratch-briefed/.okl",
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ ## 0.3.1
4
+
5
+ ### Security
6
+
7
+ - **Setting `OKL_TOKEN` now also removes `/openapi.json`, `/docs` and `/redoc`.** They
8
+ were left serving 200 to anonymous callers by the 0.3.0 work that closed every data
9
+ route, because FastAPI mounts them itself — they are not handlers, so the per-handler
10
+ auth sweep could not reach them. They leak no records, but they publish the endpoint
11
+ list, every schema, and which routes want a credential. With no token set the
12
+ interactive docs remain available, since that case is a developer's laptop.
13
+
14
+ ## 0.3.0
15
+
16
+ Everything here came from running three things that had been written but never
17
+ executed: the MCP server, the Postgres backend, and a deployment.
18
+
19
+ ### Security
20
+
21
+ - **The service token now covers reads.** Previously `OKL_TOKEN` gated writes only, so
22
+ an unauthenticated `GET /nodes` returned the entire store — every recorded defect,
23
+ retired identifier and architecture decision. Every route now requires the token when
24
+ it is set, except `/health`, which is left open for schedulers and returns no record
25
+ content.
26
+ - **`okl connect --token` no longer commits your secret.** The token is stored in
27
+ cleartext in `.okl/config.json`, and a comment claimed the directory was gitignored
28
+ while nothing wrote a `.gitignore`. `okl init` and `okl connect` now write
29
+ `.okl/.gitignore`.
30
+ - **A rejected check no longer reports success.** A 401 surfaced as `ValueError` rather
31
+ than `OKLUnreachable`, so an unauthorized `okl check` exited 0 with a traceback — which
32
+ a pre-task hook reads as "no rules apply". It now fails closed with exit 2, as does
33
+ every other command, via a backstop in `main()`.
34
+
35
+ **Breaking:** if you run a service with `OKL_TOKEN` set, clients must upgrade too.
36
+ Clients older than 0.3.0 send no credential on `GET` requests and will get 401s from
37
+ `okl drift` and the recurrence metric. Upgrade the service and its clients together, or
38
+ unset `OKL_TOKEN` during the rollover.
39
+
40
+ ### Fixed
41
+
42
+ - `uvicorn okl.service:app` served a module-level `None`: the process started, bound the
43
+ port, passed a port-liveness check and returned 500 to every request. The app is now
44
+ built lazily in a module `__getattr__`, so the standard ASGI entrypoint works while
45
+ importing the module still does not touch the database.
46
+ - The MCP server could not start under `mcp` 2.x, which renamed `FastMCP` to
47
+ `MCPServer` — and the error handler told you to install the extra you had just
48
+ installed. Both names are tried, and the real import error is reported.
49
+ - Every MCP `okl_record` call with `scope="repo"` failed. The repo default used
50
+ `setdefault`, which cannot replace an explicit `None`, and the MCP tools pass every
51
+ field explicitly.
52
+ - MCP validation errors raised as an opaque "Error executing tool". They now return the
53
+ complaint, so an agent that invents a tag is told the vocabulary.
54
+
55
+ ### Added
56
+
57
+ - `docs/DEPLOY.md`: the shared-service deployment path, including a throwaway Postgres
58
+ for trying it locally and what each failure mode looks like. Every command in it was
59
+ run against a real Postgres and a real service.
60
+ - Tests covering the live MCP server, the ASGI entrypoint, service auth on reads, the
61
+ fail-closed 401, and the config `.gitignore`.
62
+ - The Postgres/SQLite parity test now runs in a scratch schema it creates and drops. The
63
+ first version opened with `DELETE FROM node` against whatever `OKL_TEST_POSTGRES_URL`
64
+ pointed at, which would have destroyed the store of anyone who set it to their real
65
+ service.
66
+
67
+ ## 0.2.0 and earlier
68
+
69
+ See the git history.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: org-knowledge-layer
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Org Knowledge Layer — an installable sixth surface that carries encoded engineering lessons across repos.
5
5
  Author: Joshua Dell
6
6
  License: MIT
@@ -566,17 +566,19 @@ okl serve --port 8080
566
566
 
567
567
  ```bash
568
568
  pip install "org-knowledge-layer[service]"
569
- OKL_DATABASE_URL="sqlite:///okl.db" OKL_TOKEN="a-shared-secret" okl serve
569
+ OKL_DATABASE_URL="postgresql://user:pass@host/okl" OKL_TOKEN="a-shared-secret" okl serve
570
570
  # repos then: okl connect https://your-host --token a-shared-secret
571
571
  ```
572
572
 
573
- `OKL_TOKEN` (optional) gates **writes**; **reads stay open** — `/nodes` returns the
574
- whole store to anyone who can reach the port. A mature store holds your defect history,
575
- security patterns and internal architecture, so treat it as sensitive: bind it to a
576
- private network or put authentication in front of every route before exposing it. See
577
- [SECURITY.md](SECURITY.md). Deploy the service
578
- wherever you like — it's storage-agnostic by design (a `Dockerfile` and a
579
- `fly.toml` are the obvious next commit; not included in v0).
573
+ **Set `OKL_TOKEN`.** With it, every route requires the bearer token except `/health`
574
+ (left open so schedulers can probe it). Without it, every route is open — including
575
+ `GET /nodes`, which hands the whole store to anyone who can reach the port. A mature
576
+ store is a catalogue of your known defects and internal architecture, which is a map of
577
+ where you are weak. It is a single shared secret with no per-repo scoping or rotation;
578
+ put a real authenticating proxy in front if you need more.
579
+
580
+ Full instructions, including a throwaway Postgres for trying it locally and what the
581
+ failure modes look like: **[docs/DEPLOY.md](docs/DEPLOY.md)**.
580
582
 
581
583
  ## Agent integration (MCP)
582
584
 
@@ -534,17 +534,19 @@ okl serve --port 8080
534
534
 
535
535
  ```bash
536
536
  pip install "org-knowledge-layer[service]"
537
- OKL_DATABASE_URL="sqlite:///okl.db" OKL_TOKEN="a-shared-secret" okl serve
537
+ OKL_DATABASE_URL="postgresql://user:pass@host/okl" OKL_TOKEN="a-shared-secret" okl serve
538
538
  # repos then: okl connect https://your-host --token a-shared-secret
539
539
  ```
540
540
 
541
- `OKL_TOKEN` (optional) gates **writes**; **reads stay open** — `/nodes` returns the
542
- whole store to anyone who can reach the port. A mature store holds your defect history,
543
- security patterns and internal architecture, so treat it as sensitive: bind it to a
544
- private network or put authentication in front of every route before exposing it. See
545
- [SECURITY.md](SECURITY.md). Deploy the service
546
- wherever you like — it's storage-agnostic by design (a `Dockerfile` and a
547
- `fly.toml` are the obvious next commit; not included in v0).
541
+ **Set `OKL_TOKEN`.** With it, every route requires the bearer token except `/health`
542
+ (left open so schedulers can probe it). Without it, every route is open — including
543
+ `GET /nodes`, which hands the whole store to anyone who can reach the port. A mature
544
+ store is a catalogue of your known defects and internal architecture, which is a map of
545
+ where you are weak. It is a single shared secret with no per-repo scoping or rotation;
546
+ put a real authenticating proxy in front if you need more.
547
+
548
+ Full instructions, including a throwaway Postgres for trying it locally and what the
549
+ failure modes look like: **[docs/DEPLOY.md](docs/DEPLOY.md)**.
548
550
 
549
551
  ## Agent integration (MCP)
550
552
 
@@ -0,0 +1,171 @@
1
+ # Deploying the shared service
2
+
3
+ okl runs in two modes. **Local mode** keeps a SQLite file next to your config and needs
4
+ no deployment; it is the right choice for one person on one machine, and everything in
5
+ the README works there. **Shared mode** puts one service in front of one database so
6
+ that every repo, on every machine, reads and writes the same curated knowledge. This
7
+ document covers the second.
8
+
9
+ The reason to bother: a lesson recorded in one repo is worth something only if a
10
+ different repo, weeks later, is told about it before it makes the same mistake. That
11
+ cross-repo hop is the whole point of the layer, and it needs a shared store.
12
+
13
+ Every command below was run against a real Postgres and a real service while writing
14
+ this page. Where something is **not** verified, it says so.
15
+
16
+ ---
17
+
18
+ ## 1. A database
19
+
20
+ Postgres is the supported shared backend. The service selects it with one environment
21
+ variable and nothing else in the code changes:
22
+
23
+ ```bash
24
+ export OKL_DATABASE_URL="postgresql://user:pass@host:5432/okl"
25
+ ```
26
+
27
+ Any managed Postgres works — RDS, Cloud SQL, Neon, Supabase, Fly Postgres. The schema
28
+ is created on first connect; there is no migration step to run.
29
+
30
+ To try it locally first, a throwaway cluster in four commands:
31
+
32
+ ```bash
33
+ initdb -D /tmp/oklpg/data -U okl --auth=trust
34
+ pg_ctl -D /tmp/oklpg/data -o "-p 55432 -k /tmp/oklpg -c listen_addresses=''" start
35
+ createdb -h /tmp/oklpg -p 55432 -U okl okltest
36
+ export OKL_TEST_POSTGRES_URL="postgresql://okl@/okltest?host=/tmp/oklpg&port=55432"
37
+ ```
38
+
39
+ That last variable also switches on the backend-parity test, which loads one corpus into
40
+ both SQLite and Postgres and asserts they rank the same record first for the same query:
41
+
42
+ ```bash
43
+ pytest -k postgres_matches
44
+ ```
45
+
46
+ Run it when you change anything about retrieval. SQLite ranks with BM25 and Postgres with
47
+ `ts_rank`; they have diverged before, and the failure mode is quiet — a repo that moves to
48
+ the shared service silently gets worse retrieval than it had on a laptop. The test builds
49
+ its tables in a temporary schema and drops them afterwards, so it is safe to point at a
50
+ database that already has data, though pointing it at production is still a strange thing
51
+ to do.
52
+
53
+ ## 2. A token
54
+
55
+ **Set `OKL_TOKEN`.** It is optional in the code and mandatory in practice:
56
+
57
+ ```bash
58
+ export OKL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
59
+ ```
60
+
61
+ With it set, every endpoint requires `Authorization: Bearer <token>` except `/health`.
62
+ Without it, every endpoint is open to anyone who can reach the port — including
63
+ `GET /nodes`, which returns the entire store in one request. A mature okl store is a
64
+ catalogue of your organization's known defects, its retired identifiers and its internal
65
+ architecture decisions. That is a map of where you are weak, and it is precisely the
66
+ material the layer exists to accumulate.
67
+
68
+ `/health` stays open deliberately: schedulers and load balancers probe it before they
69
+ hold any credential, and a service that cannot be health-checked never goes live. It
70
+ returns a record count and a backend name, never record content.
71
+
72
+ The token is a single shared secret with no per-repo scoping, rotation or audit trail.
73
+ That is honest for what this is. If you need more, put a real authenticating proxy in
74
+ front and leave `OKL_TOKEN` set underneath as defence in depth.
75
+
76
+ ## 3. Run it
77
+
78
+ ```bash
79
+ pip install "org-knowledge-layer[service]"
80
+ okl serve --port 8080 # convenience wrapper
81
+ uvicorn okl.service:app --host 0.0.0.0 --port 8080 # standard ASGI entrypoint
82
+ ```
83
+
84
+ Both work and serve the same app. Use the second one under a process manager or in a
85
+ container, since it is the form every platform's default start command takes.
86
+
87
+ Confirm it is up:
88
+
89
+ ```bash
90
+ curl -s localhost:8080/health
91
+ # {"ok":true,"nodes":5,"backend":"postgresql"}
92
+ ```
93
+
94
+ `backend` in that response is worth reading on every deploy. If it says `sqlite` when you
95
+ expected `postgresql`, `OKL_DATABASE_URL` did not reach the process, and the service is
96
+ happily serving an empty file-backed store that will vanish with the container.
97
+
98
+ ## 4. Point repos at it
99
+
100
+ In each repo:
101
+
102
+ ```bash
103
+ okl init --repo checkout-api
104
+ okl connect https://okl.internal --token "$OKL_TOKEN"
105
+ ```
106
+
107
+ `connect` writes the URL and token to `.okl/config.json`, and okl drops a `.gitignore`
108
+ inside `.okl/` so that file is never committed. Prefer supplying the token through the
109
+ `OKL_TOKEN` environment variable in CI and shared machines; the config file is a
110
+ convenience for a developer laptop.
111
+
112
+ That is the whole client setup. From then on:
113
+
114
+ ```bash
115
+ # in checkout-api
116
+ okl record --type Defect --scope org \
117
+ --title "Refund amount trusted from the client body" \
118
+ --symptom "a refund endpoint reads amount from the request" \
119
+ --fix "look the original charge up server-side and refund that"
120
+
121
+ # later, in billing-worker — a different repo, a different machine
122
+ okl check --task "add an endpoint that issues a refund" --format actions
123
+ # OKL — 1 rule(s) apply before you start:
124
+ # - FIX: Refund amount trusted from the client body [when: a refund endpoint reads amount from the request]
125
+ # -> look the original charge up server-side and refund that
126
+ ```
127
+
128
+ Note the scope. `--scope org` is what makes a lesson cross repo boundaries; `--scope repo`
129
+ keeps it local to the repo that recorded it. Getting this wrong is the most common way a
130
+ shared store disappoints: everything is recorded, nothing propagates.
131
+
132
+ ## 5. What failure looks like
133
+
134
+ The service being unreachable, or refusing your token, must never be reported as "no
135
+ rules apply". Silence and safety are indistinguishable to an agent, and only one of them
136
+ is safe. Both cases exit non-zero and say which is which:
137
+
138
+ ```
139
+ $ okl check --task "issue a refund" # no token
140
+ OKL REFUSED THE CHECK — refusing to report a clean check.
141
+ OKL service rejected the request (401): missing or bad bearer token
142
+
143
+ $ okl check --task "issue a refund" # service down
144
+ OKL UNREACHABLE — refusing to report a clean check.
145
+ OKL service unreachable at http://okl.internal/check: ...
146
+ ```
147
+
148
+ The pre-task hook and the CI verifier both treat a non-zero exit as a block. If you wrap
149
+ okl in your own tooling, do the same: a check that cannot run has not passed.
150
+
151
+ ## Containers
152
+
153
+ **Not verified.** No Docker daemon was available where this was written, so the project
154
+ ships no Dockerfile rather than an untested one. The service is an ordinary ASGI app with
155
+ no filesystem state when `OKL_DATABASE_URL` points at Postgres, so a container is four
156
+ lines over `python:3.12-slim`: install `org-knowledge-layer[service]`, expose the port,
157
+ and run the `uvicorn okl.service:app` command above. Set `OKL_DATABASE_URL` and
158
+ `OKL_TOKEN` as secrets, and point the platform's health check at `/health`.
159
+
160
+ If you build one, a PR adding it — with the build running in CI, so the claim is backed
161
+ by something — is welcome.
162
+
163
+ ## Cost of the shared mode
164
+
165
+ Worth stating plainly before you commit to it. One service and one database is one more
166
+ thing to run, monitor and back up, and okl gives you no operational tooling for any of
167
+ that. It has no migrations, no backup command, no per-repo access control and no audit
168
+ log. Local mode has none of those problems.
169
+
170
+ Move to shared mode when a second repo actually needs a first repo's lessons. Before
171
+ that, the file on your laptop is doing the same job with none of the operations.
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  # Distribution name only. The CLI command, the import package, and the repo are all `okl`;
7
7
  # PyPI rejects `okl` as too similar to the existing `oki` (l/i are treated as confusable).
8
8
  name = "org-knowledge-layer"
9
- version = "0.2.0"
9
+ version = "0.3.1"
10
10
  description = "Org Knowledge Layer — an installable sixth surface that carries encoded engineering lessons across repos."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -184,6 +184,13 @@ def cmd_check(args) -> int:
184
184
  # FAIL CLOSED — loud, non-zero, no reassuring empty result.
185
185
  print(f"OKL UNREACHABLE — refusing to report a clean check.\n{e}", file=sys.stderr)
186
186
  return 2
187
+ except ValueError as e:
188
+ # A 4xx (usually a 401 against a token-protected service) is a REFUSED check,
189
+ # not a clean one, so it fails closed just the same. It gets its own message
190
+ # because the fix is different: a credential, not connectivity. Before this,
191
+ # an unauthorized check exited 0 with a raw urllib traceback.
192
+ print(f"OKL REFUSED THE CHECK — refusing to report a clean check.\n{e}", file=sys.stderr)
193
+ return 2
187
194
  if args.format == "json":
188
195
  _print_json(result)
189
196
  elif args.format == "actions":
@@ -203,7 +210,16 @@ def cmd_record(args) -> int:
203
210
  id=args.id)
204
211
  if args.repo:
205
212
  kwargs["repo"] = args.repo
206
- node_id = client.record(**{k: v for k, v in kwargs.items() if v is not None})
213
+ try:
214
+ node_id = client.record(**{k: v for k, v in kwargs.items() if v is not None})
215
+ except ValueError as e:
216
+ # An unknown tag or a malformed scope is the caller's mistake, and the exception
217
+ # text names the vocabulary they need. A traceback buries that under a stack.
218
+ print(f"NOT RECORDED — {e}", file=sys.stderr)
219
+ return 2
220
+ except OKLUnreachable as e:
221
+ print(f"NOT RECORDED — {e}", file=sys.stderr)
222
+ return 2
207
223
  print(node_id)
208
224
  return 0
209
225
 
@@ -589,7 +605,15 @@ def build_parser() -> argparse.ArgumentParser:
589
605
 
590
606
  def main(argv: list[str] | None = None) -> int:
591
607
  args = build_parser().parse_args(argv)
592
- return args.func(args)
608
+ try:
609
+ return args.func(args)
610
+ except (ValueError, OKLUnreachable) as e:
611
+ # The backstop, so no command can ever answer a rejected or unreachable service
612
+ # with a Python traceback. Commands that can say something more specific catch
613
+ # these themselves and never reach here; this exists so the ones that do not —
614
+ # and the ones added later — still exit non-zero with a line a human can act on.
615
+ print(f"OKL: {e}", file=sys.stderr)
616
+ return 2
593
617
 
594
618
 
595
619
  if __name__ == "__main__":
@@ -40,6 +40,15 @@ def load_config() -> dict[str, Any]:
40
40
  def save_config(data: dict[str, Any], root: Path | None = None) -> Path:
41
41
  d = (root or Path.cwd()) / CONFIG_DIR
42
42
  d.mkdir(parents=True, exist_ok=True)
43
+ # Ignore the whole directory, from inside it. Everything okl writes here is either
44
+ # machine-local (`okl_bin`, an absolute interpreter path) or secret (`token`, the
45
+ # service bearer credential that `okl connect --token` stores in cleartext), and the
46
+ # local store lands here too. The code claimed ".okl/ is gitignored" while writing
47
+ # nothing to make that true, so a `git add .` after `okl connect --token` committed
48
+ # a shared secret. A .gitignore inside .okl/ needs no edit to the repo's own.
49
+ gitignore = d / ".gitignore"
50
+ if not gitignore.exists():
51
+ gitignore.write_text("# okl: machine-local config, credentials and local store\n*\n")
43
52
  path = d / CONFIG_FILE
44
53
  path.write_text(json.dumps(data, indent=2) + "\n")
45
54
  return path
@@ -115,7 +124,11 @@ class Client:
115
124
  def record(self, **kwargs) -> str:
116
125
  # Default the repo in BOTH modes: `--scope repo` needs it to become repo:<name>,
117
126
  # and the remote path used to skip this (found by E2E: 400 on every repo-scoped record).
118
- kwargs.setdefault("repo", self.repo)
127
+ # `setdefault` is not enough: callers that pass every field explicitly (the MCP
128
+ # tools do) send repo=None, so the key EXISTS and setdefault leaves the None in
129
+ # place. Found by live-testing the MCP server: every scope="repo" record failed.
130
+ if kwargs.get("repo") is None:
131
+ kwargs["repo"] = self.repo
119
132
  if self.mode == "remote":
120
133
  return self._post("/record", kwargs)["id"]
121
134
  return core.record(self._local_store(), **kwargs)
@@ -157,10 +170,24 @@ class Client:
157
170
  return self._local_store().all_nodes()
158
171
 
159
172
  def _get(self, path: str) -> dict:
173
+ # The token goes on GETs too. It used to go only on POSTs, which was survivable
174
+ # only because the service left reads open; once reads are gated, an unauthenticated
175
+ # GET makes drift detection (/nodes) and the recurrence metric fail against every
176
+ # private deployment — and a 401 here surfaces as "unreachable", i.e. as an outage.
160
177
  url = self.service_url.rstrip("/") + path
178
+ req = _req.Request(url)
179
+ token = os.environ.get("OKL_TOKEN") or self.config.get("token")
180
+ if token:
181
+ req.add_header("Authorization", f"Bearer {token}")
161
182
  try:
162
- with _req.urlopen(url, timeout=10) as resp:
183
+ with _req.urlopen(req, timeout=10) as resp:
163
184
  return json.loads(resp.read())
185
+ except HTTPError as e:
186
+ if 400 <= e.code < 500:
187
+ raise ValueError(
188
+ f"OKL service rejected the request ({e.code} {e.reason}). "
189
+ "If this is 401, set OKL_TOKEN or add \"token\" to .okl/config.json.") from e
190
+ raise OKLUnreachable(f"OKL service error at {url}: {e.code} {e.reason}") from e
164
191
  except URLError as e:
165
192
  raise OKLUnreachable(f"OKL service unreachable at {url}: {e}") from e
166
193
 
@@ -13,12 +13,33 @@ from .client import Client, OKLUnreachable
13
13
 
14
14
 
15
15
  def _build():
16
- try:
17
- from mcp.server.fastmcp import FastMCP
18
- except ImportError as e: # pragma: no cover
19
- raise RuntimeError("The MCP server needs the 'mcp' package — install okl[mcp]") from e
16
+ """Construct the MCP server, tolerating both major versions of the SDK.
20
17
 
21
- mcp = FastMCP("okl")
18
+ The class was renamed in mcp 2.x: `mcp.server.fastmcp.FastMCP` became
19
+ `mcp.server.mcpserver.MCPServer`. The decorator API we use (`.tool()`) is the same
20
+ on both, so try the newer name first and fall back. Without this, `pip install
21
+ org-knowledge-layer[mcp]` resolves to 2.x and every tool call fails.
22
+ """
23
+ server_cls = None
24
+ errors = []
25
+ for module, name in (("mcp.server.mcpserver", "MCPServer"), # mcp >= 2
26
+ ("mcp.server.fastmcp", "FastMCP")): # mcp 1.x
27
+ try:
28
+ server_cls = getattr(__import__(module, fromlist=[name]), name)
29
+ break
30
+ except (ImportError, AttributeError) as e:
31
+ errors.append(f"{module}.{name}: {e}")
32
+ if server_cls is None:
33
+ # Surface the REAL cause. Saying "install okl[mcp]" to someone who just did is
34
+ # the same failure as reporting a validation error as an outage: the message
35
+ # sends them to fix a thing that is not broken.
36
+ raise RuntimeError(
37
+ "Could not load an MCP server class from the installed 'mcp' package.\n"
38
+ + "\n".join(f" tried {e}" for e in errors)
39
+ + "\nInstall the extra with `pip install \"org-knowledge-layer[mcp]\"`, or report "
40
+ "this if the SDK has changed again.")
41
+
42
+ mcp = server_cls("okl")
22
43
  client = Client()
23
44
 
24
45
  @mcp.tool()
@@ -62,9 +83,15 @@ def _build():
62
83
  tags (comma-sep, controlled vocabulary — e.g. react, security,
63
84
  eval-integrity) categorize the subject so `check` can filter by interest.
64
85
  """
65
- node_id = client.record(type=type, title=title, scope=scope, body=body,
66
- status=status, found_by=found_by, ttl_days=ttl_days, repo=repo,
67
- symptom=symptom, fix=fix, files=files, tags=tags)
86
+ try:
87
+ node_id = client.record(type=type, title=title, scope=scope, body=body,
88
+ status=status, found_by=found_by, ttl_days=ttl_days,
89
+ repo=repo, symptom=symptom, fix=fix, files=files, tags=tags)
90
+ except ValueError as e:
91
+ # Hand the agent the actual complaint (unknown tag, bad scope) so it can fix
92
+ # its own call. Raising here surfaces as an opaque "Error executing tool",
93
+ # which reads like an outage and teaches the agent nothing.
94
+ return f"NOT RECORDED — {e}"
68
95
  return f"recorded {node_id} ({type}, {scope})"
69
96
 
70
97
  @mcp.tool()
@@ -66,21 +66,37 @@ class VerifyReq(BaseModel):
66
66
 
67
67
 
68
68
  def create_app(store: Store | None = None) -> FastAPI:
69
- app = FastAPI(title="OKL — the sixth surface", version="0.1.0")
69
+ # Optional shared-secret gate. If OKL_TOKEN is set it covers READS as well as
70
+ # writes. Reads used to be open while writes were gated, which meant a deployed
71
+ # service handed anyone who found the URL a `GET /nodes` dump of the org's entire
72
+ # encoded body — its known defects, its retired identifiers, its architecture
73
+ # decisions. That is a catalogue of where the org is weak, and it is exactly the
74
+ # material the layer exists to collect. If you set a token, you want it private.
75
+ token = os.environ.get("OKL_TOKEN")
76
+ # The same reasoning reaches the spec routes, and they were missed when the data
77
+ # routes were closed: FastAPI serves /openapi.json, /docs and /redoc to anyone by
78
+ # default, and no `_auth` call can protect them because the framework mounts them
79
+ # itself. They leak the endpoint list, every schema and where the credential is
80
+ # required — the map you would draw before attacking the routes. Setting the token
81
+ # is the signal that this instance is not a laptop, so the spec comes down with it.
82
+ docs = {} if token is None else {"openapi_url": None, "docs_url": None, "redoc_url": None}
83
+ app = FastAPI(title="OKL — the sixth surface", version="0.1.0", **docs)
70
84
  _store = store or Store(os.environ.get("OKL_DATABASE_URL"))
71
- # Optional shared-secret gate. If OKL_TOKEN is set, writes require it.
72
- write_token = os.environ.get("OKL_TOKEN")
73
85
 
74
86
  def _auth(authorization: str | None) -> None:
75
- if write_token and authorization != f"Bearer {write_token}":
87
+ if token and authorization != f"Bearer {token}":
76
88
  raise HTTPException(status_code=401, detail="missing or bad bearer token")
77
89
 
78
90
  @app.get("/health")
79
91
  def health() -> dict[str, Any]:
92
+ # Deliberately open even when a token is set: schedulers and load balancers
93
+ # probe this before they hold any credential, and a deploy that cannot be
94
+ # health-checked never goes live. It returns a count and a backend name, no content.
80
95
  return {"ok": True, "nodes": len(_store.all_nodes()), "backend": _store.url.split(":")[0]}
81
96
 
82
97
  @app.post("/check")
83
- def check(req: CheckReq) -> dict[str, Any]:
98
+ def check(req: CheckReq, authorization: str | None = Header(default=None)) -> dict[str, Any]:
99
+ _auth(authorization)
84
100
  return core.check(_store, req.repo, req.task, limit=req.limit,
85
101
  interests=req.interests)
86
102
 
@@ -96,7 +112,8 @@ def create_app(store: Store | None = None) -> FastAPI:
96
112
  return {"id": node_id}
97
113
 
98
114
  @app.post("/search")
99
- def search(req: SearchReq) -> dict[str, Any]:
115
+ def search(req: SearchReq, authorization: str | None = Header(default=None)) -> dict[str, Any]:
116
+ _auth(authorization)
100
117
  return {"results": core.search(_store, req.query, req.scope, req.node_types, req.limit)}
101
118
 
102
119
  @app.post("/link")
@@ -114,12 +131,14 @@ def create_app(store: Store | None = None) -> FastAPI:
114
131
  raise HTTPException(status_code=400, detail=str(e)) from e
115
132
 
116
133
  @app.get("/metric/recurrence")
117
- def recurrence() -> dict[str, Any]:
134
+ def recurrence(authorization: str | None = Header(default=None)) -> dict[str, Any]:
135
+ _auth(authorization)
118
136
  rows = _store.recurrence_after_arming()
119
137
  return {"recurrence_after_arming": rows, "count": len(rows)}
120
138
 
121
139
  @app.get("/nodes")
122
- def nodes() -> dict[str, Any]:
140
+ def nodes(authorization: str | None = Header(default=None)) -> dict[str, Any]:
141
+ _auth(authorization)
123
142
  from dataclasses import asdict
124
143
  rows = [asdict(n) for n in _store.all_nodes()]
125
144
  return {"nodes": rows, "count": len(rows)}
@@ -127,11 +146,32 @@ def create_app(store: Store | None = None) -> FastAPI:
127
146
  return app
128
147
 
129
148
 
130
- app = None
149
+ _app: FastAPI | None = None
150
+
151
+
152
+ def __getattr__(name: str):
153
+ """Build `app` on first attribute access, not at import.
154
+
155
+ Every ASGI host — uvicorn, gunicorn, a platform's default start command — is
156
+ pointed at `module:app`, and that is what this module's own docstring tells you
157
+ to run. `app` used to be a module-level `None` that `run()` reassigned as a side
158
+ effect, so `uvicorn okl.service:app` served a None: the process started, bound the
159
+ port, looked healthy to anything watching the port, and answered 500 to every
160
+ request. A deploy that fails at startup is a nuisance; one that comes up and then
161
+ fails every call is an outage that reads as a bug in the caller.
162
+
163
+ PEP 562 module `__getattr__` fires only when normal lookup fails, which is what
164
+ keeps the database out of import time: `import okl.service` still touches nothing,
165
+ so the CLI and the tests can import this module without a configured backend.
166
+ """
167
+ if name == "app":
168
+ global _app
169
+ if _app is None:
170
+ _app = create_app()
171
+ return _app
172
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
131
173
 
132
174
 
133
175
  def run(host: str = "0.0.0.0", port: int = 8080) -> None:
134
176
  import uvicorn
135
- global app
136
- app = create_app()
137
- uvicorn.run(app, host=host, port=port)
177
+ uvicorn.run(create_app(), host=host, port=port)