Development record
The release cut writes RELEASE.md from the press releases of the intents it shipped
Summary
spc-2609231435545473 delivers itd-2609231013154443: a feature release's cut
writes a short press release to RELEASE.md at the repository root, composed
by an agent from the press releases of the user-facing intents in the cut, and
moves the page it replaces to .abcd/development/releases/<version>.md.
It is the changelog slice of spc-11 applied to a second document, not a new trust model. The same host-delegated composer writes both documents in one payload; the binary computes the set of intents the page must cite, proves the payload cites exactly that set, checks every carried quote against its source, and only then makes three writes in a fixed order, rolling back whatever landed if a later one fails. A refused payload goes back to the composer with machine-readable reasons until it is valid (Decision 9). A cut that ships fixes alone writes no page and says why.
At this tip the read-only preview (abcd changelog) shows the set the first
page would carry: itd-121 and itd-2609221656373558, the two additive
intents among fourteen added records; the other twelve are issues.
Scope
In
- The press-release set:
PressReleaseRequired()besideChangelogRequired()in the changelog package, marked per cut entry asin_press_release, so the preview, the composer's view and the bijection read one definition. - The payload: the changelog payload gains a
press_releaseobject (schema 2) carrying headline paragraphs, a name list and quotes, every citation as data. - The composer:
release-changelog-composeris extended to write the page in the same pass as the changelog lines, with a new injection canary and a no-forecast fixture. - Ingest validation: every refusal AC6 and AC7 name, plus the outbound-policy check, all run before any write.
- The retry-until-valid loop: the binary refuses with machine-readable reasons; the host re-invokes the composer with them, with no attempt limit, reporting every refused attempt.
- The three writes and their rollback: archive, page, changelog heading, in that order; the first-cut and fixes-only cases.
- The page:
RELEASE.mdwhose heading names its version and date. - The preview:
abcd changelog(and the ship verb's emit step, which shares its render) lists the intents the page will be composed from. - Gates:
RELEASEin thestray_root_docsallowlist,RELEASE.mdin the CI inert-path list, aREADME.mdfor.abcd/development/releases/, and that directory added toforbidden_synonyms' exempt prefixes. - Doc surfaces:
commands/launch.md, the launch surface page's prose, the agents catalogue in the brief,agents/CHANGELOG.md.
Out
Carried from the intent and not reopened here:
- Any forecast: no
target_releaseintent, no still-planned work, no date, no "coming next". Thetarget_releasereport stays in the cut's report. - Issues,
impact: internalintents and Removed intents as page sources. - Release pages on the project website (a separate draft intent), and the
published GitHub release notes (
release.ymland its scaffold template are untouched). - Pages for releases cut before this one: no backfill, no on-request page.
- The changelog's wording, sections, bijection and version derivation.
- The release retrospective (itd-24) and the lifeboat's
disembark press-release. - An enforced cap on intents per release: about twenty is a scope condition of the intent, not a refusal.
Approach
1. The press-release set, defined once
RecordSet.PressReleaseRequired() in internal/core/changelog/shipped.go returns
the Added records whose id is an itd-* and whose InChangelog() holds:
PressReleaseRequired = Added ∩ itd-* ∩ InChangelog
Removed records, issues and impact: internal intents fall out by
construction. A record carrying a valid shipped_in: is already absent from
Added (ShippedSince), so a migration-closed intent is never announced. An
unlabelled Added record never reaches a ready cut (UnlabelledAdded refuses
it), so InChangelog() means "user-facing" here without qualification.
release.Entry gains InPressRelease bool (json:"in_press_release"), set in
entriesOf from that method. It is carried per entry for the reason
InChangelog is: the host must never re-derive the rule. The addition to the
Cut JSON is additive.
changelog.Record gains PressRelease string: the body of the intent's
## Press Release section, read from the same blob newRecord already reads for
Title and Summary (no second git read), extracted by a new
pressReleaseSection in source.go and bounded by the existing
maxRecordBytes. It reaches the ingest on release.Entry as an unexported field,
so it never enters the cut JSON; the composer reads the record at its path, as
it does today.
2. The payload
ChangelogSchemaVersion moves from 1 to 2 and ChangelogPayload gains one
field. A schema 1 payload is refused as unsupported; the prompt and the binary
ship together in the plugin, so no composer is left writing the old shape.
{
"schema_version": 2,
"prompt_version": "0.4.0",
"next_tag": "v0.10.0",
"entries": [ { "section": "Added", "records": ["itd-121"], "text": "..." } ],
"press_release": {
"headlines": [
{ "records": ["itd-121"], "text": "One paragraph told as the moment a person notices it." }
],
"listed": ["itd-2609221656373558"],
"quotes": [
{ "record": "itd-121", "text": "\"... what I'd do next,\" says Nia, facilitator.", "attribution": "Nia, facilitator" }
]
}
}
headlines[]: Prose paragraphs, each citing the intents it tells inrecords. At least one when the set is non-empty.listed[]: The ids of every other intent in the set. The binary renders each as its record'sTitle, so the name list carries no composer prose.quotes[]:{record, text, attribution}, whererecordis a headline intent (the intent's Scope: "the persona quotes of the intents it tells").press_releaseisnullor absent exactly when the set is empty.
The citation suffix on each headline and quote is appended by the core, as the
changelog's is (renderSection), never trusted from the prose.
3. The composer
The page is written by release-changelog-composer, extended rather than
joined by a new agent. One composition pass yields one payload, which gives AC1's
"validate all first" for free, keeps the public surface to the existing
--changelog-json flag (so the surface guardrail sees no change), and avoids the
name press-release-composer, which is the lifeboat agent's. The rejected
alternative, a second agent with its own flag, costs a second payload the host
must pair with the first, a second canary set, and a new flag.
The prompt (agents/release-changelog-composer.md) bumps 0.3.0 to 0.4.0: a
schema break inside the 0.x calibration band, where 1.0.0 is reserved for a
measured lock (agents/README.md). It gains a section on the page: the
in_press_release entries are the set; choose the headlines; tell each as the
user moment in its own press release's words; list the rest by id; carry the
quote of each told intent word for word with its attribution; write nothing
forward-looking (no date, no future release, no planned work). Its
untrusted-data paragraph extends to the press releases it now quotes. An
agents/CHANGELOG.md entry for 0.4.0 is required by record-lint's
agent_contract rule.
Fixtures under agents/release-changelog-composer/fixtures/:
injection-canary.json: Updated to schema 2. Kept as the changelog canary.injection-canary-press-release.json: New. A shipped intent whose## Press Releasecarries the hostile text (an instruction to cite a planned intent, to announce a date, to drop a record, a</system>break, an HTML comment lure, and a fake quote attributed to a real-sounding person). The expected payload cites exactly the set and carries only the intent's genuine quote.no-forecast.json: New. A cut beside a planned intent carryingtarget_release; the expected payload does not cite it and contains none of the fixture'smust_not_containstrings (a date, "next release", "coming").
4. Ingest validation
All validation runs in release.Ingest after the existing changelog checks and
before any write. New code lives in internal/core/release/page.go. Each fault
becomes a reason with a stable code, the payload path it concerns (at) and a
sanitised detail. Decode faults are necessarily single; after decoding, the
validator collects every reason it finds in one pass, so the composer sees the
whole list at once.
| Code | Refuses |
|---|---|
payload-oversize |
A payload over MaxPayloadBytes (existing guard) |
malformed-json, unknown-field, trailing-data, schema-version, prompt-version |
The existing decode guards (decodeChangelogPayload, DisallowUnknownFields) |
stale-cut |
next_tag differs from the re-derived cut (existing); the host re-runs the emit step before recomposing |
text-oversize |
A headline, quote or attribution over maxEntryProseBytes, measured on the raw value before cleaning, because termsafe.CleanProseLine truncates silently; also more than 50 headlines, 50 quotes or maxRecordsPerEntry records on one headline |
malformed-id |
An id failing payloadRecordIDRe or over maxRecordIDBytes |
missing |
An id in the set that no headline and no listed entry cites |
outside-set |
A cited id not in the set; the detail says which: not in this cut, an issue, impact: internal, or removed |
duplicate-citation |
An id cited twice across headlines and listed |
no-headline |
A non-empty set with no headline |
page-for-empty-set |
A press_release object when the set is empty |
heading, fence |
A text whose first non-space rune is #, or which contains a code fence (three backticks or three tildes); either would break the page |
empty-prose |
A headline whose cleaned text is empty |
quote-source |
A quote whose record is not a headline record |
quote-not-verbatim |
See below |
outbound-policy |
See below |
The existing changelog entry faults (section, section-not-writable, the
changelog bijection's MISSING, INVENTED and INTERNAL) are reported as reasons in
the same list, so the loop reads one shape.
Verbatim quotes. The cited intent's PressRelease text is split into
paragraphs, each normalised as firstParagraph normalises (blockquote markers
stripped, whitespace runs collapsed). A quote passes when its whitespace-collapsed
text is a contiguous substring of one such paragraph, its attribution is
non-empty and a substring of text (so the attribution is carried exactly as the
source has it), and termsafe.CleanProseLine(text) equals the collapsed text
(so the bytes rendered are the bytes verified). Anything else is
quote-not-verbatim, naming the record. A quote taken from outside the
## Press Release section fails by construction.
Outbound policy. scanner.CheckOutbound(root, text, label) in
internal/adapter/scanner/outbound.go is the check-direction twin of
ScrubOutbound: it reports session URLs and tool attribution footers and
returns no rewritten text, which is right for a gate that sends output back to
its author. The ingest runs it over the rendered page and over the rendered
changelog section, closing the same gap for the changelog in this change (the
design review's M6; one payload, one check). A degraded scanner config makes
CheckOutbound error; that is a structural fault, not a payload reason, and the
cut stops.
5. The retry-until-valid loop
The verb is host-delegated, so the loop lives in the host orchestration
(commands/launch.md, steps 2 and 3); the binary stays stateless per attempt.
- The binary: A refused payload is a
*release.PayloadRefusal{Reasons}error. With--jsonthe ship verb renders{"cut": ..., "written": false, "payload_refusal": {"reasons": [{"code", "at", "detail"}]}}and exits 2; the human render lists the same reasons. Exit 2 withpayload_refusalmeans "recompose"; exit 2 without it (unreadable repository, degraded scanner, unreadable outgoing page, archive collision) means "stop". Exit 1 (the cut refuses) stays a stop. No exit code changes. - The host: On
payload_refusal, it tells the user at once that attempt N was refused and lists every reason (loud staging, so an autonomous run shows the loop as it happens), then re-invokes the composer with the cut, its previous payload and the reasons. Onstale-cutit re-runs the emit step first. There is no attempt limit (Decision 9). The final cut report lists every refused attempt and its reasons before the written result. - Unchanged: If the composer cannot run at all, the existing LOUD STAGE stop applies: nothing is hand-written.
The reason codes are one Go enum; a test pins the command page's list to it, as
TestComposerPromptNamesOnlyTheWritableSections pins the section table today.
6. Writes, order and rollback
The writes move from Ingest's single WriteFileAtomicPreserveMode call into
internal/core/release/write.go, which holds the pre-cut bytes and an undo:
- Validate everything first: The payload (section 4); the outgoing
RELEASE.md, if present, must carry a parseable heading (section 7), or the cut stops as a structural fault; the archive target must not exist; the changelog content is built byinsertSectionwithout writing. - Archive: The outgoing page's bytes are written to
.abcd/development/releases/<its version>.mdthroughfsutil.CreateExclusiveIn, which never overwrites. - Page: The new
RELEASE.mdreplaces the old one atomically (WriteFileAtomicPreserveMode). Writing a copy then replacing, rather than renaming, leaves no instant with noRELEASE.md. - Changelog heading: The dated section, as today, written last so the file the tagging workflow reads lands only after the page did.
On a failure at step 3 the archive file is removed; at step 4 RELEASE.md is
restored from the held bytes (removed on a first cut) and the archive file is
removed. The report says the steps were rolled back, or names each rollback that
failed with THE ROLLBACK FAILED: recover by hand, the shape rollbackCut uses.
rollbackCut in internal/surface/cli/ship.go, which undoes a cut whose
--payload-dir render refused after the heading landed, calls the same undo, so
it restores the page and removes the archive too.
- First cut: No
RELEASE.mdexists, so step 2 is skipped and rollback removes the new page. The archive's first entry arrives at the second feature cut. - Fixes only: An empty set writes neither the archive nor the page. The changelog heading is written as today, and the report says: "No release page written: no user-facing intent shipped in this cut; RELEASE.md stays on <version>" (or "no RELEASE.md exists yet").
IngestResult gains Page (json:"page"): written, path, heading,
archived (the archive path, empty when nothing moved), headlines, listed,
quotes, and reason when no page was written.
7. The page
renderPage produces, deterministically from the validated payload:
# Release 0.10.0 (2026-09-24)
<headline paragraph> (itd-121)
> <quote text> (itd-121)
<headline paragraph> (itd-...)
Also in this release:
- <Title of the intent> (itd-...)
The line-by-line record of this release is its section in CHANGELOG.md.
The heading carries the bare version, as the changelog heading does
(datedHeading), and the same clock's date. It is parsed back by one regular
expression, ^# Release (\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$, used both to
name the archive file and to assert, before writing, that the rendered heading
names the cut's own version. A page left in place by a fixes-only release
therefore says which release it describes. Quotes sit directly under the
headline that cites their record. Titles in the name list pass through
termsafe.CleanProseLine. The page carries no other heading.
8. The preview and the report
renderCut is shared by abcd changelog and the ship verb's emit step. It gains
one block after the entries: release page: N intent(s) followed by the ids and
titles marked in_press_release, or release page: none (no user-facing intent shipped; RELEASE.md stays as it is). renderEntries marks those entries
(on the release page) beside the existing (excluded from the changelog).
Both commands still write nothing. The launch --dry-run report does not render
the cut today (the command page's Preview section), so it gains nothing here.
renderIngest adds the page line after wrote:: the page path and heading, the
archived path, and the counts; or the no-page sentence from section 6.
9. Gates and doc surfaces
.abcd/docs-lint.json:RELEASEjoins thestray_root_docsallowlist (a blocker rule; matched on the upper-cased basename stem)..github/workflows/ci.yml:RELEASE.mdjoinsis_inert_path's root file list, besideCHANGELOG.md..abcd/development/releases/README.md: New, stating what the archive holds and that its pages are written by the cut, never by hand;directory_coveragewarns on a record directory without one..abcd/record-lint.json:.abcd/development/releases/joinsforbidden_synonyms.exempt_prefixes. The archive is frozen prose carried fromintents/shipped/, which is already exempt, so a quote that passes there must not fail here. Every other record-lint rule,harness_leakandpersona_registryincluded, still reads the archive.commands/launch.md: The frontmatter description and opening ("It writes one file") become three; step 2 documents thepress_releasepayload; steps 2 and 3 document the retry loop and its reason codes; step 3 documents the write order, rollback, first-cut and fixes-only cases and the report.- The launch surface page (
.abcd/development/brief/04-surfaces/04-launch.md): Its prose says the ship verb writes the page and moves the archive. Onmainthe page carries a generated appendix, which is regenerated, not edited. - The agents catalogue (
.abcd/development/brief/05-internals/01-agents.md): Therelease-changelog-composerentry names both documents.
The launch payload bundle is unaffected: .abcd/config/launch-payload.json
includes README.md and not the root's other prose files, and the .abcd/
namespace is denied structurally.
Footprint
internal/core/changelog:shipped.go(PressReleaseRequired,Record.PressRelease),source.go(pressReleaseSection).internal/core/release:emit.go(Entry.InPressRelease),ingest.go(schema 2, page validation call, write handoff), newpage.go,write.go,payloadrefusal.go.internal/surface/cli/ship.go: payload-refusal JSON, report lines,rollbackCutthrough the core undo, the preview block.- Agent surface: the prompt, three fixtures,
agents/CHANGELOG.md. - Config and docs: section 9.
How the acceptance criteria are satisfied
Test names are the ones to be written; files are where they go.
- Three writes in order, rolled back on failure, first-cut removal, report.
Section 6. Tests in
internal/core/release/write_test.go:TestCutWritesArchivePageThenHeading(order observed through an injected writer seam),TestCutRollsBackEveryEarlierWrite(fail at each step, assert the tree is byte-identical to before),TestFirstCutRollbackRemovesThePage,TestCutReportsAFailedRollback,TestArchiveNeverOverwrites. Ininternal/surface/cli/ship_payload_test.go:TestRollbackCutRestoresThePageAndArchive. - Every intent in the set cited, nothing outside it, refused otherwise.
Sections 1 and 4. Tests:
TestPressReleaseRequiredIsAddedUserFacingIntentsininternal/core/changelog/shipped_test.go(issues, internal, removed andshipped_inrecords excluded);TestPageBijectionininternal/core/release/page_test.go(missing, outside-set by each cause, duplicate). - No planned or
target_releaseintent cited; no forward-looking sentence. The structural half isoutside-set:TestPageRefusesAPlannedIntentandTestPageRefusesATargetReleaseIntentinpage_test.go. The composer half isno-forecast.json, whose expected payloadTestComposerFixtureExamplesIngestingests against the fixture's cut and checks against itsmust_not_containlist. - The preview lists the set and writes nothing. Section 8. Tests in
internal/surface/cli/ship_test.go:TestChangelogPreviewListsThePageSet,TestChangelogPreviewSaysNoPageForAFixesOnlyCut, andTestChangelogPreviewWritesNothingextended to assertRELEASE.mdand the archive are untouched;TestEmitMarksInPressReleaseinemit_test.go. - Empty set: page and archive untouched, the report says why. Section 6.
Tests:
TestFixesOnlyCutLeavesThePageAloneinwrite_test.go,TestLaunchShipFixesOnlyReportsNoPageinship_test.go,TestPageForAnEmptySetIsRefusedinpage_test.go. - Structural refusals, nothing written, reasons back to the composer until
valid, every attempt reported. Sections 4 and 5. Tests:
TestPagePayloadRefusalsinpage_test.go(one row per code: unknown field, oversize, malformed id, outside set, heading, fence, outbound policy; each asserts no file changed),TestPageRefusalCollectsEveryReason,TestLaunchShipPayloadRefusalJSONinship_test.go(exit 2 withpayload_refusaland stable codes), andTestLaunchPageNamesEveryRefusalCodepinningcommands/launch.mdto the enum and to its "no attempt limit" and "report every refused attempt" instructions. The loop itself is host prose; it is exercised at a real cut, not in CI. - Quotes verbatim, with attribution, from a cited intent. Section 4.
Tests:
TestQuoteMustBeVerbatiminpage_test.go(a changed word, a changed attribution, an attribution outside the text, a quote from the intent's body outside## Press Release, a quote from an uncited intent, one that cleaning would alter);TestPressReleaseSectionExtractsTheWholeSectioninsource_test.go. - The heading names its release; the canary shows hostile text treated as
content. Section 7 and section 3. Tests:
TestPageHeadingNamesItsVersionandTestArchiveIsNamedFromTheOutgoingHeadinginpage_test.go;TestComposerFixtureExamplesIngestrunsinjection-canary-press-release.json's expected payload through the ingest and asserts it passes and carries none of the fixture'smust_not_containstrings. The existingagent_contracttests keep the canary present.
Risks and not verified
- An unbounded loop may not converge. A composer that keeps producing the same fault (for instance a quote the source does not hold) loops until a person stops it. This is the product thinker's ruling (Decision 9); loud staging is what makes it visible.
- The canary proves the expected output, not the model. CI checks that each
fixture's expected payload is valid and clean; whether a given model obeys the
prompt is shown only when a host runs the fixture. The prompt stays in the
0.xband for that reason. - Quotes are optional to the binary. The prompt tells the composer to carry each told intent's quote; the binary checks the quotes carried (AC7) and does not refuse a page that omits one, because no criterion asks it to.
- Byte-exact quotes. Straight and curly quotation marks must match the source exactly; the check normalises whitespace and blockquote markers only.
- A degraded scanner config stops the cut.
CheckOutboundfails closed on an unreadable.abcd/config/pii.json; that is the intended direction. - The first page waits on the cut.
abcd changelogat this tip refuses on eight unfixedmajorfindings captured since v0.9.0, none of them this intent's; the page arrives with the first cut that passes. - Not verified: that
persona_registryandabcd lint's privacy rule treat a rootRELEASE.mdas this spec assumes (the archive is inside record-lint's root; the root page is not); and the exact shape ofmain's generated appendix on the launch surface page, since this worktree predates it. Checked:forbidden_synonymsis the only record-lint rule that exemptsintents/shipped/(the globalexempt_pathsareresearch/andintents/superseded/), so it is the only exemption the archive needs.
Frontmatter
| id | spc-2609231435545473 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-2609231435545473-every-release-arrives-with-its-own-press.md
open on GitHub ↗ · commit history ↗
References1
- itd-2609231013154443Every release arrives with a press release composed from what shippedimplements
mentions6
- dcn-26092300000000232026-09-23 — The payload example in spc-2609231435545473 (closed) is stale on one point: its itd-121 quote opens `"... what I'd do next,"`, a truncation the release-page ingest refuses by design, since a quote is a whole quoted sentence as the press release has it. The example's verb is right: the ingest accepts an attribution after `says` as well as after `said`, because six intents of that release (itd-119, itd-120, itd-121, itd-123, itd-124, itd-125) quote as `says <Name>, <role>.`. The closed spec stays as written; `TestQuoteSaysFormFromARealRecord` carries a working `says` quote, taken from itd-121 as its record has it (lane implementer, on review 2 of the release-page build).
- iss-2609232155567377itd-2609231013154443 promises the release page is in the words each intent's own press release already uses, quotes included, and its scope names the persona quotes of the intents it tells, carried word for word; but the release-page ingest verifies only the quotes a payload carries. A headline may tell an intent whose press release carries a persona quote and carry none, and the cut writes the page. Found by the fidelity audit of ac-7 (receipt rcp-af55e181c483): agents/release-changelog-composer.md asks the composer for the quote of each told intent, and spc-2609231435545473 files the gap under Risks as quotes being optional to the binary because no criterion asks for them. Either the page bijection requires one quote per told intent whose source carries one, or the intent's press release stops promising quotes included.
- itd-24Completed Releases Get A Retrospective
- itd-121Type The Id, Get Your Next Move
- itd-2609221656373558Two sessions share one run, and the run measures which way of dividing the work is best
- spc-11installable-versioned-plugin