Writings ⇄ vault: the obsidian import/export subsystem
Today (landed): manual, owner-triggered, batch architecture. GET /api/admin/obsidian/export streams a standmeet-vault.zip (no temp file); POST /api/admin/obsidian/import accepts a whole-vault multipart upload (browser webkitdirectory, 200 MB cap — backend/internal/routes/admin/obsidian.go:149), returns a summary of {created, updated, skipped, errors}; GET /api/admin/obsidian/state reports when the last import ran (6cf4e2b08, 2026-08-21). This page covers the writings branch of that pipeline — writings are still a separate estate from the corpus tiers in addressing (flat, slug-keyed) and in linker (writing_refs) (confusables), even though since bfb8c7129 (2026-07-09) they are rows of the one corpus_notes table with genre='writing'. Wiki / subjectivity / raw / output go through SyncVault and export_corpus.go instead — see obsidian-sync-mechanism.
Zip layout and frontmatter contract
Export writes two directories:
writings/<slug>.md— each writing as frontmatter + bodyattachments/<asset-id>.<ext>— deduped by storage key; extension guessed from content-type header
Frontmatter fields (the vault-to-writing schema):
title,slug,excerpt,tags,aliases— metadatacreated,publish— timestamps and the import gatecover_hue,cover_headline,cover_image— cover metadatavisibility,locked_body— permissions and locks
Import gate: publish: true is the ONLY path into the database. Anything else is silently counted as skipped. This is a deliberate boundary: the vault is the owner's draft or archived space; only explicitly published frontmatter matters.
Idempotency — and the web-edit guard that was removed
The idempotency key is obsidian_source_path — the vault-relative path to the .md file — with slug as the fallback (upsertFromVault, backend/internal/corpus/obsidian/import.go:252 / :279), so a moved file with a stable frontmatter slug matches its old row.
Re-import flow:
- Path (or slug) exists in DB → UPDATE the row, overwrite from vault
- Neither exists → CREATE a new row
The web-edit guard (updated_at > obsidian_imported_at + 1s → SKIP) was removed on 2026-07-15 (84080bac5, F-L-6): the vault is the single live source and sync makes destination equal source, no "who wins" (import.go:14). A web edit survives only if the owner exports it back before the next sync. SetObsidianMeta still stamps obsidian_imported_at = now after save, but as provenance, not as a guard.
flowchart TB
F["incoming .md<br/>publish: true"] --> L{"row with same<br/>obsidian_source_path,<br/>else same slug?"}
L -- no --> CR["CREATE"]
L -- yes --> UP["UPDATE<br/>overwrite from vault<br/>(no web-wins since 84080bac5)"]
CR --> ST["stamp imported_at"]
UP --> ST
Attachment reference round-trip
Obsidian and StandMeet speak different reference languages. The system must bridge both directions losslessly (or at least predictably).
Export (writings → Obsidian):
- Body contains
standmeet-asset:uuidtokens - Rewrite to
attachments/id.exton export
Import (Obsidian → writings):
- Incoming body has
![[img.png]](image embeds) or(markdown image links) - Basename resolution: match incoming reference against uploaded attachments by filename alone (e.g.,
img.png), independent of folder nesting. This follows Obsidian's own semantics for cross-vault file moves. - Pending UUID placeholder →
standmeet-asset:uuidtoken (atomic withSaveWriting) - External
http(s)://refs and unmatched refs are left as-is, not errored
flowchart LR
subgraph EXPORT
A1["body: standmeet-asset:uuid"] --> A2["rewrite → attachments/id.ext"]
end
subgraph IMPORT
B1["body: ![[img.png]]<br/>"] --> B2["resolve by BASENAME<br/>against uploaded attachments"]
B2 --> B3["pending-uuid → standmeet-asset:uuid<br/>atomic with SaveWriting"]
end
Backlinks converge in two passes — fixpoint iteration
A write-once trap for forward references: the CrossRefs input on import is ignored entirely. Instead:
SaveWritingis called with the body- Inside SaveWriting,
refreshCrossLinksre-parses literal[[X]]from the saved body - It rebuilds
writing_refsby resolving links only against writings that already exist in the database
Consequence: a forward link to a not-yet-imported writing stays unresolved on pass 1 and only connects on a second import (after both are created). The system is literally iterating refs = F(refs) to a fixed point — a deterministic, re-runnable operation that converges as more writings are imported.
sequenceDiagram participant V as vault upload participant S as SaveWriting participant R as writing_refs V->>S: import A (body has [[B]]) S->>R: rebuild refs for A — B not found, link dangles V->>S: import B S->>R: rebuild refs for B Note over R: A→B STILL missing;<br/>fixpoint not reached V->>S: re-import A (pass 2) S->>R: rebuild refs for A — B now resolves Note over R: fixpoint reached
Be precise about what IS and ISN'T guaranteed: each file is idempotent (same file → same row, no duplicates), but the ref graph is not single-pass — importing the same ZIP a second time adds the forward links the first pass couldn't resolve. Deterministic in the limit (the fixpoint), not per run; the "import twice" wart is the user-visible face of exactly this.
Class view — the subsystem's moving parts
classDiagram
class ExportDeps {
Writings *corpus.WritingRepo
Assets *corpus.AssetRepo
Storage *storage.Client
Corpus *corpus.VaultSyncRepo - corp notes, optional
}
class WriteZip {
<<func>>
takes ctx, ExportDeps, ownerID, io.Writer
streams standmeet-vault.zip - no temp file
rewrites standmeet-asset refs outbound
then writeCorpusNotes when Corpus is set
}
class ImportVault {
<<func>>
takes ctx, WritingsTxDeps, MetaSetter, ownerID, []VaultFile
returns ImportResult - created, updated, skipped, errors
publish gate + source-path-or-slug upsert, no web-edit guard
}
class SaveWriting {
<<func>>
takes ctx, WritingsTxDeps, *SaveWritingInput
returns (domain.Writing, error)
atomic with asset token swap
}
class refreshCrossLinks {
<<func>>
takes ctx, deps, pgx.Tx, *domain.Writing
re-parses literal wikilinks from the saved body
resolves against EXISTING rows only
rebuilds writing_refs - the fixpoint step
}
WriteZip ..> ExportDeps
ImportVault --> SaveWriting : per accepted file
SaveWriting --> refreshCrossLinks : inside the tx
Known gaps
Honest list of current limitations:
-
Folder hierarchy lost — writings only now: export flattens nested
writings/folders into a singlewritings/directory, andpickSlugderives a writing's slug from the bare basename. Since9f1ffcf13(2026-07-29)wiki/·subjectivity/·output/are exported as genre folder + tree + folder-notes (export_corpus.go), so those round-trip; only the writings branch is still not folder-symmetric. -
Rename orphans rows — only when the slug changes too: identity is
obsidian_source_pathorslug(import.go:252/:279; pinned bye2e/test/corpus-sync-rename.spec.ts,82dd221aa, 2026-07-05). A moved or renamed file whose frontmatter slug is stable updates its old row. A rename that also changes the slug (no frontmatter slug — the filename is the slug) creates a new row, and the old one stays: the writings branch never deletes (import.go:65), unlike the corp branch's authoritative prune. -
Mid-stream errors not surfaced: export streams
standmeet-vault.zipwithout buffering. If an error occurs mid-stream, the client receives a truncated zip and no error indication. -
Extension guessing is lossy: content-type headers are sniffed to guess file extensions. Some MIME types map to multiple extensions; the system picks one (e.g.,
.jpegvs.jpg), and re-import may not match the original extension. -
cover_imagedoesn't render inline: thecover_imagefrontmatter field round-trips as plain YAML. Obsidian won't render it as an inline image preview, making the cover experience asymmetric between vault and web. -
Wiki and output tiers not wired— shipped: import viaSyncVault(backend/internal/corpus/obsidian/sync.go:88), export viaexport_corpus.go(9f1ffcf13, 2026-07-29); see obsidian-sync-mechanism.
Both proposals landed — neither in the shape proposed here
The linker two-pass → whole-batch resolution
Proposed as two sequential passes (save bodies, then rebuild refs). What shipped is simpler: resolveLinks runs after all upserts, against the full owner-wide title index, so forward links resolve in one import. The "import twice" wart is gone, which was the point; the two-pass framing turned out to be an implementation detail of that goal rather than the goal.
Stable identity → keyed on title, and a rename deliberately orphans
Proposed as an exported id in frontmatter, resolved id > source_path > slug. Not what shipped, and the difference is a decision, not a shortfall. Identity is keyed on title (= the filename basename), owner-scoped and cross-genre — the vault's own check-links.sh already guarantees a basename is unique vault-wide, so the vault's constraint is the key.
Consequences, both deliberate: a move relocates the same row (including across genres — raw/x.md → wiki/x.md is the same note, which is exactly what made the semiotics promotion a move rather than a copy); a rename orphans, because the vault-side normalize-names already repoints wikilinks, so the vault is the place that owns renaming. obsidian_source_path / imported_at are kept as provenance (the web-wins guard they once served is gone, 84080bac5); source_path becomes the identity only when a basename is duplicated across the corpus — then the same-name files keep their own rows (F-L-2 / F-L-61, sync.go:4-7, sync_ambiguity.go).
Writing an id into the owner's own markdown was rejected for the reason it usually is: it puts a database's bookkeeping into a file a human edits by hand.
Cross-references
- obsidian-sync-mechanism — this is the current implementation of its "writings wired" part; it runs as a sync-mode connector via connector-plugins (
backend/internal/connector/obsidian.go) - backlinks-as-rebuilt-edge-tables — the
writing_refstables that backlinks rebuild on write - corpus — the parent tier that wraps writings, wiki, and output