data-olympus 0.4.1__tar.gz → 0.4.2__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 (89) hide show
  1. {data_olympus-0.4.1 → data_olympus-0.4.2}/CHANGELOG.md +39 -0
  2. {data_olympus-0.4.1 → data_olympus-0.4.2}/PKG-INFO +4 -1
  3. {data_olympus-0.4.1 → data_olympus-0.4.2}/README.md +3 -0
  4. {data_olympus-0.4.1 → data_olympus-0.4.2}/pyproject.toml +1 -1
  5. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/main.py +2 -0
  6. data_olympus-0.4.2/src/data_olympus/cli/verify_cmd.py +245 -0
  7. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/server.py +238 -64
  8. {data_olympus-0.4.1 → data_olympus-0.4.2}/.gitignore +0 -0
  9. {data_olympus-0.4.1 → data_olympus-0.4.2}/LICENSE +0 -0
  10. {data_olympus-0.4.1 → data_olympus-0.4.2}/NOTICE +0 -0
  11. {data_olympus-0.4.1 → data_olympus-0.4.2}/SPEC.md +0 -0
  12. {data_olympus-0.4.1 → data_olympus-0.4.2}/benchmarks/README.md +0 -0
  13. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/_kb_detect_workspace.sh +0 -0
  14. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/_kb_enforce.py +0 -0
  15. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/_kb_fallback.py +0 -0
  16. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/kb +0 -0
  17. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/kb-enforce-hook +0 -0
  18. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/kb-session-recap-hook +0 -0
  19. {data_olympus-0.4.1 → data_olympus-0.4.2}/bin/opencode/data-olympus-gate.ts +0 -0
  20. {data_olympus-0.4.1 → data_olympus-0.4.2}/deploy/k8s/README.md +0 -0
  21. {data_olympus-0.4.1 → data_olympus-0.4.2}/deploy/k8s/read-replica/README.md +0 -0
  22. {data_olympus-0.4.1 → data_olympus-0.4.2}/docs/releases/README.md +0 -0
  23. {data_olympus-0.4.1 → data_olympus-0.4.2}/example-bundle/projects/acme-app/README.md +0 -0
  24. {data_olympus-0.4.1 → data_olympus-0.4.2}/example-bundle/projects/acme-app/components/api/README.md +0 -0
  25. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/__init__.py +0 -0
  26. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/audit_log.py +0 -0
  27. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/audit_trailers.py +0 -0
  28. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/auth.py +0 -0
  29. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/__init__.py +0 -0
  30. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/import_cmd.py +0 -0
  31. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/indexgen.py +0 -0
  32. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/init_cmd.py +0 -0
  33. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/report_cmd.py +0 -0
  34. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cli/validity_report_cmd.py +0 -0
  35. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/config.py +0 -0
  36. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/cooccurrence.py +0 -0
  37. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/dedup.py +0 -0
  38. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/durable.py +0 -0
  39. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/embeddings.py +0 -0
  40. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/enforce_policy.py +0 -0
  41. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/format/__init__.py +0 -0
  42. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/format/document.py +0 -0
  43. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/format/frontmatter.py +0 -0
  44. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/format/lint.py +0 -0
  45. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/format/validate.py +0 -0
  46. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/git_ops.py +0 -0
  47. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/governed_lane.py +0 -0
  48. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/health.py +0 -0
  49. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/__init__.py +0 -0
  50. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/adr.py +0 -0
  51. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/flat.py +0 -0
  52. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/model.py +0 -0
  53. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/okf.py +0 -0
  54. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/run.py +0 -0
  55. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/importer/stamp.py +0 -0
  56. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/index.py +0 -0
  57. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/maintenance.py +0 -0
  58. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/markdown_parse.py +0 -0
  59. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/models.py +0 -0
  60. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/onboarding.py +0 -0
  61. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/onboarding_inflight.py +0 -0
  62. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/onboarding_playbook.py +0 -0
  63. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/pending.py +0 -0
  64. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/principals.py +0 -0
  65. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/prompts.py +0 -0
  66. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/push_queue.py +0 -0
  67. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/query_expansion.py +0 -0
  68. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/rate_limit.py +0 -0
  69. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/refresh.py +0 -0
  70. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/report.py +0 -0
  71. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/rest_api.py +0 -0
  72. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/safe_id.py +0 -0
  73. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/scaffold.py +0 -0
  74. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/search_gate.py +0 -0
  75. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/search_shortcut.py +0 -0
  76. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/session_metrics.py +0 -0
  77. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/setup_wizard.py +0 -0
  78. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/thin_pointer.py +0 -0
  79. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/tools_audit.py +0 -0
  80. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/tools_enforce.py +0 -0
  81. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/tools_onboarding.py +0 -0
  82. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/tools_read.py +0 -0
  83. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/tools_recap.py +0 -0
  84. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/tools_write.py +0 -0
  85. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/trigram.py +0 -0
  86. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/viewer/__init__.py +0 -0
  87. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/viewer/generator.py +0 -0
  88. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/worktrees.py +0 -0
  89. {data_olympus-0.4.1 → data_olympus-0.4.2}/src/data_olympus/write_gate.py +0 -0
