diff --git a/actions/changelog-armed/changelog-armed.sh b/actions/changelog-armed/changelog-armed.sh
index 0e4b0ba..258bb45 100644
--- a/actions/changelog-armed/changelog-armed.sh
+++ b/actions/changelog-armed/changelog-armed.sh
@@ -96,6 +96,11 @@ if [ -d "$fragments_dir" ]; then
fi
done <<<"$fragments"
+ if ! diagnosis="$(changelog_shape_problem "$changelog" "$fragments_dir")"; then
+ printf 'changelog-armed: %s\n' "$diagnosis" >&2
+ exit 1
+ fi
+
if version_is_dev "$ver"; then
echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)"
exit 0
diff --git a/bin/changelog-assemble b/bin/changelog-assemble
index 7f09db8..746f085 100755
--- a/bin/changelog-assemble
+++ b/bin/changelog-assemble
@@ -76,6 +76,10 @@ done
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || refuse "zero fragments in '$dir' — a release publishes prose; refusing to publish an empty release"
+if ! diagnosis="$(changelog_shape_problem "$changelog" "$dir")"; then
+ refuse "$diagnosis"
+fi
+
if ! body="$(changelog_assemble "$dir")"; then
refuse "$body"
fi
diff --git a/changelog.d/159.md b/changelog.d/159.md
new file mode 100644
index 0000000..2212ff8
--- /dev/null
+++ b/changelog.d/159.md
@@ -0,0 +1 @@
+- Make `changelog-armed` reject fragment shape drift on the PR that introduces it.
diff --git a/lib/changelog.sh b/lib/changelog.sh
index df34832..a484857 100644
--- a/lib/changelog.sh
+++ b/lib/changelog.sh
@@ -149,24 +149,17 @@ changelog_fragment_problem() {
fi
}
-# changelog_assemble
+# changelog_shape_problem
#
-# Print the assembled section body — no '## ' line; that heading belongs to
-# the caller — for every fragment in changelog_fragments order. Assumes each
-# fragment already passed changelog_fragment_problem; the one property only
-# the whole set can show is shape: a repo is grouped or flat, never both
-# (#112 D4), because merging the shapes would silently strand ungrouped
-# bullets, so a mix prints a diagnosis naming the offending fragments and
-# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed,
-# Removed, Deprecated, Security, then any other group in first-seen order —
-# appended, never dropped. Inside a group, fragment order is preserved, and
-# a bullet's continuation lines travel with it verbatim: entries in this
-# family wrap, and reflowing someone's prose is not this tool's business.
-# An empty directory prints nothing and succeeds; refusing an empty release
-# is the caller's stance, not this function's.
-changelog_assemble() {
- local dir="$1" nl=$'\n'
- local fragments f grouped_in="" ungrouped_in="" chunk g seen="" ordered="" body first=1
+# Print the first reason a fragment set cannot publish and return 1; silence
+# returns 0. Shape is a set-level property (#157 D3), so this is the one
+# definition shared by the PR-time guard and the release-time assembler:
+# fragments may not mix grouped headings with ungrouped bullets, and a
+# non-empty set must match the newest published section when one exists.
+changelog_shape_problem() {
+ local changelog="$1" dir="$2"
+ local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
+
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0
@@ -191,6 +184,57 @@ changelog_assemble() {
return 1
fi
+ if [ -f "$changelog" ]; then
+ published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")"
+ fi
+ [ -n "$published" ] || return 0
+
+ published_body="$(changelog_section "$changelog" "$published")"
+ if printf '%s\n' "$published_body" | grep -q '^### '; then
+ if [ -n "$ungrouped_in" ]; then
+ printf "fragment '%s' is flat but newest published section '%s' in '%s' is grouped — a repo is one shape or the other\n" \
+ "$ungrouped_in" "$published" "$changelog"
+ return 1
+ fi
+ elif [ -n "$grouped_in" ]; then
+ printf "fragment '%s' is grouped but newest published section '%s' in '%s' is flat — a repo is one shape or the other\n" \
+ "$grouped_in" "$published" "$changelog"
+ return 1
+ fi
+}
+
+# changelog_assemble
+#
+# Print the assembled section body — no '## ' line; that heading belongs to
+# the caller — for every fragment in changelog_fragments order. Assumes each
+# fragment already passed changelog_fragment_problem; the one property only
+# the whole set can show is shape: a repo is grouped or flat, never both
+# (#112 D4), because merging the shapes would silently strand ungrouped
+# bullets, so a mix prints a diagnosis naming the offending fragments and
+# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed,
+# Removed, Deprecated, Security, then any other group in first-seen order —
+# appended, never dropped. Inside a group, fragment order is preserved, and
+# a bullet's continuation lines travel with it verbatim: entries in this
+# family wrap, and reflowing someone's prose is not this tool's business.
+# An empty directory prints nothing and succeeds; refusing an empty release
+# is the caller's stance, not this function's.
+changelog_assemble() {
+ local dir="$1" nl=$'\n'
+ local fragments f grouped_in="" chunk g seen="" ordered="" body first=1 diagnosis
+ fragments="$(changelog_fragments "$dir")"
+ [ -n "$fragments" ] || return 0
+
+ if ! diagnosis="$(changelog_shape_problem "" "$dir")"; then
+ printf '%s\n' "$diagnosis"
+ return 1
+ fi
+
+ grouped_in="$(printf '%s\n' "$fragments" | while IFS= read -r f; do
+ if grep -q '^### ' "$f"; then
+ printf '%s\n' "$f"
+ break
+ fi
+ done)"
if [ -z "$grouped_in" ]; then
while IFS= read -r f; do
chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")"
diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh
index 2fd7320..07124a2 100644
--- a/test/changelog-armed.test.sh
+++ b/test/changelog-armed.test.sh
@@ -296,6 +296,66 @@ EOF
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
in_tree fragments-dev-grouped
+fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
+# Changelog
+
+## 1.2.3 — 2026-07-20
+
+- The shipped entry.
+EOF
+printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
+cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
+### Fixed
+
+- Grouped fragment.
+EOF
+check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \
+ "fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
+ in_tree fragments-dev-mixed
+
+fragment_tree fragments-dev-all-grouped-over-flat 1.2.4-dev <<'EOF'
+# Changelog
+
+## 1.2.3 — 2026-07-20
+
+- The shipped entry.
+EOF
+cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
+### Fixed
+
+- Grouped fragment.
+EOF
+check "fragment mode refuses an all-grouped set over a flat published section" 1 \
+ "changelog.d/115.md' is grouped but newest published section '1.2.3'" \
+ in_tree fragments-dev-all-grouped-over-flat
+
+fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
+# Changelog
+
+## 1.2.3 — 2026-07-20
+
+### Fixed
+
+- The shipped entry.
+EOF
+printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
+check "fragment mode refuses a flat set over a grouped published section" 1 \
+ "changelog.d/115.md' is flat but newest published section '1.2.3'" \
+ in_tree fragments-dev-flat-over-grouped
+
+fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF'
+# Changelog
+
+Preamble only.
+EOF
+cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
+### Fixed
+
+- Grouped fragment.
+EOF
+check "fragment mode accepts a consistent set with no published section" 0 \
+ "fragment mode" in_tree fragments-dev-no-published
+
fragment_tree fragments-unreleased 1.2.4-dev <<'EOF'
# Changelog
diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh
index c21fc1a..34f625b 100644
--- a/test/changelog-assemble.test.sh
+++ b/test/changelog-assemble.test.sh
@@ -99,8 +99,16 @@ check "flat: numeric-descending order (10.md before 9.md), cross-repo name besid
# --- grouped write: canonical order, unnamed group appended ------------------
-tree grouped <"$TMP/faithful-grouped/changelog.d/21.md"
printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md"
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
diff --git a/test/changelog.test.sh b/test/changelog.test.sh
index c9d531e..8754407 100755
--- a/test/changelog.test.sh
+++ b/test/changelog.test.sh
@@ -338,4 +338,63 @@ check "assemble: one fragment mixing both shapes is refused, file named" 1 \
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
changelog_assemble "$AX"
+# --- the fragment-set shape predicate (#159) ---------------------------------
+
+SHAPE_CHANGELOG="$TMP/CHANGELOG.shape.md"
+SHAPE_DIR="$TMP/shape-fragments"
+mkdir -p "$SHAPE_DIR"
+
+cat >"$SHAPE_CHANGELOG" <<'EOF'
+# Changelog
+
+## 2.0.0 — 2026-07-24
+
+- Newest section is flat.
+
+## 1.0.0 — 2026-07-01
+
+### Fixed
+
+- Older section is grouped.
+EOF
+printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
+check "shape: flat set matches newest flat published section" 0 "" \
+ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
+
+cat >"$SHAPE_DIR/1.md" <<'EOF'
+### Fixed
+
+- Grouped fragment.
+EOF
+check "shape: grouped set names its conflict with newest flat published section" 1 \
+ "fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
+ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
+
+cat >"$SHAPE_CHANGELOG" <<'EOF'
+# Changelog
+
+## 2.0.0 — 2026-07-24
+
+### Fixed
+
+- Newest section is grouped.
+EOF
+printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
+check "shape: flat set names its conflict with newest grouped published section" 1 \
+ "fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \
+ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
+
+cat >"$SHAPE_DIR/1.md" <<'EOF'
+### Fixed
+
+- Grouped fragment.
+EOF
+check "shape: grouped set matches newest grouped published section" 0 "" \
+ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
+check "shape: consistent set with no published section passes" 0 "" \
+ changelog_shape_problem "$TMP/no-such-changelog" "$SHAPE_DIR"
+rm "$SHAPE_DIR/1.md"
+check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \
+ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
+
summary