Architecture¶
This page explains the implementation layout and the reasons behind its boundaries. Normative behavior is owned by the contract catalog, especially the synchronization, agent-interface, workspace, and security contracts.
Module map¶
cli.py argparse tree, JSON envelope, exit-code mapping, logging
config.py connection resolution: CLI > env > workspace > discovery
api.py Joplin Data API client (urllib): pagination, GET retries,
ambiguous-write surfacing, token redaction, port discovery
mcp_service.py validated note/notebook/tag/resource operations, relationship
traversal, base64 limits, bounded Joplin availability waits
mcp_server.py combined HTTP listener and shared MCP JSON-RPC dispatcher
mcp_stdio.py local newline-delimited MCP transport over stdin/stdout
tool_registry.py shared immutable definitions, Actions exposure, effect mapping
tool_schema.py dependency-free JSON Schema subset validation
tool_executor.py shared handler invocation and failure classification
gpt_actions.py authenticated Actions routing, limits, envelopes, audit metadata
gpt_openapi.py deterministic OpenAPI 3.1 generation and registry hash
canonical.py body/tag canonicalization + SHA-256 component hashing
metadata.py managed Markdown header parse/emit (single-line JSON comment)
paths.py cross-platform filename sanitization, traversal guards
workspace.py layout, recursive scan, atomic writes, backups, quarantine
state.py SQLite: base snapshots, tombstones, conflicts, run records,
integrity check, migration framework
locking.py exclusive cross-platform lock (fcntl / msvcrt)
journal.py crash-safe run journal + recovery
planner.py PURE three-way classification and plan building
sync.py remote snapshot + executor (guard/apply/verify/commit)
diff.py summary / name-status / unified / three-way rendering
conflicts.py bundles, staleness-checked resolution
resources.py resource download
update_check.py GitHub releases freshness check
errors.py exception hierarchy = exit codes + result codes
models.py shared dataclasses and status constants
The intended separation is transport (api), state (state and workspace),
decision (planner, which is pure), execution (sync), and rendering (cli
and diff). The planner stays easy to exercise because it takes plain data
structures and performs no I/O.
The direct Joplin adapters are deliberately separate from the workspace sync engine:
HTTP MCP client ---> MCP HTTP adapter ----+
Local MCP client --> MCP stdio adapter ---+-> tool registry/executor -> mcp_service -> api -> Joplin
Custom GPT Action -> Actions HTTP adapter +
It directly manages Joplin notes, notebooks, tags, and resources and does not mutate workspace state. The service layer is transport-independent, so future transports can reuse its validation, metadata shaping, relationship handling, multipart uploads, and outage behavior.
The two transports share the same in-process executor. The Actions adapter
never calls /mcp, and HTTP controllers never bypass the registry to call the
Joplin client directly. Binary resource read/create/update stays available to
MCP but is explicitly disabled in Actions exposure metadata because its 10 MiB
contract cannot fit the Actions text/payload limit.
Data flow of a mutating command¶
lock workspace (exclusive)
check for incomplete journals -> exit 6 if any
scan local files (workspace.py)
snapshot remote (sync.build_remote_snapshot)
classify base/local/remote (planner.classify — pure)
build ordered operation plan (planner.build_plan — pure)
[--dry-run stops here]
persist plan to journal (journal.begin)
for each op: guard -> apply -> verify -> commit base -> journal.mark
finalize: cursor, journal complete, prune backups
Key design decisions¶
- Zero runtime dependencies. Everything is stdlib; tests use
unittest; the fake Joplin server ishttp.server. This keeps the wheel/pyz trivially portable and the supply-chain surface nil. - Base snapshots are full copies, not hashes only. A true three-way
comparison and offline
status/diff --offlineneed base content, and it letsbuild_remote_snapshotskip body fetches for notes whoseupdated_timeis unchanged. - Tags are reconciled via the tag map (
GET /tags+GET /tags/:id/notes), because tag attachment does not bump a note'supdated_time. Cost scales with number of tags, not notes. - Optimistic concurrency, no fake locks. The Joplin API has no conditional writes; instead every op re-reads its inputs immediately before applying and re-reads the result after, aborting that single op (exit 5) on drift. Joplin revisions are never used as versions/ETags.
- The journal marks an op applied only after the base commit, so recovery can decide "applied or not" purely from current state, without re-running network operations.
- Ambiguous writes (timeout after a PUT) are settled by re-reading: result == intended → applied; result == pre-state → reported as failed (safe rerun); anything else → concurrent modification.
/eventsis not used in v1 (full reconciliation is cheap at the target scale and events cover only notes anyway); the cursor is stored for a future optimization.- Filenames are cosmetic. Identity lives in the header id + state DB; pull normalizes names; a rename alone is never a content change.
State database¶
The current schema groups persistent information by responsibility:
meta key/value: state_schema_version, event_cursor, ...
notes id, rel_path, title, body, tags(json), parent_id,
updated_time, component hashes, combined hash
folders id, rel_path, title, parent_id
tombstones note_id, side (local/remote/both), rel_path, title, time
conflicts id, note_id, rel_path, category, created_time, status
journal_runs run_id, command, started_time, status, journal_path
Schema compatibility and migration behavior are specified by
SYN-009.
Implementation changes start in state.MIGRATIONS and must update the linked
evidence before this explanatory inventory is changed.