@@ -12,6 +12,45 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.4.2] - 2026-07-11
16
+
17
+ ### Added
18
+
19
+ - **`data-olympus verify` command.** New CLI subcommand that runs pass/fail
20
+ checks against a running instance (`--target`, `--json`, `--checks`,
21
+ `--token`): liveness (`/api/v1/health`), readiness (`/readyz`), a search
22
+ round-trip (`/api/v1/search`), and a deployment-tolerant enforcement-plane
23
+ probe (`/api/v1/gate/check`). Exit codes: `0` all pass, `4` a check failed,
24
+ `1` the target is unreachable. Used as the pre/post-release verification gate.
25
+ - **Staged-promotion release pipeline (release engineering).** A release
26
+ candidate is published to a `X.Y.Z-rc.N` ghcr channel (`rc-publish.yml`),
27
+ deployed as a canary and verified, then promoted by re-tagging the verified
28
+ digest to `vX.Y.Z` byte-identically (`tag-release.yml`), with a moving-channel
29
+ helper (`set-channel.yml`) and project-local runbooks under `.rules/`.
30
+ - **Glama registry score support.** Added the Glama score badge to the README
31
+ and documented the claimed server listing, sandbox release settings, and
32
+ score-maintenance checklist in `docs/glama.md`.
33
+ - **Release security-clearance gate.** `scripts/security_alerts.py` fails a
34
+ release when any Dependabot or CodeQL alert is open; the release planner clears
35
+ every alert (fix or justified dismissal) and the cutter verifies zero open
36
+ before building the release candidate.
37
+
38
+ ### Changed
39
+
40
+ - **Improved MCP tool schema metadata for registry introspection.** MCP tools
41
+ now expose meaningful titles, MCP annotations, and per-parameter JSON Schema
42
+ descriptions so registries such as Glama can score the running server's tool
43
+ definitions more accurately.
44
+
45
+ ### Fixed
46
+
47
+ - **Documented the fastmcp 3.4.3 host allowlist requirement behind
48
+ proxies/ingress** (docs/serving.md, docs/operations.md section 3.4).
49
+ v0.4.1's fastmcp bump enables DNS-rebinding protection that answers 421
50
+ Misdirected Request for undeclared public hostnames while health probes
51
+ keep passing; deployments behind a reverse proxy must set
52
+ `FASTMCP_HTTP_ALLOWED_HOSTS`. First-class handling tracked in #139.
53
+
15
54
  ## [0.4.1] - 2026-07-08
16
55
 
17
56
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-olympus
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -45,6 +45,8 @@ Description-Content-Type: text/markdown
45
45
 
46
46
  # data-olympus
47
47
 
