Skip to content

update silently overwrites a page that moved on: nothing records what a local copy was derived from #149

Description

@willkg

update overwrites a page that has moved on since the local copy was made, with no warning and nothing in --json a consumer could branch on. The mtime skip looks like it protects against this and does not: it protects only for as long as nobody touches the file, and it is inert in CI.

The underlying gap is that nothing records what a local copy was derived from, so update cannot tell "the page differs from my file because I have edits to publish" from "the page differs from my file because somebody else published first".

Which arrangement this is about

markfluence is used two ways, and the defect only exists in one of them.

1. The repository is the source of truth. Confluence is a published mirror. This is #29's GitHub Actions workflow, and it is also what a shared repository means, whoever runs the publish. Here an edit made in the Confluence UI is drift, not work — the next publish is supposed to overwrite it. Every row below that looks like data loss is the intended behaviour. The answer is update --force, a callout on the page saying where it is published from, and page permissions where the space allows; now documented in docs/github-actions.md.

2. Confluence is the source of truth. People author in the UI, and a local markdown copy is an export that gets edited and published back — 1.0.0's third use case. Here the data loss is real, and this issue is about this arrangement only.

That split constrains the fix: --force must keep meaning "the local side wins, unconditionally", because arrangement 1 depends on it. Whatever detection lands is default-on behaviour that --force overrides, never a new gate --force cannot get past.

The behaviour

cmd/update/update.go:279-287 skips a file when its mtime is not after the page's last-version timestamp, and publishes otherwise:

