openkos 0.2.4__tar.gz → 0.2.6__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 (71) hide show
  1. {openkos-0.2.4 → openkos-0.2.6}/PKG-INFO +14 -6
  2. {openkos-0.2.4 → openkos-0.2.6}/README.md +12 -4
  3. {openkos-0.2.4 → openkos-0.2.6}/pyproject.toml +2 -4
  4. {openkos-0.2.4 → openkos-0.2.6}/pyproject.toml.orig +10 -4
  5. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/decisions.py +30 -2
  6. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/index.py +74 -4
  7. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/ledger.py +22 -1
  8. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/merge.py +49 -4
  9. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/provenance.py +181 -5
  10. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/curate.py +154 -8
  11. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/main.py +1454 -140
  12. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/next_action.py +108 -25
  13. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/observability.py +37 -0
  14. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/config.py +334 -30
  15. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/extraction/concept.py +1179 -57
  16. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/extraction/judge.py +65 -5
  17. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/lint.py +88 -5
  18. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/base.py +19 -2
  19. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/ollama.py +49 -0
  20. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/okf.py +90 -4
  21. openkos-0.2.6/src/openkos/resolution/reconciliation.py +139 -0
  22. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/similarity.py +101 -19
  23. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/answer.py +37 -1
  24. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/fusion.py +25 -1
  25. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/findings.py +126 -0
  26. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/templates/agents.md.template +15 -2
  27. openkos-0.2.6/src/openkos/templates/openkos.yaml.template +60 -0
  28. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/vcs/git.py +28 -1
  29. openkos-0.2.4/src/openkos/templates/openkos.yaml.template +0 -33
  30. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/__init__.py +0 -0
  31. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/__init__.py +0 -0
  32. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/bundle.py +0 -0
  33. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/links.py +0 -0
  34. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/listing.py +0 -0
  35. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/log.py +0 -0
  36. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/references.py +0 -0
  37. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/relations.py +0 -0
  38. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/source_titles.py +0 -0
  39. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/__init__.py +0 -0
  40. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/extraction/__init__.py +0 -0
  41. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/fsio.py +0 -0
  42. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/__init__.py +0 -0
  43. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/analysis.py +0 -0
  44. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/base.py +0 -0
  45. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/proximity.py +0 -0
  46. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/sqlite_graph.py +0 -0
  47. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/summary.py +0 -0
  48. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/lifecycle.py +0 -0
  49. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/__init__.py +0 -0
  50. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/parsing.py +0 -0
  51. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/__init__.py +0 -0
  52. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/relations.py +0 -0
  53. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/types.py +0 -0
  54. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/py.typed +0 -0
  55. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/__init__.py +0 -0
  56. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/adjudication.py +0 -0
  57. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/candidates.py +0 -0
  58. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/contradiction.py +0 -0
  59. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/edge_typing.py +0 -0
  60. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/normalize.py +0 -0
  61. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/volatility_typing.py +0 -0
  62. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/__init__.py +0 -0
  63. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/pool.py +0 -0
  64. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/sensitivity.py +0 -0
  65. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/source_title.py +0 -0
  66. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/__init__.py +0 -0
  67. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/derived.py +0 -0
  68. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/fts.py +0 -0
  69. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/reindex.py +0 -0
  70. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/vectorstore.py +0 -0
  71. {openkos-0.2.4 → openkos-0.2.6}/src/openkos/vcs/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openkos
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Local-first engine that turns your scattered text into a living, portable knowledge base in the Open Knowledge Format (OKF)
5
5
  Keywords: okf,open-knowledge-format,knowledge-base,knowledge-graph,local-first,rag,llm,second-brain,cli
6
6
  Author: Jason
@@ -21,7 +21,7 @@ Classifier: Typing :: Typed
21
21
  Requires-Dist: networkx>=3.4
22
22
  Requires-Dist: python-frontmatter>=1.3.0
23
23
  Requires-Dist: pyyaml>=6.0.3
24
- Requires-Dist: scipy>=1.14
24
+ Requires-Dist: rich>=13.8.0
25
25
  Requires-Dist: sqlite-vec>=0.1.6
26
26
  Requires-Dist: typer>=0.27.0
27
27
  Requires-Python: >=3.12
@@ -59,15 +59,23 @@ ollama pull qwen3:8b # chat model — extraction and answers (~5 GB)
59
59
  ollama pull bge-m3 # embedding model — semantic search (~1.2 GB)
60
60
  ```
61
61
 
62
- > There is a third, **optional** model for naming relations during curation (`ollama pull gemma2:27b`, **15.6 GB**). Skip it for now — if a command wants it, the error message tells you exactly what to pull.
62
+ > These two are all curation needs out of the box. For naming relations during curation there is an **optional measured upgrade** (`ollama pull gemma2:27b`, **15.6 GB**) that nearly doubles relation-type accuracy — opt in later with `models: {edge_typing: gemma2:27b}` in `openkos.yaml`; `openkos doctor` and `curate` both point at it.
63
63
 
64
64
  **3 · Install the engine** (needs Python 3.12+ and [git](https://git-scm.com)):
65
65
 
66
66
  ```bash
