nodum 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. nodum-0.1.0/.github/workflows/ci.yml +68 -0
  2. nodum-0.1.0/.github/workflows/release.yml +69 -0
  3. nodum-0.1.0/.gitignore +24 -0
  4. nodum-0.1.0/AGENTS.md +499 -0
  5. nodum-0.1.0/Makefile +61 -0
  6. nodum-0.1.0/PKG-INFO +362 -0
  7. nodum-0.1.0/README.md +332 -0
  8. nodum-0.1.0/docs/architecture.md +603 -0
  9. nodum-0.1.0/nodum/__init__.py +14 -0
  10. nodum-0.1.0/nodum/_web_placeholder.html +97 -0
  11. nodum-0.1.0/nodum/assets.py +689 -0
  12. nodum-0.1.0/nodum/cli.py +877 -0
  13. nodum-0.1.0/nodum/cli_schema.py +83 -0
  14. nodum-0.1.0/nodum/db.py +184 -0
  15. nodum-0.1.0/nodum/embeddings.py +247 -0
  16. nodum-0.1.0/nodum/envelope.py +68 -0
  17. nodum-0.1.0/nodum/http_api.py +1482 -0
  18. nodum-0.1.0/nodum/mcp_server.py +357 -0
  19. nodum-0.1.0/nodum/migrations.py +307 -0
  20. nodum-0.1.0/nodum/models.py +372 -0
  21. nodum-0.1.0/nodum/projectors.py +367 -0
  22. nodum-0.1.0/nodum/search.py +387 -0
  23. nodum-0.1.0/nodum/service.py +2500 -0
  24. nodum-0.1.0/pyproject.toml +82 -0
  25. nodum-0.1.0/scripts/smoke-install.sh +65 -0
  26. nodum-0.1.0/tests/conftest.py +66 -0
  27. nodum-0.1.0/tests/test_assets.py +522 -0
  28. nodum-0.1.0/tests/test_cli.py +529 -0
  29. nodum-0.1.0/tests/test_embeddings.py +254 -0
  30. nodum-0.1.0/tests/test_events.py +182 -0
  31. nodum-0.1.0/tests/test_graph_reads.py +237 -0
  32. nodum-0.1.0/tests/test_http_api.py +1688 -0
  33. nodum-0.1.0/tests/test_hybrid_search.py +95 -0
  34. nodum-0.1.0/tests/test_mcp_server.py +421 -0
  35. nodum-0.1.0/tests/test_migrations.py +254 -0
  36. nodum-0.1.0/tests/test_policies.py +368 -0
  37. nodum-0.1.0/tests/test_projectors.py +127 -0
  38. nodum-0.1.0/tests/test_proposed_updates.py +310 -0
  39. nodum-0.1.0/tests/test_review.py +347 -0
  40. nodum-0.1.0/tests/test_search.py +173 -0
  41. nodum-0.1.0/tests/test_service.py +185 -0
  42. nodum-0.1.0/tests/test_subgraph.py +400 -0
  43. nodum-0.1.0/tests/test_suggest_links.py +138 -0
  44. nodum-0.1.0/tests/test_vec_projector.py +172 -0
  45. nodum-0.1.0/tests/test_wikilinks.py +195 -0
  46. nodum-0.1.0/uv.lock +1504 -0
  47. nodum-0.1.0/web/README.md +229 -0
  48. nodum-0.1.0/web/index.html +42 -0
  49. nodum-0.1.0/web/package-lock.json +3698 -0
  50. nodum-0.1.0/web/package.json +41 -0
  51. nodum-0.1.0/web/src/App.tsx +140 -0
  52. nodum-0.1.0/web/src/api/client.ts +655 -0
  53. nodum-0.1.0/web/src/api/types.ts +493 -0
  54. nodum-0.1.0/web/src/components/EmptyState.tsx +29 -0
  55. nodum-0.1.0/web/src/components/ErrorBoundary.tsx +60 -0
  56. nodum-0.1.0/web/src/components/NodeBadge.tsx +44 -0
  57. nodum-0.1.0/web/src/components/Spinner.tsx +25 -0
  58. nodum-0.1.0/web/src/components/Toast.tsx +156 -0
  59. nodum-0.1.0/web/src/components/index.ts +14 -0
  60. nodum-0.1.0/web/src/lib/failure.test.ts +162 -0
  61. nodum-0.1.0/web/src/lib/failure.ts +131 -0
  62. nodum-0.1.0/web/src/lib/index.ts +24 -0
  63. nodum-0.1.0/web/src/lib/time.test.ts +196 -0
  64. nodum-0.1.0/web/src/lib/time.ts +116 -0
  65. nodum-0.1.0/web/src/main.tsx +14 -0
  66. nodum-0.1.0/web/src/router.tsx +94 -0
  67. nodum-0.1.0/web/src/styles/app.css +259 -0
  68. nodum-0.1.0/web/src/styles/base.css +202 -0
  69. nodum-0.1.0/web/src/styles/index.css +13 -0
  70. nodum-0.1.0/web/src/styles/primitives.css +323 -0
  71. nodum-0.1.0/web/src/styles/tokens.css +119 -0
  72. nodum-0.1.0/web/src/views/assets/AssetGrid.tsx +99 -0
  73. nodum-0.1.0/web/src/views/assets/AssetLightbox.tsx +427 -0
  74. nodum-0.1.0/web/src/views/assets/AssetUploader.tsx +227 -0
  75. nodum-0.1.0/web/src/views/assets/AssetsView.tsx +273 -0
  76. nodum-0.1.0/web/src/views/assets/ExportJsonButton.tsx +131 -0
  77. nodum-0.1.0/web/src/views/assets/assets.css +479 -0
  78. nodum-0.1.0/web/src/views/assets/export.css +50 -0
  79. nodum-0.1.0/web/src/views/assets/formatting.ts +50 -0
  80. nodum-0.1.0/web/src/views/editor/EditorView.tsx +398 -0
  81. nodum-0.1.0/web/src/views/editor/MarkdownEditor.tsx +184 -0
  82. nodum-0.1.0/web/src/views/editor/MarkdownPreview.tsx +118 -0
  83. nodum-0.1.0/web/src/views/editor/NodeMetaBar.tsx +230 -0
  84. nodum-0.1.0/web/src/views/editor/cm/assetDrop.ts +83 -0
  85. nodum-0.1.0/web/src/views/editor/cm/slashCommands.ts +186 -0
  86. nodum-0.1.0/web/src/views/editor/cm/theme.ts +132 -0
  87. nodum-0.1.0/web/src/views/editor/cm/wikilinkComplete.ts +106 -0
  88. nodum-0.1.0/web/src/views/editor/editor.css +421 -0
  89. nodum-0.1.0/web/src/views/editor/leftoverBuffer.test.ts +139 -0
  90. nodum-0.1.0/web/src/views/editor/markdownRender.test.ts +325 -0
  91. nodum-0.1.0/web/src/views/editor/markdownRender.ts +346 -0
  92. nodum-0.1.0/web/src/views/editor/mermaidRender.ts +306 -0
  93. nodum-0.1.0/web/src/views/editor/useNodeDocument.ts +596 -0
  94. nodum-0.1.0/web/src/views/failureRouting.test.ts +114 -0
  95. nodum-0.1.0/web/src/views/graph/ConfidenceFilter.tsx +90 -0
  96. nodum-0.1.0/web/src/views/graph/GraphCanvas.tsx +313 -0
  97. nodum-0.1.0/web/src/views/graph/GraphToolbar.tsx +272 -0
  98. nodum-0.1.0/web/src/views/graph/GraphView.tsx +482 -0
  99. nodum-0.1.0/web/src/views/graph/NodeDetailPanel.tsx +195 -0
  100. nodum-0.1.0/web/src/views/graph/PathPanel.tsx +182 -0
  101. nodum-0.1.0/web/src/views/graph/RootPicker.tsx +138 -0
  102. nodum-0.1.0/web/src/views/graph/TruncationNotice.tsx +120 -0
  103. nodum-0.1.0/web/src/views/graph/TypeFilter.tsx +112 -0
  104. nodum-0.1.0/web/src/views/graph/errors.ts +68 -0
  105. nodum-0.1.0/web/src/views/graph/filters.test.ts +284 -0
  106. nodum-0.1.0/web/src/views/graph/filters.ts +289 -0
  107. nodum-0.1.0/web/src/views/graph/graph.css +630 -0
  108. nodum-0.1.0/web/src/views/graph/graphElements.ts +140 -0
  109. nodum-0.1.0/web/src/views/graph/graphStyle.ts +310 -0
  110. nodum-0.1.0/web/src/views/graph/rootSearch.ts +58 -0
  111. nodum-0.1.0/web/src/views/graph/truncation.test.ts +46 -0
  112. nodum-0.1.0/web/src/views/graph/useGraphData.ts +145 -0
  113. nodum-0.1.0/web/src/views/graph/useTypeCatalog.ts +73 -0
  114. nodum-0.1.0/web/src/views/history/DiffPane.tsx +109 -0
  115. nodum-0.1.0/web/src/views/history/HistoryView.tsx +218 -0
  116. nodum-0.1.0/web/src/views/history/VersionTimeline.tsx +178 -0
  117. nodum-0.1.0/web/src/views/history/history.css +254 -0
  118. nodum-0.1.0/web/src/views/history/unifiedDiff.test.ts +183 -0
  119. nodum-0.1.0/web/src/views/history/unifiedDiff.ts +102 -0
  120. nodum-0.1.0/web/src/views/review/AcceptDialog.tsx +71 -0
  121. nodum-0.1.0/web/src/views/review/Modal.tsx +153 -0
  122. nodum-0.1.0/web/src/views/review/PolicyEditor.tsx +555 -0
  123. nodum-0.1.0/web/src/views/review/PolicyRuleEditor.tsx +195 -0
  124. nodum-0.1.0/web/src/views/review/ProposalCard.tsx +326 -0
  125. nodum-0.1.0/web/src/views/review/ProposalManifest.tsx +147 -0
  126. nodum-0.1.0/web/src/views/review/RejectDialog.tsx +101 -0
  127. nodum-0.1.0/web/src/views/review/ReviewInbox.tsx +780 -0
  128. nodum-0.1.0/web/src/views/review/ReviewView.tsx +68 -0
  129. nodum-0.1.0/web/src/views/review/SideBySide.tsx +114 -0
  130. nodum-0.1.0/web/src/views/review/UpdateDiff.tsx +381 -0
  131. nodum-0.1.0/web/src/views/review/format.ts +79 -0
  132. nodum-0.1.0/web/src/views/review/grouping.test.ts +229 -0
  133. nodum-0.1.0/web/src/views/review/grouping.ts +167 -0
  134. nodum-0.1.0/web/src/views/review/linediff.ts +198 -0
  135. nodum-0.1.0/web/src/views/review/policyRules.test.ts +312 -0
  136. nodum-0.1.0/web/src/views/review/policyRules.ts +313 -0
  137. nodum-0.1.0/web/src/views/review/proposalText.ts +118 -0
  138. nodum-0.1.0/web/src/views/review/review.css +920 -0
  139. nodum-0.1.0/web/src/views/review/useReviewQueue.ts +155 -0
  140. nodum-0.1.0/web/src/views/search/ResultRow.tsx +109 -0
  141. nodum-0.1.0/web/src/views/search/SearchFilterBar.tsx +147 -0
  142. nodum-0.1.0/web/src/views/search/SearchView.tsx +626 -0
  143. nodum-0.1.0/web/src/views/search/SignalBreakdown.tsx +131 -0
  144. nodum-0.1.0/web/src/views/search/search.css +572 -0
  145. nodum-0.1.0/web/src/views/search/searchState.ts +124 -0
  146. nodum-0.1.0/web/src/views/search/signals.test.ts +189 -0
  147. nodum-0.1.0/web/src/views/search/signals.ts +178 -0
  148. nodum-0.1.0/web/src/views/search/snippet.tsx +133 -0
  149. nodum-0.1.0/web/tsconfig.json +26 -0
  150. nodum-0.1.0/web/types/cytoscape-fcose.d.ts +13 -0
  151. nodum-0.1.0/web/vite.config.ts +54 -0
  152. nodum-0.1.0/web/vitest.config.ts +40 -0
