ADR-0035 — Page content read/edit endpoints (GET/PUT /pages/{id}/content)¶
- Status: Accepted
- Date: 2026-06-30
- Sprint: v0.5 (M5 hardening — Wiki Notes editing surface)
- Features: F1 (3-panel shell — center editor) · F16 (
dataVersion-driven refresh) · K7 (Obsidian-validwiki/) - Resolves: the
GET /pages/{id}/contentreservation noted in ADR-0017 §center/right panels ("no content API exists → option (a);GET /pages/{id}/contentreserved for fast-follow"). This ADR promotes that stub to a read and write contract. - Invariants owned: I1 (HEADLINE — a PUT is a single-page targeted incremental update through
the existing write primitives; NEVER a vault rescan;
data_version+1 exactly once) · I5 (HEADLINE — editing raw markdown keepswiki/a valid Obsidian vault: frontmatter and[[wikilinks]]are preserved/re-parsed, never corrupted) · I2 (the singledata_versionbump fires the debounced GraphCache recompute; no inline FA2) · I7 (single pass, no loop, no inference; a byte cap bounds the write) - Author: solution-architect
1. Context¶
The Wiki Notes UI (the CodeMirror 6 center editor replacing the redundant center GraphPanel in the
pages section — see §8 invariant note in the companion review) needs to read a wiki page's raw
markdown and save edits back. Today the right-panel inspector renders metadata only (ADR-0017
option (a)); there is no endpoint that returns page content and none that writes an edit.
Two facts about the existing system constrain the design and are easy to get wrong:
-
pagesrows are metadata-only. ThePageORM row storesfile_path,title,type,sources[](JSONB),content_hash, coords,deleted_at— not the markdown body. The body lives on the filesystem atvault/<file_path>(wiki/<subdir>/<slug>.md). Postgres is the metadata/links system of record (ADR-0002); the file is the content system of record. The content API must therefore read/write the file, and keep the metadata row consistent with it. -
The watcher does NOT observe
vault/wiki/.watcher.pyschedules its observer onvault/raw/sources/only (observer.schedule(handler, str(watch_dir)),watch_dir = raw/sources). A write to awiki/*.mdfile produces no watcher event. This is by design (ADR-0026 §5 "Watcher non-interference": backend writes towiki/are invisible to the watcher, which prevents re-ingest loops). Consequence: the PUT endpoint cannot "let the watcher re-index it" — there is no watcher onwiki/. It MUST re-index the edited page inline, through the same incremental primitives the orchestrated writer uses, touching only that one page (I1). This is more incremental than a watcher event, not less: zero file-system round-trip, one page updated, onedata_versionbump.
1.1 Ground truth consumed (reuse, do not reinvent — I9)¶
Pagemodel +uix_pages_vault_file_path_live(models.py) — the live page row keyed by(vault_id, file_path)WHERE deleted_at IS NULL.content_hash=sha256of the serialized file (write_wiki_pagesetscontent_hash=_sha256(serialized.encode())). This hash is the optimistic lock token.write_wiki_page→ persist_metadata → upsert_vector → append_log → bump_version → parse/persist wikilinks → update_index (ingest/orchestrator.py) — the SINGLE incremental wiki-write seam (I1). The PUT endpoint reuses the same primitives to re-index the edited content (it does not callwrite_wiki_pageverbatim, because that path rebuilds frontmatter from a typedWikiFrontmatterand re-slugs the title; the editor saves a raw file whose frontmatter the user may have touched — so PUT writes the user's bytes and then re-runs the indexing primitives:persist_metadatafor the metadata fields,upsert_vectorfor the body,persist_linksfor wikilinks,update_index,bump_version). Same primitives, raw-file write.parse_wikilinks/persist_links(wiki/links.py) — K5 incremental link upsert. Re-run on the saved body solinksreflects the user's edits (added/removed[[wikilinks]]).upsert_vector(ingest/orchestrator.py) — re-embeds the body into Qdrant under the page UUID (no-op whenEMBEDDINGS_ENABLED=false, ADR-0030). Same point id, so search stays consistent (I1).bump_version()+ GraphCache debounce (graph/cache.py, ADR-0014) — one bump → the debounced FA2 recompute fires on its own schedule (I2; never inline)._relative_path/ path containment (the basename/..sanitization pattern inupload.py, ADR-0020) — the path-traversal guard model PUT/GET reuse.@app.delete("/provider/config/{id}")/DELETE /pages/{id}(main.py, ADR-0026) — the house path-param + 404 pattern the new routes mirror.
2. Decision summary¶
Add two routes under the existing /pages/{id} namespace:
GET /pages/{id}/content → 200 {file_path, title, type, content_hash, content}
PUT /pages/{id}/content → 200 {file_path, content_hash, data_version} (new hash + new version)
pagesrows stay metadata-only. Content is read from / written to the file atvault/<file_path>. The endpoints never add a content column topages(ADR-0002 split preserved).- GET resolves the page row (404 if absent or soft-deleted), reads the file bytes, and returns the
raw markdown including the YAML frontmatter block plus the row's
content_hashas the lock token. - PUT takes
{content, content_hash}: - Resolve the live page row by id (404 if absent/soft-deleted).
- Optimistic-lock check: if the supplied
content_hash != row.content_hash→ 409 Conflict (the file changed under the editor; the client must re-GET and merge). No write happens. - Path-traversal guard: recompute the absolute path as
(vault_root / row.file_path).resolve(), assert it is insidevault_root.resolve()and thatrow.file_pathstarts withwiki/(content edits are wiki-only;raw/sources/is immutable, K1). A row whosefile_pathescapes the vault or targetsraw/→ 403. The client never supplies a path — only the page id — so traversal is structurally hard; this guard is defence-in-depth. - Bound (I7): reject a body larger than
MAX_PAGE_CONTENT_BYTES(default 5 MB) → 413. - Atomic write: write the new bytes to a sibling temp file in the same directory and
os.replace()it ontorow.file_path(atomic same-filesystem rename — no torn file if the process dies mid-write; readers never see a half-written page). - Inline incremental re-index (I1 — the critical step): recompute
new_hash = sha256(new_bytes), then run the existing index primitives for this one page only:persist_metadata(updatecontent_hash, andtitle/type/sources[]parsed from the saved frontmatter so metadata tracks the edit) →upsert_vector(re-embed the body under the same UUID) →parse_wikilinks+persist_links(re-derive this page'slinksrows) →update_index→bump_version()exactly once. No other page is read or written; no vault walk. - Return
{file_path, content_hash: new_hash, data_version: <new>}.
No inference, no loop, no FA2 in this path. It resolves no provider (I6 untouched — no provider is
involved), runs no bounded loop, and never calls GraphEngine.recompute() (I2 — the debounced cache
fires on the bump).
2.1 Request/response contract (D4)¶
GET /pages/{id}/content
200 → { "file_path": "wiki/concepts/x.md", "title": "X", "type": "concept",
"content_hash": "<sha256>", "content": "---\ntype: concept\n...\n---\n\nBody [[Y]]…" }
404 → page id unknown OR soft-deleted
PUT /pages/{id}/content
body: { "content": "<full raw markdown incl. frontmatter>", "content_hash": "<sha256 from GET>" }
200 → { "file_path": "...", "content_hash": "<new sha256>", "data_version": 124 }
404 → page id unknown OR soft-deleted
409 → content_hash stale (file changed since GET) — re-GET and retry; NO write performed
413 → body exceeds MAX_PAGE_CONTENT_BYTES
403 → resolved path escapes vault_root OR targets a non-`wiki/` file (raw/ is immutable)
make openapi regenerates docs/api/openapi.json with both routes (I8). No DB migration — pages
gains no column; content_hash already exists. D2 unchanged.
3. Optimistic concurrency (the 409 contract)¶
content_hash (sha256 of the on-disk serialized file) is the version token. The flow is read-modify-
write with compare-and-swap:
- The editor GETs
{content, content_hash=H0}and lets the user edit in CodeMirror. - On save it PUTs
{content=edited, content_hash=H0}. - The server compares
H0against the currentrow.content_hash: - match → write, set the row's hash to
sha256(new_bytes), return the new hash. The lock is advanced; the next save must carry the new hash. - mismatch → 409. Something changed the page since the GET (a cascade-delete rewrite, ADR-0026; a Create from the review queue, ADR-0034; a re-ingest; another editor tab). The client re-GETs, shows the user the divergence, and retries. No partial/last-writer-wins clobber.
The hash is compared against row.content_hash (Postgres), which persist_metadata keeps in lock-step
with every write through the seam — so the lock is authoritative even though the body lives on disk.
(Belt-and-braces option, deferred: also hash the on-disk bytes at PUT time and 409 if the file drifted
from the row; not needed in M5 because every legitimate wiki/ writer goes through the seam and updates
the row hash. Recorded as §6 risk 2.)
4. Why inline re-index, not a watcher event (I1 clarification)¶
The naïve framing "PUT writes the file, the watcher debounces one FS event and re-indexes" does not
apply here: the watcher observes raw/sources/ only and is deliberately blind to wiki/ (ADR-0026 §5).
Extending the watcher to wiki/ would be wrong — it would re-fire on Synapse's own wiki writes
(cascade-delete rewrites, review Creates, orchestrated generation), creating exactly the re-ingest loop
ADR-0026 §5 prevents. So PUT re-indexes inline:
- It updates one page's metadata row, one Qdrant point, one page's
linksrows, the catalogue, and bumpsdata_versiononce. This is the textbook I1 "a file change updates only affected records" — strictly a single-page targeted update. - It NEVER enumerates
vault/wiki/, NEVER re-embeds other pages, NEVER rebuilds the graph inline. - The single
bump_version()triggers the debounced FA2 recompute (I2), so the edited page's new/removed links eventually re-flow the layout server-side — never on the UI thread.
This is more incremental than a watcher round-trip (no FS event, no re-read of the file we just wrote).
5. Obsidian compatibility (I5)¶
The editor saves raw markdown including the frontmatter block — the user edits the same text Obsidian
shows. The endpoint does not round-trip the frontmatter through PyYAML on the write path (which
would reorder keys / reflow lists — the DEFECT-F13-002 class of corruption, ADR-0026 §4.3): PUT writes
the user's bytes verbatim. To keep the metadata row consistent it parses the saved frontmatter
read-only (frontmatter.loads) to extract title/type/sources[] for persist_metadata; the parse
is non-mutating and the bytes on disk are exactly what the user typed.
- If the user's edit produces invalid YAML frontmatter, the parse fails. PUT then returns 422 with a clear message and does not write (an invalid-frontmatter page would break Obsidian and the indexer — fail closed, never persist a vault-invalidating file). The body-only edit case (no frontmatter change) parses fine and proceeds.
[[wikilinks]]the user adds/removes are re-parsed byparse_wikilinksand re-persisted, so the K5 link graph and the dangling flags stay correct — the vault's link structure tracks the edit.- The file stays a valid Obsidian note: frontmatter fence intact, wikilinks intact, body markdown intact.
test_obsidian_check.pyremains the gate (must stay green after an edit round-trip).
6. Flagged tensions & risks¶
- Title/slug edits via frontmatter. If the user edits the
titlein frontmatter, the file name (<slug>.md) no longer matches the new title. M5 decision: PUT updates the metadatatitlebut does not rename the file (a rename is a delete+create that touches every inbound[[wikilink]]— that is cascade-delete/rename territory, ADR-0026, out of scope here). The slug-title drift is cosmetic and Obsidian-valid (the filename is just a slug). A proper title-rename flow is a post-M5 ADR. Recorded, not built. - Out-of-band file drift. The 409 lock compares the request hash to
row.content_hash, which every in-seam writer updates. A hand-edit on the TrueNAS filesystem (outside Synapse) would drift the file from the row without bumping the row hash; PUT would then overwrite it. Acceptable for M5 (single operator; Synapse ownswiki/). The optional on-disk re-hash guard (§3) closes this if it ever bites. - Concurrent PUT + cascade-delete/Create race. Both advance
content_hashthrough the seam, so the second writer's 409 fires correctly (compare-and-swap). The atomicos.replaceensures no torn file. - No content column. Reading from disk on every GET is an extra FS read vs. a DB column, but it preserves the ADR-0002 metadata/content split and avoids a dual-write consistency problem (the file is the content source of truth). Acceptable; the file read is cheap and bounded by the 5 MB cap.
7. Do-NOT list (rejection triggers — any one is a block at PR review)¶
- DO NOT add a
contentcolumn topagesor otherwise store the body in Postgres — content is on the filesystem;pagesis metadata-only (ADR-0002). - DO NOT rescan the vault on save. PUT updates exactly ONE page through the index primitives and
bumps
data_versionONCE (I1). Aglob('wiki/**/*.md')or a full re-embed on save is a reject. - DO NOT subscribe the watcher to
vault/wiki/to "pick up" the edit — that recreates the ADR-0026 §5 re-ingest loop. Re-index inline in the endpoint instead. - DO NOT call
GraphEngine.recompute()/ FA2 inline (I2). Onebump_version(); the debounced cache does the rest. - DO NOT skip the optimistic-lock check. A stale
content_hashis a 409, never a last-writer-wins clobber. - DO NOT accept a client-supplied file path — only the page id; resolve the path from the row
and assert vault containment +
wiki/prefix (noraw/, no.., no absolute). Path escape → 403. - DO NOT PyYAML round-trip the frontmatter on the write path (key reorder / list reflow = I5 corruption). Write the user's bytes verbatim; parse read-only for metadata.
- DO NOT persist a page whose frontmatter is invalid YAML (would break Obsidian) — 422, no write.
- DO NOT write the file non-atomically. Temp-file +
os.replace()so a crash never leaves a torn page (a half-written page breaks I5 and the indexer). - DO NOT ship without
make openapiregenerated (I8). No DB migration is needed (no D2 change).
8. Invariant compliance¶
| Inv | How this design guarantees it |
|---|---|
| I1 | A PUT is a single-page targeted incremental update: one metadata row, one Qdrant point, one page's links, one update_index, one data_version +1. No vault walk, no re-embed of other pages, no watcher round-trip. This is the canonical I1 "change updates only affected records". |
| I2 | The single bump_version() triggers the debounced GraphCache recompute; the endpoint never runs FA2 inline. Layout stays server-side and cached. |
| I5 | The editor saves raw markdown incl. frontmatter; PUT writes the bytes verbatim (no PyYAML reorder), re-parses [[wikilinks]] (K5), and refuses to persist invalid-YAML frontmatter (422). wiki/ stays a valid Obsidian vault; test_obsidian_check.py is the gate. |
| I7 | No loop, no inference. The write is bounded by MAX_PAGE_CONTENT_BYTES (413). Single pass. |
| I6 | Not engaged — the content path involves no inference provider; nothing is hardcoded because nothing is routed. |
| I8 | make openapi regenerates docs/api/openapi.json (two new routes). No schema change → D2 unchanged. D3: the existing wiki-write sequence may note the editor PUT as an alternate entry; no new container (D1 unchanged — the route lives in the existing FastAPI service). |
No invariant is traded. The two genuine tensions (title-rename, out-of-band drift) are flagged in §6 and deferred with recorded rationale.
9. Implementation plan¶
- backend-engineer [BE]: add
GET/PUT /pages/{id}/contenttomain.py(PydanticPageContentResponse/PageContentUpdate); the GET file read; the PUT lock check (409), path guard (403), byte cap (413), invalid-frontmatter guard (422), atomic temp-fileos.replacewrite, and the inline re-index viapersist_metadata/upsert_vector/parse_wikilinks+persist_links/update_index/bump_version. AddMAX_PAGE_CONTENT_BYTEStoconfig.py. Runmake openapi→ commitdocs/api/openapi.json(I8). Tests: GET 200/404; PUT 200 bumpsdata_versionexactly once + advances hash + re-persists wikilinks; 409 on stale hash performs no write; 413 on oversize; 403 on a non-wiki/or escaping path; 422 on invalid frontmatter;test_obsidian_check.pygreen after an edit round-trip; PUT does NOT touch any other page's row/point. - frontend-engineer [FE]: the Wiki Notes CodeMirror 6 editor (see the companion invariant review)
fetches
GET /pages/{id}/content, edits, andPUTs{content, content_hash}; on 409 it re-GETs and surfaces a conflict notice; on success it relies on the bumpeddata_version(F16) to refresh the tree/graph. The editor is CodeMirror 6 (I4 — never WYSIWYG/ProseMirror). - tech-writer: this ADR (format + README row), USER.md (editing a wiki page, conflict behavior), and the D4 OpenAPI surface. No D1 topology change (no new container/component).
10. Sign-off¶
APPROVED to implement. pages stays metadata-only; content read/write targets the filesystem.
GET returns raw markdown + the content_hash lock token; PUT does compare-and-swap (409 on stale),
guards path traversal (403) and size (413), refuses invalid-frontmatter (422), writes atomically, and
re-indexes the single edited page inline through the existing incremental primitives — one
data_version bump, no rescan, no watcher event, no FA2. I1 and I5 are satisfied by construction;
I2/I7 are honoured; I6 is not engaged. No DB migration; make openapi regenerated (I8).
Handoff: ADR-0035 → tech-writer (format, README row, USER.md). Interface contract (§2) → backend-engineer [BE] + frontend-engineer [FE]. PR verdicts → orchestrator.