67
67
  uv tool install openkos # or: pipx install openkos — or: pip install openkos
68
+ openkos --version # check what you actually got
68
69
  ```
69
70
 
70
- To track the latest unreleased `main` instead: `uv tool install git+https://github.com/jasonssdev/openkos`.
71
+ **Check that version.** If it does not match the badge at the top of this page, your package index is serving an older release. Install from the repository instead — same engine, current code:
72
+
73
+ ```bash
74
+ uv tool install --force git+https://github.com/jasonssdev/openkos
75
+ openkos --version
76
+ ```
77
+
78
+ That command is also how you track unreleased `main` at any time.
71
79
 
72
80
  **4 · Check the setup before anything can fail:**
73
81
 
@@ -75,7 +83,7 @@ To track the latest unreleased `main` instead: `uv tool install git+https://gith
75
83
  openkos doctor
76
84
  ```
77
85
 
78
- Every line should read `[PASS]` except *Workspace initialized* — you haven't created one yet. `doctor` is also the first thing to run whenever something misbehaves later.
86
+ Outside a workspace, expect one `[FAIL]` — *Workspace initialized*, since you haven't created one yet — and six `[SKIP]` lines for the checks that need a workspace to inspect. Everything else should `[PASS]`. Anything that fails prints the command that fixes it. `doctor` is also the first thing to run whenever something misbehaves later.
79
87
 
80
88
  **5 · Create a knowledge base and run the loop:**
81
89
 
@@ -140,7 +148,7 @@ The wedge, in one line: **the local-first, personal producer-consumer-runtime fo
140
148
  OpenKOS ships in three MVP arcs, each usable on its own. Full detail in [`docs/roadmap.md`](https://github.com/jasonssdev/openkos/blob/main/docs/roadmap.md).
141
149
 
142
150
  - **MVP 1 — The Compiler. (Complete.)** The Karpathy loop, locally, over text: ingest → OKF concepts with provenance → cited query → freshness lint. Useful in an afternoon.
143
- - **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
151
+ - **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), contradiction detection with durable verdicts (findings persist, so a repeat check costs no model calls, and `reconcile` records how you settled each one), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
144
152
  - **MVP 3 — The Runtime and Interoperability.** An MCP server and APIs so agents use OpenKOS as durable memory; full OKF import/export with the wider ecosystem.
145
153
 
146
154
  Beyond that: a desktop app, graph visualization, richer memory, and federation — explored only after the MVPs prove out with real users.
@@ -26,15 +26,23 @@ ollama pull qwen3:8b # chat model — extraction and answers (~5 GB)
26
26
  ollama pull bge-m3 # embedding model — semantic search (~1.2 GB)
27
27
  ```
28
28
 
29
- > There is a third, **optional** model for naming relations during curation (`ollama pull gemma2:27b`, **15.6 GB**). Skip it for now — if a command wants it, the error message tells you exactly what to pull.
29
+ > These two are all curation needs out of the box. For naming relations during curation there is an **optional measured upgrade** (`ollama pull gemma2:27b`, **15.6 GB**) that nearly doubles relation-type accuracy — opt in later with `models: {edge_typing: gemma2:27b}` in `openkos.yaml`; `openkos doctor` and `curate` both point at it.
30
30
 
31
31
  **3 · Install the engine** (needs Python 3.12+ and [git](https://git-scm.com)):
32
32
 
33
33
  ```bash
34
34
  uv tool install openkos # or: pipx install openkos — or: pip install openkos
35
+ openkos --version # check what you actually got
35
36
  ```
36
37
 
37
- To track the latest unreleased `main` instead: `uv tool install git+https://github.com/jasonssdev/openkos`.
38
+ **Check that version.** If it does not match the badge at the top of this page, your package index is serving an older release. Install from the repository instead — same engine, current code:
39
+
40
+ ```bash
41
+ uv tool install --force git+https://github.com/jasonssdev/openkos
42
+ openkos --version
43
+ ```
44
+
45
+ That command is also how you track unreleased `main` at any time.
38
46
 
39
47
  **4 · Check the setup before anything can fail:**
40
48
 
@@ -42,7 +50,7 @@ To track the latest unreleased `main` instead: `uv tool install git+https://gith
42
50
  openkos doctor
43
51
  ```
44
52
 
45
- Every line should read `[PASS]` except *Workspace initialized* — you haven't created one yet. `doctor` is also the first thing to run whenever something misbehaves later.
53
+ Outside a workspace, expect one `[FAIL]` — *Workspace initialized*, since you haven't created one yet — and six `[SKIP]` lines for the checks that need a workspace to inspect. Everything else should `[PASS]`. Anything that fails prints the command that fixes it. `doctor` is also the first thing to run whenever something misbehaves later.
46
54
 
47
55
  **5 · Create a knowledge base and run the loop:**
48
56
 