if !force && page.Version.CreatedAt != "" {
    if pageUpdated, err := time.Parse(time.RFC3339, page.Version.CreatedAt); err == nil {
        if info, err := os.Stat(filename); err == nil && !info.ModTime().After(pageUpdated) {
            r.ok = true
            r.status = statusSkipped

Those two observations are all it has, and each maps onto three different realities:

What update sees The situation it's actually in What update does Correct?
Page version time is newer P1 Nothing changed since the last publish skip
P2 Only the page changed — someone edited it in the UI skip ✓ outcome, but reported identically to P1
P3 Both changed, and they saved last skip ✗ my edits silently never publish
File mtime is newer F1 Only my file changed publish
F2 Nothing changed, but mtime moved — clone, pull, checkout, touch publish ✗ republishes the whole tree for no reason
F3 Both changed, and I saved last publish ✗ their edit destroyed, reported ok: true

Three pairs worth naming:

  • P3 and F3 are the same situation, split only by which save happened later. That is last-writer-wins, and the loser's work vanishes silently in both directions — F3 loses theirs to a successful-looking publish, P3 loses mine to a skip that reads as "nothing to do".
  • P1 and P2 are indistinguishable in the output. Both are status: skipped, ok: true. One means "no work to do", the other means "the page has moved past you". P1 is the common case — every page is newer than its file immediately after a publish — so the message reads as benign even when it is not.
  • F2 is why a CI workflow reaches for --force, which then removes the check entirely and makes F3 unconditional.

And three properties of the comparison itself:

  • mtime is not a content signal. git does not preserve it, so clone, pull, checkout, touch, a file copy and a restored backup all look exactly like an edit (F2). On a fresh CI checkout every file's mtime is the clone time, so the whole tree republishes every run.
  • It compares two unsynchronized clocks — the local filesystem's against Confluence's server clock. Skew one way under-publishes, the other over-publishes.
  • Every failure path fails open, toward overwriting. A missing Version.CreatedAt, an unparseable timestamp, or a failed os.Stat all skip the skip and publish.

What does not help

updateLanded is not a concurrency guard. It exists to recover a lost response (client.go:945-955): it re-reads the page and accepts the update only when version, title and body all match what markfluence just sent. That answers "did my write land?", not "did somebody write before I started".

The versioned PUT guards only a race inside one run. UpdatePage sends version = current + 1, so an edit landing between the GET and the PUT is refused. An edit that happened before the run is invisible.

Timestamps cannot be made to work at all. Publishing sets the version timestamp to now while the file's mtime is from whenever it was saved, so every page is newer than its file immediately after a successful update. Any check built on timestamps either misses the real case or fires constantly on the benign one.

A content property recording "the version markfluence last published" is not sufficient. It answers one question — "has something other than a markfluence publish written to this page?" — which is real but narrow, and it is blind to two people publishing from markfluence by construction: both writers are markfluence, so it reports clean for each of them.

Comparing content against the live page is not enough either. "My content differs from the page" is the definition of having something to publish. It fires on every legitimate update and separates nothing.

The general reason all of those fail. Telling "I changed it" from "they changed it" needs a merge base: what this copy was derived from. That is a per-copy fact, and no page-side state can hold it, because the page cannot know what a given local copy came from.

The fix: a local action log

markfluence keeps an append-only JSONL log at the project root, recording what each action did — when, to which file, from which source bytes, to which page, producing which version:

{"time":"2026-09-13T07:30:00Z","action":"update","file":"docs/some-page.md","sha256":"b800cc4f…","page_id":"123456789","page_version":44,"markfluence":"1.2.3"}

The last entry for a file is that copy's merge base, and update decides from two comparisons:

local file sha vs logged live version vs logged outcome
P1 same same nothing to do; skip, silently
P2 same live ahead the page moved and you have no local edits — reportable as stale, which it is not today
P3 differs live ahead divergence; warn, or refuse without --force
F1 differs same publish
F2 same same skip — mtime is never consulted
F3 differs live ahead divergence; same as P3, which is correct — they are one situation
no entry for this file unknown; never reported as changed

All six rows resolve. P3 and F3 collapse into one answer, and P2 finally separates from P1.

Decisions

JSONL, not a bespoke line format. A key:value; line format would be this project's third parser after YAML and the .env reader, and internal/frontmatter's history is a long argument against hand-rolled ones. One JSON object per line appends just as well, parses with encoding/json, and tolerates unknown keys, so a field can be added later without a format version.

The sha is of the raw source file on disk, not of the rendered storage. It answers "did I edit this file since I published it?" locally, with no network call, which is what kills F2 and gives a proper L4 idempotence check to replace mtime. The consequence to accept: a file whose source is unchanged but whose rendered output would differ — a page carrying <!-- markfluence-version --> after a markfluence upgrade, or a link that would now rewrite because a sibling gained a page_id — is skipped. That is no worse than the mtime check does today (an untouched file's mtime does not move either), and --force is the escape. A rendered-storage sha can be added as a second field later without a format change.

Each line records the markfluence version that wrote it. Provenance is the obvious use — "which build published this, and when" is the first question on any surprising page — but it also partly rescues the source-sha limitation above. A page carrying <!-- markfluence-version -->, or one whose rendering changed because the converter did, can be republished on the strength of the version differing from the logged one, without needing a rendered-storage sha. internal/buildinfo already supplies it. Note the page's version and markfluence's are different things, hence page_version and markfluence as separate fields rather than a bare version.

A log, not a state file. Only the last entry per file is needed for the check, so the data structure could be a map — but the history has other uses (#148's status view, and answering "why did this page change?"), so it stays a log and growth is bounded by a compaction setting rather than by throwing the history away.

It is not committed, and the reason is structural rather than ergonomic. A shared repository is a declaration that the repository is the source of truth — which is arrangement 1, where --force is the answer and the log is not needed. So the log serves exactly one arrangement: one person, one machine, source of truth in Confluence. Per-checkout state is the correct shape there, and another person's sync point is irrelevant to mine — Bo needs to know what his copy was derived from, not what Ana did. Committing it would buy nothing and would generate a tail-append merge conflict on every concurrent publish. export plants markfluence.yaml; it should plant the ignore entry too.

Which actions write a line

Not every logged action establishes a base, so the check reads the most recent base-establishing line for a file, not simply the last line for it.

commands
Establishes a base create, update, export Each records a source sha and a page version together, which is what makes the pair meaningful. export matters as much as the other two: it is the other end of the merge base, and without it an export → edit → update flow has no base for its first publish.
Refreshes the sha only fix It rewrites the file's bytes without touching the page body, so it must record the new sha while carrying the previous entry's page version forward. Otherwise it invalidates a base it did not actually change and the next update republishes identical content. Empty if #151 removes fix.
History only attachment-upload, attachment-download Worth recording, never read by the check. An attachment upload does not bump the page version (measured, above), so it cannot invalidate a base — and a download writes a local asset, not a page copy.
No line check, info, read, children, find, search, attachment-list, schema, completion Read-only, or no local-file/page pairing. read is the interesting one: it prints to stdout, so markfluence does not know where the bytes went, which is exactly why it cannot record a base the way export can.

Four rules that go with it:

  • One line per page, appended as that page completes — not one per run. Same reasoning as markfluence.yaml pages: page metadata outside the markdown file #139's D10: a run that dies partway must leave every already-finished page recorded.
  • The sha is taken after any frontmatter write-back. create writes page_id back into the file, which changes its bytes; a sha captured before that makes the very next run see a changed file.
  • --dry-run writes nothing. A preview that logged would claim a publish happened.
  • A failure logs, with a status, and the base reader ignores any line that is not a success. That keeps the history complete for debugging without letting a failed publish become a base.

When there is no base, or a partial one

This is the common case rather than an edge: every file published before this lands has no entry, and a fresh clone has no log at all (correctly — it is not committed). So the fallback behaviour decides whether the feature is adoptable.

The rule: an unknown base means publish, silently, exactly as today. Unknown must never read as "changed" — a warning on every file would be scrolled past within a day and the real one with it — and it must never read as "unchanged", which would skip a file that needs publishing.

situation what update does
No log file — fresh project, or published by a markfluence predating this unknown; publish, silent
Log exists, no entry for this file — published from another machine, or compacted past unknown; publish, silent
Entry exists but names a different page_id than the file resolves to now discard the entry, treat as unknown. The base describes another page, so comparing its version against this one is meaningless and would invent a "the page moved 40 versions" warning out of a retarget. This is the one case that produces a wrong answer rather than no answer
Entry has a sha but no page_version, or the reverse — an older line, or a partial write degrade per field, not per line. The two comparisons are independent: a sha alone still answers "did I edit this?" (so F2 still skips), and a version alone still answers "did the page move?" (so P2/P3 still fire)
A malformed line — truncated JSON from an interrupted append skip that line, keep reading, never fail the command. The log is bookkeeping, not truth; a corrupt advisory file must not be load-bearing. Report it under --debug
The log cannot be read — permissions, read-only checkout unknown; publish, silent, with one warning
The log cannot be written after a successful publish warn, do not fail. The page is already published, so failing would report a false failure — the same non-fatal shape pagewidth.Apply and labels.Apply already have
No project root — no markfluence.yaml anywhere above the file no log is read and none is created, matching #139's rule that a root with no project file refuses rather than creating one

Two consequences worth stating plainly:

Protection accrues; it is never migrated. A file becomes protected the first time create, update or export records a base for it, so there is no init step and no adopt command — the remedy for "unknown" is to publish once. But the first update after this lands is unprotected for every existing file, which needs saying in the docs, because "I upgraded and it still overwrote my colleague's edit" is the predictable report.

--json should say the base was unknown; human output should not. A CI consumer needs to know the check did not run, and the human case is the common one where a line would be noise. Same split as metadata_source.

Open: does the mtime check survive as a fallback?

With no base, something has to decide, and today that is mtime. Keeping it as a fallback is tempting and probably wrong: its failure modes are precisely the ones this issue exists to fix, and it would keep them for exactly the population that has no base — which at first is everyone.

Dropping it costs one noisy run: a tree of 200 pages with no log republishes all 200, then has a base for each and behaves from then on. That is tolerable, and F2 already means a fresh checkout republishes everything anyway.

The alternative is to let mtime skip only when the base is unknown, never publish — which bounds the damage to P3 (my edits silently not published) and removes F3 (their edit destroyed). Worth deciding explicitly rather than by leaving the code in place.

Measured, so the version number is usable

A page's version.number tracks the body and the title and nothing else — verified 2026-09-13 and recorded in docs/confluence/api.md. Neither content-property write, neither label write, and an attachment upload all leave it and version.createdAt untouched. Two things follow that this design depends on: a publish advances the page by exactly one version even though update writes width, labels and attachments after the body; and the version in the PUT response is what a later GET reports, so the log can record it without a re-read.

Design consequences

  • No frontmatter changes at all, and pristine markfluence.yaml pages: page metadata outside the markdown file #139 files stay pristine. This is the main reason the log beats a version: field.
  • --force keeps its unconditional meaning, per the arrangement split above.
  • A missing or stale entry is unknown, never changed — the tri-state discipline client.LookupUser and the mention path already practise.
  • A new safety id in docs/guarantees.md, not an extension of S3: S3 (no-overwrite-without-force) protects an existing file on disk and is about the filesystem, and there is no counterpart protecting an existing page — the remote side being the one with somebody else's work on it.
  • An explicit note beside L2. An uncommitted log means two people running the same command on the same tree can get different behaviour — one warns, one does not. That is not L2 as written, which constrains resolution and naming, but it is the spirit that keeps pagedoc.UserCache unpersisted. It is also unavoidable for any per-copy base, so it should be stated rather than noticed later.
  • It only ever detects. The outcome is publish or refuse, with "the page moved since your copy; re-export before publishing" as the remedy. There is no merge, and there should not be: bodies round-trip through read/export, and a three-way merge of Confluence storage does not belong in this tool.

Considered and rejected

  • version: in frontmatter or in the pages: entry. Same information, but it relocates the bookkeeping into the markdown: it breaks markfluence.yaml pages: page metadata outside the markdown file #139's pristine-file goal, introduces the first frontmatter field that is markfluence's own bookkeeping rather than a declaration about the page (so L9 would not apply to it, check would have to skip it, and internal/pagemeta would need a third category beside coordinates and soft fields), and needs a D9 placement rule for a file with no frontmatter. The log gets the same answer with none of it.
  • A content property alone, and a content comparison alone — see "What does not help".
  • Committing the log. See above: it serves an arrangement that does not need it, and conflicts on every concurrent append.
  • Merging a Confluence-side edit into the file. fix reconciles metadata, not bodies.

Relationship to other issues

Not in scope

  • Deciding warning vs. refusal for the default path. Both need the same state; the choice can be made when it exists. What is not open is --force, which must keep meaning "the local side wins, unconditionally".
  • Body reconciliation or merge, permanently.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions