Skip to main content

KilnCMS 1.5.0

Upgrade notes

  • After deploying, run a firing sweep once, so documents fired before 1.5 stop naming draft reference targets and join the reference re-fire graph: mix kiln.refire_all, or bin/kiln_cms rpc "KilnCMS.Firing.Sweep.run()" against a running release. Two 1.5 changes only reach an artifact when it is next fired. A fired artifact could name a :reference field's draft target to anonymous readers (#2013); the read path masks it now, but an artifact fired before the upgrade keeps the name until it is re-fired. And a referrer only becomes an edge in the re-fire graph when it fires (#1998), so until then renaming its target re-fires nothing and its artifact keeps the old slug and title. The sweep enqueues a re-fire of every published document through the :firing queue and changes no editorial state, so it is safe to run while serving. The seven migrations in this release only add a table (propagation_records) and nullable or defaulted field_definitions columns; they need nothing beyond the usual migrate. (#1998, #2013)

Added

  • Related content uses the reference picker: search, reorder, remove and open, over the record's own type, with the order kept. Part of R9 in relationships-plan.md. The editor's "Related types" multi-select, which listed the 500 most recently edited records of the type, is now the picker reference fields use: search as you type, drag or move with the keyboard to reorder, remove, open the target in a new tab, and "Add new". The record being edited is never offered, and a search is a read as the editor, so it finds only what they may open. Related content stays the same type only; for links to other types the panel points to a reference field that allows several types, with a link to the fields admin for admins. The picker still writes related_<type>_ids, so the links, the optimistic lock and a published record's working copy behave as before. The order is now kept in each kind: :related link's position: the complete list numbers them, add_related_<type>_ids appends after the ones already there, and a working copy holds the list in its order (it was sorted), so publishing a reorder lands it. Nothing is removed: the arguments, relationship and API are unchanged, and links saved before keep position 0 until their list is next saved. (#2030)
  • The fields admin asks for a relationship as one: "A relationship", then both sides ("Recipe links to Ingredient"), then how many on each side; the field list reads it back from both sides in one sentence. (#2025)
  • API clients can connect, disconnect and reorder references: GraphQL's add<Type>References, remove<Type>References and move<Type>Reference, and payload-link routes at /api/json/content-links. Workstream R8 of relationships-plan.md. Each mutation is the record's own update with one :reference field rewritten, so policies, the :read_write key scope, expectedLockVersion, the lock, history, webhooks, re-fire and the field's validation (bounds, target types, link fields) all apply. position takes start, end, before: id or after: id, as in Hygraph's connect; targets take {id, type, link}. Adding a record the field already holds moves it, so a retry converges. On JSON:API a reference field is written by PATCHing its array (now documented with examples), and a payload link — a content_link with no field — has POST, PATCH and DELETE routes, each authorized as an update of the link's source record; reference edges are refused there. Everything is additive: new mutations, new input and result types, new routes. (#2014)
  • Reference fields gain max_sources, a limit per target, and on_target_delete: keep, clear or restrict on a permanent delete. Workstreams R1b and R5 of relationships-plan.md. max_sources bounds the other end of a relationship ("each Author is on at most 3 Books"): checked inside the source record's own write, where its edges are written, so the editor, API writes and Publish changes on a working copy are all held to it. Only a target the write adds is checked; every connection counts, drafts and trashed records included; a per-target advisory lock keeps two records racing for the last slot from both getting it; and the refusal names the count, never the other records. Tightening it leaves existing connections alone. The picker lists a full target disabled, "Full — 3 of 3 connected". on_target_delete acts on the permanent delete only: :keep (the default, today's behaviour) leaves a broken reference, :clear takes the record out of every referrer's value as a versioned system write that re-fires published referrers and keeps link data and edges in step, and :restrict refuses the purge while anything connects, which the trash explains in plain words; the nightly purge of old trash skips such a record rather than failing on it each night (#2022). The fields admin asks both from the other side, in the relationship's own words. The schema export reports x-kiln-max-sources and a non-default x-kiln-on-target-delete. Additive: two new options with defaults that keep every field as it was. (#2017)
  • mix kiln.gen.join <type>.<field> graduates a reference field to a typed Ash join resource, copies its connections, and retires the field. Workstream R10 of relationships-plan.md, the graduation path D22 promises. The first run writes the resource and its code interfaces: two belongs_tos, position, one typed attribute per link field, a unique_connection identity, an index on target_id, and the content read rule as policies. mix ash.codegen then makes its table. A second run, --data-migration, writes a migration that copies the field's content_links edges into the table in plain SQL. mix kiln.join.retire (or KilnCMS.Release.retire_join_field/2) then deletes the field definition, its stored values and its edges. It refuses while any connection is missing from the join table, or while a working copy holds an unpublished change. A field that links to several types is refused: split it first. Retiring removes the field from custom_fields, so move readers to the join resource before that step. See extending-content.md. (#2016)
  • Connections can be added, removed and reordered from the record they point at, and each change is saved as an edit of the record that holds it. Workstream R4b of relationships-plan.md (decision D23). The reverse field gains a picker and per-row remove and move. Each change runs the source's own update as the editor, so its permissions, validations, history and lock apply. A published source's change waits in its working copy, and the row says "goes live when Source is published" with a link. A source the editor may not change shows a lock. A lock conflict fails only its row. Nothing reshapes: no schema change. (#2018)
  • A reference field can name its other side, and every record it connects shows the records connecting to it: "Recipes using this ingredient". Workstream R4 of relationships-plan.md. New optional inverse_label / inverse_name on a :reference field definition (the name is suggested from the label, and unique among the reverse fields of each target type). The target's editor shows a read-only reverse field: the sources, with each connection's link values, ordered by title and ten to a page, read as the editor so a source they could not open is neither listed nor counted. The fields admin asks "What Ingredient calls this". GraphQL referencedBy gains inverse:, and the schema export reports x-kiln-inverse and x-kiln-reverse-fields. Additive: two nullable columns, a new argument, new annotations. (#2015)
  • Reads follow reference fields to the records on the other end: GraphQL's typed references(field:) / referencedBy on every content type, and ?expand=<field> on GET /api/content/:type/:slug. Workstream R7 of relationships-plan.md. In GraphQL every content object (Page, Post, a project type, Entry) now implements a ContentRecord interface, and references(field: "ingredients") returns { position, link, target: ContentRecord } in the field's order; referencedBy(field:, sourceType:) returns { field, position, link, source }. An interface rather than a union, because every dynamic type shares the one Entry object (D17). Everything is read as the caller, so a draft on either end is invisible to an anonymous caller and visible to an editor, and a list of records costs one edge read plus one read per target type. Both fields are priced like any to-many relationship (5 rows without limit) and cap at 100 rows. On the JSON side the plan asked for a spike of ?include=content_links.target first; AshJsonApi accepts only public relationships as include paths, and a relationship has one destination, so the delivery API answers it instead: ?expand= adds each target's fired json artifact under "expanded", for targets an anonymous reader may read, with an ETag that changes when a target does. link is the connection's stored data, passed through for link fields (R3) to fill. Nothing existing changes shape: new fields, a new interface on existing objects, a new query parameter and response key. (#2010)
  • A reference custom field can carry link fields: typed details on each connection, such as the quantity of each ingredient a recipe connects. Workstream R3 of relationships-plan.md. A link field is a FieldDefinition owned by the reference field (new nullable link_of_id) instead of a content type, coerced by the same rules as any custom field (required, default, :select), and limited to the plain types. Each snapshot gains an added "link" map ({"id", "type", "slug", "title", "link": {"quantity": 500}}), and the connection's ContentLink edge carries the same map as its metadata, written in the same transaction, so the two copies cannot disagree. A connection with no link value has no "link" key, so a reference without link fields stores exactly what it did. Link values merge per connection: an item that leaves one out keeps what that connection stored. Renaming a link field moves its values in both copies, deleting one removes them. custom_filter[ingredients.link.role]=main (and GraphQL customFilter) matches a record when some connection's value matches, read from the edges under R6's both-ends read rule, so a link value cannot reveal a draft target. The editor shows one input per link field on each connected record's row; the fields admin adds them under the reference as "Details on each connection". The schema export types the "link" object and lists the names as x-kiln-link-fields. A curated link's metadata stays a free map. (#2011)
  • Computed fields can use today's date: today(), years_between, and "Days until a date" and "Age in years" recipes, refreshed once a day. A formula runs on write and again on fire, so a clock inside the engine would let a record and its artifact disagree; instead today() answers a date pinned once per evaluation pass — the site's day, in the events timezone (config :kiln_cms, KilnCMS.Events, time_zone:). A write pins it for its slug, alias and every computed field, and the fire it enqueues carries it, so the artifact matches what was stored even after midnight; publishing now re-derives computed fields on the publish day. A formula that calls today() is time-dependent, and a new hourly AshOban scheduled action on FieldDefinition (:refresh_time_dependent) re-derives and re-fires, on the first tick after the site's midnight, the published records of every type with one, as the :computed_fields system actor — writing only the records whose value moved, versioned for the sync API, with no webhook, no updated_at change and no lock bump for an open editor. The builder lists today() under Dates, previews on today's date, and notes that the value refreshes daily. No migration. See docs/extending-content.md, "Computed fields". (#2009)
  • custom_filter filters through relationships: has, has_all, has_none and count on a reference field, and has/has_all/has_none on tags and related_<type>s. Workstream R6 of relationships-plan.md, on JSON:API (custom_filter[ingredients][has][]=<id>) and GraphQL (customFilter: {ingredients: {has: [...]}}). has matches a record holding any of the ids, has_all every one, has_none none of them, and count compares how many targets it holds (eq/gt/gte/lt/lte). They read the relationship's content_links edges (or taggings) through their indexes, never the jsonb snapshot, so eq/in keep their meaning. A target the reader may not read does not exist for the filter: a draft never matches and is never counted for an anonymous caller, so a filter cannot reveal that a draft exists. The gate is the target type's own read policy, as Ash.can/3 derives it for the actor. The plan sketched the built-ins under filter[tags][has], but AshJsonApi and AshGraphql own that grammar and take no new operators, so they sit in custom_filter beside the reference ones (a custom field named tags keeps the name). Measured on 600k edges: the set operators take about a millisecond in the database, and count about 5 µs per record it scans. A rewrite of the gate that the measurement prompted took a full count scan from 5.3 s to 135 ms. (#2003)
  • A reference custom field can hold a list of references (multiple: true), with optional min_items / max_items. Workstream R1 of relationships-plan.md. The value is an ordered list of the same {"id", "type", "slug", "title"} snapshots a single reference stores, the shape a media gallery already has (#1992). Each item must resolve to a record of the target type the writer can read, in one read for the whole list. A record listed twice is refused, naming it. A lone id is a one-item list, and blanks drop out, so an emptied list clears the field. The bounds apply to a list that holds items; required still asks for at least one. Flipping the flag reshapes stored values as it does for a gallery: one becomes a one-item list, a list keeps its first item and the other items' edges are deleted. The fields admin warns before a narrowing save and edits the bounds. Each item has its own ContentLink edge at its position, and reordering rewrites the positions. Each item is also a re-fire edge (R0, #1998). custom_filter eq on a list means "contains this id", not_eq "does not contain", and in "contains any". The schema export types the field as an array of snapshots and reports the bounds as x-kiln-min-items / x-kiln-max-items. They are not minItems / maxItems, because a value stored before a bound was tightened may fall outside it. The editor gets a small ordered list with an add select, and the search picker follows in R9. Single references keep their 1.0 shape. (#2000)
  • A reference custom field can link to several content types (target_types). Workstream R2 of relationships-plan.md. A :reference field names its one type in target_type, as before, or several in the new target_types list; exactly one of the two is set, and every name must be a compiled or dynamic type on the site, once. Each stored snapshot already names its own "type", so a value is self-describing, and a list of references (R1) may mix types. A write to a field with several types must name each item's type, {"id", "type"}; a bare id is refused, and so is a type the field does not allow, naming the item. The id is looked up among that type's records only. A field with one type keeps accepting a bare id. The ContentLink edges and the re-fire edges (R0, #1998) take each item's own type, and custom_filter keeps matching on the id. The schema export reports every reference field's allowed types as x-kiln-target-types, an annotation rather than an enum, and the TypeScript export comments them on the field. In the fields admin, Links to becomes a set of checkboxes: one ticked type is saved as target_type, several as target_types. The editor's reference picker (R9) searches every allowed type, shows each result's type, and keeps each pick's type through reorders and saves. The typed GraphQL interface over the allowed types is R7. No existing field changes. (#2004)
  • The editor fills a reference custom field from a search picker instead of a select of up to 500 records. Workstream R9 of relationships-plan.md, first part. Typing searches the target type by title on the server, as the editor and in their site, one page of 20 at a time with "Show more", so there is no cap and a record the editor may not read is never offered. An empty search lists the most recently edited records. Results and connected records show their title, type and state, and each connected record links to its own editor in a new tab. A list of references (R1) adds several in a row, reorders by dragging a row's handle or with its up and down buttons, and removes rows; a single reference holds one record and offers the search again once it is removed. A full list stops offering more. A stored reference the editor can no longer read stays in place, marked unavailable, rather than being dropped on the next save. The search is a combobox: arrow keys move through the results, Enter picks, Escape closes, and Enter never submits the page. The value is still written into the content form, so saving, the working copy and the stale-edit check behave as they do for any other field. Several target types (R2), per-link fields (R3) and the reverse list (R4) add to the same picker later. (#2002)
  • The reference picker can create a record and connect it in one step: "Add new Type" next to the search. Workstream R9 of relationships-plan.md, "Create and connect". Each target type the editor may create gets an "Add new" action under the search; it opens a small form inline with the title and the type's required fields that have no default, nothing else. The record is created as the editor through the type's own create action, so it is an ordinary draft and the type's policies apply, and it is connected as if it had been picked: added to the end of a list, or filling a single reference. The row shows its Draft badge and a link to open it. The connection is part of the document's unsaved change like any pick, so the working copy and the stale-edit check are untouched; abandoning that change leaves the new draft in the content list. The action is hidden when the type's can_create_*? helper (new ContentTypes.can_create?/4) refuses, when the field is full or locked, and for a type with a required media, reference or plugin-typed field and no default, which the small form can't fill. Enter in the form creates instead of saving the page, and Escape closes it. (#2005)
  • Computed fields have a formula builder: pick a recipe and fill in the blanks, or write the formula with value and function palettes, and see the result live on your own content. Custom fields → Computed used to be a single text box for a {{ … }} template, which only someone who already knew the syntax could fill. It now opens in a Recipe view: twelve common computations (reading time, word count, a value with a label, combine values, URL slug, shortened text, first non-empty value, total, multiply, percentage, if … then …, days between dates), each with dropdowns over the document's values and the type's other fields. The Formula view is the template with buttons that insert a value or a function at the caret and every function listed with what it does. Both preview the result against the type's five most recently edited documents. The formula language gains conditions (if, equals, greater_than, less_than, contains, is_empty, and, or, not), replace, and dates (days_between, year, format_date). Nothing new is stored and there is no migration: a recipe writes an ordinary compute formula, and editing a field opens the recipe view only when the formula is exactly a recipe's output, so a formula changed by hand is never rewritten. Three evaluation rules change for existing formulas, each of which used to store a wrong value rather than none: an interpolated template whose expressions are all blank is now blank ({{ size }} EU on a record without a size was "EU"); product is blank when a factor is missing (it used to skip it, so price × no quantity was the price); and default sees a filled list or map value (a gallery, a reference) as filled. Affected computed values change on the next save or fire. See docs/extending-content.md, "Computed fields". (#2006)
  • A computed field can read another computed field: {{ sum(subtotal, shipping) }} over a computed subtotal. Computed fields used to be evaluated in one pass, so a formula naming another computed field always saw it blank. They are now evaluated in dependency order — on write, on the refresh after a document edit, on fire and in a slug or alias pattern's [field:…] token, all through one KilnCMS.CMS.Computed.derive/3 so the stored and published values agree. A loop (a field reading itself, directly or through others) is refused when the field is defined or edited, naming the loop, including when the new field is the one that would close it. The formula builder's value pickers and palette now offer the type's other computed fields, less any that already read the one being edited, and the preview derives them in the same order. Nothing is stored differently and there is no migration; a formula that named a computed field starts seeing its value on the next save or fire. See docs/extending-content.md, "Computed fields". (#2008)
  • kilncms.dev now publishes Kiln's security policy at /security, and a security notice per published GitHub advisory at /security-notices/<ghsa-id>, with a feed. A new workflow, publish-security.yml, runs scripts/publish_security.exs daily, when a release is published, and when .github/SECURITY.md changes on main. The page is SECURITY.md, with its relative links pointed at GitHub, plus the list of notices. Each notice is an entry of a security_notice content type whose custom fields are package, affected_versions, patched_versions, severity, advisory_id, cve_id, published_on and advisory_url. package is kiln_cms for the core and a plugin's Hex name for a plugin's notice, so the plugin registry (#1447) can share the type and its feed at /security-notices/feed.xml. A withdrawn advisory is not published, and the script never unpublishes a notice. The type is a one-time setup on the site; the script names any field it is missing. SECURITY.md and the README link to the page and the feed. Nothing changes for a self-hosted instance: the script and workflow publish to kilncms.dev only. The per-site security.txt this issue also asked for shipped in 1.1.0 (#1881). (#1873)
  • Tags, tag groups and categories send webhooks: tag.created, tag.updated, tag.deleted, and the same for tag_group and category. A receiver that caches taxonomy can now invalidate on a change instead of polling. Each create, edit or delete sends one event after it commits, to endpoints of the term's site that selected it. None is in the default subscription, so existing endpoints receive nothing new until an admin opts in. The payload is id, slug, name, updated_at (plus tag_group_id for a tag); an updated adds previous_slug when the slug changed and previous_tag_group_id when a tag moved group, and a deleted is the record as it was. Deleting a tag or tag group sends no event for the records the delete cascades to. A taxonomy delete is not a retraction: it is not retried for a day or sent to auto-disabled endpoints. The console does not mark tag.created as "includes unpublished content". Taxonomy events skip automation rules and federation, and trigger the optional CDN purge. A new dynamic content type may not be named tag, tag_group or category, so those event names always mean taxonomy; an existing type with such a name can still be edited. See docs/webhooks.md, "Taxonomy events". (#1965)
  • A release can adopt imported images: bin/kiln_cms rpc 'KilnCMS.Release.adopt_image_backend()'. System → Image backend labels it and the Mix task, each with a dry run first. The page listed mix kiln.media.adopt_image_backend and a raw rpc call one under the other, unlabelled, so an operator pasted both into a release's shell, where there is no Mix. The rpc call printed nothing, and the org ID's inner quotes were easy to lose to a docker exec or ssh -C wrapper, which fails as a bare CompileError whose cause is only in the server's log. KilnCMS.Release.adopt_image_backend/1 is the task for a release, like the importers beside it: it prints the task's report, returns refusals instead of raising, and refuses under eval, since each adopted row enqueues a job. For the default site it takes no org, so its command has no inner quotes; org: "<uuid>" names another. (#1955)
  • A media custom field can hold a gallery: an ordered set of images rather than one. The fields admin now asks what a media field holds, a single item (the default, and what every existing field stays) or a gallery (FieldDefinition.multiple). A gallery stores a list of the same %{"id", "url", "alt"} snapshots a single media field stores, in the order the editor arranged them. In the editor it is a strip of thumbnails, filled from the library drawer's multi-select (click order is gallery order), with buttons on each tile to move it or remove it. Writers may send a list of ids or snapshots; a lone id is a one-image gallery, repeats are kept once, and one id that does not resolve fails the field. custom_filter on a gallery matches by containment (eq = holds this image, in = holds any of them), every image counts as a media usage, and the JSON schema export types the field as an array. Flipping the setting on a field that already has values reshapes them: a single image becomes a one-image gallery, and a gallery keeps only its first image (as does retyping a gallery away from media), which the fields admin warns about before the save; published documents of the type re-fire to pick up the new shape. A single media field handed a stale gallery list keeps its first image, so drafts and versions saved before the switch still publish and restore. Additive migration: one boolean column defaulting to false. (#1992)
  • Every publish and withdrawal is measured end to end: one propagation record per transition, mix kiln.propagation for p50/p95, and a telemetry event to graph it. The path forward's first claim, that a correction reaches everything a site serves within N seconds, had no N. The webhook fan-out now opens a propagation record for each published, unpublished, or trashing of a live document, with the number of endpoints it was sent to. Each delivery points back at it. The record settles once every delivery has a 2xx or has been given up on, and the CDN purge (when KILN_CDN_PURGE_URL is set) has been acknowledged or has failed. Settling stamps the transition-to-last-endpoint and transition-to-purge latencies and emits [:kiln_cms, :propagation, :complete], summarised in KilnCMSWeb.Telemetry. KilnCMS.Propagation.percentiles/2 and mix kiln.propagation report p50/p95 over a window, counting lost and still-open transitions apart. docs/benchmarks.md describes the method and has the table for the first measurement. A migration adds the propagations table (pruned after 90 days) and webhook_deliveries.propagation_id. (#1927)

Fixed

  • mix kiln.gen.join keeps a reference field's target rules: a graduated on_target_delete: :restrict still refuses the purge, and max_sources still caps connections. Found in the relationships close-out: R1b/R5 and R10 were built in parallel, so the generator always wrote on_delete: :delete on the target and dropped the cap. Graduating a :restrict field quietly turned "this record can't be deleted while something connects to it" into a cascading delete. Now :restrict becomes on_delete: :restrict, and the purge is refused first with the same error the field gave, while the nightly trash purge skips such records as before. :clear and :keep become on_delete: :delete, and for :keep the generator says that is a change. max_sources becomes a check on the join resource's create (KilnCMS.CMS.JoinGraduation.SourceCap) under the field's own advisory lock, race-safe. The task prints the rules it carried over, dry run included. See extending-content.md, "When to use a dedicated join resource". (#2024)
  • Messages that name a content type no longer put "a" in front of it ("A Ingredient can't be deleted…", "Connect a Article…"). (#2023)
  • GraphQL's referencedBy(inverse:) now lists the connecting records on an entry of an admin-defined type; it returned an empty list there. Found by the end-to-end pass that closed out the relationships plan (relationships-plan.md §8). The reverse field is named per content type, and an entry's type comes from its type_definition_id, which AshGraphql does not load unless the query selects it. The resolver now looks the type up for such entries, in one read per batch. Compiled types were not affected. The same pass is kept as a browser test of the whole feature (e2e/tests/relationships_journey.spec.js). (#2026)
  • An automation rule for "any content type" no longer fires when a content release is published; a rule scoped to release still does. Automation matched an event on its verb alone, so release.published counted as a publish of a document of type release. Every "any content type, on published" rule ran its reaction with a release where a document belonged: an email or broadcast about a release, and reactions that look the document up quietly did nothing. The documents in the release fire their own <type>.published as before. KilnCMS.Automation.trigger/1 now classifies the event: a domain event (WebhookEndpoint.domain_events/0: releases, experiments, forms, memberships, taxonomy) with a trigger verb matches only rules scoped to its own namespace, as docs/content-releases.md promises. The list holds exact event names, not namespaces, so a dynamic type named experiment keeps all its rules. A dynamic type named release is the one exception: its release.published no longer reaches "any content type" rules. New types can no longer take that name (#1994). Tasks, the health sweep and archived dynamic types are unchanged. (#1995)
  • Publishing or withdrawing an entry of an archived dynamic type now opens a propagation record, and domain events no longer enqueue a federation job that only skips. KilnCMS.Propagation.open/5 checked an event's namespace against the content-type registry, which leaves archived dynamic types out. Their entries still fire <name>.* (#938) and webhooks still deliver them, so those transitions were delivered but never measured. It now rules out the exact domain events (WebhookEndpoint.domain_events/0) instead, and release.published stays excluded. The ActivityPub announcer enqueued an AnnounceWorker job for release.published, which the worker then skipped because no release type syndicates; it now skips domain events before enqueueing. Follows #1995. (#2012)
  • A new content type can no longer be named after a built-in webhook event namespace (release, task, form, experiment, membership). A type's events are <name>.<verb>, so a type named release sent release.published to every endpoint subscribed to content releases, with a document where a release belongs, and automation rules scoped to release matched both. Only the taxonomy namespaces (tag, tag_group, category) were refused before. The list is now WebhookEndpoint.reserved_namespaces/0, derived from the same event lists as the endpoint picker, and a test fails if a new event family is added without being reserved. The rule applies only when a type is created, so a type that already has one of these names can still be edited. (#1994)
  • A variant job no longer takes an image backend's failed or rate-limited read for a deleted original. VariantWorker treated every failed read of the original as "removed, nothing to do" and completed. For a file on disk that is usually true; for an image backend, a remote API, it is usually a timeout, a 5xx or a rate limit. An adopted row then kept no dimensions or variants, and since it was no longer an imported link, nothing enqueued it again. A read refused with {:rate_limited, retry_after_ms}, the shape Kiln's own budgets use, now snoozes the job, which doesn't spend an attempt, so a large adoption paces itself under a backend's API budget. Any other failed read of a backend-held original is retried, unless the backend says :not_found. Reads from Kiln's own storage are unchanged. (#1955)
  • Regenerating variants skips images over the pixel cap instead of re-reading each original on every run. An image recorded over max_pixels (50 MP) can never be given variants: its job reads the original and refuses it, as the transform route refuses it from the same recorded size. The missing-only run still enqueued it every time, and for an image backend's item each of those reads is an API download. A 52.9 MP photo adopted from a Sanity import cost one on every run. Regeneration.run/2 now counts such images in over_pixel_cap without enqueuing them, and mix kiln.media.regenerate_variants reports the count. (#1955)
  • GraphQL published* queries without offset return their page fields (hasNextPage, hasPreviousPage, pageNumber, lastPage) again: an omitted offset now means 0. (#1883)
  • Renaming a document now re-fires the documents that point at it through a :reference custom field, and their fired artifacts carry its new slug and title. A :reference field's value was mirrored as a backlink (#1594) but never became an edge in the re-fire graph (decision D13), which only read block references, block media, the featured image and :media fields. So publishing a rename re-fired nothing, and every referrer's :json artifact kept the title and slug stored when the referrer was last saved. The edges are now written on every fire, single and list values alike, with media snapshots (which carry a "url") still recorded as media. Firing also reads the target's current slug and title for the fired copy of the value, when the target is published; an unpublished target keeps the stored label, so no draft title reaches a public artifact. The stored custom_fields value and its %{"id", "type", "slug", "title"} shape are unchanged, and so is what the delivery APIs return for it. Existing referrers get their edges the next time they fire. No migration. (#1998)

Security

  • bandit 1.13.0 fixes EEF-CVE-2026-105660 (a client could choose conn.host/conn.port via the HTTP/1 request target) and the HTTP/2 DoS EEF-CVE-2026-105659, so the endpoint speaks HTTP/2 again. (#1991)
  • The examples/phoenix_showcase template's lock clears 23 Hex advisories (phoenix, phoenix_live_view, bandit, plug, mint, hpax, lazy_html), and CI now audits it. The headless LiveView example is not part of the release image, but it is offered as the starting point for a decoupled frontend, so a copy inherited its lock: phoenix 1.8.8, phoenix_live_view 1.2.5, bandit 1.12.0 (five advisories, among them EEF-CVE-2026-105659 and EEF-CVE-2026-105660), plug 1.20.2, mint 1.9.0 (nine), hpax 1.0.3 and lazy_html 0.1.11. The lock moves to phoenix 1.8.15, phoenix_live_view 1.2.12, bandit 1.13.1, plug 1.20.3, mint 1.11.0, hpax 1.1.0 and lazy_html 0.1.13; no mix.exs requirement changed. Nothing in CI built the example, which is how it went stale, so the Dependency audit job now runs mix hex.audit in examples/phoenix_showcase too.
  • A reference custom field no longer reveals a draft target to a reader who cannot read it: the fired artifact, JSON:API and GraphQL leave that item out of custom_fields. A :reference value stores %{"id", "type", "slug", "title"} per target, taken when the referrer was saved, whatever state the target was in. Every read surface served that map verbatim, so a published post linking to a draft page told anonymous callers the draft's id, slug and title: in its fired json artifact (GET /api/content/:type/:slug, and everything served from it) and in custom_fields on JSON:API and GraphQL. The edges (content_links, GraphQL references, ?expand=) already applied the both-ends rule; the snapshot now does too. The snapshot is a covered 1.x shape, so the fix is read-side only and keeps it: an item whose target the reader could not read directly, through the target type's own read policy (published, audience, passphrase lock), is left out of a list, and a single-valued field reads as null, exactly as if it were unset. The stored value is never rewritten, and the item reappears once the target is readable: publishing it re-fires the referrer (D13). The artifact is masked as an anonymous reader, since it is served to anyone at its URL, point-in-time reads (?as_of=) included. API reads are masked as the actor; editors, admins, system actors and authorize?: false reads get the value whole, so a masked value never reaches anyone who could save it back. No migration; artifacts correct themselves on their next fire. See docs/extending-content.md, "References are edges too".