@@ -107,7 +115,7 @@ The wedge, in one line: **the local-first, personal producer-consumer-runtime fo
107
115
  OpenKOS ships in three MVP arcs, each usable on its own. Full detail in [`docs/roadmap.md`](https://github.com/jasonssdev/openkos/blob/main/docs/roadmap.md).
108
116
 
109
117
  - **MVP 1 — The Compiler. (Complete.)** The Karpathy loop, locally, over text: ingest → OKF concepts with provenance → cited query → freshness lint. Useful in an afternoon.
110
- - **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
118
+ - **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), contradiction detection with durable verdicts (findings persist, so a repeat check costs no model calls, and `reconcile` records how you settled each one), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
111
119
  - **MVP 3 — The Runtime and Interoperability.** An MCP server and APIs so agents use OpenKOS as durable memory; full OKF import/export with the wider ecosystem.
112
120
 
113
121
  Beyond that: a desktop app, graph visualization, richer memory, and federation — explored only after the MVPs prove out with real users.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openkos"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Local-first engine that turns your scattered text into a living, portable knowledge base in the Open Knowledge Format (OKF)"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -34,7 +34,7 @@ dependencies = [
34
34
  "networkx>=3.4",
35
35
  "python-frontmatter>=1.3.0",
36
36
  "pyyaml>=6.0.3",
37
- "scipy>=1.14",
37
+ "rich>=13.8.0",
38
38
  "sqlite-vec>=0.1.6",
39
39
  "typer>=0.27.0",
40
40
  ]
