Skip to main content
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".