You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 is different from my file because I have edits to publish" from "the page is different from my file because somebody else published first".
A file is skipped when its mtime is not after the page's last-version timestamp, and published otherwise. Read as a safety property that is "the page is protected while the file is older than it", which is not a property anyone can rely on.
Repro 1 — an edit made in Confluence. Publish runbook.md. A colleague fixes a typo in the Confluence UI, so the page gains a version at T1. You edit runbook.md locally at T2 > T1 and run update runbook.md. The file is newer, so markfluence publishes, replacing the body wholesale, discarding the fix, and reporting an ordinary successful published. The colleague's change survives only in the page history, where nobody is looking.
Repro 2 — two people publishing from markfluence, which is worse. Ana and Bo both export page P at version 5 and edit locally. Ana runs update and the page becomes version 6 with Ana's content. Bo runs update and the page becomes version 7 with Bo's content; Ana's work is gone, with no warning, no conflict, and an ok: true result. This is not an edge case: it is 1.0.0's third use case ("download a tree of content, edit it locally, and then update all of the pages at the same time") performed by two people.
--force removes the check outright, so update --force docs/**/*.md in CI overwrites everything unconditionally — which is the shape #29's workflow has.
The mtime skip is already inert in CI. git does not preserve mtimes, so on a fresh checkout every file's mtime is the clone time, which is after every page's last version. Every file publishes on every run. The guard does nothing precisely where #29 aims it, which is what makes reaching for --force tempting, which then removes it for real.
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 by Confluence. An edit that happened before the run is invisible: markfluence reads the current version, increments it, and writes.
Timestamps cannot be made to work. 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 either, and this was the original proposal here. It answers exactly one question — "has something other than a markfluence publish written to this page?" — which is real but narrow, and it is blind to repro 2 by construction: both writers are markfluence, so it reports clean for Ana and clean for Bo. It is blind in CI for the same reason, since CI is the only publisher there and every CI publish updates the property.
Comparing content alone is not enough. Hashing the storage this run would publish and comparing it to the live body distinguishes "nothing to publish" from "something to publish" — genuinely useful, and a proper machine-independent replacement for the mtime skip (L4) — but "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 these fail. Telling "I changed it" from "they changed it" needs a merge base: the content or version this copy was derived from. That is a per-copy fact, and no amount of page-side state can hold it, because the page cannot know what Bo's copy came from.
The fix: record the page version in the file
Store the Confluence page version a local copy was derived from, alongside the page's other metadata — version: 5 in frontmatter, or in the file's pages: entry. The base then travels with the copy, which is what makes it work for N copies on N machines with no reference to git.
update then decides from two comparisons:
live vs recorded base
local render vs live body
outcome
equal
—
the page has not moved since this copy; publish
—
identical
nothing to publish; skip
live ahead
differs
divergence — the page moved and disagrees with this copy; warn, or refuse without --force
no version: recorded
—
unknown — never reported as changed
Repro 2 lands in row 3 and is caught. Repro 1 lands in row 3 and is caught.
Row 2 is what makes CI survive. A fresh clone's recorded base is whatever was committed, so it is stale after the first publish — but the content is identical to what CI itself published, so the run skips silently rather than warning. Without row 2 this design would need a commit per publish, which is the machinery #139 rejected for page creation.
update has to write the new version back, and that is the real cost. A human who edits and publishes twice from one copy otherwise hits row 3 on the second publish: base 5, live 6, content differs. Writing it back trades the promise update --help makes today — "update never writes back to the file or to markfluence.yaml, so fixing a wrong page_id is always safe" — which is a smaller trade than it reads, since the stated reason for that promise is about page_id and this is bookkeeping rather than page metadata, but it does mean every publish dirties the working tree, and two people publishing produce a version: merge conflict. That conflict is arguably the correct signal.
Measure this first
update publishes the body and then applies width (two content properties) and labels (a v1 endpoint). If a content-property or label write bumps the page's version, one publish leaves the live version at base+2 or +3, and "the version I just published" is not what the page reports a moment later — so the equality in row 1 breaks and write-back has to store a re-read rather than the PUT's own version. Nothing in docs/confluence/ records this: api.md, page-width.md and labels.md are all silent. One small live experiment settles it and decides part of the design.
Design consequences
version: is the first piece of frontmatter that is markfluence's own bookkeeping rather than a declaration about the page.L9 (declared-metadata-is-asserted) does not apply to it, check must not validate it as a declaration, and internal/pagemeta needs a third category beside coordinates and soft fields — "which version is this copy" cannot be graded as agreement or disagreement between two locations.
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; there is no counterpart protecting an existing page, and the remote side is the side with somebody else's work on it.
A hand-edited or copy-pasted version: misleads — it can suppress a real warning or invent one. Same hazard class as a copy-pasted page_id, which the codebase already treats seriously.
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
A content property alone. Blind to two markfluence publishers and blind in CI; see above. It would still be needed if attributing who moved the page mattered, but the version base makes the warning correct without it.
A content comparison alone. Fires on every legitimate update. Worth building anyway as the L4 idempotence check and the mtime replacement, but it is not a conflict guard.
A local, untracked state file (.markfluence/state) mapping page to last-synced version. Per-checkout, so a fresh clone knows nothing and CI is permanently "unknown", and it cuts against pagedoc.UserCache being deliberately unpersisted for L2 reasons.
Merging a Confluence-side edit into the file.fix reconciles metadata, not bodies.
updateoverwrites a page that has moved on since the local copy was made, with no warning and nothing in--jsona 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
updatecannot tell "the page is different from my file because I have edits to publish" from "the page is different from my file because somebody else published first".The behaviour
cmd/update/update.go:250-258:A file is skipped when its mtime is not after the page's last-version timestamp, and published otherwise. Read as a safety property that is "the page is protected while the file is older than it", which is not a property anyone can rely on.
Repro 1 — an edit made in Confluence. Publish
runbook.md. A colleague fixes a typo in the Confluence UI, so the page gains a version at T1. You editrunbook.mdlocally at T2 > T1 and runupdate runbook.md. The file is newer, so markfluence publishes, replacing the body wholesale, discarding the fix, and reporting an ordinary successfulpublished. The colleague's change survives only in the page history, where nobody is looking.Repro 2 — two people publishing from markfluence, which is worse. Ana and Bo both
exportpage P at version 5 and edit locally. Ana runsupdateand the page becomes version 6 with Ana's content. Bo runsupdateand the page becomes version 7 with Bo's content; Ana's work is gone, with no warning, no conflict, and anok: trueresult. This is not an edge case: it is 1.0.0's third use case ("download a tree of content, edit it locally, and then update all of the pages at the same time") performed by two people.--forceremoves the check outright, soupdate --force docs/**/*.mdin CI overwrites everything unconditionally — which is the shape #29's workflow has.The mtime skip is already inert in CI. git does not preserve mtimes, so on a fresh checkout every file's mtime is the clone time, which is after every page's last version. Every file publishes on every run. The guard does nothing precisely where #29 aims it, which is what makes reaching for
--forcetempting, which then removes it for real.What does not help
updateLandedis 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.
UpdatePagesendsversion = current + 1, so an edit landing between the GET and the PUT is refused by Confluence. An edit that happened before the run is invisible: markfluence reads the current version, increments it, and writes.Timestamps cannot be made to work. 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 either, and this was the original proposal here. It answers exactly one question — "has something other than a markfluence publish written to this page?" — which is real but narrow, and it is blind to repro 2 by construction: both writers are markfluence, so it reports clean for Ana and clean for Bo. It is blind in CI for the same reason, since CI is the only publisher there and every CI publish updates the property.
Comparing content alone is not enough. Hashing the storage this run would publish and comparing it to the live body distinguishes "nothing to publish" from "something to publish" — genuinely useful, and a proper machine-independent replacement for the mtime skip (L4) — but "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 these fail. Telling "I changed it" from "they changed it" needs a merge base: the content or version this copy was derived from. That is a per-copy fact, and no amount of page-side state can hold it, because the page cannot know what Bo's copy came from.
The fix: record the page version in the file
Store the Confluence page version a local copy was derived from, alongside the page's other metadata —
version: 5in frontmatter, or in the file'spages:entry. The base then travels with the copy, which is what makes it work for N copies on N machines with no reference to git.updatethen decides from two comparisons:--forceversion:recordedRepro 2 lands in row 3 and is caught. Repro 1 lands in row 3 and is caught.
Row 2 is what makes CI survive. A fresh clone's recorded base is whatever was committed, so it is stale after the first publish — but the content is identical to what CI itself published, so the run skips silently rather than warning. Without row 2 this design would need a commit per publish, which is the machinery #139 rejected for page creation.
updatehas to write the new version back, and that is the real cost. A human who edits and publishes twice from one copy otherwise hits row 3 on the second publish: base 5, live 6, content differs. Writing it back trades the promiseupdate --helpmakes today — "update never writes back to the file or to markfluence.yaml, so fixing a wrong page_id is always safe" — which is a smaller trade than it reads, since the stated reason for that promise is aboutpage_idand this is bookkeeping rather than page metadata, but it does mean every publish dirties the working tree, and two people publishing produce aversion:merge conflict. That conflict is arguably the correct signal.Measure this first
updatepublishes the body and then applies width (two content properties) and labels (a v1 endpoint). If a content-property or label write bumps the page's version, one publish leaves the live version at base+2 or +3, and "the version I just published" is not what the page reports a moment later — so the equality in row 1 breaks and write-back has to store a re-read rather than the PUT's own version. Nothing indocs/confluence/records this:api.md,page-width.mdandlabels.mdare all silent. One small live experiment settles it and decides part of the design.Design consequences
version:is the first piece of frontmatter that is markfluence's own bookkeeping rather than a declaration about the page. L9 (declared-metadata-is-asserted) does not apply to it,checkmust not validate it as a declaration, andinternal/pagemetaneeds a third category beside coordinates and soft fields — "which version is this copy" cannot be graded as agreement or disagreement between two locations.no-overwrite-without-force) protects an existing file on disk and is about the filesystem; there is no counterpart protecting an existing page, and the remote side is the side with somebody else's work on it.version:misleads — it can suppress a real warning or invent one. Same hazard class as a copy-pastedpage_id, which the codebase already treats seriously.read/export, and a three-way merge of Confluence storage does not belong in this tool.Considered and rejected
L4idempotence check and the mtime replacement, but it is not a conflict guard..markfluence/state) mapping page to last-synced version. Per-checkout, so a fresh clone knows nothing and CI is permanently "unknown", and it cuts againstpagedoc.UserCachebeing deliberately unpersisted for L2 reasons.fixreconciles metadata, not bodies.Relationship to other issues
markfluence status) wants the same recorded state for a drift column. This issue is the safety consequence and the better reason to build it; worth doing first and letting markfluence status: show the local page tree and whether Confluence has moved on #148 consume it.--forceis tempting.fixsurvives, it is the natural place to adopt a page by recording its current version into a file that has none.Not in scope