@@ -0,0 +1,68 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ lint:
13
+ name: Lint
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ with:
18
+ fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
19
+ - name: Set up uv
20
+ uses: astral-sh/setup-uv@v7
21
+ with:
22
+ python-version: "3.12"
23
+ - name: Ruff check
24
+ run: uv run --locked ruff check .
25
+ - name: Ruff format check
26
+ run: uv run --locked ruff format --check .
27
+
28
+ test:
29
+ name: Tests (py${{ matrix.python-version }})
30
+ runs-on: ubuntu-latest
31
+ strategy:
32
+ matrix:
33
+ python-version: ["3.12", "3.13"]
34
+ steps:
35
+ - uses: actions/checkout@v6
36
+ with:
37
+ fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
38
+ - name: Set up uv
39
+ uses: astral-sh/setup-uv@v7
40
+ with:
41
+ python-version: ${{ matrix.python-version }}
42
+ - name: Run test suite
43
+ run: uv run --locked pytest -q
44
+
45
+ web:
46
+ name: Frontend build
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v6
50
+ - name: Set up Node
51
+ uses: actions/setup-node@v7
52
+ with:
53
+ node-version: "24"
54
+ cache: npm
55
+ cache-dependency-path: web/package-lock.json
56
+ - name: Install the frontend dependencies
57
+ run: make web-install
58
+ # Vitest over the pure modules in web/src. The suite pins its own
59
+ # timezone: this runner is UTC, and the zone-less-timestamp bug
60
+ # src/lib/time.ts fixes is invisible in UTC.
61
+ - name: Frontend unit tests
62
+ run: make web-test
63
+ # `web-build` runs `tsc --noEmit` before Vite, so this step is the type
64
+ # check as well as the build — the frontend cannot rot silently.
65
+ - name: Build the UI bundle
66
+ run: make web-build
67
+ - name: Check the bundle was emitted
68
+ run: test -f nodum/_web/index.html && test -d nodum/_web/assets
@@ -0,0 +1,69 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ test:
10
+ name: Tests (py${{ matrix.python-version }})
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ with:
18
+ fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
19
+ - name: Set up uv
20
+ uses: astral-sh/setup-uv@v7
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ - name: Run test suite
24
+ run: uv run --locked pytest -q
25
+
26
+ smoke:
27
+ name: Clean-install smoke
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v6
31
+ with:
32
+ fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
33
+ - name: Set up uv
34
+ uses: astral-sh/setup-uv@v7
35
+ - name: Clean-install smoke
36
+ run: bash scripts/smoke-install.sh
37
+
38
+ build-and-publish:
39
+ name: Build and publish to PyPI
40
+ # Never publish a wheel that fails tests, or one that fails to install and
41
+ # self-describe clean — tag pushes do not trigger ci.yml, so the release
42
+ # workflow must gate on the suite itself. The matrix mirrors ci.yml's so the
43
+ # tag gate is not weaker than the PR gate.
44
+ needs: [test, smoke]
45
+ runs-on: ubuntu-latest
46
+ permissions:
47
+ id-token: write # required for OIDC trusted publishing
48
+ contents: read # required so actions/checkout@v6 can read the repo
49
+ steps:
50
+ - uses: actions/checkout@v6
51
+ with:
52
+ fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
53
+
54
+ - name: Set up uv
55
+ uses: astral-sh/setup-uv@v7
56
+
57
+ - name: Build
58
+ run: uv build
59
+
60
+ - name: Publish to PyPI
61
+ # Pinned to an exact release tag (not the floating `release/v1` branch):
62
+ # this job holds OIDC publish rights, so its action ref must not be a
63
+ # moving target someone else controls.
64
+ uses: pypa/gh-action-pypi-publish@v1.14.1
65
+ with:
66
+ # A tag re-push (e.g. amending lint fixes onto an already-released
67
+ # version) re-runs this job; without this the second run hard-fails
68
+ # with "400 File already exists" instead of being a no-op.
69
+ skip-existing: true
nodum-0.1.0/.gitignore ADDED
@@ -0,0 +1,24 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .coverage
7
+ htmlcov/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .env
11
+
12
+ # JetBrains IDE project files — per-developer, never shared.
13
+ .idea/
14
+
15
+ # Frontend (web/) — source is tracked, build output and deps are not.
16
+ web/node_modules/
17
+ web/dist/
18
+ # Vite's dep-optimizer cache; pinned to web/node_modules/.vite, ignored anywhere.
19
+ .vite/
20
+ # The built bundle Vite emits into the package, included in the wheel as a
21
+ # hatchling artifact. Ignored whole: Vite's emptyOutDir wipes this directory on
22
+ # every build, so a tracked file here would be deleted each time and leave the
23
+ # tree dirty. The "UI not built" fallback lives at nodum/_web_placeholder.html.
24
+ nodum/_web/
nodum-0.1.0/AGENTS.md ADDED
@@ -0,0 +1,499 @@
1
+ # AGENTS.md — nodum
2
+
3
+ Agent-facing instructions for working in this repository. Read this before
4
+ editing anything here.
5
+
6
+ ## What this repo is
7
+
8
+ `nodum` is a **DB-native knowledge graph**: a typed graph of Markdown-content
9
+ nodes and typed edges in one SQLite file (WAL mode), behind a deterministic,
10
+ LLM-free service layer. Every mutation is validated, state-machine-checked,
11
+ logged in an append-only event log with full before/after payloads, versioned
12
+ (nodes), and reversible (`undo`). `[[wikilinks]]` in content are materialized
13
+ as `mentions` edges on write. The Typer CLI is a thin adapter emitting exactly
14
+ one JSON object per command.
15
+
16
+ Phase 1 (core) landed; Phase 2 (agent-native) is underway. Built so far in
17
+ Phase 2: **event-log projectors** (`nodum.projectors`) with per-projector
18
+ checkpoints and rebuild mechanics, the **`fts` projector** (FTS5 over node
19
+ title + content), the **`vec` projector** (sqlite-vec chunk embeddings,
20
+ local in-process fastembed model — migration 0006), **hybrid search**
21
+ (`nodum.search`, CLI `search`): BM25 + vector lists fused by reciprocal rank
22
+ fusion, then one-hop graph-expansion re-ranking, with a per-signal `signals`
23
+ breakdown, **agent policies** (DB-stored per-agent rulesets, design §8.3,
24
+ with auto-accept evaluation on the edge write path), the **review/accept
25
+ API** (proposal listing with reviewer context, batch accept/reject by id or
26
+ filter — the human tier: a non-`human` actor is refused, as it is for
27
+ `archive` and `undo`), **proposed updates** (agent `update_node` stages a
28
+ `proposed` version recording which fields it named; accept applies exactly
29
+ those, reject archives it — migrations 0005/0008), the **MCP server**
30
+ (`nodum.mcp_server`, stdio, read + additive tiers only; review and curative
31
+ tools are never registered), and **assets + image renditions**
32
+ (`nodum.assets` — migration 0007): thin content-addressed asset registration
33
+ (a metadata row + an in-database blob + sha256) and lazily generated, stored,
34
+ evictable `thumb`/`preview` WebP renditions (design §5.7), exposed over MCP
35
+ as `get_asset` (metadata + rendition image block — never the original).
36
+ Phase 3 (human UI) has landed: the **HTTP API** (`nodum.http_api`, `nodum
37
+ serve`) is the human surface — a Starlette app serving the JSON API under
38
+ `/api` and the built web UI at `/`, with every write forced to `actor =
39
+ human` and no request field able to say otherwise — the shared **envelope**
40
+ module (`nodum.envelope`) both the CLI and the API render through, and the
41
+ **web UI** itself (`web/`, React 19 + TypeScript, built into `nodum/_web/` by
42
+ `make web-build`; gitignored, shipped in the wheel as a hatchling artifact):
43
+ six views — Markdown editor, hybrid search, review queue + policy editor,
44
+ graph, assets, per-node version history.
45
+ **Deliberately not built yet** (later phases — do not add): the
46
+ Phase-4 ingestion pipeline (text extraction, chunking, source/claim
47
+ proposals, `ingest_file`/`ingest_url`), `page:<n>` PDF rasters,
48
+ `get_download_url`/`request_upload_url`, the internal agent runtime and
49
+ consolidation cycle, **Markdown Mirror** and any whole-graph export (the only
50
+ export that exists is the thin per-node snapshot,
51
+ `GET /api/export/node/{id}?depth=`, which is `get_neighborhood` with a
52
+ `content-disposition` header — not a format, not a backup), the curative tier
53
+ (`merge_nodes`, `retype`, `supersede_edge`, `bulk_relink`, `consolidate`),
54
+ and the **dream-journal view**, which Phase 3 deferred to Phase 5 on purpose —
55
+ it belongs with the consolidation cycle that gives it something to show. The
56
+ schema reserves room for them (`graph_id`, `merge_redirects`, `cycle_id`,
57
+ `assets.extracted_text`); each lands as its own append-only migration.
58
+ A node's `type` is likewise **fixed at creation by design**, not by omission:
59
+ `service.update_node` takes `title`/`content`/`props` only, and retyping is a
60
+ curative operation (§8.2 `retype`). Do not add a `type` field to
61
+ `PATCH /api/nodes/{id}` — the editor withholds its type commands on a saved
62
+ node for exactly this reason.
63
+
64
+ ## Architecture
65
+
66
+ - **`nodum.service`** is the spine and the only writer — validation, the
67
+ `proposed → active → archived` state machine, the event log, versions
68
+ (including `proposed` version updates: agent edits stage the fields they
69
+ name, accept applies exactly those, reject archives), undo, wikilink
70
+ materialization, agent policies (CRUD + auto-accept evaluation on the write
71
+ path), the review queue (proposal listing, batch accept/reject), the human
72
+ tier (`_require_human_reviewer` refuses a non-`human` actor for `accept`,
73
+ `reject`, `archive`, and `undo` — every operation that writes or retires
74
+ live state), and the curated graph reads
75
+ (`get_neighborhood`, `traverse`, `find_path`, `get_schema`,
76
+ `diff_versions`, `propose_edges`). Two reads exist for interactive clients
77
+ rather than agents: **`subgraph`** — `traverse` plus edge state/confidence/
78
+ author and node-type filters, all applied in SQL, with a node `limit`
79
+ enforced *during* the breadth-first walk — tested **before** the far side
80
+ of an edge is read, so a hub costs `limit` node reads and not one per
81
+ neighbour — a separate edge cap (`limit * SUBGRAPH_EDGE_FACTOR`), since a
82
+ node cap bounds nodes only and one pair of nodes can carry any number of
83
+ edges, a server-side ceiling on `limit` itself (`MAX_SUBGRAPH_LIMIT`, 2000 —
84
+ the value the graph view's slider already clamps to), an edge list **closed
85
+ over the returned node set** so the outermost ring is joined up rather than
86
+ drawn with gaps, and a `truncated` flag saying whether **either** cap bit —
87
+ and **`suggest_links`**, a title-prefix lookup for a `[[` autocomplete that
88
+ reads `nodes` directly, so it answers on a database whose projectors have
89
+ never run. Each public function opens its own short-lived connection
90
+ (applying pending migrations idempotently) and commits. New behaviour and
91
+ validation go here first; adapters must not add behaviour the service lacks.
92
+ - **`nodum.mcp_server`** — the MCP adapter (stdio, official Python SDK
93
+ FastMCP), the **external-agent** surface. Registers the design §8.1 read +
94
+ additive tiers and nothing else, each tool a thin delegate to a
95
+ service/search function; one configured `--actor` per server attributes
96
+ every write and must be an `agent:<name>` identity. The review tools
97
+ (`accept`, `reject` — the §8.1 "write (human/policy)" tier) and the
98
+ curative tools (`merge_nodes`, `retype`, `supersede_edge`, `bulk_relink`,
99
+ `consolidate` — §8.2) are **never registered**: structural enforcement, not
100
+ a runtime check. Launched by `nodum mcp serve`.
101
+ - **`nodum.http_api`** — the HTTP adapter (design §9), the **human** surface
102
+ and the exact inverse of the MCP server. `create_app(*, db_path, token)`
103
+ builds a Starlette app: the JSON API under `/api`, the built UI at `/`,
104
+ launched by `nodum serve` (loopback, port 8600). Every write is attributed
105
+ to `HTTP_ACTOR` (= `service.ACTOR_HUMAN`) and **no request field, header, or
106
+ query parameter can set an actor** — a body carrying `{"actor": "agent:x"}`
107
+ is ignored, not honoured. That absence is structural, not a filter: the
108
+ module binds `actor` in exactly one expression (inside `_write`, to the
109
+ constant), handlers forward only fields they name, and `_write` refuses a
110
+ caller-supplied actor outright. Three tests in `tests/test_http_api.py`
111
+ enforce it over the *live route table* and the module's AST, so a new
112
+ endpoint is covered without being added to a list — if you add an endpoint,
113
+ route its writes through `_write` and never mention an actor in a handler.
114
+ One `EXCEPTION_STATUS` table becomes the error envelope. It covers every
115
+ class `cli._run` catches — the `sqlite3.Error` and `OSError` rows are the
116
+ **base** classes, so `DatabaseError`/`IntegrityError`/`ProgrammingError`/
117
+ `DataError` land on a status rather than a generic 500 — plus
118
+ `sqlite3.OperationalError` → 503, `OverflowError` → 400, `PayloadTooLarge` →
119
+ 413 and `ClientDisconnect` → 499, which only a network surface meets.
120
+ `test_every_exception_cli_run_catches_is_mapped` reads `cli._run`'s own
121
+ except clauses and asserts the claim instead of restating it. Unmapped
122
+ exceptions are a generic 500 with no traceback in the body.
123
+ `RequestGuardMiddleware` is the origin control under all of it (see the
124
+ HTTP contract below) — binding loopback keeps other machines out, not other
125
+ *origins*, and a browser reaches `127.0.0.1` from any page.
126
+ - **`nodum.envelope`** — the JSON envelope both the CLI and the HTTP API emit:
127
+ `envelope()`, `list_envelope()` (the `{"<plural>": [...], "count": n}`
128
+ convention), and `render_json()`. Extracted so the surfaces cannot drift;
129
+ `GET /api/nodes/{id}` is byte-identical to `nodum node get <id>` on stdout.
130
+ New list output goes through `list_envelope`, never a hand-built dict.
131
+ - **`web/`** — the human UI (React 19 + TypeScript + Vite), built into
132
+ `nodum/_web/` by `make web-build` and served by `nodum serve`. Seven routes
133
+ over six views, each lazily loaded so CodeMirror, Mermaid, and Cytoscape stay
134
+ out of the initial bundle. `src/api/client.ts` is the only `fetch` in the
135
+ app and has **no actor parameter anywhere** — the server's structural rule,
136
+ mirrored in the client. It is also where the optional bearer token is
137
+ adopted, from the `#token=…` fragment `nodum serve --token` prints, into
138
+ `sessionStorage`; and it sends `Content-Type: application/json` on every
139
+ non-GET request, bodyless ones included, because the server requires it.
140
+ `src/lib/` holds the cross-view invariants
141
+ (timestamps, failure classification); `src/components/` holds shared React
142
+ components; a view owns its own directory and links to other views by URL,
143
+ never by import. Full conventions: `web/README.md`.
144
+ - **`nodum.projectors`** — derived-index consumers of the event log. A
145
+ projector registry (`REGISTRY`), per-projector checkpoints in
146
+ `projector_checkpoints`, incremental `run_projectors`, and
147
+ `rebuild_projector` (reset derived state, replay from event 0). The `fts`
148
+ projector maintains `node_fts`; the `vec` projector maintains `chunks` +
149
+ `node_vec` (rebuild = the model-change re-embed path, design D6). The
150
+ service layer never calls projectors — the event log is the only coupling.
151
+ A projector whose requirements are unmet (`vec` without a usable embedding
152
+ provider) reports itself unavailable in `projector status` and its runs
153
+ are no-ops — the backlog waits, nothing crashes.
154
+ - **`nodum.embeddings`** — the embedding provider seam (design D10) and
155
+ chunking (design D6). The provider interface is `model_id` + `dimensions`
156
+ + `embed(texts) -> vectors`; the default is a local in-process fastembed
157
+ model (`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`,
158
+ 384-dim, multilingual, ONNX/CPU — no daemon, no API key) behind the
159
+ optional `embeddings` extra. A model is never downloaded implicitly: the
160
+ provider resolves only from the local HF cache unless
161
+ `NODUM_EMBED_DOWNLOAD=1` is set (first run fetches it).
162
+ `NODUM_EMBED_MODEL` overrides the model name (a different dimensionality
163
+ needs a new migration — the vec0 table is fixed at 384). Tests inject a
164
+ deterministic hashing fake via `embeddings.set_provider`.
165
+ - **`nodum.assets`** — content-addressed binaries and their derived
166
+ renditions (design §5.5/§5.7). **Bytes live in the database, not on the
167
+ filesystem**: `assets` holds metadata, `asset_blobs` holds the bytes under
168
+ the same sha256 key, so the whole system is one file and disaster recovery
169
+ is `DB = everything`. Registration is idempotent sha256 dedup with no
170
+ event-log entry (there is nothing to undo), and streams through
171
+ `Connection.blobopen` so a large file is never held in memory — never
172
+ inline asset bytes into an event payload. The two read passes (hash, then
173
+ copy) are cross-checked: the copy is re-hashed, so a source that changed in
174
+ between is refused (`AssetSourceChanged`) instead of stored under a key it
175
+ does not match, and a file above `SQLITE_LIMIT_LENGTH` (1 GB) is refused up
176
+ front (`AssetTooLarge`). Note the streamed copy holds SQLite's single write
177
+ lock for its whole duration. Renditions (`thumb` ≤256px WebP
178
+ q75, `preview` ≤1024px WebP q80 with a 300 KB quality-stepping target) are
179
+ keyed by `sha256(asset_hash + ':' + profile)`, generated lazily with Pillow
180
+ on first request, stored as blobs, and evicted by `purge_renditions` (CLI
181
+ `asset purge`) — fully regenerable. Non-image assets are rejected cleanly;
182
+ `page:<n>` rasters are Phase 4. Pillow reads originals through
183
+ `_BlobReader`, which restores the file-style tolerant seeks that
184
+ `sqlite3.Blob` refuses and Pillow's format probing depends on.
185
+ - **`nodum.search`** — the query path (design §7). BM25 over the `fts`
186
+ projector's index and vector ANN over the `vec` projector's chunks
187
+ (closest chunk per node wins), fused by reciprocal rank fusion (K=60) with
188
+ `type`/`state`/`created_by`/date filters; optional one-hop graph expansion
189
+ over `active` edges (`--expand`) applies after fusion. Hits carry the
190
+ fused `score` plus a per-signal `signals` breakdown (`bm25` / `vector` /
191
+ `graph`). With no embedding provider the vector signal is skipped —
192
+ search silently degrades to BM25 + graph.
193
+ - **`nodum.db`** — connection management (WAL, foreign keys), `NODUM_DB`
194
+ resolution, the migration runner. Each migration's script and its
195
+ `schema_migrations` row are one transaction (`apply_migration`), so an
196
+ interrupted upgrade rolls back whole and retries cleanly instead of wedging
197
+ the database half-migrated.
198
+ - **`nodum.migrations`** — the append-only migration list (`0001_core` …
199
+ `0008_version_proposed_fields`). Never edit a shipped migration; append a
200
+ new one. A migration must never leave data readable only through a store a
201
+ later migration replaces: introduce a table where its bytes already belong
202
+ (this is why asset bytes are part of `0007` and there is no `path` column
203
+ anywhere).
204
+ - **`nodum.models`** — the pydantic I/O schema shared by every surface.
205
+ - **`nodum.cli`** (Typer) — each command calls one service function and prints
206
+ the result as a single JSON object on stdout; human/error messages go to
207
+ stderr with exit code 1. No `--json` flag.
208
+
209
+ See `docs/architecture.md` for the design-section → module mapping and the
210
+ Phase-1 decision log.
211
+
212
+ ## Workflow rules
213
+
214
+ - **uv for everything.** `uv sync --all-groups` (or `make dev-install`), `uv
215
+ run nodum …`, `uv run pytest`. Never raw `pip`/`venv`. Commit `uv.lock`;
216
+ `.venv/` stays gitignored. Python ≥ 3.12. The local embedding model lives
217
+ behind the optional `embeddings` extra (`uv sync --extra embeddings`) —
218
+ tests never need it (they inject a fake provider; one real-model smoke
219
+ test is opt-in via `NODUM_RUN_SLOW=1`).
220
+ - **`make format` after every code change** (ruff check --fix + format); CI
221
+ runs `make lint` and `make test` on Python 3.12 and 3.13.
222
+ - **Tests**: `make test` (pytest, rooted at `tests/`). No external services:
223
+ every test that touches the database takes the `fresh_db` fixture, which
224
+ points `NODUM_DB` at a fresh temp file and migrates it (it is opt-in — the
225
+ pure-logic tests in `tests/test_embeddings.py` need no database at all), and
226
+ the autouse `_no_embedding_provider` fixture forces the embedding provider
227
+ unavailable so nothing can reach the network.
228
+ - **Version** comes from the git tag (`vX.Y.Z`) via hatch-vcs at build time;
229
+ never bump a version in code.
230
+ - **Releasing.** Land the change on `main`, then push an annotated `vX.Y.Z`
231
+ tag on a commit reachable from `origin/main`. That triggers
232
+ `.github/workflows/release.yml`: the test matrix and the clean-install smoke
233
+ gate a `uv build`, which publishes to PyPI over OIDC trusted publishing (no
234
+ API token). Tag pushes do **not** trigger `ci.yml`, which is why the release
235
+ workflow re-runs the suite itself. The publish step sets `skip-existing:
236
+ true`, so re-pushing a tag onto an already-released version is a no-op rather
237
+ than a `400 File already exists` failure, and pins the publish action to an
238
+ exact tag because that job holds OIDC publish rights.
239
+ - **Docstrings on public APIs**: one-line summary plus args/returns where
240
+ applicable. Comment the *why*, not the *what*. Don't annotate code you
241
+ didn't change.
242
+ - **Keep adapters thin.** When you add or change a service operation, expose it
243
+ through the CLI in the same change, and update `README.md`,
244
+ `docs/architecture.md`, and this file in the same commit.
245
+ - **Line length 100**; ruff rules `E, F, I, UP, B, SIM`.
246
+ - **Frontend**: `make web-install` once, then `make web-build` (which runs
247
+ `tsc --noEmit` first, so the build is the type gate) or `make web-dev` for
248
+ the Vite server on 5700 proxying to `nodum serve` on 8600. Two gates, both in
249
+ CI: `tsc --noEmit` over the whole tree, and **`make web-test`** — Vitest over
250
+ the pure modules in `web/src` (`*.test.ts` beside the module it covers).
251
+ There is no ESLint and no component/DOM harness, so anything React renders is
252
+ still verified by type-checking it and driving it in a browser.
253
+ **The Vitest run pins `TZ` to a non-UTC zone** (`web/vitest.config.ts`) and
254
+ `time.test.ts` asserts the pin took: the zone-less-timestamp bug `lib/time.ts`
255
+ fixes is invisible in UTC, so an ambient-timezone run would pass while the
256
+ code was broken. Do not remove the pin, and do not add a test that depends on
257
+ the ambient zone. `nodum/_web/` is gitignored whole and rewritten by every
258
+ build; a release must `make web-build` before `uv build --wheel`.
259
+
260
+ ## CLI contract (for agents driving the CLI)
261
+
262
+ - Every command prints **one JSON object** on stdout and nothing else on the
263
+ success path — parse stdout directly. A command returning a list wraps it in
264
+ a named key plus a `count` (`{"nodes": [...], "count": 2}`); keep new list
265
+ commands to that shape.
266
+ - DB path resolution: `--db` flag → `NODUM_DB` env var →
267
+ `~/.local/share/nodum/nodum.db`.
268
+ - Writes default to actor `human` (state `active`); pass `--actor agent:<name>`
269
+ to land writes in `proposed` instead — unless the agent's stored policy
270
+ auto-accepts the write (`policy set`). An agent `node update` stages a
271
+ `proposed` *version* recording which fields it named; `accept <version-id>`
272
+ applies **only those fields** to the node as it stands then (so a human edit
273
+ made while the proposal waited is not reverted), `reject` archives it.
274
+ A `[[wikilink]]` written by an agent materialises a `proposed` `mentions`
275
+ edge; accepting the node brings it to `active`.
276
+ - **Everything that writes or retires live state requires `--actor human`**
277
+ (the default): `accept`, `reject`, `archive`, `undo`, every `review`
278
+ subcommand, and `policy set` (a policy grants auto-accept, so an agent
279
+ setting one would self-grant the direct live write the human tier withholds).
280
+ An `agent:*` actor exits 1 with `only the 'human' actor may
281
+ <action>`. It is not delegable, whoever filed the proposal — `undo` most of
282
+ all, since restoring an event's payload can write `state = 'active'` back.
283
+ Both spellings of a reject — single-item `reject <id> --reason` and batch
284
+ `review reject … --reason` — require the reason and record it in the reject
285
+ event's payload: one operation, one audit guarantee.
286
+ - Errors are always one line on stderr with exit 1, never a traceback — that
287
+ includes a missing file (`asset register /missing.png`), a database another
288
+ writer holds (`database error: database is locked`), and an undo the graph
289
+ has grown past (a created node that now has children).
290
+ - `--set key=value` is repeatable; values are parsed as JSON with a raw-string
291
+ fallback.
292
+ - A policy rule's `min_confidence` grades the *agent's own* reported
293
+ confidence, so it is inert unless the rule also sets
294
+ `"trust_self_reported_confidence": true`.
295
+ - `--version` prints `nodum <version>` and exits 0; `schema-dump` prints the
296
+ CLI's whole command tree as JSON. Both short-circuit without touching a
297
+ database, so they work on a bare install — that is what
298
+ `scripts/smoke-install.sh` asserts against a freshly built wheel. Note
299
+ `schema-dump` (the CLI adapter's own surface) is a different thing from
300
+ `schema <type>` (one node/edge type's catalog entry from the database).
301
+ - Surface: `init`, `node create/get/update/list/children`, `edge
302
+ create/list/create-batch`, `accept <id>` / `reject <id> --reason` /
303
+ `archive <id>` (each takes a node, edge, or proposed-version id), `undo [seq]`,
304
+ `history <node-id>`, `events`, `types`, `schema <type>`, `schema-dump`,
305
+ `search <query>`,
306
+ `traverse`, `subgraph <root-id>`, `suggest-links <prefix>`, `find-path`,
307
+ `diff`, `projector run/status/rebuild`,
308
+ `policy set/get/list`, `review queue/accept/reject/accept-all/reject-all`,
309
+ `asset register/get/list/rendition/purge`,
310
+ `mcp serve --actor agent:<name>`,
311
+ `serve [--host 127.0.0.1] [--port 8600] [--token TOKEN] [--allow-host NAME]
312
+ [--db PATH]`. `serve` refuses a non-loopback bind without `--token` (exit 1),
313
+ prints the database path and the `#token=…` UI URL on stderr, and translates
314
+ uvicorn's own startup failure (a port already in use) into the contract's
315
+ exit 1 — it used to escape as uvicorn's exit 3.
316
+ - Reads are not state-filtered by default beyond edge traversal: `node get`,
317
+ `node children`, `node list`, and `history` return `proposed` rows, and
318
+ `search --state any` includes them. Only *traversals* (`node get --depth`,
319
+ `traverse`, `subgraph`, `find-path`, `search --expand`) are restricted to
320
+ `active` edges — proposed structure is inert, not hidden. `subgraph
321
+ --edge-state proposed` is the one way to walk it, and it has to be asked
322
+ for. `suggest-links` follows the node-read rule with one exception:
323
+ `archived` titles are never suggested, since a retired node is not a link
324
+ target.
325
+ - `subgraph` is the bounded read, and it is bounded twice: `--limit` is a hard
326
+ node cap applied while walking (tested before the far node is read, so the
327
+ cost is `O(limit)`, not `O(neighbours)`), and the edge list has its own cap
328
+ at `limit * SUBGRAPH_EDGE_FACTOR` — without it a single pair of nodes with
329
+ 300 edges between them returns 300 edges under a 2-node cap. `--limit` is
330
+ itself clamped to `MAX_SUBGRAPH_LIMIT` (2000), so a caller passing
331
+ `--limit 1000000000` gets the ceiling rather than the graph. `truncated` is
332
+ true when **either** cap bit and is deliberately conservative: it reports a
333
+ walk that stopped early even if the graph happened to have nothing more to
334
+ give. A filter removing nodes is **not** truncation — the caller asked for
335
+ that. A limit below 1 is still an error rather than SQL's "unbounded". Every
336
+ filter composes as one conjunction, and an edge whose far node is filtered
337
+ out is dropped with it — the result never names an edge endpoint it does not
338
+ also return. The edge list is also *closed* over the node list: an edge
339
+ between two returned nodes comes back even when the walk never traversed it
340
+ (the B–C edge of a triangle read at depth 1), which the uncapped `traverse`
341
+ does not do.
342
+ - Asset images reach agents only as renditions: `asset rendition` prints
343
+ rendition metadata alone — the WebP bytes stay in the database and are never
344
+ inlined into the JSON (`--out <file>` is how you extract them); the MCP
345
+ `get_asset` tool returns metadata + a WebP image block of the requested
346
+ rendition — originals are never served over MCP (design §5.7).
347
+
348
+ ## HTTP contract (for agents touching `nodum serve`)
349
+
350
+ - **The HTTP surface is the human's.** Every write it makes is `actor =
351
+ human`; the actor is never read from a request. Do not add an "actor"
352
+ parameter, header, or override "for testing" — the MCP surface is where
353
+ agent identity lives, and the inversion is the whole point.
354
+ - Route handlers are thin delegates: one service/search/assets call each, no
355
+ behaviour the service lacks. Writes go through `_write(service.fn, …)`,
356
+ which is the only place the actor is bound. **Never import a service function
357
+ that takes an `actor` into `http_api`** — an alias hides it from every
358
+ source-level check, and `test_no_write_service_function_is_reachable_under_
359
+ any_name` fails on the import itself. Never splat request data into a call
360
+ either: `**` may only unpack a dict an allowlisting helper built, and any new
361
+ one fails `test_no_call_splats_anything_but_an_allowlisting_helper` until it
362
+ is reviewed.
363
+ - **The test that actually holds the boundary is the runtime sweep**
364
+ (`test_no_endpoint_can_attribute_a_write_to_an_agent`): it drives every
365
+ state-changing method of every route in `app.routes` with actor-carrying
366
+ bodies, query strings and headers, then asserts nothing written during the
367
+ sweep names anything but `human`. The AST properties beside it are a belt —
368
+ all of them were evadable by a handler that forwarded a body it never
369
+ inspected, which is how a rogue endpoint once produced
370
+ `created_by: "agent:evil"` on a fully green suite.
371
+ - **A state-changing request must prove it is same-origin**
372
+ (`RequestGuardMiddleware`), because `nodum serve` binds loopback with no token
373
+ and loopback is reachable from every page the user visits. The rule:
374
+ `Sec-Fetch-Site` in `{same-origin, none}`, **or** an `Origin` whose host is
375
+ allowed, **or** the `X-Nodum-Client` header — which is how a non-browser
376
+ client declares itself, since a browser always sends one of the first two and
377
+ cannot be scripted out of either. A cross-site `Sec-Fetch-Site` or a
378
+ mismatched `Origin` is refused outright. Reads are unencumbered.
379
+ - **Every JSON route requires `Content-Type: application/json`, bodyless ones
380
+ included.** That is not pedantry: `application/json` is not a CORS-simple
381
+ content type, so a cross-origin page cannot send it without a preflight, and
382
+ this app answers none. `POST /api/assets` is the one exception — multipart
383
+ *is* simple — so it rests entirely on the same-origin proof above. A new
384
+ upload route goes in `MULTIPART_ROUTES` or it inherits the JSON rule.
385
+ - **The `Host` header is validated** against `resolve_allowed_hosts(host,
386
+ --allow-host)`. This is the DNS-rebinding defence and the only check that
387
+ protects *reads*: after a rebind the attacker's page is same-origin by every
388
+ other measure. Host names are compared without ports, which is what keeps the
389
+ `make web-dev` proxy (`Host: localhost:5700`) working.
390
+ - **`--token` is the only defence against a local process.** Any process on the
391
+ machine can satisfy every origin check with three curl headers — including an
392
+ MCP server launched with `--actor agent:x`, which would thereby regain over
393
+ HTTP the `accept` the MCP tool list structurally withholds. `nodum serve`
394
+ says so in its startup banner when no token is set, and refuses a non-loopback
395
+ bind without one. The UI receives the token from the `#token=…` fragment the
396
+ banner prints (`web/src/api/client.ts`, `adoptToken`) — a fragment because it
397
+ never reaches the wire, a log, or a `Referer`.
398
+ - **A wrong verb on a real route is a 405 with an `Allow` header**, not the
399
+ catch-all's 404. The catch-all claims every method so a `fetch` never gets
400
+ HTML, which also means it out-matches a real route's 405 unless it asks the
401
+ real routes what they would have matched — which `api_not_found` does.
402
+ - **`/healthz` reports liveness only.** It sits outside auth, so anything it
403
+ says is said to everyone; it used to say the absolute database path.
404
+ - **`POST /api/assets` is bounded before it buffers**: `MAX_REQUEST_BYTES` is
405
+ checked against `Content-Length` and then enforced mid-stream (the header is
406
+ client-supplied and cannot be the only guard), the type is sniffed from the
407
+ bytes against `UPLOAD_MIME_ALLOWLIST` rather than read off the filename, and
408
+ `assets.MAX_IMAGE_PIXELS` refuses a decompression bomb from the image header.
409
+ The allowlist is deliberately narrower than what `assets.register_asset` will
410
+ store: the CLI registers a local file the operator owns, this one takes a
411
+ file from a stranger. **There is no delete route**, so anything that does land
412
+ is only reclaimable out of band — a known gap, not an oversight.
413
+ - **Do not invent request fields the domain has no representation for.**
414
+ `PUT /api/policies/{agent}` takes `{"rules": [...]}` and nothing else: a
415
+ policy is disabled by storing an empty ruleset, which is the service's only
416
+ spelling of it, and `PolicyOut` has no `enabled` field to echo one back. An
417
+ `enabled: false` flag was tried and removed — it silently wiped the stored
418
+ ruleset with no way to recover it. Same rule everywhere: if a body key has
419
+ no counterpart in `nodum.models`/`nodum.service`, it does not belong here.
420
+ - Responses use `nodum.envelope`: single results as the model dump, lists as
421
+ `{"<plural>": [...], "count": n}`, rendered exactly as the CLI prints them.
422
+ A new list endpoint keys on the same plural the CLI command uses.
423
+ - Failures are `{"error": {"type", "message"}}` from `EXCEPTION_STATUS`; add a
424
+ new mapping there rather than catching in a handler. Anything unmapped is a
425
+ 500 with a generic body — never leak a traceback to a client.
426
+ - Repeatable filters (`edge_type`, `edge_state`, `node_type`) are repeated
427
+ query keys; `/healthz` sits outside `/api` and outside auth; an unknown `/api`
428
+ path is a JSON 404 while unknown non-API paths fall through to the SPA
429
+ entry point (or the "UI not built" placeholder). **`/favicon.ico` is the one
430
+ exemption**: a browser asks for it unprompted and it is definitely not a
431
+ client route, so it is answered with the bundle's icon if there is one and a
432
+ 204 otherwise — never an HTML document under a 200, which a client asking for
433
+ an image has no way to detect. Any other path a browser requests on its own
434
+ belongs in that same exemption list, not in the catch-all.
435
+ - Asset originals are never served — only `thumb`/`preview` renditions, as
436
+ WebP bytes at `/api/assets/{id}/rendition/{profile}` (design §5.7).
437
+
438
+ ## Frontend contract (for agents touching `web/`)
439
+
440
+ - **One `fetch`.** Everything goes through `src/api/client.ts`. It has no actor
441
+ parameter and must never grow one — the server forces `actor = human` and the
442
+ client being unable to express an actor is the second layer under that. Two
443
+ things it *does* own, both because the server made them requirements: the
444
+ bearer token (`adoptToken` reads `#token=…` once, stores it in
445
+ `sessionStorage`, and strips the fragment — `setAuthToken` had no caller at
446
+ all before, so `--token` shipped a UI in which every request was a 401) and
447
+ `Content-Type: application/json` on every non-GET request, bodyless ones
448
+ included. Neither belongs in a view.
449
+ - **Never call `new Date()` on a server string.** SQLite writes
450
+ `datetime('now')` — UTC, no zone marker — which every browser reads as *local*
451
+ time. Parse through `parseTimestamp` (`src/lib/time.ts`) and format through
452
+ its formatters. `new Date()` on a client-side epoch number ("saved at",
453
+ "checked at") is fine and is the only exception.
454
+ - **Never re-derive a failure's meaning.** `describeFailure` (`src/lib/failure.ts`)
455
+ is the one place that tells *the API refused this* apart from *nothing was
456
+ listening* — and the two are not one test: same-origin it is a `fetch`
457
+ `TypeError`, behind the dev proxy it is a 502. Map its `kind` onto your own
458
+ panel; do not re-test `status` or `instanceof`.
459
+ - **A view owns its directory and links to other views by URL.** No view imports
460
+ another. Route paths live in `src/router.tsx`; grep for the path string before
461
+ renaming one. A view's entry component keeps a **default export** — the routes
462
+ are lazily loaded and `lazy()` needs it.
463
+ - **Promote to `src/lib/` or `src/components/` on the second user, not the
464
+ first.** Both are inherited by every view.
465
+ - **Do not render a control for something the service cannot do.** A node's
466
+ `type` is immutable after creation, so the editor drops the type commands on a
467
+ saved node rather than offering one that silently no-ops. Same rule as the
468
+ HTTP contract's "do not invent request fields", one layer up.
469
+ - **The design system has two colour axes and both are taken**: the brass accent
470
+ means "you can act on this", the state ramp means the service-layer state
471
+ machine (`proposed` violet, `active` sea-green, `archived` lowest-contrast).
472
+ Anything else needs its own hue, kept view-local until a second view names it.
473
+ Class names are `nd-`-prefixed because Mermaid and Cytoscape inject global
474
+ stylesheets on `.node`, `.label`, and `.edge`.
475
+ - **A pure module gets a `*.test.ts` beside it** (`make web-test`, Vitest). The
476
+ harness is unit-only by design — no component rendering — so pull the logic
477
+ worth testing out of the component and test it there, which is what
478
+ `filters.ts`, `unifiedDiff.ts`, `signals.ts`, `grouping.ts`, and
479
+ `policyRules.ts` already are. Assert the *semantics* the module encodes (a
480
+ `min_confidence` of 0 is a filter, not a no-op; a 502 is unreachable, not a
481
+ refusal), not its line coverage. The global environment is `node`; a suite
482
+ that genuinely needs a DOM says so in **its own** docblock
483
+ (`// @vitest-environment jsdom`, as `markdownRender.test.ts` does) rather than
484
+ changing the config for everyone.
485
+ - **Nothing reaches `innerHTML` without going through DOMPurify.** The preview
486
+ renders Markdown that *agents* wrote, in the origin that may write to the API,
487
+ so `markdownRender.ts` reduces it to an allowlist with **no SVG and no
488
+ MathML** — that namespace is where `<animate>` retargets an anchor's `href` to
489
+ `javascript:` and where a lowercase `<style>` slips past any check keyed on
490
+ `tagName`. `mermaidRender.ts` runs a second, SVG-shaped policy over mermaid's
491
+ output. Both are covered by `markdownRender.test.ts`; a new sink means a new
492
+ policy, not a new exception. `nodum.http_api.CONTENT_SECURITY_POLICY` is the
493
+ runtime backstop under both — `script-src 'self'`, no `'unsafe-inline'`.
494
+ - **A dialog locks body scroll and hands focus somewhere real.** Both the review
495
+ `Modal` and the assets lightbox set `body.style.overflow` on open and restore
496
+ it on close. On close, focus returns to the opener *only if it is still in the
497
+ document* — after a successful confirm it usually is not, and focusing a
498
+ detached node silently drops the user on `<body>`. The view places focus in
499
+ that case (the review inbox sends them to the outcome panel).