KilnCMS 1.7.0
Upgrade notes
- Overlays: run
mix ash.codegen <name>before deploying (#1890). TheKilnCMS.CMS.Contentmacro adds a nullablepath_alias_derivedcolumn to every content type, so it lands on your overlay's tables too, and your tree carries its own migrations (overlay-contract.md). Until the migration runs, reads of those types fail withundefined_column. The core's own tables get it from the release's migrations. Adding a nullable column takes no table rewrite.
Breaking
- Provenance is on by default (#1929).
KILN_PROVENANCE_ENABLEDnow defaults totrue. What that means for an install upgrading to 1.7:- With a key mounted (
KILN_PROVENANCE_KEY_FILEorKILN_PROVENANCE_PRIVATE_KEY): every fire from now on stores a signed manifest. Runmix kiln.refire_all(KilnCMS.Firing.Sweep.run()overrpc) to sign what was fired before. - With no key: nothing is refused. Artifacts are served
unsigned, the verify endpoint says so, and platform admins see a strip on the console overview namingKILN_PROVENANCE_KEY_FILE. Either mount a key and set that variable, or press Generate a key there (stored encrypted, as/setupdoes on a new install), then re-fire. - To keep the pre-1.7 behaviour, set
KILN_PROVENANCE_ENABLED=false: no manifests, and every/api/provenance/*endpoint404s. The release's migrations addpublished_artifacts.manifest(nullable) and theprovenance_settingstable. Both are additive.
- With a key mounted (
Added
- Provenance on by default, with a public verify endpoint (#1929).
Provenance signing existed since #340 but was off by default, and its
manifests were derived from the live body each time they were asked for, so
"verify" compared a body with itself. Now:
- Every fire stores its manifest (
published_artifacts.manifest), made byKilnCMS.Provenance.sign_fired/4from the body as it will read back. One RSA signature per surface, the key resolved once per fire. A signing failure leaves the artifact unsigned; it never fails the fire. GET /api/provenance/verify/:type/:slugchecks the served body against that stored manifest:verified,tampered(a changed body or a changed manifest, against a key this instance holds),unsigned(nothing stored) orunverifiable(a key it no longer holds, never reported as tampering). Anonymous,public, max-age=60, any?surface=, and covered by the API contract. The manifest endpoint now serves the stored manifest./setupgenerates a signing key unless one is mounted:KilnCMS.Provenance.Settings, one row, encrypted undersecret_key_baselike the database DKIM key, platform-admin only, with no rotate action. A mounted key always wins; when one replaces the generated key, the generated key is listed as retired automatically, so what it signed keeps verifying.- The share signed is measured:
KilnCMS.Provenance.coverage/1,mix kiln.provenance.coverage, andkiln_cms.provenance.coverage.*telemetry every five minutes (benchmarks.md). See provenance.md.
- Every fire stores its manifest (
- A plain-text rendition can keep its paragraph breaks. A
:textfield delivered as plain text (deliver_plain, #2037) read as one line:KilnCMS.PlainText.from_html/1collapses every run of whitespace, so a three-paragraph description came out as one. A new option under it in the fields admin, "Keep paragraph breaks" (plain_breaksonFieldDefinition, off by default), delivers that field throughKilnCMS.PlainText.from_html_paragraphs/1instead: a blank line ("\n\n") between block elements — paragraphs, headings, quotes, lists, table rows — and a newline for each<br>and list item, whitespace inside a line collapsed as a browser shows it. Text with no markup keeps the newlines it was typed with. List items carry no marker. The key, the string type and every other field's value are unchanged; the schema export marks such a fieldx-kiln-plain-breaks: true. Cleared withdeliver_plain, and flipping it re-fires the type's published documents.
- Swift models from the schema export.
mix kiln.export.schema --format swift [--out Kiln.swift]writes one standard-library-only file for a native app: apublic struct … : Codable, Hashable, Sendableper content type, block, shared definition and inline object (a:grouprow, a media snapshot), nested under the struct that holds it. Swift names arelowerCamelCase, withCodingKeyskeeping the JSON keys exact. A nullable or non-required key is an optional. A stringenumbecomes aString-backed enum withunknown(String). The block union ispublic indirect enum Block, switching on_type, and the content types areDocument, switching ontype; both have.unknown(type:). Open maps are[String: JSONValue], withJSONValuebundled in the file.format, bounds andconstpins are dropped, as in the.d.ts. Both emitters now read the document through one shared walker,KilnCMS.SchemaExport.Walk, and the.d.tsoutput is byte-for-byte unchanged. Whenswiftcis onPATH, the test suite compiles the output and decodes a real fired artifact with it (native-apps.md → Swift models).
- Kotlin models from the schema export.
mix kiln.export.schema --format kotlin [--package com.example.kiln] [--out Kiln.kt]writes one kotlinx.serialization file for an Android or JVM app, on the same shared walker and with the same naming and nesting as the Swift output. Each content type, block, shared definition (MediaSnapshot) and inline object (a:grouprow) is a@Serializable public data class. Kotlin names arelowerCamelCase, with@SerialNamekeeping the JSON keys exact. A nullable or non-required key is nullable with a= nulldefault. A stringenumis a@JvmInline value classoverStringwith a constant per listed value, so any value decodes. The block union issealed interface Blockwith a bundledBlockSerializerthat switches on_typeand keeps an unknown type asBlock.Unknown(type, raw), and the content types areDocument, switching ontype. The serializers name every member's serializer directly, so there's no reflection for R8 to break. Open maps areJsonObject, integers areLong, and the file bundlesKilnJson(ignoreUnknownKeys = true) to decode with.--packagedefaults tokilnand is refused for other formats. Whenkotlinc,kotlinandKOTLINX_SERIALIZATION_CLASSPATH(the runtime jars) are all present, the test suite compiles the output with-Werrorand decodes a real fired artifact with it (native-apps.md → Kotlin models).
- A SwiftUI example (#2124).
examples/swiftui-readeris a Swift package (iOS 17, macOS 14) that reads a Kiln site the way native-apps.md describes, so a mobile team evaluating Kiln can see the intended path in code. It checks in theKiln.swiftthatmix kiln.export.schema --format swiftgenerates for a demorecipetype (kiln/seed.exscreates it,bin/regenerateregenerates it). A smallKilnClientreads/api/syncand thejsonartifact, withIf-None-Matchand?fields=. A file mirror saves each sync page and its cursor in one atomic write. It restarts withinitial=trueonly oninvalid_cursor, keeps local data until the new snapshot ends, and retries everything else withRetry-Afteror jittered backoff. The two screens show a:groupfield's rows as a native grid, render Portable Text to anAttributedStringwith no web view (the plain rendition is the fallback), and crop images around the media snapshot's focal point, choosing a variant by pixel width.swift testdecodes JSON captured from a real local Kiln and runs the sync loop against a stubbedURLProtocol. A macOS CI job runs it when the example or the Swift emitter changes. The docs gain one fix: the generated Swift and KotlinPortableTextBlockis the schema's open node and dropschildren,styleandmarkDefs, which native-apps.md now says. The api.md sync section now gives the delta window's real lag, 10 seconds.
- Sections in the Custom fields panel. A field definition's new
section(free text, shared by name) files the field under a collapsible heading in the content editor, at the place of the section's first field inpositionorder; unsectioned fields render bare where they fall. A section starts open when its fields already hold a stored value (the first section, on a record with none) and whenever one has an error; each heading counts its filled fields, and Expand all / Collapse all sit in the panel's header. Toggling uses LiveViewJScommands, so a section stays as the writer left it while they type elsewhere, and a collapsed section's inputs still submit. The fields admin sets the section with the site's headings as suggestions and shows it on each field; the schema export reports it asx-kiln-section. Link fields and group sub-fields carry none. Addsfield_definitions.section.
- Display labels on
:selectoptions. A newoption_labelsmap on a field definition names what an editor reads for each option — the stored value stays the code, so filters, delivery and API clients are unchanged. The editor's single select, the multiple select's tag picker (its chips and suggestions), and select link fields and group sub-fields show labels, and a write naming a label (case-insensitively) stores its value — so an editor can type "Clear heat" into a tag picker. In the fields admin an option's line takesvalue | Label, and a label may hold commas. Labels are pruned to the current options and cleared off a field that is no longer a select; one longer than a line is refused. Addsfield_definitions.option_labels(default{}).
- A
:stringfield can hold a list of phrases. Withmultiple: trueits value is a JSON list of strings — symptoms, indications, synonyms, services — for lists too large or too free for a select's vocabulary. The editor shows one input per item, edited in place, removed with × and dragged to reorder, and an add box: Enter adds an item, and pasting several lines adds one item per line (an item written with line breaks is split the same way on write). Items are trimmed, blanks and repeats dropped, bounded bymin_items/max_items, and a JSON-encoded list sent as a string is refused.custom_filterasks whether the list contains an item; the schema export carries an array of strings. Anames_recordlist names the record by each item — the alias leg now matches each element of a list, not its JSON text.mix kiln.fields.converttargets it, and--linesreads a plain-text source as one item per line. The editor's list events are nowlist_item_add/list_item_remove/list_item_reorderfor this and the multiple select alike.
- A
:selectfield can hold several terms. Withmultiple: trueits value is a JSON list of strings, edited in a tag-style picker: chips with ×, drag to reorder, and a text box that adds on Enter or comma and suggests the options.min_items/max_itemsbound it, as for a gallery.allow_custom: trueopens the vocabulary: editors add new terms,optionsbecome suggestions (and may be empty), and the picker also suggests the terms already used on the type. A new term does not joinoptions. A JSON-encoded list sent as a string is refused rather than stored as one term.custom_filterasks whether the list contains a term; JSON:API, GraphQL and the schema export carry an array of strings, and single selects are unchanged. Flippingmultiplereshapes stored values as it does for a gallery; retyping a:textfield into one does not wrap its JSON strings.mix kiln.fields.convertnow also targets a multiple select: its report lists the distinct terms with counts and the ones not yet options, and every report counts the published records a run writes (field-conversion.md). Adds two columns tofield_definitions(allow_custom,format), both with defaults.
- Rich text on
:textcustom fields. A:textfield's newformatis:richby default: the editor shows TipTap with a short toolbar (paragraphs, bold, italic, links, lists) over the stored HTML, which is sanitized on write to the rich-text block's allow-list. Text with no markup, JSON data, and markup the sanitizer removes nothing from are stored byte for byte, and a field holding JSON data keeps the textarea; opening a record never rewrites its value.format: :plainkeeps the textarea and stores the text as typed. The value stays an HTML string, sodeliver_plainand API clients are unaffected; the schema export marks a rich fieldcontentMediaType: text/html.
A rich
:textcustom field can also be delivered as Portable Text. A new per-field toggle in the fields admin, "Also deliver as Portable Text" (deliver_portableonFieldDefinition, off by default, beside "Also deliver as plain text"), adds acustom_fields_portablemap besidecustom_fields: each flagged field's HTML as the Portable Text tree arich_textblock'sbodyholds — paragraphs, headings, quotes, nested lists, marks and links — so a native app renders a field's prose with the code that renders a block's body, and needs no web view for it. This is the structured rendition the mobile-delivery plan asked a:rich_textfield type for; with:textrich by default since #2099, no new type is needed.custom_fieldsitself is never rewritten.Opt-in rather than always on for
format: :rich: Portable Text spells out every span, so it is several times the size of the HTML it comes from, and every web client and every rich field that already renders HTML would carry it for nothing on every artifact and sync item. The conversion isKilnCMS.Blocks.PortableText.from_html/1after the rich-text sanitizer, thensanitize_body/1— notfrom_html_faithful/1, which refuses whatever it would lose: a delivered rendition must always be there, and the HTML beside it stays the full value. Markup-free text becomes one paragraph per blank-line-separated run; JSON data stored as text isnull.Where it appears: the fired
:jsonartifact (soGET /api/content/:type/:slug, its?fields=, and every/api/syncitem) whenever the type flags a field; thecustom_fields_portablecalculation on JSON:API and GraphQL (customFieldsPortable), on request; and the delivery schema, where each value is$ref'd to the sharedportable_text_block, so the.d.tstypes it asArray<PortableTextBlock> | null. Computed at fire time; flipping the toggle, or switching the field toformat: :plain, re-fires the type. Cleared on plain and non-:textfields and on link and group sub-fields (a group's rows are delivered whole); refused on a private field.
Field localization can share tags and curated related content (#1859). A content type's
localization:option — and, for the core types,config :kiln_cms, :i18n, field_localization:— now accepts:tagsand:relatedinshared:. When the default-locale variant publishes, every translation takes:- its tag set, by reference. Tags have no locale, so the tag ids are copied and no tag is touched.
- its related list, resolved per locale, in order. Each target becomes
its document's variant in the translation's locale; a target with none
follows the site's fallback chain (#1579), the same rule
?fallback=siteuses, and one with no variant on the chain is left out.
The copy is still
:sync_shared_fields: versioned, optimistic-locked, and ignored by coverage, so a copied tag set does not make an outdated translation look fresh. A translation's pending working copy that holds its own tags or related list gets the shared value too. The #1860 write refusal covers both — the complete-set arguments and theadd_*/remove_*verbs — and the editor shows both pickers read-only on a translation, with a link to the source. The:localizationsystem actor gainsTaggingcreate/destroyandRelatedLinkread/create/destroyfor the copy (docs/policy-matrix.md). No migration; a type that names neither behaves as before. The design is in field-level localization.
Changed
- Field-level localization reaches blocks nested inside
columns(#1858, #2129).localized: :sharedandlocalized: :fallbackblock fields worked on top-level blocks only; a block inside acolumnscolumn was skipped by both. Every path now walks the whole tree, matching a nested block to its counterpart by the_idthe variants share (and its_type), at any depth and even when the counterpart sits at another depth:- The shared-value copy (
KilnCMS.I18n.SharedFields) writes the source's value into the translation's nested block, inblocksand, for a translation with a pending working copy, inworking_blockstoo, so Publish changes does not revert it. Theworking_basefingerprints are moved with it, so the copy is not reported as a conflict. - The fallback fill (
KilnCMS.I18n.FieldFallback) fills an empty nested field from a donor, and names it underinherited_fields.blocksby the nested block's id. - The #1860 refusal of a translation's write to a shared field covers nested blocks.
- The editor shows a nested shared field read-only on a translation and
an empty nested fallback field with its inherited placeholder.
A nested child is stored as the raw map it was written as and never meets
the block union's
cast_stored, so it is typed throughTypedBlocks.to_typed/1, which upcasts it to its block's current version first; both sides of a comparison are read at the same schema version, as top-level blocks are. A nested child is rewritten only when one of its shared values changes, and then in its upcast shape, with each value dumped to its stored form. The traversal lives inKilnCMS.I18n.FieldLocalization(typed_blocks/1,blocks_by_id/1,map_reduce_block_fields/3). A nested child is matched only when it carries an id; the editor always gives it one.
- The shared-value copy (
- The Portable Text node declares its standard keys (#2126). The
portable_text_blockdefinition that arich_textblock'sbodyand everycustom_fields_portablevalue point at declared only_typeand_key. That is lenient enough for TypeScript, whose index signature keeps the rest, but the Swift and Kotlin emitters decode declared properties only, so a generatedPortableTextBlockkept two strings and dropped every span. It now declaresstyle,listItem,level,children(spans:_type,_key,text,marks) andmarkDefs(_type,_key,href). Every one is optional and every object staysadditionalProperties: true, since a headless write can still store any node and the schema describes what is served. The definition is built in the SDK (Kiln.Block.JsonSchema), so kiln_plugin gets a patch entry; no plugin callback changed, and the core builds it from its path dependency, so no SDK release is needed for KilnCMS itself.examples/swiftui-readerregenerates its models and drops its hand-writtenPortableTextNode: the renderer takes the generatedPortableTextBlock.
Whether a path alias was derived is recorded, not inferred (#1890). A move re-derived the paths beneath it only where it could tell a derived alias from a hand-written one, and it told them apart by re-deriving and comparing (
KilnCMS.CMS.Slugs.underived?/2). That breaks once a derivation's inputs change for a reason the job does not know about: rename a section's slug and every[ancestors]path beneath it differs from both the old and the new derivation, exactly as a hand-written one does, so the next move left those stale paths alone for good. Every content type now haspath_alias_derived:truewhenChanges.DeriveAliasfilled the alias or a regeneration rewrote it,falsewhen anything else wrote it (the editor, the working copy, an import, a restore, the API).Changes.TrackAliasDerivationrecords it on every create and update. Re-saving an unchanged alias leaves it as it was, and clearing one derives it again.SlugRegenerationreads the fact and falls back to the inference only where it isnil.Rows saved before 1.7 hold
niland keep the old inference, including the pre-move chain a move job reconstructs, so nothing changes for them until their alias is next written. That is why the move job'sold_parent_idstays for now. A blanket "assume pinned" backfill was considered and left out: it would have stopped every existing tree-derived path from following its next move. The column is internal: it is on no API surface and no action accepts it. Re-deriving the subtree when an ancestor is renamed, which the recorded fact now makes safe, is not part of this change.
- JSON:API
/by-slug/:slugand GraphQL*BySlugno longer requirelocale: left out, the walk starts at the site's default locale, as the delivery API did. (#2115)
- Moving a section in the content tree reads only its subtree (recursive CTE), not the whole type: a 10,000-document move peaks at 27 MB, not 180 MB. (#2117 · benchmark)
- A bare
GET /apiredirects (302) to/developers, the API summary, instead of answering 404; every/api/*route is unchanged. (#2114)
- One
$defsentry for the media snapshot.GET /api/schemaandmix kiln.export.schemaused to repeatMedia.Snapshot's shape wherever it is delivered: a:mediafield, a:mediagallery's items, animageblock'smedia, each gallery image'smedia. Every one is now a$refto#/$defs/media_snapshot. A JSON Schema validator accepts exactly what it accepted before. The.d.tsand Swift emitters each produce oneMediaSnapshottype, where they used to produce an anonymous copy per use. A nullable use isanyOf: [{"$ref": …}, {"type": "null"}], the only way 2020-12 expresses a nullable$ref. The old["object", "null"]type list stays beside it, so a generator that predatesanyOfgets a loose but true type instead of a broken one.kiln-typesbefore this release writesRecord<string, unknown> | null; the next one reads the pair (its CHANGELOG). The hoist happens over the assembled document rather than inKiln.Block.JsonSchema, whose per-block schemas are still validated on their own by the block suite andmix kiln.plugins.doctor.
- ash_graphql 1.13.0 defaults an omitted
offsetto0, replacing Kiln's #1883 workaround and fixing offset-paged relationship lists too. (ash_graphql#480)
Fixed
- An autosave closed the inline block inserter under the author (#2133).
The "Insert block here" menus between blocks render inside each block card,
and the autosave's form rebuild re-renders the cards. Its patch put back the
server-rendered
hiddenon a menu theBlockInserterhook had opened: the filter box lost focus, LiveView reset its value, and the rest of the word went nowhere. A usability round reported it as dropped keystrokes. Every attribute the hook toggles now carriesphx-mounted={JS.ignore_attributes(…)}, so patches leave the menu's open state and the filter's results alone.e2e/tests/typing_survives_patches.spec.jstypes across a real autosave patch, and across a patch of the media drawer around the alt-text field.
- Fallback custom fields in the editor (#1861). For a custom field set to
Inherited when a translation leaves it empty,
Localization.inherited/2returned the unmatched map (%{}) instead ofnilwhenever the field inherited nothing. That covers the source variant, and any translation that kept its copied value. The field's note then calledelem/2on that map and the editor's LiveView crashed, so the first Save of a new EN draft with such a field failed. Separately, an empty fallback custom field showed only the note and never the inherited value as a placeholder, which block fields already did.custom_field_inputnow takes aplaceholder, used by the scalar input and the plain textarea. The newe2e/tests/field_localization.spec.jsfound both bugs. It addsfrfor itself through the e2e-only/dev/e2e-localesroute, so the rest of the suite stays single-locale.
- A moved block snapped back on the next autosave (#2132). Move up/down
and drag reordered the editor's block sub-forms with
AshPhoenix.Form.sort_forms/3, which leaves the form's touched set alone. Autosave submitsAshPhoenix.Form.params/1, and that carries only touched keys, so a move on an otherwise untouched form sent noblocksat all. The row kept its old order, and the form rebuilt from the row put the blocks back on screen, so a later Save wrote the old order too. A usability round found it by moving an image between a heading and a text block, four times out of four.BlockParams.sort_blocks/2now sorts and marksblockstouched, and every reorder in the editor goes through it.
- A
page[...]valueash_json_apicould not cast answered 500.page[limit]=abc, an emptypage[limit]=,page[limit]=1.5,page[offset]=abcandpage[count]=maybereachedash_json_api1.7.1's own rejection, which buildsInvalidPaginationwith a field the struct does not have: aKeyError, so a 500.page[limit]=0got past the cast and came back as an opaquesomething_went_wrong, and a negative limit or offset as a schema-patterninvalid_query.KilnCMSWeb.Plugs.AshJsonApiParams, which already refused the wrong shapes (#763), now checks the values too, and every rejection is a 400invalid_pagination(orinvalid_sort/invalid_includes) whosesource.parameternames the parameter, asash_json_api's own sort errors do.KilnCMSWeb.ApiError.send/5takes the optionalsource_parameter:. Found by an outside audit of kilncms.dev.
GET /api/search?query=answered an empty 200. The /developers page documented?query=, and the controller read onlyq, so a client following the page could not tell a wrong parameter name from no matches. The page now says?q=, and the endpoint readsquerywhenqis absent.