Synapse v2.1.16 — "a rename carries its own mode"¶
Patch release closing a defect that quietly makes the shared vault unreadable from the Obsidian side, a data-loss guard missing from one of two sibling maintenance ops, and the only dependency advisory that reaches the production bundle. No schema migrations, no new endpoints, no API changes, no new configuration keys.
Like 2.1.8 through 2.1.15, none of them came from a live failure report — all three were found by reading code during weekly maintenance. And like most of those weeks, the shape is a guard applied at one of the places that need it. What makes this week worth writing down is that the repository had already written the fix down and then not generalised it:
Four weeks ago, 2.1.12 added an
os.chmodto the one vault write site it was converting, with a comment explaining exactly why the vault needs it. Six older sites write the same way. None of them got it.
The second fix is the same sentence about a different pair: ops/reconcile_folders documents
and enforces a "never overwrite" guard, and its twin module — whose header says it mirrors that
architecture — does not have one.
Fixed¶
Every file published into the vault was owner-only, and saving a page edit downgraded an existing one¶
Every crash-safe vault write in Synapse has the same two steps: write the bytes to a temp file
in the destination's own directory, then rename it over the destination, so no reader — Obsidian,
LiveSync, the watcher — ever observes a half-written file. The temp file comes from
tempfile.mkstemp:
tmp_fd, tmp_name = tempfile.mkstemp(dir=str(abs_path.parent), suffix=".block_tmp")
os.write(tmp_fd, data)
os.close(tmp_fd)
Path(tmp_name).replace(abs_path) # ← the destination's mode does not survive this
mkstemp creates its file 0600 — correct, it is a temp file. The mistake is in the last
line. os.replace does not copy the destination's permissions onto the incoming file: it
unlinks the destination inode and gives its name to the temp file, which keeps the mode it
already had. So the consequence is worse than "new vault files are owner-only":
- a newly generated page lands at
0600; - rewriting an existing
0644page takes it down to0600.
The second is the one a user meets. PUT /pages/{page_id}/content — the editor's save — goes
through this path on every call, so editing a page through the UI silently changed its
permissions.
Why that matters here and not in a generic app: the backend runs as uid 1000 inside its
container, while vault/ is a bind mount shared with Obsidian/LiveSync on the host
(CLAUDE.md §1 — the vault-within-vault model is the whole point of the product). A 0600
file is one that only the backend can read. Invariant I5 says wiki/ must remain a valid
Obsidian vault; a vault whose pages Obsidian cannot open is not one. And the failure is
completely silent: nothing logs it, no functional test notices (the backend reads its own
files fine), and the symptom surfaces on the other side of the sync — which is why a user
would file it against LiveSync, not against Synapse.
Six sites were affected:
| Site | What it writes |
|---|---|
ingest/block_writer._atomic_write |
every page the ingest loop generates |
ingest/orchestrator.reindex_wiki_page_body |
the in-place rewrite behind wikilink enrichment |
PUT /pages/{page_id}/content |
the editor's save — downgrades a live page |
POST /ingest/upload |
uploaded source files |
POST /ingest/from-text |
pasted / save-to-wiki sources |
POST /clip |
Chrome clipper captures |
The sites that still use write_text()/write_bytes() — wiki/index.py, so index.md — were
never affected: those create at the process umask, i.e. 0644. That is the tell. It is
precisely the sites that were converted to mkstemp (for streaming, or for atomicity) that
regressed, one at a time, each conversion silently importing the mode along with the
crash-safety. POST /ingest/convert-marker was the most recent such conversion, in 2.1.12, and
it is the only one that noticed:
# mkstemp() creates 0600; the vault is shared with Obsidian/LiveSync and was
# previously written by write_bytes() at the process umask, so restore 0644
# rather than handing the vault a file only this UID can read.
os.chmod(tmp_name, 0o644)
The comment is right, and it is the diagnosis for all seven sites. It was applied to one.
Root cause: a file's mode treated as a property of the destination path when it is a property of the inode being renamed into place.
The chmod and the rename are now a single operation in a single place — app/vault_io.py, with
VAULT_FILE_MODE as the one named constant — and every site goes through it, including the
marker site, which no longer carries its own copy:
def publish_tmp_file(tmp_name: str | Path, dst: Path) -> None:
os.chmod(tmp_name, VAULT_FILE_MODE)
os.replace(tmp_name, dst)
The chmod deliberately happens before the rename, so dst is never momentarily owner-only:
the rename stays the single atomic step that publishes the bytes and the mode together.
The same helper fixes a second, narrower bug it inherited from the open-coded idiom. Each site closed the descriptor in the happy path and closed it again in its error path:
os.close(tmp_fd)
Path(tmp_name).replace(abs_path) # if THIS raises…
except Exception:
os.close(tmp_fd) # …the fd is already closed
A failure in the rename is the one failure that happens after the close, so the handler
called os.close on a descriptor number the process may already have reissued to an unrelated
file or socket — closing someone else's file. atomic_write_bytes tracks the close and does it
exactly once.
backend/tests/test_vault_file_mode.py pins the invariant at all seven sites and at both
primitives. All 11 cases fail without the fix, reading 0o600 where 0o644 is asserted, and
so does the pre-existing convert-marker mode assertion — confirming the refactor kept its
coverage.
Migrating a lint stub could overwrite the real page it was standing in for¶
ops/migrate_lint_query_stubs moves the legacy type=query stubs Wiki Lint used to write for
missing wikilink targets out of wiki/queries/ and into entities/ or concepts/. The move
had no destination-exists check.
The destination path is built from the stub's own stem, so it collides with a genuine page whenever the vault already holds the page the stub was a placeholder for. That is not an adversarial case — it is the ordinary end state:
- a page links
[[Google Cloud]], which does not exist yet; - Lint writes the placeholder
wiki/queries/google-cloud.md,title: Google Cloud; - later, ingest authors the real
wiki/entities/google-cloud.mdwith the same title; - wikilinks resolve by title, so the link quietly starts resolving to the real page —
and nothing removes the stub, which keeps sitting in
queries/under the same slug.
Applying the migration then renamed the stub over the real page: the real body was destroyed,
and two live rows ended up pointing at one file, the surviving row carrying a content_hash
that no longer matched its own bytes.
The sibling module states the rule in its header and enforces it:
ops/reconcile_folders.py:
- Collision safety: if the destination path already exists, skip + log (never overwrite).
migrate_lint_query_stubs's own header says it mirrors that architecture. It was the one of
the two without the guard.
Root cause: a path derived from a slug treated as necessarily free.
The move now raises FileExistsError before any mutation, leaving both files intact so an
operator can resolve the collision by hand. _run_inner's existing per-page handler catches
it, counts the page as failed and continues — so one collision no longer costs the rest of
the batch either.
Latent today. Nothing in the running app reaches this module: no router, no scheduler. It is a hand-run remediation tool (ADR-0067 D1 — 133 stubs on prod), so the defect has never fired. It is fixed here rather than filed in the 3.0 backlog because the day someone runs it is the day it would matter, and the guard is six lines.
Security¶
dompurify 3.4.13 → 3.4.16¶
GHSA-p98j-92pf-mc4p (DOM XSS). This is the only advisory that reaches the production
bundle: npm audit --omit=dev goes from 1 to 0. dompurify is a direct dependency and the
sanitizer that every rendered markdown string passes through (renderMarkdown.ts) — LLM chat
output, wiki page bodies, and web-fetched research text.
A patch bump inside the existing ^3.4.13 range, so package.json is untouched and only
package-lock.json moves.
Scoped honestly: the advisory needs IN_PLACE: true together with a node-removing
afterSanitize hook, and renderMarkdown.ts uses neither — there is no DOMPurify.addHook
anywhere in the codebase — so it is not reachable through Synapse's own usage. It is taken
anyway because this is the one library whose entire job is to be the XSS boundary for
model-generated and web-fetched HTML, and the bump costs nothing.
The 6 remaining advisories are dev/build-chain only and each requires a MAJOR bump of vite or
vitest, which stays out of scope for a patch release. They are already recorded in
docs/reference/ROADMAP-3.0-IDEAS.md.
Also in this release¶
Three new entries in the 3.0 backlog (docs/reference/ROADMAP-3.0-IDEAS.md):
- No automatic check that a file published into the vault is readable by the vault's other
readers. This release routes all seven write sites through one helper, but nothing
prevents the eighth: the next hand-written
mkstemp+os.replacereintroduces the same defect, silently, and 2.1.12 shows that this is what actually happens. The current defence is a test that enumerates the known sites, which grows only if whoever adds a site remembers to. Closing it means choosing between a static gate and a runtime check incheck_obsidian— a decision, not a patch. GraphCacheloses the follow-up recompute when a bump lands during a background revalidate._kick_background_revalidate()sets_in_flightsynchronously and thencreate_tasks, so the task body's_pending = False— whose comment holds fortick(), which has noawaitin between — can erase anotify_bump()that arrived in that window. Low impact: it self-heals on the nextGET /graphmiss. The small diff comes with a hard test to write, because the state machine has three entry points and no existing test can schedule inside that window.- The update check has no negative cache: a GitHub 403 makes it retry on every request.
_fetch_latest_version()promises an ~1h cache "to respect the unauthenticated rate limit", but only the success path writes the cache, so a rate-limit response is exactly when the cache stops existing. Fixing it needs two TTLs and a decision about what the UI shows while a negative cache is warm — a behaviour change on an existing flow.
The long-standing pypdf entry is unchanged and still growing: pip-audit now reports 49
distinct advisories against the pinned 5.9.0 — up from 41 at 2.1.15 — every one fixed only in
the 6.x line, behind a <6 constraint. Still a major bump, still out of scope for a patch.
Upgrade notes¶
docker compose pull && docker compose up -d
- No schema migration. No SQLAlchemy model changed, so
docs/er/schema.mmdis unchanged and no Alembic revision ships with this release. - No API change.
docs/api/openapi.jsondiffers only in the version string. - No new configuration. Nothing new to set in
.env; no existing key changes meaning. - Existing vault files keep the permissions they already have. This release fixes the
writes; it does not sweep the vault. Pages written or edited before upgrading are still
0600, and they will be corrected individually the next time each one is written — which for a page nobody edits is never. If yourwiki/is shared with Obsidian/LiveSync (the standard setup), fix the existing files once, from the host, as the user that owns the mount:
find /path/to/vault -type f -perm /u+r ! -perm -g+r -exec chmod 644 {} +
Then confirm Obsidian can open a page it previously could not. If your vault is accessed only by the Synapse container, nothing was ever broken for you and there is nothing to do. - If the Obsidian side has been failing to read recently-edited pages, this was the cause. No page content was ever lost or corrupted by this defect — only the permission bits changed; the bytes on disk were always written correctly. - No client-side state to clear. Existing OAuth grants, refresh tokens, API tokens, conversations and vault content are untouched.