48
+ [![knaisoma/data-olympus MCP server](https://glama.ai/mcp/servers/knaisoma/data-olympus/badges/score.svg)](https://glama.ai/mcp/servers/knaisoma/data-olympus)
49
+
48
50
  **New here? Start with [WHY.md](WHY.md).** It is the story behind the project: the
49
51
  problem we kept hitting with coding agents, what data-olympus does differently, how
50
52
  it relates to Google's Open Knowledge Format, and where our benchmarks say it is
@@ -94,6 +96,7 @@ See `docs/quickstart.md` for the full local-run walkthrough, including curl and
94
96
  - [`docs/operations.md`](docs/operations.md): production runbook — backup, upgrade, recovery playbooks (degraded/fetch-failed, history rewrite, frozen/demoted push entries, orphaned locks), and the health/alerting model.
95
97
  - [`docs/comparison.md`](docs/comparison.md): how data-olympus relates to OKF, enterprise catalogs, markdown KB tools, agent-context conventions, RAG, and ADR tooling.
96
98
  - [`docs/okf-profile.md`](docs/okf-profile.md): field-by-field OKF profile — which governance extensions are stable, which are runtime-only serving fields, and which are experimental candidates.
99
+ - [`docs/glama.md`](docs/glama.md): Glama registry claim, release, and score-maintenance notes.
97
100
  - [`docs/enforcement.md`](docs/enforcement.md): turning the KB into a mandatory consultation gate (hooks, `kb enforce`).
98
101
  - [`benchmarks/README.md`](benchmarks/README.md): retrieval benchmark methodology and how to reproduce the numbers in `docs/comparison.md`.
99
102
  - [`SECURITY.md`](SECURITY.md): supported versions and how to report a vulnerability.
@@ -1,5 +1,7 @@
1
1
  # data-olympus
2
2
 
3
+ [![knaisoma/data-olympus MCP server](https://glama.ai/mcp/servers/knaisoma/data-olympus/badges/score.svg)](https://glama.ai/mcp/servers/knaisoma/data-olympus)
4
+
3
5
  **New here? Start with [WHY.md](WHY.md).** It is the story behind the project: the
4
6
  problem we kept hitting with coding agents, what data-olympus does differently, how
5
7
  it relates to Google's Open Knowledge Format, and where our benchmarks say it is
@@ -49,6 +51,7 @@ See `docs/quickstart.md` for the full local-run walkthrough, including curl and
49
51
  - [`docs/operations.md`](docs/operations.md): production runbook — backup, upgrade, recovery playbooks (degraded/fetch-failed, history rewrite, frozen/demoted push entries, orphaned locks), and the health/alerting model.
50
52
  - [`docs/comparison.md`](docs/comparison.md): how data-olympus relates to OKF, enterprise catalogs, markdown KB tools, agent-context conventions, RAG, and ADR tooling.
51
53
  - [`docs/okf-profile.md`](docs/okf-profile.md): field-by-field OKF profile — which governance extensions are stable, which are runtime-only serving fields, and which are experimental candidates.
54
+ - [`docs/glama.md`](docs/glama.md): Glama registry claim, release, and score-maintenance notes.
52
55
  - [`docs/enforcement.md`](docs/enforcement.md): turning the KB into a mandatory consultation gate (hooks, `kb enforce`).
53
56
  - [`benchmarks/README.md`](benchmarks/README.md): retrieval benchmark methodology and how to reproduce the numbers in `docs/comparison.md`.
54
57
  - [`SECURITY.md`](SECURITY.md): supported versions and how to report a vulnerability.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "data-olympus"
3
- version = "0.4.1"
3
+ version = "0.4.2"
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"
@@ -141,6 +141,8 @@ def build_parser() -> argparse.ArgumentParser:
141
141
  add_validity_report_subparser(sub)
142
142
  from data_olympus.cli.init_cmd import add_init_subparser
143
143
  add_init_subparser(sub)
144
+ from data_olympus.cli.verify_cmd import add_verify_subparser
145
+ add_verify_subparser(sub)
144
146
  return parser
145
147
 
146
148
 
@@ -0,0 +1,245 @@
1
+ """`data-olympus verify`: automated pass/fail health + functional round-trip
2
+ checks against a running data-olympus instance. Used by hand and as the
3
+ pre/post-release verification gate.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ import httpx
12
+
13
+ if TYPE_CHECKING:
14
+ import argparse
15
+
16
+ # NOTE: declare every module-level import here in Task 1. Later tasks add
17
+ # functions only (no new top-level imports), so ruff's E402 never fires.
18
+ # `json` is used by run_verify (Task 4); the argparse annotations are strings.
19
+
20
+ _ALL_CHECKS = ("health", "readiness", "search", "enforcement")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CheckResult:
25
+ name: str
26
+ ok: bool
27
+ detail: str
28
+ connection_error: bool = False
29
+
30
+
31
+ def check_health(client: httpx.Client) -> CheckResult:
32
+ """GET /api/v1/health: pass on 200 with degraded=false (503 = degraded)."""
33
+ try:
34
+ resp = client.get("/api/v1/health")
35
+ except httpx.HTTPError as exc:
36
+ return CheckResult("health", False, f"request failed: {exc}", connection_error=True)
37
+ if resp.status_code != 200:
38
+ return CheckResult("health", False, f"status {resp.status_code} (degraded or down)")
39
+ try:
40
+ body = resp.json()
41
+ except ValueError:
42
+ return CheckResult("health", False, "non-JSON health body")
43
+ if not isinstance(body, dict):
44
+ return CheckResult("health", False, "unexpected non-object health body")
45
+ if bool(body.get("degraded")):
46
+ return CheckResult("health", False, "health reports degraded")
47
+ return CheckResult("health", True, "healthy")
48
+
49
+
50
+ def check_readiness(client: httpx.Client) -> CheckResult:
51
+ """GET /readyz: the k8s readiness probe target; pass on 200."""
52
+ try:
53
+ resp = client.get("/readyz")
54
+ except httpx.HTTPError as exc:
55
+ return CheckResult("readiness", False, f"request failed: {exc}", connection_error=True)
56
+ ok = resp.status_code == 200
57
+ return CheckResult("readiness", ok, "ready" if ok else f"status {resp.status_code}")
58
+
59
+
60
+ def check_search(client: httpx.Client, probe: str) -> CheckResult:
61
+ """GET /api/v1/search: confirms the index answers reads with a hits list."""
62
+ try:
63
+ resp = client.get("/api/v1/search", params={"q": probe, "limit": 1})
64
+ except httpx.HTTPError as exc:
65
+ return CheckResult("search", False, f"request failed: {exc}", connection_error=True)
66
+ if resp.status_code != 200:
67
+ return CheckResult("search", False, f"status {resp.status_code}")
68
+ try:
69
+ body = resp.json()
70
+ except ValueError:
71
+ return CheckResult("search", False, "non-JSON search body")
72
+ if not isinstance(body, dict):
73
+ return CheckResult("search", False, "unexpected non-object search body")
74
+ hits = body.get("hits")
75
+ if not isinstance(hits, list):
76
+ return CheckResult("search", False, "response missing 'hits' list")
77
+ return CheckResult("search", True, f"{len(hits)} hit(s) for probe {probe!r}")
78
+
79
+
80
+ def check_enforcement(client: httpx.Client, token: str | None = None) -> CheckResult:
81
+ """Probe /api/v1/gate/check. Deployment-tolerant: 404 (routes not
82
+ mounted) and 401/403 without a token are informational passes; a token
83
+ that is rejected, or a 5xx, fail."""
84
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
85
+ payload = {"workspace": "data-olympus", "session_id": "verify-probe"}
86
+ try:
87
+ resp = client.post(
88
+ "/api/v1/gate/check", json=payload, headers=headers
89
+ )
90
+ except httpx.HTTPError as exc:
91
+ return CheckResult(
92
+ "enforcement", False, f"request failed: {exc}", connection_error=True
93
+ )
94
+ code = resp.status_code
95
+ if code == 200:
96
+ return CheckResult("enforcement", True, "enforcement plane responding")
97
+ if code == 404:
98
+ return CheckResult(
99
+ "enforcement", True, "enforcement routes not mounted (open deployment)"
100
+ )
101
+ if code in (401, 403):
102
+ if token:
103
+ return CheckResult(
104
+ "enforcement", False, f"enforcement rejected the token ({code})"
105
+ )
106
+ return CheckResult(
107
+ "enforcement",
108
+ True,
109
+ "enforcement active (auth required; pass --token to test round-trip)",
110
+ )
111
+ return CheckResult("enforcement", False, f"unexpected status {code}")
112
+
113
+
114
+ def run_verify(
115
+ *,
116
+ target: str,
117
+ as_json: bool = False,
118
+ timeout: float = 10.0,
119
+ probe: str = "the",
120
+ checks: list[str] | None = None,
121
+ token: str | None = None,
122
+ client: httpx.Client | None = None,
123
+ ) -> int:
124
+ """Orchestrate selected checks and report results to stdout. Returns 0, 1,
125
+ or 4."""
126
+ owns_client = client is None
127
+ if client is None:
128
+ client = httpx.Client(base_url=target.rstrip("/"), timeout=timeout)
129
+ try:
130
+ selected_checks = (
131
+ checks if checks is not None else list(_ALL_CHECKS)
132
+ )
133
+ results = []
134
+ for check_name in _ALL_CHECKS:
135
+ if check_name not in selected_checks:
136
+ continue
137
+ if check_name == "health":
138
+ results.append(check_health(client))
139
+ elif check_name == "readiness":
140
+ results.append(check_readiness(client))
141
+ elif check_name == "search":
142
+ results.append(check_search(client, probe))
143
+ elif check_name == "enforcement":
144
+ results.append(check_enforcement(client, token))
145
+ finally:
146
+ if owns_client:
147
+ client.close()
148
+
149
+ all_ok = all(r.ok for r in results)
150
+ if as_json:
151
+ print(
152
+ json.dumps(
153
+ {
154
+ "target": target,
155
+ "ok": all_ok,
156
+ "checks": [
157
+ {"name": r.name, "ok": r.ok, "detail": r.detail}
158
+ for r in results
159
+ ],
160
+ },
161
+ indent=2,
162
+ )
163
+ )
164
+ else:
165
+ for r in results:
166
+ mark = "PASS" if r.ok else "FAIL"
167
+ print(f"{mark} {r.name}: {r.detail}")
168
+ print(
169
+ f"{'ok' if all_ok else 'FAILED'}: "
170
+ f"{sum(r.ok for r in results)}/{len(results)} checks passed "
171
+ f"against {target}"
172
+ )
173
+ if all_ok:
174
+ return 0
175
+ if results and all(r.connection_error for r in results):
176
+ return 1
177
+ return 4
178
+
179
+
180
+ def add_verify_subparser(
181
+ sub: argparse._SubParsersAction[argparse.ArgumentParser],
182
+ ) -> None:
183
+ """Wire the verify subcommand into the argparse dispatcher."""
184
+ p = sub.add_parser(
185
+ "verify",
186
+ help="run health + functional round-trip checks against a running instance",
187
+ )
188
+ p.add_argument(
189
+ "--target",
190
+ default=None,
191
+ help="base URL (default: $KB_ENDPOINT or http://localhost:8080)",
192
+ )
193
+ p.add_argument("--json", action="store_true", help="emit JSON")
194
+ p.add_argument(
195
+ "--timeout",
196
+ type=float,
197
+ default=10.0,
198
+ help="per-request timeout seconds",
199
+ )
200
+ p.add_argument("--probe", default="the", help="search probe term (default: the)")
201
+ p.add_argument(
202
+ "--checks",
203
+ default=None,
204
+ help="comma-separated subset to run (default: all): "
205
+ "health,readiness,search,enforcement",
206
+ )
207
+ p.add_argument(
208
+ "--token",
209
+ default=None,
210
+ help="bearer token for enforcement check (default: $KB_AUTH_TOKEN)",
211
+ )
212
+ p.set_defaults(func=_cmd_verify)
213
+
214
+
215
+ def _cmd_verify(args: argparse.Namespace) -> int:
216
+ """Entry point for the verify subcommand; resolve target and call
217
+ run_verify."""
218
+ import os
219
+ import sys
220
+
221
+ target = args.target or os.environ.get("KB_ENDPOINT") or (
222
+ "http://localhost:8080"
223
+ )
224
+
225
+ checks = None
226
+ if args.checks:
227
+ checks = [c.strip() for c in args.checks.split(",")]
228
+ unknown = set(checks) - set(_ALL_CHECKS)
229
+ if unknown:
230
+ print(
231
+ f"error: unknown check(s): {','.join(sorted(unknown))}",
232
+ file=sys.stderr,
233
+ )
234
+ return 2
235
+
236
+ token = args.token or os.environ.get("KB_AUTH_TOKEN")
237
+
238
+ return run_verify(
239
+ target=target,
240
+ as_json=args.json,
241
+ timeout=args.timeout,
242
+ probe=args.probe,
243
+ checks=checks,
244
+ token=token,
245
+ )
@@ -6,10 +6,11 @@ import contextlib
6
6
  import contextvars
7
7
  import logging
8
8
  import time
9
- from typing import TYPE_CHECKING, Any
9
+ from typing import TYPE_CHECKING, Annotated, Any
10
10
 
11
11
  from fastmcp import FastMCP
12
12
  from fastmcp.server.middleware import Middleware
13
+ from pydantic import Field
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from collections.abc import Callable
@@ -61,6 +62,170 @@ _current_principal: contextvars.ContextVar[Principal] = contextvars.ContextVar(
61
62
  "current_principal", default=LOCAL_TRUSTED
62
63
  )
63
64
 
65
+ READ_ONLY_TOOL = {
66
+ "readOnlyHint": True,
67
+ "destructiveHint": False,
68
+ "idempotentHint": True,
69
+ "openWorldHint": False,
70
+ }
71
+ STATEFUL_NONDESTRUCTIVE_TOOL = {
72
+ "readOnlyHint": False,
73
+ "destructiveHint": False,
74
+ "idempotentHint": False,
75
+ "openWorldHint": False,
76
+ }
77
+ PROPOSAL_WRITE_TOOL = {
78
+ "readOnlyHint": False,
79
+ "destructiveHint": False,
80
+ "idempotentHint": False,
81
+ "openWorldHint": True,
82
+ }
83
+ PROPOSAL_EDIT_TOOL = {
84
+ "readOnlyHint": False,
85
+ "destructiveHint": True,
86
+ "idempotentHint": False,
87
+ "openWorldHint": True,
88
+ }
89
+ DESTRUCTIVE_WRITE_TOOL = {
90
+ "readOnlyHint": False,
91
+ "destructiveHint": True,
92
+ "idempotentHint": False,
93
+ "openWorldHint": True,
94
+ }
95
+
96
+ VerboseParam = Annotated[
97
+ bool,
98
+ Field(description="False returns the compact response; true includes all fields."),
99
+ ]
100
+ QueryParam = Annotated[str, Field(description="Natural-language search query.")]
101
+ LimitParam = Annotated[int, Field(description="Maximum number of results, clamped to 1..100.")]
102
+ TierParam = Annotated[
103
+ str | None,
104
+ Field(description="Optional tier filter such as T1, T2, T3, or T4."),
105
+ ]
106
+ RequiredTierParam = Annotated[
107
+ str,
108
+ Field(description="Required tier such as T1, T2, T3, or T4."),
109
+ ]
110
+ CategoryParam = Annotated[
111
+ str | None,
112
+ Field(description="Optional category filter within a tier."),
113
+ ]
114
+ StatusParam = Annotated[str | None, Field(description="Optional frontmatter status filter.")]
115
+ InForceParam = Annotated[
116
+ bool,
117
+ Field(description="True returns only currently governing documents."),
118
+ ]
119
+ DocTypeParam = Annotated[str | None, Field(description="Optional document type filter.")]
120
+ AbstainParam = Annotated[
121
+ bool,
122
+ Field(description="True returns an explicit abstention when the query has no KB signal."),
123
+ ]
124
+ IncludeExpiredParam = Annotated[
125
+ bool,
126
+ Field(description="True allows expired documents in search results."),
127
+ ]
128
+ ValidityStateParam = Annotated[
129
+ str | None,
130
+ Field(description="Optional validity facet: expired, stale, or expiring_within:N."),
131
+ ]
132
+ DocumentIdParam = Annotated[str, Field(description="Stable KB document id to retrieve.")]
133
+ WorkspaceParam = Annotated[str, Field(description="Project or workspace key in the KB.")]
134
+ ComponentParam = Annotated[
135
+ str | None,
136
+ Field(description="Optional component key under the workspace."),
137
+ ]
138
+ RemoteUrlParam = Annotated[
139
+ str | None,
140
+ Field(description="Optional git remote URL used for matching."),
141
+ ]
142
+ LocalFilesParam = Annotated[
143
+ list[dict[str, str]],
144
+ Field(description="Local docs to compare, each with path and content keys."),
145
+ ]
146
+ JaccardParam = Annotated[
147
+ float,
148
+ Field(description="Similarity threshold above which a local doc is treated as duplicate."),
149
+ ]
150
+ TextParam = Annotated[str, Field(description="Markdown memory text to propose.")]
151
+ TagsParam = Annotated[list[str], Field(description="Short tags for the proposed memory.")]
152
+ SourceSessionParam = Annotated[
153
+ str,
154
+ Field(description="Stable id of the agent session making the call."),
155
+ ]
156
+ AgentIdentityParam = Annotated[
157
+ str,
158
+ Field(description="Human-readable agent identity for audit events."),
159
+ ]
160
+ ConfidenceParam = Annotated[
161
+ float,
162
+ Field(description="Caller confidence in the proposal, from 0.0 to 1.0."),
163
+ ]
164
+ EvidenceParam = Annotated[
165
+ list[str] | None,
166
+ Field(description="Optional supporting evidence strings, max 10 items of 500 chars each."),
167
+ ]
168
+ TargetPathParam = Annotated[
169
+ str,
170
+ Field(description="KB-relative markdown path to create or edit."),
171
+ ]
172
+ PostimageParam = Annotated[
173
+ str,
174
+ Field(description="Complete markdown file content after the proposed edit."),
175
+ ]
176
+ BaseCommitParam = Annotated[str, Field(description="Git commit the proposal was based on.")]
177
+ BaseBlobShaParam = Annotated[
178
+ str | None,
179
+ Field(description="Optional git blob sha for compare-and-swap protection."),
180
+ ]
181
+ TargetFileHashParam = Annotated[
182
+ str | None,
183
+ Field(description="Optional content hash for compare-and-swap protection."),
184
+ ]
185
+ ReasonParam = Annotated[str, Field(description="Short reason for the proposed change.")]
186
+ PendingIdParam = Annotated[str, Field(description="Pending proposal id to resolve.")]
187
+ DecisionParam = Annotated[
188
+ str,
189
+ Field(description="Resolution decision: approve or reject."),
190
+ ]
191
+ EditedTextParam = Annotated[
192
+ str | None,
193
+ Field(description="Optional replacement postimage used when approving."),
194
+ ]
195
+ OverrideSecretScanParam = Annotated[
196
+ bool,
197
+ Field(description="Operator override for false-positive secret-scan matches."),
198
+ ]
199
+ SinceParam = Annotated[float | None, Field(description="Optional Unix timestamp lower bound.")]
200
+ AgentParam = Annotated[str | None, Field(description="Optional agent_identity filter.")]
201
+ EventStatusParam = Annotated[str | None, Field(description="Optional audit event status filter.")]
202
+ AuditLimitParam = Annotated[int, Field(description="Maximum audit events to return.")]
203
+ BootstrapFilesParam = Annotated[
204
+ list[dict[str, str]],
205
+ Field(description="Bootstrap files, each with target_path and postimage keys."),
206
+ ]
207
+ TriggerParam = Annotated[
208
+ str,
209
+ Field(description="Consult trigger: explicit or prompt_hook."),
210
+ ]
211
+ SessionIdParam = Annotated[
212
+ str,
213
+ Field(description="Agent session id checked against consult history."),
214
+ ]
215
+ ToolNameParam = Annotated[str, Field(description="Name of the tool or command about to run.")]
216
+ ActionPathParam = Annotated[
217
+ str | None,
218
+ Field(description="Optional path or URL affected by the pending action."),
219
+ ]
220
+ ActionDiffParam = Annotated[
221
+ str,
222
+ Field(description="Short description or diff summary of the pending action."),
223
+ ]
224
+ EventTypeParam = Annotated[
225
+ str,
226
+ Field(description="Client-reported event type: gate_bypass or gate_degraded."),
227
+ ]
228
+
64
229
 
65
230
  class MCPAuthMiddleware(Middleware):
66
231
  """Enforce principal capabilities on MCP write tools.
@@ -444,8 +609,8 @@ def build_app(
444
609
 
445
610
  app: FastMCP = FastMCP(name="data-olympus-mcp")
446
611
 
447
- @app.tool()
448
- def kb_health(verbose: bool = False) -> dict[str, object]:
612
+ @app.tool(title="KB Health", annotations=READ_ONLY_TOOL)
613
+ def kb_health(verbose: VerboseParam = False) -> dict[str, object]:
449
614
  """Return service health: kb_commit, index_built_at, staleness, degraded flag,
450
615
  and write-side state (pending_count, push_queue_size, last_index_*).
451
616
 
@@ -480,8 +645,8 @@ def build_app(
480
645
  )
481
646
  return shape_response(resp, verbose=verbose)
482
647
 
483
- @app.tool()
484
- def kb_outline(verbose: bool = False) -> dict[str, object]:
648
+ @app.tool(title="KB Outline", annotations=READ_ONLY_TOOL)
649
+ def kb_outline(verbose: VerboseParam = False) -> dict[str, object]:
485
650
  """Return the tree of tiers and categories with doc counts.
486
651
 
487
652
  verbose: kb_outline is already lean, so compact and full modes return the
@@ -489,19 +654,19 @@ def build_app(
489
654
  resp = kb_outline_fn(idx=state.idx)
490
655
  return shape_response(resp, verbose=verbose)
491
656
 
492
- @app.tool()
657
+ @app.tool(title="KB Search", annotations=READ_ONLY_TOOL)
493
658
  def kb_search(
494
- query: str,
495
- limit: int = 20,
496
- tier: str | None = None,
497
- category: str | None = None,
498
- status: str | None = None,
499
- in_force: bool = False,
500
- doc_type: str | None = None,
501
- abstain: bool = False,
502
- include_expired: bool = False,
503
- validity_state: str | None = None,
504
- verbose: bool = False,
659
+ query: QueryParam,
660
+ limit: LimitParam = 20,
661
+ tier: TierParam = None,
662
+ category: CategoryParam = None,
663
+ status: StatusParam = None,
664
+ in_force: InForceParam = False,
665
+ doc_type: DocTypeParam = None,
666
+ abstain: AbstainParam = False,
667
+ include_expired: IncludeExpiredParam = False,
668
+ validity_state: ValidityStateParam = None,
669
+ verbose: VerboseParam = False,
505
670
  ) -> dict[str, object]:
506
671
  """Full-text search across the KB.
507
672
 
@@ -555,8 +720,8 @@ def build_app(
555
720
  )
556
721
  return shape_response(resp, verbose=verbose)
557
722
 
558
- @app.tool()
559
- def kb_get(id: str, verbose: bool = False) -> dict[str, object]:
723
+ @app.tool(title="KB Get Document", annotations=READ_ONLY_TOOL)
724
+ def kb_get(id: DocumentIdParam, verbose: VerboseParam = False) -> dict[str, object]:
560
725
  """Retrieve a document by id (STD-U-001, ADR-002, T-NNN, etc.).
561
726
  Returns the full content markdown plus metadata.
562
727
 
@@ -582,8 +747,11 @@ def build_app(
582
747
  return {"error": "not_found", "message": str(e)}
583
748
  return shape_response(resp, verbose=verbose)
584
749
 
585
- @app.tool()
586
- def kb_list(tier: str, category: str | None = None, verbose: bool = False) -> dict[str, object]:
750
+ @app.tool(title="KB List Documents", annotations=READ_ONLY_TOOL)
751
+ def kb_list(
752
+ tier: RequiredTierParam, category: CategoryParam = None,
753
+ verbose: VerboseParam = False,
754
+ ) -> dict[str, object]:
587
755
  """List doc ids in the given tier (and optional category), ordered by id.
588
756
 
589
757
  verbose: False (default) drops per-entry `path` (fetch via kb_get(id)) and
@@ -592,11 +760,11 @@ def build_app(
592
760
  resp = kb_list_fn(idx=state.idx, tier=tier, category=category)
593
761
  return shape_response(resp, verbose=verbose)
594
762
 
595
- @app.tool()
763
+ @app.tool(title="KB Onboarding Status", annotations=READ_ONLY_TOOL)
596
764
  def kb_onboarding_status(
597
- workspace: str, component: str | None = None,
598
- workspace_remote_url: str | None = None,
599
- component_remote_url: str | None = None,
765
+ workspace: WorkspaceParam, component: ComponentParam = None,
766
+ workspace_remote_url: RemoteUrlParam = None,
767
+ component_remote_url: RemoteUrlParam = None,
600
768
  ) -> dict[str, object]:
601
769
  """Compute onboarding status for a workspace + optional component.
602
770
  State is one of: absent, partial, onboarded, rename_candidate."""
@@ -632,10 +800,10 @@ def build_app(
632
800
  "error": "too many requests; retry later"}
633
801
  return None
634
802
 
635
- @app.tool()
803
+ @app.tool(title="KB Cleanup Plan", annotations=READ_ONLY_TOOL)
636
804
  def kb_cleanup_plan(
637
- workspace: str, local_files: list[dict[str, str]],
638
- component: str | None = None, jaccard_threshold: float = 0.6,
805
+ workspace: WorkspaceParam, local_files: LocalFilesParam,
806
+ component: ComponentParam = None, jaccard_threshold: JaccardParam = 0.6,
639
807
  ) -> dict[str, object]:
640
808
  """Read-only. Classify local project-repo docs against KB content for this
641
809
  workspace/component and return thin-pointer replacements for duplicates.
@@ -657,11 +825,11 @@ def build_app(
657
825
  if not read_only:
658
826
  # Write + enforcement-write surface. A read-only replica
659
827
  # (issue #44) exposes none of these tools.
660
- @app.tool()
828
+ @app.tool(title="KB Propose Memory", annotations=PROPOSAL_WRITE_TOOL)
661
829
  def kb_propose_memory(
662
- text: str, tags: list[str], source_session: str,
663
- agent_identity: str, confidence: float,
664
- evidence: list[str] | None = None,
830
+ text: TextParam, tags: TagsParam, source_session: SourceSessionParam,
831
+ agent_identity: AgentIdentityParam, confidence: ConfidenceParam,
832
+ evidence: EvidenceParam = None,
665
833
  ) -> dict[str, object]:
666
834
  """Propose a new memory file. High confidence auto-commits and
667
835
  enqueues for push; low confidence enters the pending queue for operator
@@ -693,12 +861,13 @@ def build_app(
693
861
  )
694
862
  return resp.model_dump()
695
863
 
696
- @app.tool()
864
+ @app.tool(title="KB Propose Edit", annotations=PROPOSAL_EDIT_TOOL)
697
865
  def kb_propose_edit(
698
- target_path: str, postimage: str, base_commit: str,
699
- base_blob_sha: str | None, target_file_hash: str | None,
700
- reason: str, source_session: str, agent_identity: str, confidence: float,
701
- evidence: list[str] | None = None,
866
+ target_path: TargetPathParam, postimage: PostimageParam,
867
+ base_commit: BaseCommitParam, base_blob_sha: BaseBlobShaParam,
868
+ target_file_hash: TargetFileHashParam, reason: ReasonParam,
869
+ source_session: SourceSessionParam, agent_identity: AgentIdentityParam,
870
+ confidence: ConfidenceParam, evidence: EvidenceParam = None,
702
871
  ) -> dict[str, object]:
703
872
  """Propose an edit to an existing (or new) markdown file under an
704
873
  indexed tier. High confidence auto-commits + queues for push; low
@@ -733,11 +902,13 @@ def build_app(
733
902
  )
734
903
  return resp.model_dump()
735
904
 
736
- @app.tool()
905
+ @app.tool(title="KB Resolve Pending", annotations=DESTRUCTIVE_WRITE_TOOL)
737
906
  def kb_resolve_pending(
738
- pending_id: str, decision: str, edited_text: str | None = None,
739
- source_session: str = "operator-resolve", agent_identity: str = "operator",
740
- override_secret_scan: bool = False,
907
+ pending_id: PendingIdParam, decision: DecisionParam,
908
+ edited_text: EditedTextParam = None,
909
+ source_session: SourceSessionParam = "operator-resolve",
910
+ agent_identity: AgentIdentityParam = "operator",
911
+ override_secret_scan: OverrideSecretScanParam = False,
741
912
  ) -> dict[str, object]:
742
913
  """Resolve a pending proposal: approve (optionally with edited text) or
743
914
  reject. Approval commits + enqueues for push.
@@ -769,7 +940,7 @@ def build_app(
769
940
  )
770
941
  return resp.model_dump()
771
942
 
772
- @app.tool()
943
+ @app.tool(title="KB List Pending", annotations=READ_ONLY_TOOL)
773
944
  def kb_list_pending() -> dict[str, object]:
774
945
  """List currently pending proposals awaiting operator decision."""
775
946
  assert state.pending is not None
@@ -777,10 +948,10 @@ def build_app(
777
948
  resp = kb_list_pending_fn(pending=state.pending)
778
949
  return resp.model_dump()
779
950
 
780
- @app.tool()
951
+ @app.tool(title="KB Audit", annotations=READ_ONLY_TOOL)
781
952
  def kb_audit(
782
- since: float | None = None, agent: str | None = None,
783
- status: str | None = None, limit: int = 100,
953
+ since: SinceParam = None, agent: AgentParam = None,
954
+ status: EventStatusParam = None, limit: AuditLimitParam = 100,
784
955
  ) -> dict[str, object]:
785
956
  """Return recent audit events, most-recent first. Optional filters:
786
957
  since (unix ts), agent (agent_identity), status (event status)."""
@@ -790,8 +961,8 @@ def build_app(
790
961
  agent=agent, status=status, limit=limit)
791
962
  return resp.model_dump()
792
963
 
793
- @app.tool()
794
- def kb_session_recap(source_session: str) -> dict[str, object]:
964
+ @app.tool(title="KB Session Recap", annotations=READ_ONLY_TOOL)
965
+ def kb_session_recap(source_session: SourceSessionParam) -> dict[str, object]:
795
966
  """Read-only per-session write summary (issue #112 feedback loop):
796
967
  N committed, M demoted-to-pending, K rejected for source_session.
797
968
  Call this (or `kb pending`) whenever a write response indicated a
@@ -804,13 +975,13 @@ def build_app(
804
975
  resp = kb_session_recap_fn(audit_log=state.audit_log, source_session=source_session)
805
976
  return resp.model_dump()
806
977
 
807
- @app.tool()
978
+ @app.tool(title="KB Bootstrap Project", annotations=PROPOSAL_WRITE_TOOL)
808
979
  def kb_bootstrap_project(
809
- workspace: str, files: list[dict[str, str]],
810
- source_session: str, agent_identity: str, confidence: float,
811
- component: str | None = None,
812
- workspace_remote_url: str | None = None,
813
- component_remote_url: str | None = None,
980
+ workspace: WorkspaceParam, files: BootstrapFilesParam,
981
+ source_session: SourceSessionParam, agent_identity: AgentIdentityParam,
982
+ confidence: ConfidenceParam, component: ComponentParam = None,
983
+ workspace_remote_url: RemoteUrlParam = None,
984
+ component_remote_url: RemoteUrlParam = None,
814
985
  ) -> dict[str, object]:
815
986
  """Bootstrap a new workspace/component. Only valid when status=absent
816
987
  or partial. High confidence commits atomically; low confidence
@@ -842,10 +1013,11 @@ def build_app(
842
1013
  )
843
1014
  return resp.model_dump()
844
1015
 
845
- @app.tool()
1016
+ @app.tool(title="KB Consult", annotations=STATEFUL_NONDESTRUCTIVE_TOOL)
846
1017
  def kb_consult(
847
- workspace: str, intent: str, source_session: str,
848
- agent_identity: str, trigger: str = "explicit",
1018
+ workspace: WorkspaceParam, intent: QueryParam,
1019
+ source_session: SourceSessionParam,
1020
+ agent_identity: AgentIdentityParam, trigger: TriggerParam = "explicit",
849
1021
  ) -> dict[str, object]:
850
1022
  """Record a consultation for (source_session, workspace) and return the
851
1023
  governing rules for the intent. Call before code/architectural work.
@@ -876,10 +1048,11 @@ def build_app(
876
1048
  )
877
1049
  return resp.model_dump(exclude_none=True)
878
1050
 
879
- @app.tool()
1051
+ @app.tool(title="KB Gate Check", annotations=STATEFUL_NONDESTRUCTIVE_TOOL)
880
1052
  def kb_gate_check(
881
- workspace: str, session_id: str, tool_name: str,
882
- action_path: str | None = None, action_diff: str = "",
1053
+ workspace: WorkspaceParam, session_id: SessionIdParam,
1054
+ tool_name: ToolNameParam, action_path: ActionPathParam = None,
1055
+ action_diff: ActionDiffParam = "",
883
1056
  ) -> dict[str, object]:
884
1057
  """Return a verdict (allow | consult_required) for a pending code action.
885
1058
  Governed actions require a fresh consultation on record."""
@@ -897,9 +1070,9 @@ def build_app(
897
1070
  )
898
1071
  return resp.model_dump()
899
1072
 
900
- @app.tool()
1073
+ @app.tool(title="KB Compliance", annotations=READ_ONLY_TOOL)
901
1074
  def kb_compliance(
902
- since: float | None = None, agent: str | None = None,
1075
+ since: SinceParam = None, agent: AgentParam = None,
903
1076
  ) -> dict[str, object]:
904
1077
  """Aggregate enforcement events (consult / gate_*) overall and per agent."""
905
1078
  if state.audit_log is None:
@@ -908,10 +1081,11 @@ def build_app(
908
1081
  resp = kb_compliance_fn(audit_log=state.audit_log, since=since, agent=agent)
909
1082
  return resp.model_dump()
910
1083
 
911
- @app.tool()
1084
+ @app.tool(title="KB Record Event", annotations=STATEFUL_NONDESTRUCTIVE_TOOL)
912
1085
  def kb_record_event(
913
- event_type: str, workspace: str, agent_identity: str,
914
- source_session: str, reason: str = "",
1086
+ event_type: EventTypeParam, workspace: WorkspaceParam,
1087
+ agent_identity: AgentIdentityParam, source_session: SourceSessionParam,
1088
+ reason: ReasonParam = "",
915
1089
  ) -> dict[str, object]:
916
1090
  """Record a gate_bypass or gate_degraded enforcement event in the audit."""
917
1091
  if state.audit_log is None:
File without changes
File without changes
File without changes
File without changes
File without changes