KilnCMS 1.6.0
Upgrade notes
- Optional: run
mix kiln.media.resnapshot --applyonce after deploying. A:mediacustom field's stored value gainswidth,height,mime_type,focalandvariantswhen its record is next saved; this re-takes every stored value now, through the content write path (versioned,updated_atand the lock kept, no webhook). Without--applyit is a dry run that reports what it would write. In a release, the task's work isKilnCMS.Media.Resnapshot.run/2overbin/kiln_cms rpc. Fired artifacts need nothing: they re-fire on their next read (format_version3), or at once withmix kiln.refire_all.
Added
- "What readers saw" on the governance trail
(#1928). The
detail view at
/editor/governance/:type/:idgains one row per publish or withdrawal, newest first: its approver (for a scheduled transition, whoever last set the schedule), its propagation record from 1.4 (the CDN purge's enqueue and acknowledgement or failure, and every webhook endpoint's delivery time or terminal failure, each with its gap from the transition), its?as_of=link and the history chain's verdict on that version. The JSON export carries the rows astransitions; the CSV export as rows of kindtransition, with six columns appended (cdn_purge,cdn_enqueued_at,cdn_settled_at,endpoints,as_of,chain) that every other row leaves empty, so a sheet reading the first eight by position still lines up. A propagation record carries no version id; each transition takes the first record of its kind opened between its own write and the next transition's (KilnCMS.Propagation.for_document/3, read as the webhooks system actor and failing closed). A restore of a document trashed while live is a row too. Past the delivery ledger's 30 days the row counts the endpoints whose rows were pruned instead of claiming there were none. Alongside:webhook_deliveriesgains a nullablefailed_at, stamped when a delivery is given up on, so a later recovery stamp or the payload scrub no longer moves the reported give-up time (WebhookDelivery.given_up_at/1, also used by the lost-retractions panel); and point-in-time delivery now treatsarchive_scheduledas a withdrawal, where it used to keep serving the last publish. The guide's end-to-end screenshot of one real correction is still to come: it needs staging data.
- Public bylines
(#2044). A
:referencecustom field can say what its targets are to the page in schema.org terms:schema_org_property: :authoror:contributor, the What they are to the page step of a relationship in the fields admin (additive migration: one nullable column onfield_definitions). The fired:json_ldmain node then carries onePerson(name, profileurl) per target under that property, in the field's order, from the resolved values: a renamed target is named by its current title, and one an anonymous reader could not open is left out. Kiln's own pages read the same byline (KilnCMS.Firing.Bylines): their inline JSON-LD names those people, and the "By" line links them ("By Ada Lovelace and Alan Turing · With Grace Hopper",public-bylineclasses); without anauthorfield they keep naming the console user. An Event carries neither property. Setting or clearing the option re-fires the type's published documents.ProfilePagejoins the schema.org allowlist and fires with its person asmainEntity. The guide is docs/author-profiles.md: anauthortype, authors and contributors fields, and the profile's article lists.
- A link-field filter tied to particular targets
(#2044).
custom_filter[<field>.link.<name>]matched a record when some connection of the field carried the value, so asking for the posts where an author is the photographer also returned the posts where anyone else was. Atargetkey beside the operators now narrows every operator in that condition to the connections pointing at the given ids:custom_filter[contributors.link.role][eq]=photographer&custom_filter[contributors.link.role][target]=<id>. It takes one id or a list (at most 100, each a UUID), needs an operator beside it, and keeps the both-ends read gate, so a draft target still matches nothing for an anonymous reader. GraphQL'scustomFiltertakes the same shape. Withouttarget, link filters mean what they did.
- Private custom fields and a
:userfield type (#2044). AFieldDefinitionwithprivate: true(additive migration: one boolean column, default false) holds a value for the site's editors and admins only. It is left out of every fired artifact (KilnCMS.Firing.CustomFields, for every caller, visual-editing previews included) and out of every content read below editor (Preparations.MaskPrivateFields, beside the reference mask and skipped by the same rule). Nothing public is derived from it: computed fields cannot read it, slug and SEO patterns read[field:<name>]as empty, the provenance manifest ignores a privateai_disclosure, and the schema export leaves it out. Below editor,custom_filter/custom_sorttreat it as unknown (decided by the caller's tier even onauthorize?: falsedelivery reads), so a value cannot be probed. Only plain top-level types can be private, andsearchable,names_record,deliver_plainand a:fallbacklocalization are refused beside it. Flipping it re-fires the type; the per-site map of private names (PrivateFields.all/1) is cached with the type registry and dropped with it after commit. The new core:usertype stores a console account's id, validated against the site's people (Scoping.users_with_tier/2), and is always private; the editor picks from the same roster, and the fields admin offers Private on every type that can be.
LiveBlogPostingin schema.org output (#2046).LiveBlogPostingjoins the allowlisted Article family, so a compiled type (schema_org_type:) or a dynamic one (/editor/types) can declare it. Its node keepsheadline,articleBodyand the CreativeWork dates, and gainscoverageStartTime/coverageEndTimefrom the type's firstdatetime_rangefield (with none, coverage starts atpublished_atand stays open) andliveBlogUpdate: oneBlogPostingper update, newest first, capped at 50 (config :kiln_cms, KilnCMS.Firing.LiveBlog, max_updates:). An update is any record pointing at the story through a:referencefield with another side (inverse_label); the story type's first reverse field is its update stream. No new resource, no migration. Only published, public, unlocked updates are fired, read from their live values, so a members-only update never leaks through a public story and an unpublished one drops out the next time the story fires. The served page's inline JSON-LD (KilnCMSWeb.StructuredData) carries the same properties. Publishing an update does not itself re-fire the story yet: the reference edge runs update → story, so the story picks up a new update on its next fire.
- A Google News sitemap,
/news-sitemap.xml(#2045). Each<url>carries<news:news>: the publication name (the site's branding name), its language (the record's locale as an ISO 639 code,zh-cn/zh-twfor Chinese),news:publication_dateandnews:title. Only articles published in the last 48 hours, newest first, at most 1,000 URLs, as Google News asks. A type opts in by declaring the schema.org typeNewsArticle(the Content macro'sschema_org_type:or the dynamic type's setting);robots.txtlists the route once one has. Like the feeds, only publishedaudience: :publicrecords appear. Per org, cached for five minutes under the:proberate limit, and dropped byKilnCMS.Cache.bust_sitemap/1with the main sitemap, so an unpublished article is gone on the next request.
- Headless draft mode: a preview token opens its draft on JSON:API and the
fired-artifact read
(#1887). A
front end that renders pages from the plain reads could not render a
shared draft in its own templates: a token unlocked only
GET /preview/:token(a thin JSON) and the stega-annotated visual-editing read, so verscienta.com redirected reviewers to Kiln's generic preview page. Presented asx-kiln-preview-token(or?preview_token=), the token is now a read grant for its one record on/api/jsonand onGET /api/content/:type/:slug.KilnCMSWeb.Plugs.PreviewGrantverifies it once, pins it to the serving site and puts aKilnCMS.CMS.PreviewGrantin the Ash context's:sharedkey; the newChecks.PreviewGrantin both content read policies (the lock one included) admitsid == grant.idon the primary read only. Noauthorize?: false:by-slug, the/publishedtwins, search and writes are unchanged, and GraphQL and/mcpignore the token.LinkEndsReadablecarries the grant into its re-read, soinclude=keeps the draft's edges to published documents and drops those to other drafts. The artifact route renders the working copy live, on any surface, with?fields=/?expand=, uncounted and without experiments. A presented token that does not verify is a404 invalid_preview, never the anonymous read. For a live document with unpublished edits, JSON:API under the token answers the working copy (Preparations.PreviewWorkingCopy), the reference masking honours the grant, and?expand=follows the working copy's references. The token is read onGET/HEADonly; a write ignores it. Responses under a token areprivate, no-storeand every response of an honouring routeVarys on the header, while GraphQL and search, which ignore the token, keep their caching. A token read is charged to a new:preview_apibucket (120/min), the visual-editing read included, which now goes through the same plug.preview_token,unlockandpassphrasejoinpasswordin Phoenix'sfilter_parameters, and Sentry events drop the token and grant headers and mask them in the raw query string, whichSentry.PlugContextcopied verbatim (SentryFilter)./preview/:token, its live view and the bridge socket verify through the samePreviewGrant.verify/2.Engine.fire/2takessurfaces:for a preview, so a live read renders only the surface it serves. A database outage under a token is a503, notinvalid_preview. Docs:docs/api.md→ "Headless draft mode".
- A public change feed, server side (2.1 of
#2050).
KilnCMS.Delivery.Feedbroadcasts one event per published content change on the site'sfeed:<org_id>topic over nativePhoenix.PubSub:published(a publish, or a restore straight onto the delivery path),updated(an edit to live content) andwithdrawn(an unpublish, an archive of a live document, or a trash of one). It is a consumer ofKilnCMS.Webhooks.dispatch/4, the funnel the webhooks use, so it hears exactly the transitions webhook receivers do — drafts,archivedtombstones, taxonomy and release events stay off it. An event carries identifiers only:type,id,slug,locale,published_at,affected(identity of the published documents that embed this one) andpublic(fail-closed:falsefor a members-only or locked document,nilwhen the payload cannot say). Never content: a client fetches through the existing cached delivery routes. Each broadcast emits[:kiln_cms, :delivery, :feed, :event]. Subscribe in-process withKilnCMS.Delivery.Feed.subscribe/1; the SSE and channel transports are 2.2.
Kiln.SafeFetch(kiln_plugin1.3): plugins get a covered, SSRF-safe way to fetch a URL they didn't write (#2047). A plugin that fetches a URL from content or settings — a feed poller, a link unfurler — had to reach past the overlay contract toKilnCMS.SafeFetch, and a plugin shipped as a Hex dependency couldn't reach it at all.Kiln.SafeFetchinkiln_pluginoffersget/2,head/2,post/3andrequest/3; inside a KilnCMS build every call goes toKilnCMS.SafeFetch, so the address is checked and pinned, redirects are followed only on request and re-checked per hop, and the body is capped. Only the documented options pass through::req_options, which could replace the pinned connection, is dropped. With no core loaded every call is an error unlessconfig :kiln_plugin, :safe_fetchnames a stand-in for the plugin's own tests, and that key is ignored whenever the core is present. Covered from this release (docs/overlay-contract.md); a plugin should require{:kiln_plugin, "~> 1.3"}.
mix kiln.fields.convertmoves JSON-in-text values into a:groupfield. Lists kept as JSON strings in:textfields (Verscienta'sdosages,safety_warnings, …) can now become validated group rows:mix kiln.fields.convert herb dosages --to dosage_rows [--map dose=amount]. It accepts a JSON array of objects, a single object, and either encoded twice; applies--mapkey renames; and coerces every row through the group's own rules (Changes.ApplyCustomFields, through a new internal:convert_custom_fieldcontent action, as the:field_conversionsystem actor). A dry run by default. Its report counts converted, empty and already-converted records, and lists each invalid one by id and slug with the reason: unparseable (prose) or the coercion error and its path (dosage_rows[2].unit).--report out.jsonsaves the report as a file. Nothing is written while any record is invalid unless--skip-invalidis given. The source field is never touched (expand/migrate/contract), and a re-run skips records whose group already has rows unless--overwriteis given. Each write is versioned, keepsupdated_atand the lock, sends no webhook, and re-fires a published record. A live record's pending working copy is converted with it. In a release:bin/kiln_cms rpc 'KilnCMS.Release.convert_field(...)'. Guide:docs/field-conversion.md.
Media in delivered content carries dimensions, MIME type, focal point and variants. A
:mediacustom field's stored value (single, or each item of a gallerymultiplefield) was%{"id", "url", "alt"}: a native app could not lay out before the image loaded, pick a size, or crop around the focal point. It now also carrieswidth,height,mime_type,focal({"x", "y"}, ornullfor non-images) andvariants— each rendition'slabel,width,height,format(MIME type),url,bytesandcropped(truefor the focal-awarecardcrop and a video poster, which are not interchangeable by width).id,urlandaltare unchanged. The fired:jsonartifact adds the same object asmediaonimageblocks and on each gallery image, so one decoder reads both (KilnCMS.Media.Snapshot, documented indocs/api.md, "Media in delivered content"). The delivery schema and its.d.tsspell the shape out.Variants are derived after the upload, so a snapshot taken first holds
"variants": []andnulldimensions. Firing now re-reads every item a document shows, and a media write that changes the shape (the variant worker finishing, a moved focal point, new alt text) re-fires the published documents pointing at it; the variant worker now records each rendition'sbytes. The stored value is refreshed by the next save, or by the newmix kiln.media.resnapshot(dry run by default,--applyto write; through a new internal:resnapshot_mediacontent action, versioned, keepingupdated_atand the lock, as the:mediasystem actor).The
:jsonartifactformat_versionis now 3, so an older artifact re-fires on its next read;mix kiln.refire_allgets there faster.
A
:textcustom field can also be delivered as plain text. A new per-field toggle in the fields admin, "Also deliver as plain text" (deliver_plainonFieldDefinition, off by default), adds acustom_fields_plainmap besidecustom_fields: each flagged field's value with HTML tags removed, entities decoded and whitespace collapsed (KilnCMS.PlainText.from_html/1). It is for clients that cannot render the HTML fragments importers put into:textfields — a native app no longer needs a browser engine's HTML parser to show a paragraph.custom_fieldsitself is never rewritten.Where it appears: in the fired
:jsonartifact, and so inGET /api/content/:type/:slugand every/api/syncitem, whenever the type flags a field (absent otherwise, so other types' payloads are byte-identical); as thecustom_fields_plaincalculation on JSON:API (fields[page]=…,custom_fields_plain) and GraphQL (customFieldsPlain), served only on request likeinherited_fields; and in the delivery schema (GET /api/schema, the.d.ts) as a closed object of nullable strings. Flipping the toggle re-fires the type's published documents. Not carried: the other fired surfaces (:web,:llm,:json_ldrender text already), the public HTML page, feeds and CSV export, and a reference's link fields (the toggle is cleared on them, as on every non-:texttype).
?fields=trims the firedjsonartifact; a guide to Kiln in a native app.GET /api/content/:type/:slug?fields=title,slug,custom_fields.pricereturns only the named top-level keys and custom fields, for clients on constrained networks that don't run GraphQL.idandtypeare always kept, as isexpandedwith?expand=; unknown names are ignored; at most 50 names;surface=jsononly (400 invalid_fieldsotherwise); works with?as_of=. TheETagof a fieldset response is a digest of the filtered body, so it moves only when a kept value does — a republish that changed onlyblocksstill304s a?fields=titlerevalidation — and every spelling of one fieldset shares it.x-kiln-fieldsechoes the canonical spelling (sorted, de-duplicated) so clients can share one CDN entry. A response withoutfieldsis unchanged, byte for byte and in itsETag./api/syncstill embeds the whole artifact.New page native-apps.md: persisting the sync cursor, restarting with
initial=trueonly oninvalid_cursor, backoff and the per-IP rate limit, iOS/Android background-refresh limits, ETags for single-record refresh,?fields=, webhooks as the push path for apps with a backend, and typed models fromGET /api/schema.
- A
:groupcustom field holds structured rows: one object, or a list of them, keyed by typed sub-fields, with every bad key reported by its path. Lists of records — an herb's dosages, its drug interactions — used to be JSON strings in:textfields, which the schema export called strings and every client parsed on its own. A group's sub-fields areFieldDefinitionrows owned through the newgroup_of_id(scalar types only, one level deep, like R3 link fields), each key is coerced by its sub-field's rules, an unknown key is refused, and errors name their path (Unit (dosages[2].unit) is required).multiplemakes it repeating rows withmin_items/max_items. Renaming or deleting a sub-field moves or purges its key in every stored row. The schema export types a group as a closed object (an array of them whenmultiple), and/api/json/type-definitions?include=field_definitions.group_fieldslists the sub-fields. Groups are defined through the code interface or the API for now; the editor shows an existing group read-only, and the row editor follows. The migration adds one nullable column. (#2036)
- Group fields are edited in the admin and the content editor. The fields
admin offers
:groupas a field type, asks whether it holds one set of values or repeating rows (with optional row bounds), and lists the group's sub-fields under it as "Fields in each row" — added, edited, ordered and deleted in place with the plain types, as a reference's link fields are. The content editor replaces #2036's read-only row count: a single group is one fieldset of its sub-fields' inputs, and a repeating one is a list of rows with "Add row", remove, move up/down and drag-to-reorder (the sharedSortablehook). Rows post by position and are folded back into the ordered list the write coerces, so a row edit is dirty and autosaves like any other field edit; a blank row just added stays on screen through an autosave. Each path error (Unit (dosages[2].unit) is required) shows next to its row and sub-field, andmin_items/max_itemsdisable the add and remove buttons while the save still enforces them. Also fixed on the way: a server-side edit of one custom field (a gallery's remove, a reference list's reorder) on a form no change event had filled yet showed every other custom field blank, and the next save posted those blanks. (#2053)
KilnCMS.Firing.refire/3re-fires named records for an overlay (#2048). An overlay sometimes knows a fired artifact is stale when Kiln doesn't: an article's card was built at fire time from another install's API, and that install's signed webhook says the card's source changed. Until now the only ways in weremix kiln.refire_all(operator tooling) andFireWorker.enqueue_backfill/1(internal).KilnCMS.Firing.refire!(type, ids, actor: KilnCMS.SystemActor.new(:overlay), tenant: org_id)is covered: each id gets the normal fire job, which fires it as a publish would and cascades to every document embedding it through the reference graph, then — as an edit of live content does — drops its delivery cache and dispatches<type>.updated, which reaches webhooks, automation, federation and the CDN purge. It dispatches after the fire, so whatever refetches on the event gets the new body. A document that isn't published fires nothing. It is a generic:refireaction onFiring.PublishedArtifactwith a code interface on theFiringdomain, admitting the:overlaysystem actor and the org's editors and admins; it returns how many fires were queued, and a refire already pending for an id is not queued twice.
Changed
- Related content comes back in the order the editor set: JSON:API
include=related_<type>s, GraphQLrelated<Type>sandrelated_links. Since 1.5 the editor keeps the related list's order in eachkind: :relatedlink'sposition(#2025), but only the editor read it back: Ash sorts a many-to-many by its destination, and a sort that reaches the join row needs the join resource unique on(source_id, target_id), whichContentLinkis not — reference edges share the table. The curated rows now have a join resource of their own,RelatedLink: the same table, no migration, narrowed by a base filter (kind = 'related' AND field IS NULL).related_<type>sgoes through it sorted byposition, alimittakes the first of that order for each parent, andrelated_links(MCP write echoes included) follows. Therelated_<type>_idsarguments are unchanged; they write an unsorted private twin,curated_<type>s, over the same join, becausemanage_relationshipcan't take that sort. One narrowing: a payload link (R8) of anotherkindbetween two records of the same type used to appear among their related records, and no longer does, in the relationship or itshas/has_all/has_nonefilters — related content is the curated links only. (#2061, #2064)
- A single media custom field is picked from the library drawer, with a thumbnail, instead of a dropdown of filenames. The dropdown listed only the editor's recent media window, so an older image could not be chosen at all, and it offered no preview, search or Unsplash import. The field now shows the current image with Choose from library (or Replace) and Remove, the featured image's shape, and the drawer opens in single-select mode. The thumbnail comes from the stored snapshot, so an image outside the window still shows. The form posts the same id as before, so writes and the API are unchanged; the controls follow the gallery's rules (writers only, not on a shared field of a translation, and a pick lands only in the field the drawer was opened for). (#2054 · #2055)
- Fired artifacts are gzipped: their
ETagis now weak (W/"…") andIf-None-Matchis compared weakly. Bandit skips compression for any response carrying a strongETag, soGET /api/content/:type/:slugwas never gzipped by Kiln, whatever the client'sAccept-Encoding. The tag keeps its value and gains aW/prefix, andIf-None-Matchnow uses the weak comparison the cached JSON:API/GraphQL/search responses already use (KilnCMSWeb.Plugs.PublicCache):W/ignored, a comma-separated list matches if any tag does,*matches. A client still holding the old, strong form revalidates to a304; one that compares tags itself sees each document's tag change once and refetches it one time. ETags are opaque, so this is not a contract change. A passphrase-unlocked document still has noETagand is never304ed. (#2041)
- The opt-in ML stack (
KILN_ML=1) moves to Bumblebee 0.8, Axon 0.9 and Nx/EXLA 1.0. Axon 0.8.1 drew two redundant-clause warnings from Elixir 1.20's type checker on every ML build (Axon.pop_node/1,Axon.ModelState.transform_to_parameters/1); Axon 0.9 fixes both, and only Bumblebee 0.8 accepts it, on Nx 1.0.nxandexlacarryoverride: true:image(through 0.72.0) still declares optional~> 0.12requirements on both, and Kiln uses none of image's Nx functions. Bumblebee 0.8 no longer needsprogress_bar, so its lock entry and thedecimaloverride it forced are gone. The default lean build is unchanged. Checked with the semantic-search suite, plus the realbge-small-en-v1.5embedding andbge-reranker-basereranker run throughNx.Serving. (#2067)
Fixed
- Similar content reads a page's searchable custom fields. The editor's
Similar content panel (near-duplicates and tag suggestions) built its
vectors from the block tree only, so a record's custom fields never reached
it, and a page whose text lived in its fields was told to "add some
content".
KilnCMS.Search.BlockIndexernow embeds the fields flaggedsearchable(the same textsearch_textcarries, #1585) as one more input under the key"custom-fields", on both the in-memory draft path and the stored index. Unflagged and private fields stay out. Drafts see it at once; published documents on their next fire (mix kiln.refire_allsweeps them). (#2096)
mix setupandmix assets.buildwork on macOS 27.2. macOS 27.2 (arm64) SIGKILLed the tailwind 4.3.0 standalone binary at launch: exit 137 with no output, even for--help, somix assets.buildfailed with "mix tailwind kiln_cmsexited with 137". The binary is a Bun executable whose ad-hoc linker signature does not cover the payload Bun appends, andcodesign --verifyrejects every release from 4.3.0 to 4.3.3, but 4.3.1 and later happen to launch. tailwind moves to 4.3.3 (and the:tailwindhex package to 0.5.2, which also lifts:httpc's header limit for its own download), andmix assets.setupnow re-signs the binary ad hoc (codesign --force --sign -) on macOS whencodesign --verifyfails, so a later release that macOS kills is covered too. Linux, CI and the Docker build are untouched.
mix setup,mix assets.setupandmix deps.compilework on OTP 29.1.1, loading the CI/Dockerhttpcheader-limit patch into Mix's VM only. OTP 29.1.1 (inets 9.8) caps:httpcresponse headers at 10240 bytes by default, and tailwind, rustler_precompiled (y_ex) and elixir_make (lazy_html, vix) download from GitHub without raising it, so a fresh local setup failed with{:header_too_long, {:max, 10240}}whenever GitHub's redirect headers ran over. The three aliases now runrel/docker/httpc_header_limit.escript(the one #2062 and #2063 use) withOUT=_build/httpc_patch/<md5 of the stock beam>/, once per OTP install, and load the result into the running Mix VM. The OTP install is never written to,iex/mix phx.server/tests run the stockhttpc, and a release still copies stock inets. On an OTP without the 10240 default the step does nothing. (#2080)
- A release that rolls back no longer announces or notifies what it undid;
post-publish broadcasts, notifications, audit anchors and cache busts wait
for the release to commit. A release publishes every item inside one
transaction, so each item's
after_transactionhooks ran before anything committed. The calendar, collab-room, task-pin and GraphQL-retraction broadcasts went out early and went out even when a later item rolled the release back. A workflow notification, or an audit anchor that lost a race, could fail the whole go-live: a failed statement aborts the transaction, and the hook's rescue cannot undo that. Cache evictions ran before the commit, so a reader in between could re-cache the old page. Those effects now go throughKilnCMS.AfterCommit, which holds them until the release commits and drops them when it rolls back. The Oban jobs (fire, webhooks, embeddings) and thepublished_version_idpointer still commit with the release, as before. Duplicating a record with its links, promoting a dynamic type, and the tasks a publish completes get the same treatment. The published-content and type-registry busts (BustContentCache,BustTypeRegistry) moved fromafter_actionto after the commit on every write, not only inside a release: busted inside the transaction, a reader could re-cache the old page or type set for a full TTL. (#2072)
KILN_VECTOR_SEARCH_EXACT/KILN_HNSW_EF_SEARCHno longer crash-loop a deployment whose compiled config sets the othervector_scan:mode. Runtime config is deep-merged over compiled config key by key, soKILN_VECTOR_SEARCH_EXACT=trueover a project'svector_scan: [ef_search: 150]became[ef_search: 150, exact: true], andKilnCMS.Reporefused to boot with "vector_scan sets both ef_search and exact", although only one variable was set (the mirror case failed the same way). The env var now writes its whole mode ([ef_search: nil, exact: true]or[ef_search: N, exact: false]), so it replaces the compiled one. Setting both variables at once is still refused. (#2019 · #2060)
Security
- ash 3.37.1 and ash_postgres 2.15.2 fix EEF-CVE-2026-115627,
EEF-CVE-2026-115635 and EEF-CVE-2026-114214; Kiln was not exposed.
The three advisories, published 2026-10-25, failed the dependency audit
on every PR. EEF-CVE-2026-115627: a lateral-joined
many_to_many(one with a sort or limit) built its join-resource query without the read action, so attribute multitenancy never filtered the join rows. The sortedrelated_<type>srelationships load that way, but the destination read kept its tenant filter, so another site's records never appeared. A foreign-org join row could only add records of the reader's own site, and Kiln's writes can't produce one:manage_relationshipresolves both ends under the tenant, and payload links locate both ends in the writing site. A test now plants such a row and checks it is ignored (it fails on ash_postgres 2.13.1). EEF-CVE-2026-115635 needsreturn_skipped_upsert?and EEF-CVE-2026-114214 needs athrough:relationship, and Kiln uses neither. ash_sql moves to 0.8.4. The bump adds one migration, ash functions v7, which redefinesash_required/2in place (same signature and return type), so it is safe under expand/contract. (#2091)
- bandit 1.14.0, jose 1.11.13 and ash 3.37.2 fix the twenty advisories
published 2026-10-26. bandit 1.13.0 had eleven, all reachable on a
deployment because bandit is Kiln's HTTP server: HTTP/2 flow control,
SETTINGS, HPACK, header-list and body-timeout resource exhaustion, HTTP/1
chunked-body memory amplification, quadratic
content-length/port parsing, WebSocket fragment and post-close issues, and request smuggling through bare LF/CR in chunk extensions (EEF-CVE-2026-108525, -108529, -109584, -110314, -110499, -111101, -111103, -113436, -114213, -114216, -115269). 1.14.0 also advertises a default HTTP/2max_concurrent_streamsof 500 and a 256 KiB header list, and rejects malformed HTTP/1 request lines and absolute-form targets with 400. jose 1.11.12 had eight (EEF-CVE-2026-65625 to -65632). Kiln signs its own tokens with HS256, which none of them touch; the RSA, ECDSA and JWE fixes cover SSO's verification of an identity provider's tokens. ash 3.37.2 fixes EEF-CVE-2026-96581: under attribute multitenancy an upsert on the primary key, or on anall_tenants?identity, could read, overwrite and move another tenant's record. Kiln was not exposed: every one of its upserting create actions upserts on a named, tenant-scoped identity and none accepts the primary key, and the two runtime upserts targetOrgMembership, which is not multitenant. (#2094)
- The release image's Debian packages are current again, and stay so.
The runner stage is pinned to a dated
debian:trixie-*-slimtag, and the release workflow builds with the GitHub Actions layer cache. With the runner's instructions unchanged, every release replayed the cachedapt-getlayer from the first build after the last tag bump, so v1.6.0-rc.1 shipped weeks-old packages: Trivy counts 87 fixable OS findings, 20 of them HIGH (curl, expat, libvips, librsvg, libheif, liblzma, ca-certificates). Three changes. The runner moves totrixie-20261020-slim. Its firstRUNnowapt-get upgrades, sinceapt-get installnever moves a package the base already ships (libssl3t64,liblzma5,ca-certificates). And the stage is namedrunnersorelease.ymlrebuilds it withno-cache-filters: runner: the builder, where the cost is, stays cached. A fresh runner scans at zero fixable findings. The builder stays onbookworm-20261005-slim, the newest datehexpm/elixirpublishes for Elixir 1.20.4 / OTP 29.1.1; it ships nothing but the release tarball. Self-built images get the same fix on their next uncached build (docker build --no-cache, or--no-cache-filter runner). (#2097)