Publish Confluence page labels from frontmatter - #141
Merged
Conversation
Plans #138: a `labels` frontmatter field that `update`/`create` assert against the live page, `fix`/`read`/`export` recover, `info` displays, and `check` validates offline. Two layers, and the lower one is the larger job. `internal/frontmatter` errors on any sequence value today, so `labels: [a, b]` breaks every command that reads the file, not just the label-aware ones -- sequence support has to land first, and has to be general enough that `reviewers: [ana, bo]` works too. Records what was probed rather than assumed. Confluence splits a label name on spaces and commas (so `[Runbook Two]` publishes as two labels that read back as neither, and re-add forever under assert-exactly), refuses a colon, caps names at 255 UTF-16 units, and only accepts label writes on v1 with removal via `?name=` -- the path form 400s on the real `ci/cd` label. goccy reads both sequence styles correctly and a block list survives Normalize intact, so both are accepted and an author's style is preserved on rewrite; the write-side self-check has to run per style, because mapping context passes `x,y` and `has]bracket` while block context turns `? q` into a mapping.
toMap errored on any sequence value, so `labels: [a, b]` -- or any other list field -- failed out of Parse and broke every command that read the file, not just the ones that knew about the key. #138 needs the field, and #21/#100 need the same door opened, so this supports sequences generally: the parser learns a kind, not a name, and a test pins that an unrelated `reviewers: [ana, bo]` survives a write untouched. Scalars stay in Frontmatter and sequences land in Lists, so a key is in exactly one map -- leaving a flattened sequence in the scalar map is how MarkdownFile.field would hand update a title of "[a, b]". An empty list is present-and-empty rather than absent, which a destructive field like labels depends on: "[]" means remove them all. Both YAML spellings are read, because goccy parses both correctly and a block list survives Normalize intact, so refusing one would reject a file that was understood perfectly. A rewrite keeps the style it found -- a set large enough to want block form is exactly the set whose flow spelling is an unreadable single line -- but style only, not formatting: block items are re-emitted at this package's own indent, not the author's. Three findings from probing goccy, each now pinned by a test: - An element's origin carries a *leading* newline whenever the element began on a new line, which is every block item and a wrapped flow list. spansLines trims only the right, so reusing it would refuse "[a,\n b]" for no reason. elementValue applies the line rule to the origin trimmed at both ends, and still refuses an element whose own value runs past its line. - A "- |-" element reports Literal/"|-" with an origin that does *not* span lines, so the node-kind whitelist is the only thing that catches it. The whitelist is now shared by scalarValue and elementValue rather than copied. - "x,y" and "has]bracket" pass readsBackAs -- they are fine as a mapping value -- and corrupt a flow sequence, splitting into two elements and ending the sequence respectively. readsBackInSeqAs verifies in the style being written; verifying in block while writing flow is the direction that corrupts, and the hazard corpus now runs through both styles to pin it. Also corrects two comments the probes proved wrong: a continued plain scalar does not come back as a "|-" block (it folds onto one line; the block scalar is the shape that breaks), and dropBlankLines' safety no longer rests on "nothing spans more than one line", since a passed-through block list does. It rests on no emittable value carrying a meaningful blank line.
The verified findings behind #138's design, so the client's comments have something to cite. Probed 2026-09-08 against a scratch page: names POSTed one at a time via v1, read back through v1 and v2, both delete forms exercised. The one to read before touching any of this: Confluence splits a label name on spaces and commas, silently and with a 200. `Runbook Two` becomes `runbook` + `two`, which under assert-exactly reads back as neither and so is re-added on every run with no frontmatter spelling that can remove it. Two labels in the SRE space look like this having already happened to somebody. Also recorded: the server's own reject set (a colon is in it, so there is no prefix syntax to offer an author), the 255-**UTF-16-code-unit** cap that a byte or rune check would get wrong in opposite directions, v2 being read-only for labels, and the `?name=` removal form -- the path form 400s with a Tomcat HTML body once a name holds a `/`, and `ci/cd` is a real label in the SRE space, so that is a real failure on a real page rather than a hypothetical. The scope for the two v1 write routes goes in the table as unverified; api.md already records that v1 scopes cannot be looked up.
Reads are v2, writes are v1 -- the same split attachments have, and not a choice: v2 answers POST and DELETE on a page's label collection with a 405. ListLabels returns the list **unfiltered**. Callers asserting a frontmatter field filter to the "global" prefix themselves, because info shows every label a page carries including the my:/team: ones no frontmatter could express, and a client that dropped them would make that impossible. RemoveLabel puts the name in the query string, never the path. The path form works right up until a name holds a "/", which answers 400 with a Tomcat HTML body however the slash is encoded, while ?name= answers 204 for the same label -- and "ci/cd" is a real label in the SRE space, so this is a real failure on a real page. The test asserts the path and the query separately, since a name appended to the path would leave the query empty and still 204 against a lenient fake. Its 404 check goes through notFound rather than comparing the status, because a rejected credential is also a 404 with a body that names nothing. Read as "already gone", that would report a whole batch of removals as a success against a page nobody can read. Pinned by a test that fails if the gate is weakened to a status comparison. AddLabels batches into one request and makes none at all for an empty list, so publishing a file with no labels costs nothing. Every name must already be valid when it gets here: Confluence splits a name on spaces and commas with no warning and a 200, so the validation that prevents it lives in internal/labels and runs before any write.
The vocabulary package for the labels frontmatter field, modeled on internal/pagewidth: Declared reads and validates, Diff answers what to add and remove, Apply/Plan run it against the client. Set arithmetic lives here because three commands need the same answer and three copies of a set difference is three chances to leave a label behind. Validation mirrors Confluence's own reject set rather than inventing an allowlist, so ci/cd, dataops_reports and héllo-wörld publish unchanged. It refuses rather than repairs, because the input it exists to catch is silently destructive: Confluence splits a name on a space or comma with a 200 and no warning, so `labels: [Runbook Two]` publishes two labels that read back as neither and are re-added on every run, forever, with no frontmatter spelling that removes them. The error quotes the reject set, since half of it is surprising -- a "." means a version-shaped label is impossible. Case is the one repair, with a warning naming the label as written, because Confluence lowercases server-side and a file that disagreed would never converge. A leading or trailing space is *not* trimmed: a space is a separator there, so trimming would quietly repair the exact class of input that produced the "continuous" + "delivery" pair in the SRE space. Length is counted in UTF-16 code units, pinned at the three points measured against the server (255 accented runes pass, 256 fail, 128 emoji fail). A byte check fails the first and a rune check passes the last, so the test catches a drift in either direction. Three shapes kept deliberately distinct: - absent: the page's labels are untouched, so a hand-labeled page survives a run that never mentioned labels; - `[]`: remove every managed label; - a scalar, including every null spelling: an error. The field is destructive, so `labels:` with nothing after it must not be read as "strip this page" on a file where an author typed a key and got distracted. Those cases run through frontmatter.Parse rather than a hand-built map, since the parser is what flattens "~" and "null" to "" and a test that passed "" three times would prove one case and claim three. Only the global prefix is managed. A my: label is personal and a team: one belongs to a space's vocabulary; neither has a frontmatter spelling, so removing one would delete data the file could not have expressed.
An invalid label is the same class as an invalid page_width -- a guaranteed
publish defect visible with no network -- and worse in one respect, which is
why check is the command that should catch it. A page_width Confluence refuses
fails loudly. A label name holding a space *succeeds*: Confluence splits it,
stores the pieces, and returns 200, so the file now declares a label that reads
back as neither piece and is re-added on every run with no spelling that
removes it. There is no later opportunity to catch that one.
So an invalid label is status: failed with code: VALIDATION, beside the
page_width check, and the case repair is a warning carrying both spellings so
an author can make the file say what will actually happen.
Also fixes the message to quote the label as written. Validation ran after
lowercasing, so a file saying "Runbook Two" was told about `label "runbook
two"` -- a string that is not in their file. It now validates the raw name
first for the message, and re-checks the normalized one because case folding
can change UTF-16 length ("İ" lowercases to two code points).
Label warnings lead the warnings list, since they are a property of the
frontmatter and hold whatever the converter went on to find in the body.
Declared means exact: a label on the page that the file does not list is removed. Absent means untouched, which mirrors page_width's existing asymmetry and is what keeps a hand-labeled page from being stripped by a run that never mentioned labels. That last part is pinned as "makes no label request at all", not "makes no write". No request means there is no path by which a run that said nothing about labels can decide to change them, which makes it a property rather than an implementation detail. Ordering, both halves deliberate. Validation runs before any request and is fatal, because the defect it catches is not recoverable afterwards: a name holding a space publishes *successfully* as several labels that read back as none of what the file says. Application runs last and is non-fatal, matching pagewidth.Apply -- the body is published by then, so failing the result would report that the publish did not happen. A failed application leaves labels null rather than claiming a set that is not there. --json gets `labels`: the full declared set every run, not just the changes, so a consumer can read a page's labels off a publish without a second call. It is `*[]Label` rather than a slice because an empty declared set and an absent key are different answers -- "[]" means the labels were removed, null means they were never touched -- and a nil slice cannot say which. In a dry run the same field carries the actions that would be taken, read-only. Unmanaged labels survive, tested: a my: or team: label has no frontmatter spelling, so a set that cannot mention it must not remove it.
#127's guarantee applied to labels: a defect in the file must not leave a created page behind. It matters more here than for most phase-1 checks, because the defect is not recoverable afterwards -- a label Confluence splits on a space publishes *successfully*, so a page created before the check would carry labels no later run can remove, and the author would have a page_id written into their file to undo by hand. Pinned by a test asserting no page is created, no label request is made, and no page_id is written back. Validated beside resolveWidth rather than beside the preflight conversion, which is where the plan put it. The width check is the closer precedent: both are offline reads of one frontmatter field, so neither spends a request to find out, and the page_id precedence the conversion had to be sequenced around doesn't apply to a local check. A bad label takes newFailure's fallback and so reports VALIDATION -- not CONVERT, since the converter never saw it, and not a request code, since no request was made. The validated set is carried on the record rather than re-read at publish, so the two phases cannot disagree about what was checked. Application is last and non-fatal, matching the width: the page exists and carries its content by then, so failing the result would report that it does not. The dry run previews labels without asking for the live set, unlike update's. There is no page yet, so every declared label is an add and there is nothing to remove -- looking it up would be a request against an id that does not exist.
This is the only way to adopt a page somebody labeled in the UI, which is why
it reconciles labels even for a file with no labels key -- the opposite of
update and create, where an absent key means "leave the page alone". The
directions are not symmetric and should not be: fix reconciles the file to the
page, so the page is the authority here in exactly the way the file is there.
Compared as sets, so a file that merely orders its labels differently, repeats
one, or spells one in the wrong case is left alone and keeps the author's own
ordering. When a write is needed the list is emitted sorted and deduplicated,
since neither label GET returns a useful order.
The live read is best-effort like the width read, and nil carries a meaning an
empty slice cannot: "not known" rather than "the page has no labels". A failed
read plans nothing, because the alternative is a transient failure proposing
that every label be stripped from the file. Tested both ways.
A block list stays a block list through the write, end to end, via
UpdateListField. The change struct carries newList alongside the display
string, so --json's `new` stays a string ("[a, b]") and the reported shape of a
change does not vary by field -- no new schema case for one list field.
Also: an invalid label in the file is reconciled rather than refused, the one
place fix repairs something check would have failed. The live set is what gets
written and it came from the server, so it is valid by construction -- which is
what lets an author recover a file that already suffered the space-splitting
bug. A convergence test pins the property that pair in the SRE space is the
absence of: reconcile once, and the second run has nothing to do.
Two rows, the second appearing only when there is something in it per info's
existing "empty fields omitted" rule:
page_width: max (Confluence default)
labels: ci/cd, howto, runbook
labels/unmanaged: my:mine, team:eng
info is the one command that shows what markfluence will not touch, which is
why client.ListLabels returns the list unfiltered: a my: label is personal and
a team: one belongs to a space's own vocabulary, neither has a frontmatter
spelling, and a reader looking at a page's labels should see all of them rather
than the subset markfluence happens to manage.
The fetch is best-effort like the width's -- a page nobody can label is still
worth describing. In --json, [] means the page genuinely has none and null
means the fetch failed, the distinction the report keeps labelsKnown for;
collapsing them would make a transient failure indistinguishable from a fact
about the page.
`managed` travels with each label rather than being left to a consumer to
derive, so the prefix rule can only be wrong in one place.
Through pagedoc, so read and export gain it together and cannot drift -- the same reason Render/Options exist. A round-tripped tree now republishes its labels instead of dropping them, which is what made an export lossy for the one piece of metadata people actually search on. Global labels only: a my: or team: label has no frontmatter spelling, so emitting one would produce a file that cannot publish what it says. Sorted, because neither label GET returns a useful order and a tree that reshuffles its own frontmatter between runs is noise in every later diff. An empty list and a failed fetch are written the same way -- no labels: key at all -- and that is the one place this deliberately does *not* mirror how update reads the field. An emitted "labels: []" would tell the next publish to remove every label, so a read of a page with no labels would quietly assert that on the author's behalf and strip anything added in the UI in between. Pinned by a test covering both cases. read --json gets `labels` as a plain sorted string array, a different shape from info's on purpose: info describes the page, read describes the document it produced. null when the fetch failed, so "none" and "unknown" stay distinguishable. Costs one extra request per page on an export walk, which is the price of the round trip being complete.
README: a `labels` row in the frontmatter table, the assert/absent asymmetry in the `update` and `fix` sections, `labels` in what `read`/`export` emit and what `check` validates, and a note on the existing `--cql 'label = "runbook"'` example -- that search only pays off for pages someone labeled, which is the gap this closes. The frontmatter description no longer says "no lists". guarantees.md gets **L9** (`declared-metadata-is-asserted`), status **Partial**, and the gap is `page_width` rather than `labels`: `update` honours it for both, but `create` asserts a default width of `max` for a file that declares none, so an omitted field is not left alone there. `labels` holds in both verbs. `fix` is outside the law rather than violating it -- it reconciles the file to the page, so an absent field is filled in, which is the only way to adopt a page labeled by hand. C2's wording now admits sequences, and its prose records what extending the writer's self-check cost, because that was the closest thing to a repeat of #130: the scalar check verifies a value as a *mapping* value, and `x,y` and `has]bracket` pass it and then split or end a flow sequence. Flow turns out to be the stricter context, so verifying there is always safe and the style parameter buys minimal quoting -- the corrupting direction is a block check standing in for a flow write. Also corrects two claims the probes disproved: a continued plain scalar folds onto one line rather than returning as a `|-` block, and `dropBlankLines`' safety no longer rests on "nothing spans more than one line", since a passed-through block list does.
The refusal message explained the separator behaviour for every character in the reject set, so `labels: [v1.2]` was told it "would publish as several labels" -- which it would not. A dot is simply refused. Being wrong about the case in front of the reader is how the accurate warning, which is about a real and unrecoverable bug, stops being read. Two messages now: the splitting explanation for a space or comma, and a plain refusal otherwise. Both still quote the reject set, since a "." making a version-shaped label impossible is the part nobody guesses.
Found by testing against the live instance, which is the only thing that could have found it: the unit tests asserted the path markfluence sent, against a fake that answered anything. The first real `create` published its page, set its width, and reported "could not set labels: HTTP 405". `child/` is the collection attachments, pages and comments hang off, and for labels it is **read-only** -- POST and DELETE there answer 405 with `allow: HEAD,GET,OPTIONS`, identically on the gateway and on the site domain, with an empty body. The write route has no `child/` segment. The attachment routes are what make the wrong guess easy: those really are `child/attachment`. Everything else #138 recorded about the forms is confirmed against the live API and now carries its own measurement: the path form removes a plain name and 400s with a Tomcat HTML body on `ci%2Fcd`, the `?name=` form does both, and an absent name is a 404 whose body *names the label*, which is what lets notFound tell it from a rejected credential. labels.md gains the corrected routes at the top and an end-to-end section: a declared set applied on create and update, the surplus removed including `ci/cd`, a hand-added `my:` label surviving every run, `labels: []` removing only the managed ones, an absent key touching nothing at all, `fix` adopting hand labels and converging on the second run, and a block-style list still block-style after a rewrite. All against a scratch page in a personal space, purged afterward. It also records an unrelated bug found on the way out: a **purged** page's v2 404 body is a bare `"title":"Not Found"` naming nothing, which is the shape RejectedCredential uses for a revoked token -- so `info` on a purged page blames the credentials. The claim that "every genuine v2 404 names what it could not find" holds for a page that never existed and for a trashed one, not a purged one. Not fixed here.
Looked up the same way the rest of the scope table was (api.md's "Derived
2026-08-20" method): the label operations in Atlassian's v1 and v2 OpenAPI
documents, taking their Current x-atlassian-oauth2-scopes entries.
GET /wiki/api/v2/pages/{id}/labels read:page:confluence
POST /wiki/rest/api/content/{id}/label write:confluence-content
DELETE (same path) write:confluence-content
So #138's guess was right, and `write:confluence-content` is now in the union
block as the one scope labels add. It is also the entry most likely to be
missing from a token granted before labels existed, and the failure is narrow
enough to recognize: every command keeps working and only the label half of
create/update 401s, because the read side is covered by read:page:confluence,
which any working token already has.
Two things worth knowing before the obvious assumption gets made. The granular
`read:label:confluence` does exist as Current -- on `GET /labels` and the
space-label routes, neither of which markfluence calls -- so "labels need a
label scope" is wrong for the route we use. And the Beta granular pair for the
writes is `write:label:confluence`, with `read:label:confluence` also on the
POST, the same both-halves shape the property writes have.
labels.md's open question narrows accordingly: the scope is no longer unknown,
only untested against a real scoped token, since the end-to-end run used an
unscoped personal one and those are scope-blind.
**labels.Apply refused to be prefix-safe, and the API cannot be.** Confluence's removal route takes a name and nothing else, and on a page carrying the same name under two prefixes it deletes the **personal** one. Measured: a page with global:probe-dup and my:probe-dup answered `DELETE ?name=probe-dup` with 204 and left global:probe-dup in place; `&prefix=global` is ignored, a colon-qualified name 404s, the path form behaves identically, and a second delete then takes the global one. So the removal markfluence issued for a surplus label would destroy exactly the label this package guarantees it never touches. There is no request that expresses the intended one, so a colliding removal is now skipped: the label is reported `kept` (new in the schema enum) and warned about, failing assert-exactly visibly rather than deleting somebody's personal label quietly. The earlier end-to-end run missed it because `my:mine` collided with nothing. **frontmatter read a scalar field written as a list as *absent*.** Sequence support routed every sequence key into Lists, so `parent:` written as a list -- an error before this branch -- silently became "no parent": create published the page at the space root and then wrote `parent: null` over the author's intent, with nothing reported anywhere. Same silence for `title:` and `page_width:`. The five fields markfluence reads as single values now refuse a sequence; unknown keys stay permissive both ways, so `reviewers: [ana, bo]` still works and #21/#100 still have their door. **create dropped the label case warning.** The warning is raised in preflight and create never re-reads the field at publish, so it was computed and thrown away -- leaving an author with a page labelled "runbook", a file still saying "Runbook", and nothing said about either, since --persist does not write labels back. **fix would not correct a label case mismatch.** It normalized case before comparing, so `labels: [Runbook]` against a page carrying `runbook` reported "already consistent" -- while update and check warned "Update the file to match" on every run. The command whose job is that updating was the one command that would not do it. **fix would not repair a scalar labels: value** unless the page happened to carry labels, so a file that check, update and create all reject was reported "already consistent" -- inconsistently, since the same file was repaired when the page had labels. **info had a dead store**, assigning an empty slice that the next statement overwrote. Each fix has a test; the collision guard is pinned by one that fails if the guard is weakened.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
labelsfrontmatter field thatupdate/createassert against the live page,fix/read/exportrecover,infodisplays, andcheckvalidates offline. Fixes #138.Labels are how Confluence content is actually organized and searched — the SRE space carries 94 distinct labels across 1123 pages — and until now markfluence could not see them at all: a published page had to be labeled by hand afterward, and
read/exportsilently dropped whatever a page already carried. The README's ownsearch --cql 'label = "runbook"'example only paid off for pages someone had tagged manually.Design and evidence:
_plans/036_labels.mdanddocs/confluence/labels.md.The field
Either YAML style is accepted — inline, or
-lines — and a rewrite keeps the style it found, because a set large enough to want block form is exactly the set whose inline spelling is an unreadable 200-column line. Style is preserved; indentation is not.labels: []global:only. Amy:/team:label is shown byinfoand never written or removedfixis the one verb that fills in an absent key — the only way to adopt a page somebody labeled in the UI. It reconciles the file to the page whereupdate/createreconcile the page to the file, so an absent field means the opposite thing in each direction. That asymmetry is written down as the new law L9 indocs/guarantees.md(status Partial —createstill asserts a defaultpage_widthfor a file that declares none).Two things #138 got wrong, found by testing against the live API
The write route. #138 specified
POST /content/{id}/child/label. That collection is read-only for labels — 405,allow: HEAD,GET,OPTIONS, on the gateway and the site domain alike. The write route has nochild/segment. The unit tests could not catch this: they assert the path markfluence sends, against a fake that answers anything. The first realcreatepublished its page, set its width, and reportedcould not set labels: HTTP 405.A name-only removal deletes the personal label. Found in review and then measured. Confluence's removal takes a name and nothing else, and on a page carrying the same name under two prefixes it deletes the
my:one:DELETE ?name=probe-dupmy:probe-dupgone,global:probe-dupremains?name=probe-dup&prefix=global?name=global:probe-dup/label/probe-dupSo there is no request that removes the managed label of a colliding pair, and the obvious one destroys the label this feature promises never to touch. A colliding removal is now skipped: the label is reported
keptand warned about, failing assert-exactly visibly rather than deleting somebody's personal label quietly.The larger half: frontmatter sequences
internal/frontmattererrored on any sequence value, solabels: [a, b]failed out ofParseand broke every command that read the file, not just the label-aware ones. Sequence support had to land first and had to be general — the parser learns a kind, not a name, and a test pins that an unrelatedreviewers: [ana, bo]survives a write untouched, since #21 and #100 need the same door open.Three findings from probing goccy, each pinned by a test:
spansLinestrims only the right, so reusing it would refuse[a,\n b]for no reason. Elements get their own check, trimmed at both ends.- |-element reportsLiteral/|-with an origin that does not span lines, so the node-kind whitelist is the only thing that catches it.x,yandhas]bracketpass the existing write-side check and then corrupt a list — the check verifies a value as a mapping value, where a comma and a bracket are inert. Emitted bare into an inline sequence the first becomes two elements and the second ends the sequence. Verification now runs in the style about to be written; inline turns out to be the stricter context, so the style parameter buys minimal quoting and the corrupting direction is a block check standing in for an inline write.Verified against the live instance
A scratch page in a personal space through the gateway, purged afterward: a declared set applied on create and update including
ci/cd; the surplus removed,ci/cdincluded; a hand-addedmy:label surviving every run;labels: []removing only the managed ones; a file with nolabels:key touching nothing and reportinglabels: null;infoshowing both rows;reademittinglabels: [howto, runbook]sorted betweenpage_idandpage_width;fixadopting hand labels and converging on the second run; a block-style list still block-style after a rewrite.Scopes
Derived from the same OpenAPI documents
docs/confluence/api.md's existing table comes from:GET /wiki/api/v2/pages/{id}/labelsread:page:confluencePOST /wiki/rest/api/content/{id}/labelwrite:confluence-contentDELETE(same path)write:confluence-contentwrite:confluence-contentis the only scope labels add, and the one a token granted before this will lack. The failure is narrow and recognizable: everything keeps working and only the label half ofcreate/update401s. Note that the granularread:label:confluenceexists but belongs toGET /labelsand the space-label routes, neither of which markfluence calls — "labels need a label scope" is wrong for the route we use.Not done
label-*subcommands, no--labelsflag (the only natural CLI separators are the two characters Confluence splits on), no--labelfilter onfind/search, no space-wide defaults (markfluence.yaml: support project-wide settings #100)."title":"Not Found", which tripsRejectedCredential, soinfoon a purged page blames your credentials; andupdatereports a version bump Confluence did not make when republishing unchanged content.