diff --git a/bin/changelog-assemble b/bin/changelog-assemble
index 746f085..5d0eb74 100755
--- a/bin/changelog-assemble
+++ b/bin/changelog-assemble
@@ -62,12 +62,15 @@ done
# Every entry in the directory must be a publishable fragment. A stray file
# in a machine-assembled directory is a mistake to surface, never to skip —
-# except README.md, the directory's marker (#112 D1). This runs before the
-# zero-fragments check so a directory holding only 'notes.txt' names the
-# stray file instead of claiming emptiness.
+# except README.md, the directory's marker (#112 D1), and 'shape', the
+# declared anchor (#182), which changelog_shape_problem validates below and
+# which deliberately survives the consumption: it is the declaration a
+# reader in the directory finds, and it must still be there after the
+# release empties the fragments out.
for f in "$dir"/*; do
[ -e "$f" ] || continue
[ "${f##*/}" = "README.md" ] && continue
+ [ "${f##*/}" = "shape" ] && continue
if ! diagnosis="$(changelog_fragment_problem "$f")"; then
refuse "$diagnosis"
fi
diff --git a/lib/changelog.sh b/lib/changelog.sh
index a177665..a333819 100644
--- a/lib/changelog.sh
+++ b/lib/changelog.sh
@@ -199,9 +199,31 @@ changelog_fragment_problem() {
# 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.
+#
+# The anchor is declarable (#182): an optional sentinel '
/shape',
+# holding exactly 'flat' or 'grouped' on one line, pins the set's shape and
+# outranks the newest-published-section inference — the door a deliberate
+# flip walks through, while undeclared drift stays red (#159). Absent, the
+# inference binds unchanged. Any other content — empty, trailing junk, an
+# unknown word — is a diagnosis naming the file, never a silent fallback.
+# The sentinel lives in the fragments dir so it binds in both callers: the
+# assembler calls with changelog="" and still sees it. It is not a fragment
+# — changelog_fragments matches *.md only, so 'shape' never enters the list.
changelog_shape_problem() {
local changelog="$1" dir="$2"
local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
+ local sentinel="$dir/shape" declared=""
+
+ if [ -f "$sentinel" ]; then
+ declared="$(cat "$sentinel")"
+ case "$declared" in
+ flat | grouped) ;;
+ *)
+ printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
+ return 1
+ ;;
+ esac
+ fi
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0
@@ -227,6 +249,20 @@ changelog_shape_problem() {
return 1
fi
+ if [ -n "$declared" ]; then
+ if [ "$declared" = "grouped" ] && [ -n "$ungrouped_in" ]; then
+ printf "fragment '%s' is flat but '%s' declares grouped — a repo is one shape or the other\n" \
+ "$ungrouped_in" "$sentinel"
+ return 1
+ fi
+ if [ "$declared" = "flat" ] && [ -n "$grouped_in" ]; then
+ printf "fragment '%s' is grouped but '%s' declares flat — a repo is one shape or the other\n" \
+ "$grouped_in" "$sentinel"
+ return 1
+ fi
+ return 0
+ fi
+
if [ -f "$changelog" ]; then
published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")"
fi