Skip to content

feat(notion-md): per-file sync-direction policy on the V2 engine #746

@schickling-assistant

Description

@schickling-assistant

Problem

@overeng/notion-md sync is bidirectional and remote-first. On the V2 engine, syncPage (src/sync.ts:968-1019) chooses direction at runtime from drift, and pullPage (src/sync.ts:543) unconditionally overwrites the local .nmd body + frontmatter from Notion; syncWorkspace (src/workspace.ts:408-520) walks the remote tree and reconciles each page two-way. There is no way to declare that a file (or tree) is generated locally and that Notion is a push-only render target whose edits are non-authoritative.

A downstream consumer generates a local .nmd tree (strict V2 JSON frontmatter, bound page_ids) and wants the repo to be the source of truth with Notion as a one-way render target. Today that requires a bespoke driver that force-pushes and then restores the local body to undo the lossy reconcile-pull (the blockquote-adjacent merge corruption; see also #744 for inline <page> corruption). We want to declare direction natively and drop the workaround.

The in-flight subtree prototype (#745) is already de-facto push-only — it only ever writes to Notion and uses a last-pushed body-hash as its noop oracle — but direction is implicit (a property of which command you ran), not selectable per file, and it runs on a separate, mutually-unreadable frontmatter format (lite title: / notion_page_id: YAML) from the V2 engine (strict notion_md JSON envelope).

Proposed config surface

  • New optional field on the V2 frontmatter (@overeng/notion-effect-client/src/nmd.ts:447, NmdFrontmatterV2.notion_md): sync_direction: 'push_only' | 'pull_only' | 'bidirectional' (absent ⇒ bidirectional, back-compat).
  • Optional default_sync_direction on the workspace manifest so a whole generated tree declares it once. Resolution: frontmatter.sync_direction ?? manifest.default_sync_direction ?? 'bidirectional'.
  • A one-shot --direction <mode> CLI override (not the durable source of truth) + --write-direction to stamp frontmatter.

Semantics

  • bidirectional — current behavior (drift-driven push/pull, base-snapshot 3-way merge, guarded-push conflict).
  • push_only — local authoritative. Push local changes; never pull; remote-only edits reported in status, never reconciled. Retains the base snapshot (cheap; needed to re-establish a merge ancestor if direction is ever flipped back) — it simply skips the pull/merge/conflict branch. It is a guarded force: it still refuses when unresolved unknown/unsupported blocks are present unless an explicit destructive flag is set, and when it does drop them it reports what it bypassed (do not silently inherit a hardcoded allowDeletingContent: true).
  • pull_only — remote authoritative, local is a read-only mirror. Pull on remote change; refuse push (local edits surfaced, never sent — do not inherit pullPage's silent local clobber by default).

push_only is conflict-free by construction and structurally sidesteps the reconcile-pull round-trip corruption (nothing is ever pulled back into local).

Explicit non-goals / caveats

  • push_only ≠ create-from-local. The V2 engine requires a bound page_id (nmd.ts:452); a never-pushed / unbound file cannot be loaded under push_only. Create-from-local remains the subtree prototype's job (feat(notion-md): native directory-tree ↔ Notion-subtree sync (prototype) #745).
  • Invariant: any frontmatter-rewrite path (page_id writeback, regeneration) MUST preserve sync_direction.
  • Out of scope (separate follow-up): converging the subtree engine's create-from-local / child-anchor / cross-ref writer onto the V2 frontmatter + sidecar. The two .nmd formats are currently mutually unreadable, so a shared sync_direction field across both is not free; that convergence should not block this V2-only increment and must not regress feat(notion-md): native directory-tree ↔ Notion-subtree sync (prototype) #745.

Minimal first increment

Add sync_direction to NmdFrontmatterV2 (default bidirectional) + implement push_only and pull_only in the V2 syncPage (branch before the drift logic) and syncWorkspace (per-page guard before syncPage). This alone unblocks the generated-doc use case. Subtree convergence is a separate effort.

Acceptance criteria

  • sync_direction validated on NmdFrontmatterV2; absent ⇒ bidirectional; preserved across frontmatter rewrites (page_id writeback, regeneration).
  • push_only never invokes pull/merge (unit-asserted against a fake gateway); remote drift reported in status, not reconciled. syncWorkspace: a push_only doc edited on remote is not pulled (direct test).
  • push_only retains the base snapshot; flip bidirectional→push_only→bidirectional re-establishes the base via a pull before the next guarded push (no stale-base error).
  • push_only over a page with unresolved unknown / unsupported blocks refuses unless an explicit destructive flag is set, and reports exactly what it bypasses (no hardcoded allowDeletingContent).
  • pull_only never invokes a write (updateMarkdown / property / metadata); a local edit under pull_only is surfaced, not silently clobbered.
  • Manifest default_sync_direction + frontmatter override resolution.
  • status reports per-page effectiveDirection.
  • A push_only .nmd tree round-trips with NO local-body restore workaround.

Refs

Posted on behalf of @schickling
field value
agent_name 💪 cl2-grit
agent_session_id 20af0604-b67f-4c87-a5f5-631198740bab
agent_tool Claude Code
agent_tool_version 2.1.160
agent_runtime Claude Code 2.1.160
agent_model claude-opus-4-8
worktree dotfiles/schickling/2026-06-03-scg-v2
machine dev3
tooling_profile dotfiles@unknown-dirty

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:notionNotion API client / react / schema packages · Set: manualorigin:agentFiled or primarily produced by an AI agent · Set: AI agent or manualtype:featureNew user-visible or system capability · Set: manual

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions