027 - confluence page rename / move / archive
Purpose: Imperative mdd subcommands (rename-page, move-page, archive-page, unarchive-page) that mutate a Confluence page then refresh the local mirror.
Status: Implemented (2026-05-23)
Introduction
Section titled “Introduction”These commands close the gap left by S14: sync is bidirectional but propagation is Confluence → mirror for structural changes (rename, move, archive). Anything done locally to a page’s title, parent, or status is overwritten on the next sync. These commands let users make those changes from the mirror side and have them flow the right direction.
This spec extends S09 (page primitives, frontmatter, storage↔markdown), S14 (sync’s per-page refresh logic, dirty-tree posture, single-commit summary), and S26 (managed-elsewhere fail-closed gate). Read those first.
Requirements
Section titled “Requirements”- Provide four subcommands:
rename-page,move-page,archive-page,unarchive-pageundermdd confluence(hyphenated form, consistent with existingexport-page/create-page/update-page/sync-space). - Resolve the target page from a local
.mdfile’sconfluence.page_idfrontmatter; reject files without it. - For
move-page, accept--parentas a page ID, Confluence URL, or path to another local.md. - Enforce same-space precondition for
move-page; error on cross-space moves. Cross-space moves are not supported viamdd; the user must perform them in the Confluence UI and letmdd confluence sync-spacereconcile both mirrors. - For same-space
move-page, the local file MUST land in the new parent’s mirror directory after the operation completes. If any link in the new parent’s ancestor chain is not yet materialised in the mirror (the page exists as a flat.mdbut not as a directory, or is absent entirely),move-pagematerialises the missing links by pulling them — same model as S14 sync — before performing the local file move. See §Local tree materialisation on move. - Refuse mutations on managed-elsewhere pages per S26.
- Abort if the working tree has uncommitted changes within the output directory.
- Detect version drift (Confluence
version_numberahead of local frontmatter) and abort before writing. - Print a confirmation prompt before any API call;
--yesskips it;--dry-runskips both prompt and call. - After a successful API call, run a per-page refresh: compute new local path,
git mvthe file (and attachments dir), rewrite frontmatter, and flip frontmatterconfluence.statusbetweenCURRENTandARCHIVEDfor archive/unarchive. - Commit the result with a structured
chore(mirror):message;--no-commitstages and exits without committing. - Print a recovery hint and exit 1 if the API call succeeded but the local refresh failed.
- Exit 0 on success or clean dry-run; exit 1 on any failure.
Design Approach
Section titled “Design Approach”- Follow the same imperative model as S09’s
update-page: Confluence is the source of truth, local file is updated after the API call, not before. - Keep the direction unambiguous: the commands push from mirror to Confluence, not the reverse; sync handles Confluence → mirror for structural events.
- Reuse
mdd.confluence.apply(git_mv+compute_rename_path+resolve_collision_pair+is_dirtyalready used by S14) rather than duplicating it. - Reuse the per-page refresh logic already in
mdd.confluence.sync.renames(the bulk event handlersapply_renames_movesandapply_archive_unarchive) by calling them with single-element event lists. If that proves clumsy, extract the per-event inner helpers (_apply_one_rename_move,_apply_one_archive) to a stable public surface. Avoid forking a parallel implementation. - Use Confluence Cloud v2 API for rename/move; prefer the v2 archive/unarchive endpoint with v1 fallback; encapsulate choice in
confluence.client. - Four orchestrators (
rename_page,move_page,archive_page,unarchive_page) are thin wrappers over five reusable checks plus the existing apply layer; most surface area is argument parsing, confirmation, and error messages. - No new dependencies: uses existing
httpx,lxml,pyyaml, and thegitbinary required by S14.
Implementation Notes
Section titled “Implementation Notes”src/mdd/commands/ confluence.py # add 'rename-page', 'move-page', # 'archive-page', 'unarchive-page' subcommandssrc/mdd/confluence/ mutate.py # NEW — rename_page(), move_page(), # archive_page(), unarchive_page() orchestrators client/__init__.py # extend put_page() (parent_id, status) + add # archive_page() / unarchive_page() primitives # (encapsulate v2 vs v1 fallback per [S09](S09-confluence-command.md)) apply.py # reuse is_dirty(), compute_rename_path(), # git_mv(), move_attachments_alongside(), # resolve_collision_pair() from [S14](S14-confluence-sync.md) sync/renames.py # extract per-event helpers from the existing # apply_renames_moves() / apply_archive_unarchive() # bulk handlers so mutate.py can call them with a # single page (or call the bulk form with a # one-element event list)tests/commands/ test_confluence_rename.py test_confluence_move.py test_confluence_archive.pytests/confluence/ test_mutate.py # orchestrator unit tests with mocked client + tmp git repo fixtures/mutate/ # before/after working-tree layouts-
Reuse, don’t duplicate. The following already exists and is the canonical implementation — call it, do not fork:
mdd.confluence.apply.is_dirty(repo_dir)— dirty-tree check shared with sync.mdd.confluence.apply.compute_rename_path(),git_mv(),move_attachments_alongside(),resolve_collision_pair()— the S14 collision rule ((page-id)suffix on both siblings).mdd.confluence.paths.sanitize(title)/disambiguate(path, page_id)— filename derivation per S09.mdd.confluence.frontmatter.read()/write()— YAML I/O.mdd.confluence.url.parse(ref, expected_host=...)— parses numeric IDs, full URLs, short URLs; used for--parent <url>.mdd.confluence.managed.classify_page(),load_managed_config(),build_page_info_from_page_data()— S26 gate; same posture asupdate-page.mdd.confluence.client.ConfluenceClient.put_page()— already used byupdate-page. Currently takes(page_id, title, body_xhtml, version, message)and hardcodesstatus="current". For S27, extend it (or wrap it) to acceptparent_idandstatusso move/archive/unarchive can reuse the same path; see “API calls” below.mdd.confluence.sync.renames.apply_renames_moves()/apply_archive_unarchive()— bulk event handlers; the per-event inner functions are private (_apply_one_rename_move,_apply_one_archive). Either call the bulk form with a one-elementSyncEventlist, or promote the helpers to a stable internal surface. Choose one approach during implementation and apply it consistently.
A natural shape:
# in mdd.confluence.mutate (NEW)def rename_page(file, new_title, *, client, config, repo) -> int: page_state = _load_local(file) _check_managed(page_state, client, config) _check_dirty(repo) _check_version(page_state, client) api_result = client.put_page( page_state.page_id, title=new_title, body_xhtml=_fetch_current_body(client, page_state.page_id), version=page_state.version + 1, message=message, ) # Build a single RENAME SyncEvent and reuse the [S14] apply path: event = _event_from_api_result(api_result, EventKind.RENAME) apply_renames_moves([event], mirror_state, output_dir, page_to_outdir, used_paths, summary) if not no_commit: repo.commit(_render_subject("rename", page_state, api_result)) return 0The same pattern applies to move_page (event kind MOVE), archive_page (ARCHIVE), and unarchive_page (UNARCHIVE).
-
Version-drift check.
update_pageinmdd.confluence.updatealready does this inline (compares local frontmatterversionagainst the remoteversion.number). For S27, extract the check into a small reusable helper (e.g.mdd.confluence.apply.check_version_drift(local_version, remote_version)) and call it from bothupdate_pageand the four mutate orchestrators. Do not copy-paste the check. -
API endpoint discovery. v2 archive/unarchive endpoints have moved around in Atlassian’s API history. At implementation time, confirm the current path (Atlassian docs + a single manual call against a scratch space) and centralise the choice in
confluence.client.archive_page/unarchive_page. Tests mock these primitives, so the choice doesn’t ripple through the test suite. -
Confluence body re-send. v2 PUT requires the full page representation, so rename and move must re-send the current body XHTML. Fetch it via
client.get_page(page_id, body_format="storage")(or the equivalent get already in use). The currentput_page()signature acceptsbody_xhtml; reuse it as-is. -
Tests
- Unit: each command’s argument parsing (markdown file form; parent variants for
move). - Unit: managed-elsewhere refusal (mock client + S26 classification fixture).
- Unit: dirty-tree refusal (real
tmp_pathgit repo with an uncommitted change). - Unit: version-conflict refusal (mock client returns higher
version_numberthan local frontmatter). - Unit: cross-space rejection on
move. - Unit: rename + collision (sibling with same sanitised basename) → both files get
(page-id)suffix per S14 rule. - Unit: archive flips frontmatter
statusand export header; unarchive flips them back. - Unit:
--dry-runmakes no API calls and no working-tree changes (assert mock client receives zero calls). - Unit:
--no-commitleaves staged changes; no new commit. - Unit: refresh-failure recovery message when API succeeds but
git mvfails (simulate via permissions error). - Integration (
@pytest.mark.integration): live Cloud calls against a scratch space; gated onMDD_CONFLUENCE_TEST=1. One rename, one move, one archive, one unarchive in sequence.
- Unit: each command’s argument parsing (markdown file form; parent variants for
Subcommands
Section titled “Subcommands”mdd confluence rename-page <markdown-file> <new-title> [--config <file>] [--message <msg>] [--dry-run] [--no-commit] [--yes]mdd confluence move-page <markdown-file> --parent <id|url|file> [--config <file>] [--message <msg>] [--dry-run] [--no-commit] [--yes]mdd confluence archive-page <markdown-file> [--config <file>] [--message <msg>] [--dry-run] [--no-commit] [--yes]mdd confluence unarchive-page <markdown-file> [--config <file>] [--message <msg>] [--dry-run] [--no-commit] [--yes]- The first positional is always a path to the local
.mdwhoseconfluence.page_idfrontmatter identifies the target page. No page-id / URL form — the imperative workflow assumes the user is in the mirror clone editing files. (Future: a--pageflag for ad-hoc use outside a clone if it actually comes up.) <new-title>forrename-pageis the new page title verbatim. Quote it if it contains spaces.--parentformove-pageaccepts a page ID, a Confluence page URL (parsed viamdd.confluence.url.parseper S09), or a path to another local.mdwhose frontmatterpage_idis the parent. The markdown-file form is the natural one inside a clone.--messagesets the version comment that appears in Confluence page history. Defaults:"Renamed via mdd","Moved via mdd","Archived via mdd","Unarchived via mdd".--dry-runprints the planned operation (API call, local rename/move, frontmatter changes) without touching Confluence or the working tree.--no-commitskips the commit step; the working tree is left with staged changes for the user to commit.--yesskips the confirmation prompt (see “Confirmation” below).
Why this exists separately from sync
Section titled “Why this exists separately from sync”S14’s sync detects rename / move / archive on the Confluence
side and replays them as git mv / frontmatter rewrites in the
mirror. The arrow only points one way for those events:
- A user who renames a file locally (
git mv old.md new.md) does not rename the Confluence page — the next sync sees a “new local file” + “missing remote → trashed” situation, which at best produces a confusing diff and at worst a duplicate page if sync publishes the local file as new. - Moving a file to a different directory has the same problem.
- Setting
confluence.status: ARCHIVEDin frontmatter does not archive the page; the next sync sees a status mismatch and reverts the frontmatter.
The fix is to make the Confluence API call the source of truth and
have mdd apply it, then refresh the local file. Same model as
S09’s update-page for body edits — pushed to
Confluence first, frontmatter rewritten from the response.
Behaviour
Section titled “Behaviour”Identity resolution. Read <markdown-file>, parse
frontmatter, require confluence.page_id. If absent or
malformed, exit 1 with a clear message (“This file is not yet
published; run mdd confluence create-page first.”). For
move-page, resolve --parent:
- numeric → page ID
- URL → parse via
mdd.confluence.url.parse(S09); reject host mismatches withURLMismatchError - path with
.mdextension → read its frontmatter, takeconfluence.page_id(error if absent)
Same-space precondition. The page being moved and its target
parent must be in the same space. Cross-space moves are out of
scope (same posture as S14). Error: “Cross-space moves are
not supported. Move via the Confluence UI, then run mdd confluence sync against both spaces.” The check runs before any
local materialisation; ancestor materialisation never crosses a
space boundary.
Local tree materialisation on move
Section titled “Local tree materialisation on move”When move-page succeeds on the Confluence side, the local mirror
must end up with the moved file inside the new parent’s mirror
directory. The mirror layout per S14 is:
- A page with no children is represented as a flat
Title.mdfile. - A page with at least one child is represented as a
Title/directory containingTitle/_index.mdplus its child pages and subdirectories.
A move can therefore require any combination of three changes along the new parent’s ancestor chain (parent up to space root):
- Promote a flat file to a directory. The new parent is
currently a leaf (
Parent.md) because it had no children before this move. Convert toParent/_index.mdso it can hold the moved child. Repeat up the chain for every ancestor that is currently a leaf and needs to become a directory because of this move (rare in practice, but possible). - Pull a missing ancestor. The ancestor page exists on
Confluence but has not been pulled into the local mirror at
all. Fetch its storage XHTML, convert to markdown, write
Title/_index.md(with directory promotion as needed), refresh frontmatter from the API response. Same code path as S14’s pull pipeline, scoped to a single page. git_mvthe moved file into the now-materialised parent directory.
The materialisation walks the ancestor chain from the new parent upward, stopping at the first ancestor whose mirror representation already exists in the expected shape. The walk MUST be bounded by the space root; an ancestor that is reported as outside the page’s space is a precondition failure (treat as the same-space refusal above).
Materialised ancestors are committed as part of the same
chore(mirror): move ... commit; the user does not need to run a
follow-up sync-space. The commit body MUST list every
materialised path so the change is auditable. Example:
chore(mirror): move "RFC-7" to "Archive 2026"
Confluence -> mirror: page_id: 12345 space: ENG url: https://example.atlassian.net/wiki/spaces/ENG/pages/12345materialised ancestors (pulled from Confluence): docs/Archive 2026/_index.md (page 13500)moved: docs/RFC-7.md -> docs/Archive 2026/RFC-7.mdIf any materialisation step fails (API error, conversion error,
git error), the local refresh is aborted with the same recovery
hint as a normal refresh failure: “Confluence updated successfully
but local refresh failed: mdd confluence sync-space
to bring the mirror back in agreement.” Partial materialisation is
acceptable on failure — sync-space will reconcile any half-built
state. The Confluence-side move is the source of truth and is not
rolled back.
S26 classification applies to
materialised ancestors: pulling a managed-elsewhere ancestor uses
the same content fetch as sync-space. The managed-elsewhere
gate’s fail-closed posture applies only to write operations
against Confluence, not to local reads, so this path is unchanged
from sync.
Managed-elsewhere refusal. Before any API call, classify the
page per S26 by calling
classify_page() against the ManagedConfig returned from
load_managed_config(), with PageInfo built via
build_page_info_from_page_data() (same call sequence
update_page uses today). A managed-elsewhere classification
refuses the mutation with the publisher’s message. This is the
same fail-closed posture S26 applies
to update-page and sync-space pushes — rename / move /
archive are all writes.
Confidentiality. These commands write to Confluence, not to the git
mirror. S07’s blacklist is about
Confluence → mirror publishing and does not gate these calls. The
--no-commit default still applies a normal local commit (same as mdd confluence sync-space); pushing the mirror is a separate step and runs
through the mirror backend and the
S07 gate as usual.
Dirty working tree. Call mdd.confluence.apply.is_dirty()
against the output directory; if true, abort with the same
message as sync: "Mirror has uncommitted changes. Commit, stash, or discard before running.". Same reasoning — manual
edits and mdd-managed git operations must not commit-mix.
Confirmation. Each command prints a one-line preview of the
planned mutation and prompts for confirmation, mirroring
S09’s update-page posture
(Confluence is shared state). --yes skips the prompt;
--dry-run skips both the prompt and the actual call. Examples:
Rename: "Old Title" -> "New Title" space ENG, page 12345Confluence URL: https://example.atlassian.net/wiki/spaces/ENG/pages/12345Proceed? [y/N]Move: "Architecture Plan" (page 12345) from parent "Design Docs" (page 12000) to parent "Archive 2026" (page 13500)Proceed? [y/N]API calls (Confluence Cloud v2 unless noted; encapsulate any
v1 fallback in confluence.client per S09)
- Rename:
PUT /wiki/api/v2/pages/{id}with currentversion.number+1, newtitle, unchanged body, status, andparentId. Body must be re-sent because v2’s PUT requires a full page representation; fetch current storage XHTML as part of the operation. The existingConfluenceClient.put_page()already acceptsbody_xhtml; extend it to accept optionalparent_idandstatus(currently hardcoded to"current") so rename/move/unarchive can reuse the same primitive. Add these params with sensible defaults to avoid breakingupdate_page’s call site. - Move: same extended PUT with new
parentId, unchanged title / body / status. - Archive: prefer the v2 archive endpoint
(
POST /wiki/api/v2/pages/{id}/archiveor the equivalent current path — confirm at implementation time and centralise inconfluence.client.archive_page). Fall back to v1 (PUT /wiki/rest/api/content/{id}withstatus: "archived") only if v2 is unavailable. Theversion_messagefrom--messageis attached where the API supports it. - Unarchive: the inverse — v2 unarchive endpoint, or v1 PUT
with
status: "current".
If the page’s current Confluence version_number does not match
the local frontmatter’s version_number, abort with the same
conflict message as update-page: “Page was edited on
Confluence since last sync. Run mdd confluence sync-space (or
update-page) first.” This applies to all four commands — we
don’t quietly push past someone else’s edit. Reuse the version
check that already lives inline in update_page; extract it to
a small helper (e.g. apply.check_version_drift()) and call it
from both call sites.
Per-page refresh. After a successful API call, the local
state is brought into agreement with Confluence by running the
same per-page refresh logic S14 uses
for a single page. The concrete handlers in
mdd.confluence.sync.renames are:
apply_renames_moves(events, mirror, output_dir, page_to_outdir, used_paths, summary)— handlesRENAME,MOVE,RENAME_MOVEevent kinds; performsgit_mv+move_attachments_alongsideand updatesmirror.tracked.apply_archive_unarchive(events, mirror, summary)— handlesARCHIVE/UNARCHIVEevent kinds; rewritesconfluence.statusin frontmatter and pins mtime.
Steps:
- Fetch the page’s current metadata (the response from the PUT
typically includes everything needed; if a v1 archive call
only returns minimal fields, follow with
GET /wiki/api/v2/pages/{id}?include-labels=true&include-version= true). - Build a single
SyncEventrepresenting the operation:EventKind.RENAMEforrename-pageEventKind.MOVEformove-pageEventKind.ARCHIVE/UNARCHIVEfor archive / unarchive
- Compute the new local path:
- rename: new filename derived from new title via
mdd.confluence.paths.sanitize()andapply.compute_rename_path(). - move: new directory derived from the new parent’s ancestor chain in the mirror tree. If any ancestor is not yet materialised locally, pull the missing pages first (see §Local tree materialisation on move).
- archive / unarchive: no path change.
- rename: new filename derived from new title via
- Call
apply_renames_moves([event], ...)orapply_archive_unarchive([event], ...)as appropriate. The S14 collision rule (append(page-id)to both siblings) is already implemented inapply.resolve_collision_pair()and invoked bycompute_rename_path(). - Rewrite frontmatter via
mdd.confluence.frontmatter.write():title,parent_id,status,version,version_message,updated_at,updated_by,exported_at. (Body is unchanged for these operations — we only edited metadata, not the page body — so no storage→markdown re-conversion is needed.) Note: the existing_apply_one_archivealready handles thestatusflip but does not update version / updated_at / updated_by — those come from the API response and must be written by the mutate orchestrator. - Export header for archive / unarchive. S14 step 4c
mentions “update the export header” but the current
mdd.confluence.headermodule has no archived variant, and_apply_one_archivedoes not touch the header — only the frontmatter status. For S27, the minimum-viable behaviour is to match what sync does today: flipconfluence.statusonly, leave the export header alone. If an archived-variant blockquote is wanted, add it as a follow-up that updatesheader.pyand the sync path together so both code paths stay aligned.
If the per-page refresh fails (e.g. network error mid-flight,
disk error during git mv), the Confluence-side change is
already applied and is the source of truth. Print a clear
recovery hint: “Confluence updated successfully but local
refresh failed: mdd confluence sync-space to
bring the mirror back in agreement.” Exit 1.
Commit. After a successful refresh, git add -A (scoped to
the output directory) and git commit with a structured
message:
chore(mirror): rename "Old Title" -> "New Title"
Confluence -> mirror: page_id: 12345 space: ENG url: https://example.atlassian.net/wiki/spaces/ENG/pages/12345Subjects per command:
chore(mirror): rename "<old>" -> "<new>"chore(mirror): move "<title>" to "<new-parent-title>"chore(mirror): archive "<title>"chore(mirror): unarchive "<title>"
--message overrides the subject (full subject, not appended).
--no-commit stages changes and exits without committing.
Idempotency. Re-running the same command after success is a
no-op: the new title / parent / status already matches Confluence
state, so the version check sees no drift. (Practically, the
version_number on Confluence has advanced past the local copy
because we just wrote, so --yes-ing through a stale cache
would trigger the conflict guard. This is fine — it
short-circuits accidental double-runs.)
Output / exit codes.
- 0: success (or dry-run completed cleanly)
- 1: any failure — bad args, dirty tree, conflict, managed-page refusal, API error, refresh error
- A run summary prints to stdout for human readability; errors go to stderr.
Why no delete
Section titled “Why no delete”Confluence supports moving pages to trash (recoverable) and hard
delete (admin-only). Both are destructive shared-state
operations. The pattern in this codebase is to keep destructive
operations out of mdd (S09
explicitly puts page deletion out of scope). archive-page is
the safe substitute: it removes the page from active navigation
and surfaces the “archived” status on the mirror, while
preserving content and history. Users who genuinely want to
delete a page do it in the Confluence UI and let mdd confluence sync-space reconcile the trashed state on the next run.
Examples
Section titled “Examples”# Rename a page$ mdd confluence rename-page docs/Architecture.md "Architecture (2026)"Rename: "Architecture" -> "Architecture (2026)" space ENG, page 12345Proceed? [y/N] yRenamed on Confluence (version 8 -> 9).Renamed locally: docs/Architecture.md -> "docs/Architecture (2026).md"[main abc1234] chore(mirror): rename "Architecture" -> "Architecture (2026)"
# Move a page under a different parent (parent is another local file)$ mdd confluence move-page docs/RFC-7.md --parent docs/archive/index.mdMove: "RFC-7" from parent "Active RFCs" (page 12000) to parent "Archive" (page 13500)Proceed? [y/N] yMoved on Confluence (version 4 -> 5).Moved locally: docs/RFC-7.md -> docs/archive/RFC-7.md[main def5678] chore(mirror): move "RFC-7" to "Archive"
# Archive a page (no path change)$ mdd confluence archive-page docs/old-experiment.md --yesArchived "Old Experiment" (page 12345).Updated frontmatter: status CURRENT -> ARCHIVED.[main 9abc012] chore(mirror): archive "Old Experiment"
# Dry-run a move$ mdd confluence move-page docs/RFC-7.md --parent 13500 --dry-runWould move: "RFC-7" (page 12345) from parent "Active RFCs" (page 12000) to parent "Archive" (page 13500)Would rename locally: docs/RFC-7.md -> docs/archive/RFC-7.md(dry-run, no changes made)Related upstream specs
Section titled “Related upstream specs”- 009-confluence-command — page primitives, frontmatter, storage↔markdown, URL handling
- 014-confluence-sync — per-page refresh logic, dirty-tree posture, single-commit summary,
git mv+ collision handling - 026-managed-elsewhere — managed-elsewhere fail-closed gate
Out of scope
Section titled “Out of scope”delete page— destructive shared-state operation. Use the Confluence UI; sync reconciles the trashed state.- Cross-space moves. Error out; user moves via UI and runs sync on both mirrors.
- Bulk
rename / move / archivefor multiple pages in one command. One page per invocation; pipe / xargs / shell scripting if needed. (We accept that amdd confluence archive subtree <root>would be a useful future addition.) - Inferring rename / move from a working-tree state (“the user
did
git mvand now wants me to push it”). The command is imperative; that workflow can be a separatemdd confluence push-renamecommand if real demand emerges. - Editing labels via these commands.
mdd confluence labelsis its own future surface area. mdd confluence archive subtree <root>— bulk archive for an entire page subtree. Useful but adds enough complexity (recursion, partial-failure semantics) to deserve its own spec.--page <id|url>ad-hoc form for these commands (operating outside a clone). Add when the imperative file-path form proves insufficient.mdd confluence trash page— softer than UI-trashing because it cleans up the mirror in one step. Defer until someone asks; archive covers the common case.
Site built 2026-07-30.