@@ -61,10 +61,8 @@ dev = [
61
61
  "git-filter-repo>=2.47.0",
62
62
  "mypy>=2.3.0",
63
63
  "pre-commit>=4.6.0",
64
- "pydantic>=2.13.4",
65
64
  "pytest>=9.1.1",
66
65
  "pytest-cov>=7.1.0",
67
- "ruamel-yaml>=0.19.1",
68
66
  "ruff>=0.15.21",
69
67
  "types-networkx>=3.6.1.20260624",
70
68
  "types-pyyaml>=6.0.12.20260518",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openkos"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Local-first engine that turns your scattered text into a living, portable knowledge base in the Open Knowledge Format (OKF)"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -40,7 +40,12 @@ dependencies = [
40
40
  "networkx>=3.4",
41
41
  "python-frontmatter>=1.3.0",
42
42
  "pyyaml>=6.0.3",
43
- "scipy>=1.14",
43
+ # `cli/main.py` imports `rich.console.Console` directly. Typer happens to
44
+ # depend on rich too, but an undeclared transitive import is a dependency
45
+ # we do not control: the day Typer drops or vendors it, openkos breaks at
46
+ # import time on an install that resolved cleanly. Declare what we import.
47
+ # The floor matches Typer's own so the resolver has no extra constraint.
48
+ "rich>=13.8.0",
44
49
  "sqlite-vec>=0.1.6",
45
50
  "typer>=0.27.0",
46
51
  ]
@@ -59,14 +64,15 @@ requires = ["uv_build>=0.11.29,<0.12.0"]
59
64
  build-backend = "uv_build"
60
65
 
61
66
  [dependency-groups]
67
+ # `git-filter-repo` is a real test requirement, not just `purge`'s runtime
68
+ # system tool: `test_filter_repo_available_true_when_installed` asserts the
69
+ # PATH probe finds it, so it fails on a machine without the binary.
62
70
  dev = [
63
71
  "git-filter-repo>=2.47.0",
64
72
  "mypy>=2.3.0",
65
73
  "pre-commit>=4.6.0",
66
- "pydantic>=2.13.4",
67
74
  "pytest>=9.1.1",
68
75
  "pytest-cov>=7.1.0",
69
- "ruamel-yaml>=0.19.1",
70
76
  "ruff>=0.15.21",
71
77
  "types-networkx>=3.6.1.20260624",
72
78
  "types-pyyaml>=6.0.12.20260518",
@@ -151,7 +151,16 @@ def read_decisions(concept_id: str, bundle_dir: Path) -> list[DecisionRecord]:
151
151
  sidecar on disk (no decision ever declined/reopened under this concept
152
152
  id) returns `[]` -- mirrors `bundle.ledger.read_entries`'s own
153
153
  "absent file" contract."""
154
- path = decisions_path_for(concept_id, bundle_dir)
154
+ return read_decisions_at(decisions_path_for(concept_id, bundle_dir))
155
+
156
+
157
+ def read_decisions_at(path: Path) -> list[DecisionRecord]:
158
+ """Read every `DecisionRecord` from the sidecar AT `path` -- the walked-
159
+ path reader the privacy sweep and the declined-listing views use, so a
160
+ record is read from the file actually on disk, never from a path rebuilt
161
+ from a (possibly drifted or hostile) `concept_id` frontmatter field.
162
+ `read_decisions` is the id-addressed wrapper for callers that legitimately
163
+ own the id. Absent file returns `[]`."""
155
164
  if not path.is_file():
156
165
  return []
157
166
  metadata, _ = okf.load_frontmatter(path.read_text(encoding="utf-8"))
@@ -172,7 +181,26 @@ def write_decisions(
172
181
 
173
182
  Written via `fsio.write_atomic`, over `okf.dump_frontmatter`'s output
174
183
  with an empty body (ADR-0002 invariant 3, preserved literally)."""
175
- path = decisions_path_for(concept_id, bundle_dir)
184
+ return rewrite_decisions_at(
185
+ decisions_path_for(concept_id, bundle_dir),
186
+ concept_id=concept_id,
187
+ records=records,
188
+ )
189
+
190
+
191
+ def rewrite_decisions_at(
192
+ path: Path, *, concept_id: str, records: list[DecisionRecord]
193
+ ) -> Path:
194
+ """(Re)write the sidecar AT `path` to hold EXACTLY `records`, using
195
+ `concept_id` only as container CONTENT -- never to derive the path.
196
+
197
+ The walked-path twin of `bundle.ledger.rewrite_entries_at`: the privacy
198
+ sweep must write each rewrite back to the path it WALKED, not to a path
199
+ rebuilt from the (possibly drifted or hostile) `concept_id` frontmatter
200
+ field, which would let a traversal id escape the bundle and silently
201
+ scrub the wrong file. `write_decisions` is the id-addressed wrapper. An
202
+ empty `records` list removes the file; removing an absent file is a
203
+ no-op."""
176
204
  if not records:
177
205
  if path.is_file():
178
206
  path.unlink()
@@ -52,6 +52,70 @@ def _reject_newline(field: str, value: str) -> None:
52
52
  raise ValueError(f"index.md: {field!r} must not contain a newline")
53
53
 
54
54
 
55
+ _TITLE_LABEL_DELIMITERS_RE = re.compile(r"[\[\]]")
56
+ _SLUG_TARGET_DELIMITERS_RE = re.compile(r"[()]")
57
+
58
+
59
+ def reject_title_link_delimiters(field: str, value: str) -> None:
60
+ r"""Raise `ValueError` if `value` contains `[` or `]` (RISK-1, insert path).
61
+
62
+ A `title` is interpolated verbatim into the LABEL span of the bullet
63
+ (`* [{title}](...)`). A `[`/`]` there forges a second link or closes the
64
+ label early, so `_LINK_RE`/`_LABELLED_LINK_RE` then match the FORGED link
65
+ instead of the real one -- the entry becomes invisible to
66
+ `remove_index_entry`/`relabel_index_entry`, and a forged id can delete a
67
+ DIFFERENT entry. This is the untrusted, LLM-derived counterpart of
68
+ `_reject_markdown_link_delimiters` (which guards the operator-supplied
69
+ relabel path): the STRICT insert-time backstop that fails closed for any
70
+ caller. Callers that want to PRESERVE a title carrying brackets (a benign
71
+ filename like `notes[1].txt`, as well as a hostile one) sanitize it first
72
+ with `sanitize_link_label`, which neutralizes the delimiters instead of
73
+ rejecting the whole value.
74
+
75
+ Parentheses and backticks are deliberately NOT rejected here: inside the
76
+ label they sit within `[^\]]*` and never reach the target, so a common
77
+ title like ``map() function`` or ``` `useState` `` stays valid --
78
+ rejecting them would be a false positive with no security benefit.
79
+ """
80
+ if _TITLE_LABEL_DELIMITERS_RE.search(value):
81
+ raise ValueError(
82
+ f"index.md: {field!r} must not contain a markdown link label "
83
+ "delimiter ('[' or ']')"
84
+ )
85
+
86
+
87
+ def _reject_slug_link_delimiters(field: str, value: str) -> None:
88
+ """Raise `ValueError` if `value` contains `(` or `)` (RISK-1, insert path).
89
+
90
+ A `slug` is interpolated into the link TARGET span
91
+ (`](/{link_dir}/{slug}.md)`). A `(`/`)` there closes or forges the
92
+ target, changing the bullet's resolved identity. Slugs produced by
93
+ `_slugify` can never contain these, so this is defense-in-depth for the
94
+ generic public inserter.
95
+ """
96
+ if _SLUG_TARGET_DELIMITERS_RE.search(value):
97
+ raise ValueError(
98
+ f"index.md: {field!r} must not contain a markdown link target "
99
+ "delimiter ('(' or ')')"
100
+ )
101
+
102
+
103
+ def sanitize_link_label(title: str) -> str:
104
+ r"""Return `title` with markdown link LABEL delimiters (`[`, `]`) replaced
105
+ by their round-paren equivalents, so the value is safe to interpolate
106
+ into a bullet's `[label](...)` span in `index.md`/`log.md`.
107
+
108
+ Brackets close or forge the label for the regex-based `_LINK_RE`/
109
+ `_LABELLED_LINK_RE` matchers -- maliciously, via an injected LLM-extracted
110
+ title, or benignly, via a filename like `notes[1].txt`. Parentheses sit
111
+ inside `[^\]]*` and are inert there, so the substitution keeps the entry
112
+ matchable/removable by its id while preserving the visual grouping. This
113
+ is DISPLAY text only: identity is the slug, which `_slugify` derives
114
+ independently, so the substitution never changes which object exists.
115
+ """
116
+ return title.replace("[", "(").replace("]", ")")
117
+
118
+
55
119
  # `_CANONICAL_SECTION_ORDER` is now derived from
56
120
  # `openkos.model.types.REGISTRY` -- see that module for the single source of
57
121
  # truth.
@@ -83,10 +147,14 @@ def insert_index_entry(
83
147
  eight currently exist -- preserving the historical Sources-last behavior
84
148
  byte-identically.
85
149
  `title`/`slug`/`description` are each rejected (`ValueError`) if they
86
- contain a newline (RISK-1) -- see `_reject_newline`. This guard applies
87
- to every section, including untrusted, LLM-derived object fields.
88
- `section` MUST be one of the canonical sections, else `ValueError` --
89
- there is no defined rank for an unknown section.
150
+ contain a newline (RISK-1) -- see `_reject_newline`. `title` is
151
+ additionally rejected if it carries a markdown link LABEL delimiter
152
+ (`[`/`]`) and `slug` if it carries a link TARGET delimiter (`(`/`)`) --
153
+ see `reject_title_link_delimiters`/`_reject_slug_link_delimiters` -- so an
154
+ untrusted, LLM-derived title can never forge or break the bullet's first
155
+ link. This guard applies to every section, including untrusted object
156
+ fields. `section` MUST be one of the canonical sections, else
157
+ `ValueError` -- there is no defined rank for an unknown section.
90
158
  """
91
159
  if section not in _CANONICAL_SECTION_ORDER:
92
160
  raise ValueError(
@@ -95,6 +163,8 @@ def insert_index_entry(
95
163
  _reject_newline("title", title)
96
164
  _reject_newline("slug", slug)
97
165
  _reject_newline("description", description)
166
+ reject_title_link_delimiters("title", title)
167
+ _reject_slug_link_delimiters("slug", slug)
98
168
  frontmatter_block, body = _split_frontmatter_verbatim(index_text)
99
169
  chunks = _SECTION_SPLIT_RE.split(body)
100
170
  preamble, section_chunks = chunks[0], chunks[1:]
@@ -212,7 +212,28 @@ def write_entries(
212
212
  `entries` list removes the sidecar file entirely (a survivor with no
213
213
  remaining merges keeps no empty ledger on disk); removing an
214
214
  already-absent sidecar is a no-op, not an error."""
215
- path = ledger_path_for(concept_id, bundle_dir)
215
+ return rewrite_entries_at(
216
+ ledger_path_for(concept_id, bundle_dir),
217
+ survivor_id=survivor_id,
218
+ entries=entries,
219
+ )
220
+
221
+
222
+ def rewrite_entries_at(
223
+ path: Path, *, survivor_id: str, entries: list[okf.MergeLedgerEntry]
224
+ ) -> Path:
225
+ """(Re)write the sidecar AT `path` to hold EXACTLY `entries`, using
226
+ `survivor_id` only as container CONTENT -- never to derive the path.
227
+
228
+ The privacy sweep walks real sidecar paths and MUST write each rewrite
229
+ back to the path it WALKED, not to a path rebuilt from the (possibly
230
+ drifted or hostile) `survivor_id` frontmatter field. Rebuilding it both
231
+ lets a traversal id escape the bundle (arbitrary-file create/delete with
232
+ the `.ledger.okf` suffix) AND silently writes a legit NFC/NFD-drifted
233
+ sidecar to the wrong place while reporting the walked one as cleaned --
234
+ for a privacy sweep, a silent scrub miss. `write_entries` is the
235
+ id-addressed wrapper for callers that legitimately own the id. An empty
236
+ `entries` list removes the file; removing an absent file is a no-op."""
216
237
  if not entries:
217
238
  if path.is_file():
218
239
  path.unlink()
@@ -72,6 +72,21 @@ def _reject_same_or_blank(survivor_id: str, absorbed_id: str) -> None:
72
72
  )
73
73
 
74
74
 
75
+ def _absorbed_snapshot_has_content(absorbed_snapshot: str) -> bool:
76
+ """`True` unless `absorbed_snapshot`'s BODY is provably empty or
77
+ whitespace-only (#685): only a body with actual content can have been
78
+ woven undelimited into a reconciled survivor, so only it earns the
79
+ carried-content annotation. Fails CLOSED to `True` (annotate) on a
80
+ snapshot this planner cannot parse -- under-redacting a body that might
81
+ exist is the failure mode the annotation exists to prevent (#602's
82
+ privacy-over-reversibility rule)."""
83
+ try:
84
+ _, body = okf.load_frontmatter(absorbed_snapshot)
85
+ except Exception: # broad: any parse failure means "cannot prove empty"
86
+ return True
87
+ return body.strip() != ""
88
+
89
+
75
90
  def _reject_already_merged(
76
91
  absorbed_id: str, existing_entries: list[okf.MergeLedgerEntry]
77
92
  ) -> None:
@@ -115,9 +130,10 @@ def plan_merge(
115
130
  rewrite-provenance-on-merge) likewise defaults to `[]`; the actual
116
131
  third-party inbound scan (`bundle/provenance.py`) and CLI wiring are
117
132
  PR2's concern -- this layer only carries whatever the caller injects
118
- into the new ledger entry, and ALWAYS writes `MERGE_LEDGER_SCHEMA_V3`
119
- (the reader still accepts v1 and v2 entries already on disk from before
120
- this merge).
133
+ into the new ledger entry, and ALWAYS writes `MERGE_LEDGER_SCHEMA_V4`
134
+ (#667: v4 adds `carried_content_ids`, computed HERE from
135
+ `existing_entries` vs `survivor_text`; the reader still accepts v1-v3
136
+ entries already on disk from before this merge).
121
137
 
122
138
  `existing_entries` (durable-derived-state slice 1a) is the survivor's
123
139
  CURRENT sidecar content, read by the caller via `bundle.ledger.
@@ -148,9 +164,25 @@ def plan_merge(
148
164
  survivor_id,
149
165
  )
150
166
 
167
+ # #667: a prior absorbed id whose delimited section is ABSENT from the
168
+ # survivor text being snapshotted has been woven in undelimited (a #645
169
+ # reconciliation, or an operator edit -- either way the #602 structural
170
+ # excision cannot reach it inside THIS entry's `survivor_before`).
171
+ # Recorded at snapshot time, when the fact is still decidable.
172
+ # #685: a prior whose absorbed BODY was empty/whitespace-only
173
+ # contributed no content, so its missing delimiter must not annotate --
174
+ # the annotation would buy no privacy and cost a wholesale
175
+ # `survivor_before` redaction on a later forget.
176
+ carried_content_ids = [
177
+ prior.absorbed_id
178
+ for prior in existing
179
+ if okf.merged_content_heading(prior.absorbed_id) not in survivor_text
180
+ and _absorbed_snapshot_has_content(prior.absorbed_snapshot)
181
+ ]
182
+
151
183
  sensitivity_before = survivor_metadata.get("sensitivity")
152
184
  entry = okf.MergeLedgerEntry(
153
- schema=okf.MERGE_LEDGER_SCHEMA_V3,
185
+ schema=okf.MERGE_LEDGER_SCHEMA_V4,
154
186
  merged_at=merged_at,
155
187
  absorbed_id=absorbed_id,
156
188
  absorbed_snapshot=absorbed_text,
@@ -168,6 +200,7 @@ def plan_merge(
168
200
  provenance_rewrites=list(provenance_rewrites)
169
201
  if provenance_rewrites is not None
170
202
  else [],
203
+ carried_content_ids=carried_content_ids,
171
204
  )
172
205
 
173
206
  merged_survivor = okf.dump_frontmatter(merged_metadata, merged_body)
@@ -242,6 +275,18 @@ def plan_unmerge(
242
275
  f"one command, or unmerge each pair in that order"
243
276
  )
244
277
 
278
+ # #667: forget's sweep replaces a carried-content snapshot with the
279
+ # redaction sentinel (privacy over reversibility). Restoring it would
280
+ # overwrite the LIVE survivor body with the notice string -- refuse
281
+ # loudly instead; this merge is no longer reversible by design.
282
+ if tail.survivor_before == okf.REDACTED_SNAPSHOT_SENTINEL:
283
+ raise ValueError(
284
+ f"{survivor_id!r}'s ledger entry for {absorbed_id!r} was redacted "
285
+ "by `openkos forget` (it carried a forgotten concept's "
286
+ "reconciled content); this merge is no longer reversible -- "
287
+ "unmerge refused"
288
+ )
289
+
245
290
  return UnmergePlan(
246
291
  restored_survivor=tail.survivor_before,
247
292
  restored_absorbed=tail.absorbed_snapshot,
@@ -370,6 +370,170 @@ def _source_levels(files: Mapping[str, str]) -> dict[str, str]:
370
370
  return source_levels
371
371
 
372
372
 
373
+ def _levels_by_id(files: Mapping[str, str]) -> dict[str, object]:
374
+ """Parse every file once into `id -> raw sensitivity value`, keeping the
375
+ value RAW (`object`, not `str`).
376
+
377
+ Ranking is unaffected by the choice: `okf.combine_sensitivity` ranks
378
+ through the private fail-closed `okf._rank` (ADR-0003), where a missing
379
+ value floors at `private` and a dirty non-string floors at `confidential`
380
+ -- and an unrecognized STRING floors at `confidential` too, so `42` and
381
+ `"42"` rank identically. This docstring used to claim the opposite (that
382
+ coercing to `str`, as `_source_levels` must to satisfy
383
+ `resolve_source_raises`'s `level: str` signature, "would turn a dirty
384
+ `int` into a string that no longer fails closed the same way"); measured
385
+ against every value YAML frontmatter can produce -- `int`, `bool`, `list`,
386
+ `dict`, `float`, `date`, blank and unrecognized strings -- that claim is
387
+ FALSE, and `_source_levels`'s own docstring already said so correctly
388
+ (issue #736). Nothing in this module's behavior rested on it.
389
+
390
+ WHAT RAW IS ACTUALLY LOAD-BEARING FOR is reporting, not ranking:
391
+ `resolve_cited_high_water_raises` hands this value straight to
392
+ `okf.DescendantRaise.current`, which a preview shows the operator as what
393
+ the file HOLDS. Coercing would print `'42'` where the frontmatter holds
394
+ `42` -- a preview claiming a string the operator never wrote. Of everything
395
+ stated here, that reporting property is the ONLY one a `str` coercion
396
+ breaks, and it is what
397
+ `test_a_present_but_dirty_sensitivity_fails_closed_and_propagates` pins.
398
+
399
+ A file whose frontmatter fails to parse is SKIPPED, mirroring
400
+ `_parse_provenance_by_id`.
401
+ """
402
+ levels: dict[str, object] = {}
403
+ for path, text in files.items():
404
+ metadata: dict[str, object] | None
405
+ try:
406
+ metadata, _ = okf.load_frontmatter(text)
407
+ except Exception: # broad: malformed frontmatter is skipped rather
408
+ # than surfaced, mirroring `_parse_provenance_by_id`
409
+ metadata = None
410
+ if metadata is None:
411
+ continue
412
+ levels[_normalize_id(path)] = metadata.get("sensitivity")
413
+ return levels
414
+
415
+
416
+ def resolve_cited_high_water_raises(
417
+ files: Mapping[str, str],
418
+ ) -> list[okf.DescendantRaise]:
419
+ """Pure fixpoint maintaining ADR-0003's high-water mark for a concept
420
+ whose provenance spans MORE THAN ONE Source (issue #697).
421
+
422
+ ADR-0012 deferred exactly this case, and named the deferral: a concept in
423
+ no single Source's closure "stays reported, not resolved, until a human
424
+ acts". `resolve_source_raises` cannot reach it because
425
+ `provenance_closure`'s conservative NON-EMPTY SUBSET rule keeps out any
426
+ concept still citing a Source outside the root's closure -- correct for
427
+ propagating ONE Source's level down its exclusive descendants, and exactly
428
+ wrong for a high-water mark, where citing a confidential source and a
429
+ public one means confidential. The subset rule under-classifies here.
430
+
431
+ THE RULE MIRRORS BIRTH, not the closure walk. `cli/main.py`'s
432
+ `_stage_filed_answer` folds `okf.combine_sensitivity` over each CITED
433
+ concept's own level to compute a filing's sensitivity; this recomputes the
434
+ same fold over the same direct `provenance` entries, so an object's
435
+ maintained level is the one its own birth rule would produce today. Reading
436
+ Source ANCESTORS instead (`provenance_source_ancestors`, #628) would miss a
437
+ cited intermediate concept raised by hand without its Source.
438
+
439
+ WHY A FIXPOINT rather than one pass: raising `mid` changes the input for
440
+ everything citing `mid`. A single pass would repair one level and leave the
441
+ same gap one level up. Termination is the same argument
442
+ `provenance_closure` makes -- `combine_sensitivity` is monotone and
443
+ `okf.SENSITIVITY_ORDER` is finite, so levels only ever rise and the loop
444
+ halts, including on a provenance cycle (two concepts citing each other
445
+ converge on their shared maximum) and on self-referential provenance.
446
+
447
+ THE RE-SCAN IS NOT WHERE THE TIME GOES, so do not "fix" it first. The walk
448
+ order is arbitrary, and on a linear chain inserted in reverse the loop
449
+ needs one pass per link -- genuinely quadratic. Measured once, on synthetic
450
+ bundles, 2026-08-16 (issue #736); the ratios are what to trust, not the
451
+ millisecond figures, which are one machine on one day. A real bundle shape,
452
+ where every insight cites Sources DIRECTLY as `query --save` files them,
453
+ converged in TWO passes and spent 2.3% of the call in this loop at 1k docs
454
+ and at 10k alike; the other ~98% was the three whole-bundle frontmatter
455
+ parses above. Even the adversarial 400-deep reverse chain -- a shape
456
+ nothing in this product builds -- cost 36ms.
457
+ Reordering the walk topologically was therefore REJECTED: it optimizes 2%
458
+ of a maintenance verb an operator runs explicitly. If this call ever does
459
+ need to be faster, collapse the three parses into one.
460
+
461
+ A concept is staged only when EVERY cited id resolves to a file in `files`.
462
+ A dangling citation leaves it unstaged, matching `lint`'s own
463
+ `multi-source-uncovered` rule and ADR-0012 design D8: that signal belongs
464
+ to `check_dangling_provenance`. The alternative -- folding an unresolvable
465
+ citation to `confidential`, as `_stage_filed_answer` does at birth -- is
466
+ deliberately NOT copied here. At birth it guards ONE document the operator
467
+ is creating; in a bundle-wide sweep it would raise every descendant of one
468
+ dangling ref, a blast radius no preview makes safe, and it would contradict
469
+ the `lint` finding that sent the operator to this verb.
470
+
471
+ A `type: Source` is NEVER staged: a Source's level is operator-set truth,
472
+ never derived from what it cites. In practice a Source cites its raw
473
+ `resource`, which never resolves to a bundle id and would leave it unstaged
474
+ anyway; the explicit type guard states the invariant rather than resting on
475
+ that coincidence.
476
+
477
+ Raise-only by construction (`combine_sensitivity` never lowers), so a
478
+ concept deliberately classified ABOVE everything it cites keeps its level.
479
+ The returned list is `sorted()` by `concept_id`, each entry's `content` a
480
+ full `okf.dump_frontmatter` re-render of the ORIGINAL metadata with only
481
+ `sensitivity` replaced.
482
+ """
483
+ provenance_by_id = _parse_provenance_by_id(files)
484
+ levels = _levels_by_id(files)
485
+ source_ids = set(_source_levels(files))
486
+
487
+ # Only concepts whose every citation resolves are eligible; the ineligible
488
+ # are excluded once, before the loop, so the fixpoint cannot admit one
489
+ # through a later iteration.
490
+ eligible = {
491
+ concept_id: parents
492
+ for concept_id, parents in provenance_by_id.items()
493
+ if parents
494
+ and concept_id not in source_ids
495
+ and all(parent in levels for parent in parents)
496
+ }
497
+
498
+ resolved = dict(levels)
499
+ changed = True
500
+ while changed:
501
+ changed = False
502
+ for concept_id, parents in eligible.items():
503
+ current = resolved[concept_id]
504
+ combined = current
505
+ for parent in parents:
506
+ combined = okf.combine_sensitivity(combined, resolved[parent])
507
+ if combined != current:
508
+ resolved[concept_id] = combined
509
+ changed = True
510
+
511
+ raises: list[okf.DescendantRaise] = []
512
+ for concept_id in sorted(eligible):
513
+ current = levels[concept_id]
514
+ # Re-combined rather than read straight out of `resolved`, for a typed
515
+ # `str`: the fold seeds from RAW frontmatter values (`object`), which
516
+ # is what keeps `okf._rank`'s fail-closed ranking intact. The fold only
517
+ # ever raises, so this is the canonical form of `resolved[concept_id]`.
518
+ # The comparison stays against the RAW `current`, matching
519
+ # `resolve_source_raises`: a document with no `sensitivity` key at all
520
+ # differs from any canonical level and is written explicitly.
521
+ new_level = okf.combine_sensitivity(current, resolved[concept_id])
522
+ if new_level == current:
523
+ continue
524
+ metadata, body = okf.load_frontmatter(files[f"{concept_id}.md"])
525
+ metadata["sensitivity"] = new_level
526
+ raises.append(
527
+ okf.DescendantRaise(
528
+ concept_id=concept_id,
529
+ current=current,
530
+ new_level=new_level,
531
+ content=okf.dump_frontmatter(metadata, body),
532
+ )
533
+ )
534
+ return raises
535
+
536
+
373
537
  def resolve_backfill_raises(files: Mapping[str, str]) -> list[okf.DescendantRaise]:
374
538
  """Pure, bundle-wide sweep core `backfill-sensitivity` needs (design
375
539
  D4/D5/D6) -- everything the verb requires that is NOT the Typer command:
@@ -418,15 +582,27 @@ def resolve_backfill_raises(files: Mapping[str, str]) -> list[okf.DescendantRais
418
582
 
419
583
  best: dict[str, okf.DescendantRaise] = {}
420
584
  best_rank: dict[str, int] = {}
585
+
586
+ def _offer(descendant_raise: okf.DescendantRaise) -> None:
587
+ concept_id = descendant_raise.concept_id
588
+ new_rank = okf.SENSITIVITY_ORDER.index(descendant_raise.new_level)
589
+ if concept_id not in best or new_rank > best_rank[concept_id]:
590
+ best[concept_id] = descendant_raise
591
+ best_rank[concept_id] = new_rank
592
+
421
593
  for source_id in sorted(source_levels):
422
594
  for descendant_raise in resolve_source_raises(
423
595
  files, source_id=source_id, level=source_levels[source_id]
424
596
  ):
425
- concept_id = descendant_raise.concept_id
426
- new_rank = okf.SENSITIVITY_ORDER.index(descendant_raise.new_level)
427
- if concept_id not in best or new_rank > best_rank[concept_id]:
428
- best[concept_id] = descendant_raise
429
- best_rank[concept_id] = new_rank
597
+ _offer(descendant_raise)
598
+
599
+ # Second producer (issue #697), merged by the SAME max rule and offered
600
+ # LAST so a strictly higher cited high-water mark wins over a per-Source
601
+ # raise, while an equal one leaves the closure sweep's record in place --
602
+ # the existing "ties resolve to the first" contract, extended by one
603
+ # producer rather than reinterpreted.
604
+ for descendant_raise in resolve_cited_high_water_raises(files):
605
+ _offer(descendant_raise)
430
606
 
431
607
  return [best[concept_id] for concept_id in sorted(best)]
432
608