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.
- nodum-0.1.0/.github/workflows/ci.yml +68 -0
- nodum-0.1.0/.github/workflows/release.yml +69 -0
- nodum-0.1.0/.gitignore +24 -0
- nodum-0.1.0/AGENTS.md +499 -0
- nodum-0.1.0/Makefile +61 -0
- nodum-0.1.0/PKG-INFO +362 -0
- nodum-0.1.0/README.md +332 -0
- nodum-0.1.0/docs/architecture.md +603 -0
- nodum-0.1.0/nodum/__init__.py +14 -0
- nodum-0.1.0/nodum/_web_placeholder.html +97 -0
- nodum-0.1.0/nodum/assets.py +689 -0
- nodum-0.1.0/nodum/cli.py +877 -0
- nodum-0.1.0/nodum/cli_schema.py +83 -0
- nodum-0.1.0/nodum/db.py +184 -0
- nodum-0.1.0/nodum/embeddings.py +247 -0
- nodum-0.1.0/nodum/envelope.py +68 -0
- nodum-0.1.0/nodum/http_api.py +1482 -0
- nodum-0.1.0/nodum/mcp_server.py +357 -0
- nodum-0.1.0/nodum/migrations.py +307 -0
- nodum-0.1.0/nodum/models.py +372 -0
- nodum-0.1.0/nodum/projectors.py +367 -0
- nodum-0.1.0/nodum/search.py +387 -0
- nodum-0.1.0/nodum/service.py +2500 -0
- nodum-0.1.0/pyproject.toml +82 -0
- nodum-0.1.0/scripts/smoke-install.sh +65 -0
- nodum-0.1.0/tests/conftest.py +66 -0
- nodum-0.1.0/tests/test_assets.py +522 -0
- nodum-0.1.0/tests/test_cli.py +529 -0
- nodum-0.1.0/tests/test_embeddings.py +254 -0
- nodum-0.1.0/tests/test_events.py +182 -0
- nodum-0.1.0/tests/test_graph_reads.py +237 -0
- nodum-0.1.0/tests/test_http_api.py +1688 -0
- nodum-0.1.0/tests/test_hybrid_search.py +95 -0
- nodum-0.1.0/tests/test_mcp_server.py +421 -0
- nodum-0.1.0/tests/test_migrations.py +254 -0
- nodum-0.1.0/tests/test_policies.py +368 -0
- nodum-0.1.0/tests/test_projectors.py +127 -0
- nodum-0.1.0/tests/test_proposed_updates.py +310 -0
- nodum-0.1.0/tests/test_review.py +347 -0
- nodum-0.1.0/tests/test_search.py +173 -0
- nodum-0.1.0/tests/test_service.py +185 -0
- nodum-0.1.0/tests/test_subgraph.py +400 -0
- nodum-0.1.0/tests/test_suggest_links.py +138 -0
- nodum-0.1.0/tests/test_vec_projector.py +172 -0
- nodum-0.1.0/tests/test_wikilinks.py +195 -0
- nodum-0.1.0/uv.lock +1504 -0
- nodum-0.1.0/web/README.md +229 -0
- nodum-0.1.0/web/index.html +42 -0
- nodum-0.1.0/web/package-lock.json +3698 -0
- nodum-0.1.0/web/package.json +41 -0
- nodum-0.1.0/web/src/App.tsx +140 -0
- nodum-0.1.0/web/src/api/client.ts +655 -0
- nodum-0.1.0/web/src/api/types.ts +493 -0
- nodum-0.1.0/web/src/components/EmptyState.tsx +29 -0
- nodum-0.1.0/web/src/components/ErrorBoundary.tsx +60 -0
- nodum-0.1.0/web/src/components/NodeBadge.tsx +44 -0
- nodum-0.1.0/web/src/components/Spinner.tsx +25 -0
- nodum-0.1.0/web/src/components/Toast.tsx +156 -0
- nodum-0.1.0/web/src/components/index.ts +14 -0
- nodum-0.1.0/web/src/lib/failure.test.ts +162 -0
- nodum-0.1.0/web/src/lib/failure.ts +131 -0
- nodum-0.1.0/web/src/lib/index.ts +24 -0
- nodum-0.1.0/web/src/lib/time.test.ts +196 -0
- nodum-0.1.0/web/src/lib/time.ts +116 -0
- nodum-0.1.0/web/src/main.tsx +14 -0
- nodum-0.1.0/web/src/router.tsx +94 -0
- nodum-0.1.0/web/src/styles/app.css +259 -0
- nodum-0.1.0/web/src/styles/base.css +202 -0
- nodum-0.1.0/web/src/styles/index.css +13 -0
- nodum-0.1.0/web/src/styles/primitives.css +323 -0
- nodum-0.1.0/web/src/styles/tokens.css +119 -0
- nodum-0.1.0/web/src/views/assets/AssetGrid.tsx +99 -0
- nodum-0.1.0/web/src/views/assets/AssetLightbox.tsx +427 -0
- nodum-0.1.0/web/src/views/assets/AssetUploader.tsx +227 -0
- nodum-0.1.0/web/src/views/assets/AssetsView.tsx +273 -0
- nodum-0.1.0/web/src/views/assets/ExportJsonButton.tsx +131 -0
- nodum-0.1.0/web/src/views/assets/assets.css +479 -0
- nodum-0.1.0/web/src/views/assets/export.css +50 -0
- nodum-0.1.0/web/src/views/assets/formatting.ts +50 -0
- nodum-0.1.0/web/src/views/editor/EditorView.tsx +398 -0
- nodum-0.1.0/web/src/views/editor/MarkdownEditor.tsx +184 -0
- nodum-0.1.0/web/src/views/editor/MarkdownPreview.tsx +118 -0
- nodum-0.1.0/web/src/views/editor/NodeMetaBar.tsx +230 -0
- nodum-0.1.0/web/src/views/editor/cm/assetDrop.ts +83 -0
- nodum-0.1.0/web/src/views/editor/cm/slashCommands.ts +186 -0
- nodum-0.1.0/web/src/views/editor/cm/theme.ts +132 -0
- nodum-0.1.0/web/src/views/editor/cm/wikilinkComplete.ts +106 -0
- nodum-0.1.0/web/src/views/editor/editor.css +421 -0
- nodum-0.1.0/web/src/views/editor/leftoverBuffer.test.ts +139 -0
- nodum-0.1.0/web/src/views/editor/markdownRender.test.ts +325 -0
- nodum-0.1.0/web/src/views/editor/markdownRender.ts +346 -0
- nodum-0.1.0/web/src/views/editor/mermaidRender.ts +306 -0
- nodum-0.1.0/web/src/views/editor/useNodeDocument.ts +596 -0
- nodum-0.1.0/web/src/views/failureRouting.test.ts +114 -0
- nodum-0.1.0/web/src/views/graph/ConfidenceFilter.tsx +90 -0
- nodum-0.1.0/web/src/views/graph/GraphCanvas.tsx +313 -0
- nodum-0.1.0/web/src/views/graph/GraphToolbar.tsx +272 -0
- nodum-0.1.0/web/src/views/graph/GraphView.tsx +482 -0
- nodum-0.1.0/web/src/views/graph/NodeDetailPanel.tsx +195 -0
- nodum-0.1.0/web/src/views/graph/PathPanel.tsx +182 -0
- nodum-0.1.0/web/src/views/graph/RootPicker.tsx +138 -0
- nodum-0.1.0/web/src/views/graph/TruncationNotice.tsx +120 -0
- nodum-0.1.0/web/src/views/graph/TypeFilter.tsx +112 -0
- nodum-0.1.0/web/src/views/graph/errors.ts +68 -0
- nodum-0.1.0/web/src/views/graph/filters.test.ts +284 -0
- nodum-0.1.0/web/src/views/graph/filters.ts +289 -0
- nodum-0.1.0/web/src/views/graph/graph.css +630 -0
- nodum-0.1.0/web/src/views/graph/graphElements.ts +140 -0
- nodum-0.1.0/web/src/views/graph/graphStyle.ts +310 -0
- nodum-0.1.0/web/src/views/graph/rootSearch.ts +58 -0
- nodum-0.1.0/web/src/views/graph/truncation.test.ts +46 -0
- nodum-0.1.0/web/src/views/graph/useGraphData.ts +145 -0
- nodum-0.1.0/web/src/views/graph/useTypeCatalog.ts +73 -0
- nodum-0.1.0/web/src/views/history/DiffPane.tsx +109 -0
- nodum-0.1.0/web/src/views/history/HistoryView.tsx +218 -0
- nodum-0.1.0/web/src/views/history/VersionTimeline.tsx +178 -0
- nodum-0.1.0/web/src/views/history/history.css +254 -0
- nodum-0.1.0/web/src/views/history/unifiedDiff.test.ts +183 -0
- nodum-0.1.0/web/src/views/history/unifiedDiff.ts +102 -0
- nodum-0.1.0/web/src/views/review/AcceptDialog.tsx +71 -0
- nodum-0.1.0/web/src/views/review/Modal.tsx +153 -0
- nodum-0.1.0/web/src/views/review/PolicyEditor.tsx +555 -0
- nodum-0.1.0/web/src/views/review/PolicyRuleEditor.tsx +195 -0
- nodum-0.1.0/web/src/views/review/ProposalCard.tsx +326 -0
- nodum-0.1.0/web/src/views/review/ProposalManifest.tsx +147 -0
- nodum-0.1.0/web/src/views/review/RejectDialog.tsx +101 -0
- nodum-0.1.0/web/src/views/review/ReviewInbox.tsx +780 -0
- nodum-0.1.0/web/src/views/review/ReviewView.tsx +68 -0
- nodum-0.1.0/web/src/views/review/SideBySide.tsx +114 -0
- nodum-0.1.0/web/src/views/review/UpdateDiff.tsx +381 -0
- nodum-0.1.0/web/src/views/review/format.ts +79 -0
- nodum-0.1.0/web/src/views/review/grouping.test.ts +229 -0
- nodum-0.1.0/web/src/views/review/grouping.ts +167 -0
- nodum-0.1.0/web/src/views/review/linediff.ts +198 -0
- nodum-0.1.0/web/src/views/review/policyRules.test.ts +312 -0
- nodum-0.1.0/web/src/views/review/policyRules.ts +313 -0
- nodum-0.1.0/web/src/views/review/proposalText.ts +118 -0
- nodum-0.1.0/web/src/views/review/review.css +920 -0
- nodum-0.1.0/web/src/views/review/useReviewQueue.ts +155 -0
- nodum-0.1.0/web/src/views/search/ResultRow.tsx +109 -0
- nodum-0.1.0/web/src/views/search/SearchFilterBar.tsx +147 -0
- nodum-0.1.0/web/src/views/search/SearchView.tsx +626 -0
- nodum-0.1.0/web/src/views/search/SignalBreakdown.tsx +131 -0
- nodum-0.1.0/web/src/views/search/search.css +572 -0
- nodum-0.1.0/web/src/views/search/searchState.ts +124 -0
- nodum-0.1.0/web/src/views/search/signals.test.ts +189 -0
- nodum-0.1.0/web/src/views/search/signals.ts +178 -0
- nodum-0.1.0/web/src/views/search/snippet.tsx +133 -0
- nodum-0.1.0/web/tsconfig.json +26 -0
- nodum-0.1.0/web/types/cytoscape-fcose.d.ts +13 -0
- nodum-0.1.0/web/vite.config.ts +54 -0
- 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).
|