Change log
What changed in each Dédalo v7 release, newest first. Every change is filed under who it affects — users of the application, administrators who install and run it, and developers who build on its API. A release that needs something from you when you update says so first, in an Action needed box.
Each release's Wire contract list names the deliberate changes to the API's wire
shape by the id under which the source tree records each one
(engineering/wire_contract/).
Unreleased
Merged since the last release; these ship with the next one.
Action needed when you update
- New installations are connected to the official update server and always have the Languages thesaurus; a server restarted by systemd or Docker must now declare
DEDALO_SUPERVISED=true. - "A publication host can now be installed with one guided command,
provision init, on Debian, Ubuntu 24.04 and 26.04, and RHEL, Rocky or Alma 9 and 10 with SELinux; the Publication API v1 runs in a FastCGI pool of its own." - The publication host provisioner now refuses an instance declaration that anyone other than root could change.
- Publication host provisioning works with Apache on Debian and Ubuntu.
- Several publication hosts on one server are checked for isolation.
- Every AI request now counts against a daily budget per user, and generated answers need their own permission.
- The site builder's Claude Code agent no longer loads configuration from the site's own files, and refuses a Claude Code that cannot be told not to.
- The site builder's agent can no longer choose its own agent program, read another site's activity, or stall a turn with a planted brief.
- A site's AI turn no longer runs commands planted in the site's git settings, and the site builder says why it refuses a run.
- The site builder runs each site's AI agent as that site's own system user, from units that root installs. systemd 248 is now enough, and
provision applymust run before the updated daemon starts. - Only profiles granted the assistant tool can use the assistant.
- The site builder can no longer start its agent units through polkit
- Outbound fetches now refuse every IPv6 route to an internal address.
For users
Security
-
Searching or sorting by a field the user may not see no longer reveals its values.
A search on a field of the section itself (not of a linked record) did not check the user's permission on that field, so repeated searches like "starts with A", "starts with B" could reveal a hidden field's value, and sorting by it revealed its order. A filter or a sort on a field the user's profile hides now matches nothing and sorts nothing, and the request carries the usual "some content was not shown" notice. Fields shown to the user through a portal or an autocomplete they are allowed to use remain searchable there — and only there: the server checks that the portal really belongs to the section the request names and that the user's own profile grants it, so a request cannot borrow that allowance by naming some other portal, and the record information fields every user may search (created and modified date and user) stay searchable for everyone. Which section's permissions apply is decided by the records being searched, never by what the request says about them. A sort over several sections at once is applied only when the field is visible to the user in every one of them.
Wire contract:
WC-2026-09-30-search-root-step-acl. -
A search whose conditions are joined by OR at the top level no longer returns records outside the user's projects.
When every condition of a search was joined by OR (for example "title contains X or title contains Y"), one of the conditions was checked without the user's project restrictions and the other without the section being searched. A user limited to some projects could therefore see records of projects they do not hold. The conditions are now always kept inside those restrictions. Administrators without project restrictions see no difference.
Wire contract:
WC-2026-09-29-search-where-parts-parenthesized.
Removed
-
Activity and Time-machine sections no longer offer a section toolbar
The Activity and Time machine sections are read-only system logs: every record is written by the engine and never edited by hand. They nevertheless used to show a section toolbar (export, import, print, update cache, …) whose buttons all act on records the engine refuses to modify.
Both sections now ship an empty section toolbar, so the dead buttons are gone. The Time machine list also stops showing the collapse/expand buttons toggle that Activity had already dropped — the two now render the same search-only toolbar. Components inside these sections (for example the time-machine button on a historical value) are unaffected.
Wire contract:
WC-2026-10-06-consultation-only-no-section-tools.
Changed
-
The tools of a section list are now grouped in one Tools menu.
In list mode the header used to show one labelled button per tool (QR, Export, Import, Time machine, Print layout…), and the row wrapped as more tools were registered. New and Delete stay where they were; every tool now sits in a single Tools menu, sorted by name, that can be opened with the mouse or keyboard (arrow keys to move, Enter to open a tool, Esc to close). A tool can keep its own button next to the menu by setting
"pinned_in_list": truein itsproperties. -
The audio/video viewer window now fits the media: no size jump on play, no black bars.
Opening a video from a list used to show the poster at one size and then shrink the player to the stream's size when playback started, pinned to the left with black space around it. The viewer now fills its window: poster and playback share the same box, the popup resizes itself to the media's aspect ratio, and the download button no longer covers the player controls.
-
A slow server shows one quiet progress bar instead of a pile of warning bubbles.
Before, every request that took more than about 2.5 seconds raised its own yellow "Awaiting for busy server" bubble. A page that loads several things at once stacked several identical bubbles, and they stayed on screen after the answer had already arrived. Now a slow answer shows a thin moving bar along the top of the window after 1.5 seconds, adds one short sentence ("The server is taking longer than usual…") only after 8 seconds, and disappears as soon as the last answer arrives. Background calls never trigger it, and neither do long operations that show their own progress (backups, rebuilds, updates). Identical notices in the notification corner now merge into one, with a ×N count. See the slow-server cue.
-
A tool that opens in a dialog appears at once, even on a slow connection.
Before, clicking a tool button on a component or section (for example "Propagate component data") showed nothing until the tool's program files and styles had downloaded. On a slow network the page looked frozen for several seconds, and a second click could open the tool twice. Now the dialog opens on the click with the tool's name, icon and a loading spinner, and the tool fills in when it is ready. Clicking again while it loads does not open a second copy, and closing the dialog before it finishes loading cancels the tool cleanly.
-
The password field shows its requirements and says clearly whether a password was saved.
Before, the password field of a user record was an opaque box: a rejected password only turned the border red, without saying why, and nothing confirmed a successful change. Now the field lists the password requirements and ticks each one as you type, asks you to repeat the new password, and saves only when you press Save (or Enter) — then says Password saved, or why it was not saved. A password typed but not saved is marked as such, and leaving the record or closing the tab asks before discarding it — as it now does for any change the automatic save could not store, which was previously dropped without a word.
The same requirements now apply everywhere a password is set — this field, the login screen's password recovery and the installer's root password — and the server enforces them too: at least 8 (and at most 64) characters, with a lowercase letter, an uppercase letter and a number, no
&, no common words such as "password", and no runs likeabcdor1234. Existing passwords keep working; the rules apply when a password is changed. A program that sets passwords through the API receives avalidation.password_policyerror naming the first rule the password breaks.Wire contract:
WC-2026-09-30-password-policy-enforced. -
Tools no longer show a “Developed by” footer; Update cache uses the standard tool action button.
Update cache, PDF extractor and QR ended their panel with the tool icon and a “Developed by Dédalo team” line — noise below the work area. It is gone; the tool's identity stays in its header.
Update cache's action button now follows the shared tool style: the tool colour with its contrast-checked label colour, a reload icon, and the label Update. The number of records it will process is shown beside the button (“Records: 8583”) instead of being joined into the label.
-
The history of a field that links records is one timeline, whatever the working language.
A field that links records — a portal, a select, a check box, a list of informants — holds links, and links have no language, even when the field's definition is marked translatable. Its time machine history is now always one timeline: every change is one entry holding the whole field and its dataframe frames, shown in the history of every language, and restoring it or reverting a batch run puts back exactly that field, whichever language you work in. Before, such a field marked translatable filed each change under the language of the page that saved it. Language entries remain for text fields. Older entries a previous version filed under a language are part of the same timeline and are listed in every language too. The time machine no longer offers a language choice for such a field, and its restore confirmation says the whole field is replaced.
Wire contract:
WC-2026-09-27-bulk-revert-undo-log. -
Tools work on phones
On a phone (screens up to 600px wide), 34 of the 37 tools now display and work: the page no longer scrolls sideways, buttons and fields are large enough to tap, titles no longer break mid-word, and tools that open in a dialog fill the whole screen. Wide tables (such as the label translation matrix) scroll inside their own box, and the thesaurus tree has larger tap targets.
Tools that relied on dragging (cataloguing and coin ordering) gain a touch alternative: tap a record to pick it, then tap the term or slot to place it.
Also fixed along the way: the site-builder tool could not be opened from its maintenance widget; the subtitles tool crashed on a record with no subtitles yet; the section list became wider than a phone after returning from a record with a pinned semantic search; the AI assistant panel now fills the screen on a phone.
For tool developers: every tool built from the template could not reach its server (
tool_requestwas missing from the standard wiring);wire_toolnow provides it. New tools are entered in the phone check automatically. -
Tool windows and dialogs: readable buttons and one consistent header
Every tool keeps its own colour, but that colour no longer makes text hard to read.
- Buttons painted in a tool's colour now always pair it with a readable label colour (at least 4.5:1, in the light and the dark theme). Before, some tools put dark text on their colour — the Replace and Delete buttons of Propagate component data were barely legible on red.
- Headers look the same in every tool and every dialog, whether the tool opens in a dialog or in its own window: a light bar with the tool's colour as a thin top edge and a faint tint, the same shadow, and the title in the normal text colour. Plain dialogs (confirmations, record pickers) use the same header instead of a dark bar. Several tools that showed the generic orange edge now show their own colour.
- The dialog's minimise and close buttons are larger click targets and show a visible outline when reached with the keyboard.
Added
-
A new tool imports journal articles from any OAI-PMH/OJS source directly into Publication records.
Paste a journal's OAI-PMH URL (or a normal OJS article/journal URL) and the tool lists every publication it found — title, authors, series, year and page range — so you can narrow the list before committing. Kept publications become real records: the series is found or created and linked, each author is found or created and linked by name, the article's abstract is written in each language the source carries that your installation uses (a variant in any other language, or a second one for a language already written, is left out and named in the summary rather than stopping the import), and the PDF is fetched and attached when the source links to one. A publication already imported before is skipped rather than duplicated. For a journal that blocks automated fetching, you can instead save its OAI-PMH response from your browser and upload the file. An article whose metadata cannot be fetched is listed with the reason, and the rest are still shown. A commit running in the background can be stopped partway through — everything imported up to that point is kept, and the summary tells you how much of the batch landed.
Wire contract:
WC-2026-10-07-acquisition-tools-ts-only. -
A new tool imports coin-auction lots (jesusvico.com, biddr.com, aureo.com, numisbids.com, sixbid.com) directly into Numismatic Data records.
Paste an auction or single-lot URL from one of five supported houses and the tool fetches the page, shows every lot it found (weight, diameter, grade, images and all), and lets you narrow the list before committing. Kept lots become real records: the auction and coin type are found or created and linked, images are cropped and uploaded, and a lot already imported before is skipped rather than duplicated. For a page the house blocks from automated fetching, you can instead save it from your browser and upload the file. A commit running in the background can be stopped partway through — everything imported up to that point is kept, and the summary tells you how much of the batch landed.
Wire contract:
WC-2026-10-07-acquisition-tools-ts-only. -
The RDF import fills the record again, and links or creates its related records.
The RDF import only fetched and showed a resource's RDF; nothing reached the record. It now imports as it did in Dédalo 6: the resource is mapped through the External Ontology an administrator described, and its labels (in every language the source gives), descriptions, dates and places are written into the record. Related authorities (a mint, a material, a person) are linked: an authority some record already carries is linked without being fetched; a new one is fetched from its own site, created and linked. The import only fills: a field that already has a value is never overwritten, and running it twice changes nothing. Each run that changes something is one bulk process, so it can be reverted; a run that changes nothing leaves no bulk process behind. One refused value never costs the rest: a link or a field the engine refuses is listed as skipped, with the reason, and everything else of the resource is still written. A link the External Ontology maps to a section the field does not accept is skipped before anything is fetched or created, and the reason names the ontology node to fix. The result now lists what was created, what was written and what was skipped, and why. An authority that does not fit in the 15-second budget is reported not fetched — run again: the next run completes it. Only web (
http/https) addresses from the source are written or linked. An authority is looked for in every project, so one another project already holds is linked, never duplicated. A new authority is also looked for by its equivalent addresses (the same concept on Getty, Wikidata…): when exactly one record already has one, that record is linked and given the authority's address, instead of a duplicate; when several do, nothing is linked and the result names them. The same address underhttp://andhttps://counts as one. A creator (or any record between this one and an authority) is only created together with its link to that authority, and a source text carrying Dédalo tag syntax is never written. A record is never created without the identifier that finds it again: when that value cannot be written, nothing of the record is created and the line says why — nor of a chain of records leading to it (a creator and the person under it): when one of them cannot be created, none is. An authority the import created stays when its link is refused for another reason than the External Ontology (the field is full, the term is not selectable): it is a complete record, linked by the next run that may. See RDF import.Wire contract:
WC-2026-10-01-rdf-ontology-import. -
The CSV import can now add a column's values to what a record already holds, instead of replacing them.
Until now every column of a CSV import replaced the component's data, and an empty cell cleared it. Each mapped column now has a Mode: Replace (the default, unchanged) or Append. In append mode the file's values are added after the stored ones and nothing stored is changed: related records are added next to the existing links, a geolocation cell becomes a new map layer, a text becomes a new paragraph, and an empty cell leaves the record untouched. Values already present are skipped and counted in the report, so importing the same file twice adds nothing the second time. Components where adding has no meaning — media, single-choice lists, computed values — refuse append before anything is written. An append records the same Time Machine entry per language as a replace import. See Adding instead of replacing.
Wire contract:
WC-2026-09-27-csv-import-append-mode.
Fixed
-
Thesaurus: adding a child to a term now shows its expand arrow at once.
Adding a child to a term that had none opened the new record in its edit window, but the parent term kept looking like a leaf (no expand arrow) until the page was reloaded. The parent now shows the arrow and its open children as soon as the child is created.
-
The error page shows its Raspa background again.
When a page could not load (An unexpected error occurred, with its
request_idand a Home link), the faded Raspa photo meant to fill the background was hidden behind the page's grey background, so the page looked bare. It now covers the whole window again. -
Confirm-dialog buttons keep the space between icon and label on a phone.
On narrow screens the accept button of a confirmation dialog (e.g. Continue when creating or duplicating a record) printed its icon almost touching the label. It now keeps the same gap as on desktop.
-
The CSV import's column mapper shows a real value from the file as each column's Sample data, not the column's own name.
When a CSV file was staged in Import CSV, the Sample data cell of every row in the columns mapper repeated the column's name (the same text as the Name cell), because the preview was taken from the file's header line. It now shows the first non-empty value of that column in the data rows — the second line of the file, or a later line when that cell is empty — so you can check that each column is mapped to the right component before importing. Nothing was imported differently: the preview was the only thing affected.
-
The CSV importer recognises the common record columns again (created and modified by/date, and the rest of the record information).
A CSV exported from Dédalo carries the record information every section shares — who created and last modified the record and when, and the other fields of the Record information group. On import, those columns came up unchecked and could not be chosen in the column mapper, and the preflight check reported them as "not a component of section", so a re-import could not restore a record's history. They are listed again after the section's own fields, matched automatically by their column name, and imported with the dates and users the file carries (the record's "modified" stamp is not overwritten by the import itself). As before, these columns are offered to global administrators only, can only replace a value (never append to it). Computed fields, which store nothing of their own (the group's inverse references, a thesaurus's children list, indexations, external-service fields), are no longer offered in the column mapper at all; a column map that names one anyway is refused with an explanation. The Activity and Time machine sections, which have no record information, do not list them.
-
The pinned-records chip of a section list tells where the pins come from and disappears when they are cleared.
Going back to the list after creating a record showed "1 results pinned by meaning search" although no meaning search was run. The chip now reads N records pinned unless a meaning search really pinned the list, and its text stays on one line. Clearing the pins with its ✕ or Show all now removes the chip at once instead of leaving it over the unfiltered list.
-
The "Show buttons" label no longer covers the search buttons on a phone.
On narrow screens the vertical Show buttons label of a section list (and of the thesaurus/ontology toolbar) printed over Search and Show all. Below phone width only the arrow toggle is shown; its name is still read on hover and by screen readers.
-
The MARC21 and Zotero imports refuse a computed field, and an AI agent can remove one child of a thesaurus term.
A field whose value is computed and never stored (a term's children, an inverse or index list, an external record) can no longer be the target of a MARC21 or Zotero import map. Before, an import mapped onto a term's children could quietly move records under another parent in the thesaurus. Now the whole import is refused before anything is written, and the message names the field and tells you what to import instead (for children: the parent, on the child records). The CSV import already worked this way.
An AI agent using the save tool can now remove one child from a term by naming the child record. Each removal is checked against the agent user's permissions and recorded in the Time Machine. On every other field, a remove still has to name the item id.
Wire contract:
WC-2026-10-02-relation-children-write-through,WC-2026-08-30-remove-requires-item-id. -
Error messages now show their details instead of placeholders like
{section_tipo}.About twenty error messages showed their placeholders literally, for example The link into '{section_tipo}' was refused ({constraint}) or Your daily AI budget is used up ({budget_kind}: {limit}). They now show the actual values: the section, the limit, the file size, the action that was still running. The affected messages include link refusals, the AI budget, export limits and quotas, duplicate-request notices, image and file size limits, and unknown API actions.
-
Adding or removing a term's children from its Children field now saves.
A Children field lists the records that name this one as their parent. Linking a record there, removing one, or emptying the field looked accepted but changed nothing: the field showed the old children again and no record was re-parented. Each change now updates the Parent field of the child records themselves, as the thesaurus tree does — a new child gets this record as its parent (and its place at the end of the siblings), a removed child loses it. Each child's change is checked against your permissions on that child, recorded in its history, and undone by Revert the bulk process when it was part of a batch run. A link that would make a record its own ancestor is refused with an error, and the children cannot be reordered by dragging in this field (each child keeps its own order, as in the tree).
Three batch tools no longer touch a Children field: Update cache skips it (it holds nothing to regenerate), Propagate component data refuses it, and a CSV import refuses a column mapped to it — import or propagate the Parent field on the child records instead. Administrators can remove the leftover bytes the old behaviour stored with
bun scripts/relation_children_orphan_sweep.ts(a dry run that lists them; add--applyto remove them).Wire contract:
WC-2026-10-02-relation-children-write-through. -
A language field whose stored language is no longer a project language shows it again.
When a record stores a language that was later removed from the project languages (for example an "Original language" saved before the list changed), the edit form's language picker showed no selection, as if the field were empty. The picker now lists that language as an extra option marked with an asterisk ("French *") and keeps it selected, also right after a save. The name is shown in the interface language, the same as in list view.
Wire contract:
WC-2026-10-02-select-lang-missing-entry. -
The subtitles tool opens again instead of failing with an error.
Opening the subtitles tool from an audiovisual transcription showed an error instead of the tool. It now opens, with its play/pause, auto-rewind and tag-insert key settings working.
-
Browsers that ran Dédalo v6 no longer load stale v6 scripts after the upgrade to v7.
A browser that had used Dédalo v6 kept v6's file-caching service worker after the installation moved to v7. That worker went on answering the browser's requests for Dédalo's scripts with the old v6 copies, so the v7 interface loaded a mix of v6 and v7 code and misbehaved — and the browser could not replace the worker on its own.
Now the first visit to v7 removes the v6 worker and its cache, and reloads the page once with the v7 code. Nobody has to clear the browser cache by hand, and saved preferences stay where they are.
-
Autocomplete searches work again in pickers with a related-record field, and the field inputs search as you type
In an autocomplete whose search fields include a related-record field (for example the ontology "Sobrescritura" picker, with its "Modelo" field), typing in the main search box answered "No se ha podido completar la búsqueda". Such a field is now searched through the values it shows, each with its own input under the search box ("Modelo › Término", "Modelo › Código"): "section" there finds the terms whose model is section. A related-record field that shows nothing searchable (only an image, for example) no longer gets a search input.
The per-field inputs under the search box (Término, Código, tld…) also search on their own a moment after you stop typing; before, the search waited until you moved to another input.
Wire contract:
WC-2026-10-01-relation-search-display-paths. -
Result lists spanning several sections show every row again
In a result list whose records come from several sections — the ontology "Sobrescritura" picker searching across all ontology sections, for example — records of different sections that share the same number (Andorra 1, Portugal 1, Costa Rica 1…) rendered as empty rows ("
, , ,"), with only the last of them showing its values. Every row now shows its own values. -
RDF import now works with linked-data servers that redirect, and respects their robots.txt.
Linked-data servers usually answer an IRI by redirecting to the document that describes it (or from
httptohttps). The RDF import refused every redirect, so on those servers each import failed. It now asks the IRI itself for RDF/XML and follows the redirects, checking every step. Servers that only answer at the IRI with.rdfappended still work: the tool tries that form when the first answer is not RDF/XML. The tool now also reads each site'srobots.txtand spaces its requests to the same site a few seconds apart. A site that does not allow automated access gets a per-IRI message saying so, and nothing is fetched from it. The tool's IRI list was empty, so nothing could be imported at all; it now lists the record's IRIs again. The tool now always shows why an IRI could not be imported; before, a failed IRI only showed Empty results. When a remote server does not answer within 15 seconds (or drops the connection, or reports an error of its own), the result says that server is out of service and to contact its maintainer, instead of waiting until the request times out. One run fetches at most three IRIs. See RDF import.Wire contract:
WC-2026-10-01-rdf-harvest-door. -
Small editing forms no longer show tool buttons they turn off.
Some forms turn off the tool buttons on their fields, but the buttons were still shown. Now they are hidden where the form asks for it:
- the dialogs for editing a saved Export preset or a saved search preset;
- the fields of the User administration tool (the user image keeps its buttons, so a picture can still be uploaded).
A field whose own configuration sets these options keeps its configured settings.
-
A duplicated record never points at the original record's image or document files.
Duplicating a record copies its image, audio, video and document files to the new record. If a copy failed, the new record could keep pointing at the ORIGINAL record's files, with no message anywhere — and deleting either record later moved files the other still showed. The duplicate is now saved with no file list of its own, the files are copied (into the record's named folder when the media field stores its files by a folder name taken from another field), and the new record's file list is then built from the files it really has. A copy that did not complete is reported to the administrator (the
duplicate_media_incompletecounter and amedia.operation_failedline in the server log); the duplicate itself is still created.Wire contract:
WC-2026-09-30-media-key-locked-transform. -
Export — a marker shows where a dragged column will land.
In Export, dropping a field just below the Active elements list put it above the last column instead of at the end. Now, while you drag a field or a column over the list, a line shows exactly where it will be placed: before or after the row under the pointer, depending on which half of the row you are over, and at the end when you are below the list. The rows no longer shift while you drag.
Dragging a field that is already in the list highlights the existing column instead, and the drop is refused. An empty list shows a "Drag a field here" area.
-
Export preset editor — the name field no longer offers a second value.
In the dialog for editing a saved Export preset, the name field showed an "add value" button, as if a preset could have several names, and its floating button bar overlapped the dialog title. A preset has one name: the button is gone, so nothing covers the title any more.
-
Export presets panel — one-line rows, lighter design, click a row to apply.
In Export, each saved preset showed on two lines, with its delete button wrapped below and blank cells in between. Each preset now fits on one line, and the name gets the full width: the edit and delete buttons appear when the pointer is over the row (they stay visible on touch screens). The same cause (the list received every configured column instead of only the name) is fixed for the search presets list too.
The panel is lighter, so Export stays the one prominent button: a neutral header with the tool colour as text, an outlined Save changes button, and a tinted row with a filled ring for the applied preset. Header, button and rows share one left margin. Click anywhere on a row to apply the preset. Deleting a preset now asks in an application dialog that names the preset.
-
Dialogs with an edit form leave room for the field's button bar.
When a dialog shows a record to edit (editing a saved preset, or opening a linked record from a portal, a select list or a dataframe), the floating button bar of the active field went under the dialog title when that field was the first one. The form now starts a little lower, so the bar is always fully visible.
-
A tall dialog title no longer covers the dialog's content.
When a dialog's title took several lines (a long tool name and description, or a small screen), the title bar kept a fixed height and the extra lines covered the top of the dialog's content. The title bar now grows with its text, also after resizing the dialog by hand, and the content always starts below it. Tool titles also use the space better: the description sits beside the tool name when there is room and moves below it when there is not, instead of squeezing both into narrow columns.
-
The Ontology parser button in the Ontology area is readable again
In the Ontology area, the Ontology parser button was painted solid green with dark grey text and icon, which made it hard to read. It now looks like the other toolbar buttons next to it (Search, Show all): outlined at rest and highlighted on hover.
-
Restoring or undeleting a record now updates every list and search that shows who references it.
Some fields are filled in automatically from other records — for example a thesaurus term that lists every object indexed with it, or a broader-term search that finds an object indexed with a narrower term. Restoring a record from the Time Machine, undeleting it (from the Time Machine or by reverting a bulk operation) and recalculating such an automatic list did not always bring these up to date: an undeleted object could stay missing from the term that indexes it, an undeleted term could come back listing objects that no longer point at it, and a broader-term search could miss an object until someone saved it again. Every way of writing a record now brings them up to date, right after the change is saved. During a CSV import, the automatic lists are updated after each row is committed; a failure there is reported to the administrator (the
observers_propagation_failedcounter and the server log) and repaired by the observer reconcile, and it no longer undoes the imported row. A duplicated record no longer matches a broader-term search for terms only its original is listed under. Reverting a bulk revert that brought back a term together with the objects indexed with it now deletes them again, instead of keeping them as records someone else changed. Restoring an automatic list from its Time Machine history (or reverting a bulk operation that changed one) no longer brings back objects that have stopped pointing at the record since: the restored list is recalculated right after the restore, and the objects still listed keep their extra data. When such a list is itself shown in another record's automatic list, an object that stops pointing at a record now also leaves that second list. Extra data attached to the entries of such an automatic list (for example a rating on each object a term lists) now stays with the right object when the record is undeleted, restored or duplicated: it used to be reattached to a different object after an undelete, kept for objects no longer listed after a restore, and copied onto the duplicate. For a virtual section that has a field of its own, deleting a record's data now also empties its automatic lists, and restoring it recalculates them.Wire contract:
WC-2026-09-30-record-write-obligation-ledger. -
Tool action buttons show a spinning ring while working, not a solid disc.
The main action button of several tools (Update cache, Diffusion, Hierarchy, Import RDF, Ontology, Ontology parser, Propagate component data) showed a still, solid disc while its process ran. It now shows the rotating ring used by every other button.
Update cache: pressing Update now scrolls to the progress panel as soon as it appears, instead of leaving it below the component list, and the button label is white in the light theme.
-
The Update cache tool scrolls to its result
When an Update cache run finished, its summary (or a refusal) appeared below the components list, out of sight on a long selection. The tool now scrolls to it, with the same mechanism the Update code and Update ontology panels use.
-
Reverting the same bulk run a second time no longer reports records as "not reverted" when nothing changed.
Reverting a bulk revert, or a run whose dataframe removal had emptied a record, and then repeating that revert used to report some records as cascade_delete_not_reverted, as if someone had edited them. It happened when the record carried values the run itself had written, even though nothing had changed. A repeat now reports them unchanged.
A value someone really did edit after the revert is still reported as before: at the record, and also at the field when the edit touched the very value the run wrote.
Wire contract:
WC-2026-09-27-bulk-revert-undo-log. -
Removing a dataframe frame no longer deletes the frame's target record.
Since the late-September update, removing a frame (a valuation rating, say) from the frame window, or removing the value it qualified, deleted the frame's target record whenever the ontology slot carried the old
hard_delete: trueflag. That flag was retired on purpose in v6, because the Time Machine needs the target to show past states. It is ignored again: a removed frame is only unlinked, and its target record stays.Targets deleted during that window can be recovered from the Time Machine. Ontology authors who want frame-private targets emptied on unlink can set
"dataframe": {"delete_policy": "delete_target"}on the slot. The data is cleared and the record is kept.Wire contract:
WC-2026-09-29-dataframe-hard-delete-retired. -
Choosing an option in a radio-button field no longer makes the field flicker.
Clicking an option used to dim the whole field while the value saved and, for a moment, show both the previous and the new option as selected. The new option is now highlighted at once and the previous one cleared. Any field that saves on a click (radio buttons, check boxes…) only dims when a save takes noticeably long.
-
Applying a Time Machine value no longer breaks a record's rated portal.
A portal whose items carry a rating (the coloured chip of a dataframe, such as the certainty of an attribution) could stop rendering right after the Time Machine tool applied an earlier value to it: the refresh failed and the portal stayed broken until the page was reloaded. The server now sends the rating's list of options with every copy of the rating it returns, so the chip always finds its colour, and the portal refreshes normally after an apply. The client no longer depends on it either: it picks the copy of the rating that carries the options, keeps new items in the order the server sent them, and paints the default colour instead of failing when a rating has no options.
Wire contract:
WC-2026-09-29-select-family-mode-datalist. -
Searches through a related section now answer negations and combined conditions correctly.
A search condition that looks inside a related record (for example Movements → Municipality) now means what it says:
- Negations mean "none". "Does not contain X", "is empty" and "different from X" now return the records where no related record matches. Before, a record linked to one matching and one non-matching record was returned too, so the result was silently too large (on one installation, 38,749 records instead of 18,635).
- Two conditions on the same field joined with AND can now be met by different related records: "Municipality = Madrid AND Municipality = Valencia" finds people with one movement to each. Before it always found nobody.
- Conditions on different fields joined with AND still describe the same related record: "Municipality = Madrid AND Year = 1939" finds a movement to Madrid in 1939, not one to Madrid and another in 1939.
- A number search "different from n" now returns the records whose value is not n. Before, it returned the records whose value is zero.
Wire contract:
WC-2026-09-29-search-deep-leaf-mixed-rule,WC-2026-09-29-number-not-equal. -
The time machine shows a field's frames and their values as they were at the chosen change.
In the time machine, the preview of a field with a dataframe could show today's frames instead of the ones of the chosen entry, and a frame's own values — a role, a rating and its colour — were always shown as they are now. The preview and the history list now show the frames and their values as they were at that change: an entry from before a frame was added shows no frame, a rating edited later shows its earlier value, and a frame record emptied since shows what it held. Switching between entries, or clicking the same entry again, never shows the previous entry's frames, and a save made elsewhere no longer changes an open preview. The history list's frame column now matches the preview for every entry, including the entries of one language of a translatable field, which showed no frames before, and each entry's frame button shows the rating colour of that entry, not the newest one. In a record's whole history (the entries of a deleted or recovered record), each field's frames show their values as they were at that entry: a deleted record shows them as they were when it was deleted, not the emptied values its dataframe policy left after the deletion. Recovering a deleted record now adds its own entry to the record's history, so the deleted record's entry keeps showing those values after the recovery. In the time machine a frame's button is now read-only: it shows the frame's label and colour, but offers no + and opens nothing — before, clicking it opened the record as it is now, editable, from a view of the past. The same holds for a user without permission to edit the dataframe.
Wire contract:
WC-2026-09-29-tm-preview-frame-children-as-of. -
A section that fails to load says why, and can be reloaded
When a section or thesaurus element could not be loaded, the red banner always suggested a permissions problem, even when the real cause was a failed request (server restarting, timeout, network). The banner now shows the actual error, keeps the permissions hint only when the server answered with nothing to show, and offers a Reload button for temporary failures that rebuilds just that element.
It also no longer appears after logging back in: when a session expired and the user re-logged, the page loaded but the "permissions" banner was painted over it anyway. The request is now re-sent once after re-login and the page builds normally.
-
Deleting a record's data now also empties every dataframe of its fields.
Delete data empties every field of a record, and now also removes the frames of every dataframe those fields had — whatever the field is: a portal, a text or number field, or a link (IRI) with its labels — even a field that held no value of its own when the frames were saved first. Before, a dataframe that was not itself a field of the section (an IRI's labels, a dataframe named only in a field's configuration) kept its frames after the wipe, attached to nothing and impossible to remove from the edit view. Restoring an older entry from the Time machine likewise removes the frames of the values it takes out, in every dataframe of the field. The history entries Delete data writes are filed in each language's own history (and the frames in the
lg-nolanentry), so every language's time machine lists the wipe. -
A field's history now keeps its dataframe with it, and restoring an entry restores both.
A field with a dataframe — informants with their role, a value with its certainty — stores the two apart, but they mean one thing. Until now the history recorded them apart as well: the field's entries held no frames, so restoring an entry left the frames as they were today. The field's history now holds both, in two kinds of entry: an entry of a language holds that language's value, and an entry marked lg-nolan holds the value that has no language (a field that is not translatable, or the base form of a name with transliterations — Augustus, beside Αύγουστος in Greek) and all the frames. A change to a frame adds ONE lg-nolan entry to the field's own history (the dataframe has none), never a copy per language, and the history of a language lists its own entries and the lg-nolan entries together. It does not matter whether the value or the frame was saved first: the preview of any entry shows the whole state at that moment — the language's value and the frames as they were — and restoring it returns that state: a language entry puts its language back with the frames of that moment, an lg-nolan entry puts its frames back. The field's other languages, and another field sharing the same dataframe, keep theirs; a frame of an item deleted since is never put back. This holds for every kind of field a dataframe can hang from — a list of linked records, a text in several languages, a transliterable name, a number, a date, a web address with its label. Translations and duplicated records record their history the same way. Reverting a batch run does the same for every field it changed, language by language. Entries recorded by Dédalo v6 are read the same way: an entry that carries frames gives its language value and the frames of that moment, and a dataframe such an entry is silent about was empty then; a v6 language entry with no frames at all takes the frames recorded before it. Removing an item of any such field — a text, a number, a date, a web address, not only a list of linked records — now removes its frames too. See Time machine.
Wire contract:
WC-2026-09-27-bulk-revert-undo-log. -
A field's history now shows the dataframe it had at that moment.
In the Time machine preview, an entry of a field with a dataframe now shows the frames the field had at that moment — the role of each informant, the certainty of each value — instead of today's, together with the field's value as it stood then, and the field's own values no longer list the frames among them. Entries recorded by Dédalo v6 show their frames too: in an entry that carries frames, a dataframe it holds none for shows empty — which is what a restore leaves there; a v6 language entry with no frames at all shows the frames recorded before it.
Wire contract:
WC-2026-09-27-bulk-revert-undo-log. -
A transliteration saved in its language no longer replaces the base form.
A field that keeps a base form and per-language versions — a person's name such as Augustus with its Greek form Αύγουστος (component_input_text with
with_lang_versions) — now stores a version saved in a language beside the base form. Before, editing the field in Greek, importing a CSV cell with both forms, or running Propagate component data or Update cache on it replaced the base form with the version, and Update cache could even create a base form that never existed. Each form now also has its own entry in the Time machine history, listed while you work in that form's language; its preview shows the base form, and restoring it puts the form back. -
Reverting a batch run now restores exactly what the run replaced, and says what it could not.
Revert the bulk process used to guess each value's state before the run from the history just older than it. Where that history was missing — values stored before the time machine recorded them, written with it off, or appended to by a CSV import — the revert emptied the field instead of restoring it; where a later edit had been made with the time machine off, it rolled that edit back. Every batch run (CSV import, bulk component edit, update cache, MARC21 and Zotero imports, and a revert itself) now records, with each change, the exact value it replaced, so the revert puts that value back. A field someone edited after the run is left alone and reported, never overwritten; records the run created are removed only when nothing else refers to them, and records the run deleted come back together with every link that pointed at them. Reverting an old import never touches a record that a later import created again at the same id. When the revert finishes, a summary lists what was reverted, what was skipped and why, and the id of the revert, which can itself be reverted; reverting the same run a second time changes nothing and says so. Re-importing an unchanged file, or repeating the same bulk replace, records nothing, portal links included, and the dataframe frames of those links stay attached. Runs made before this update are still reverted the old way, with its known fixes, and the summary marks those values as inferred; a frame edited after such a run is left alone and reported, and the dataframe frames of an old run that saved several languages of a field are restored together instead of being refused as changed. The CSV import no longer has a Save time machine history on import switch: every import can be reverted. A revert, a single-field restore or a record recovery of a hierarchical term field now also updates its broader-term search index, so a search for a broader term finds the restored value (and no longer the value it replaced) without waiting for a later save. See Time machine.
Wire contract:
WC-2026-09-27-bulk-revert-undo-log. -
A CSV import that carries the modification date and user keeps them on records whose cells embed dataframe frames.
When a CSV row carried the record's modified date / modified by columns and also a cell with embedded dataframe frames (a
{"data":…,"dataframe":…}value from a raw export), saving those frames re-stamped the record as modified "now, by the importer", overwriting the imported values. The frames are now saved without touching the stamp, so the record keeps the date and user from the file — as it already did for every other column. -
Searching through a related record (e.g. a coin's type → its mint → the mint's name) is much faster.
A filter on a field of a linked record used to scan every record of the section being searched, whatever the filter selected. On a 184,000-coin collection, searching coins by the name of their type's mint took about 3 seconds per paint (up to 9 with a cold cache). The search now starts from the few linked records that match and walks the links back, so the same search answers in a few tens of milliseconds.
The results are the same: the faster route is used only where it provably returns exactly the same records, and every other search (for example "is empty") keeps the previous route.
-
Saving a language selector is instant again and keeps showing only the project languages.
Choosing a value in a language selector (for example a record's original language) used to answer with every language the thesaurus knows — tens of thousands of entries and several megabytes — so the save could take seconds on a busy server, and the selector then offered all of those languages instead of the project's own. The save now answers with the same short list of project languages the form shows when it opens. The same list is now used everywhere a language selector's options are offered, including list filters and the state widget.
For administrators
Security
-
Root's writes under a directory another account controls now land only in the exact directory expected.
Two places that
provision applyandprovision initwrite to sit under a directory that root does not fully control. The first is a site's web log directory: on Ubuntu,/var/logis writable by thesysloggroup. The second is the API configuration files in the state tree'sshared/directories, whose parent belongs to the agent. These writes were already pinned to the parent directory, so that a rename cannot redirect them. Any root-owned directory closed to others was accepted as that parent, though. Now the parent must be exactly the directory expected. Forshared/that means its exact owner, group and mode. For the web server's log directory it means the owner, group, mode and identity thatprovision checkobserved. The parent must also be on the same filesystem as its own parent, so a filesystem mounted over the name is refused. A directory swapped in after the write began receives nothing. Init's sudo check now also reads included policy files the way sudo does. A file that is not owned by root, or that other accounts can write, is not followed, and neither is an include whose name contains%h. Each one is listed in thehost.sudoitem. An API configuration file with the wrong owner or mode is now fixed in place, where before the fix was refused. -
The publication host provisioner now refuses an instance declaration that anyone other than root could change. (action needed)
provision checkandprovision applybuild the agent's sudo and polkit rules and its services from the instance declaration, so whoever could edit or replace that file could choose which account the nextapplygrants them to. Both commands now refuse, before reading it, a declaration that is not a regular file owned by root and writable by no one else, or that sits under a directory that is not owned by root or is writable by others. The other declarations in/etc/dedalo_publication_host/, which the check between instances reads, and that directory itself follow the same rule. Action needed: before the nextcheckorapply, runchown root:rootandchmod go-won each declaration and keep it in/etc/dedalo_publication_host/.provision renderis unchanged and still works on a draft anywhere. See Publication host agent. -
Master media folders are protected whatever their letter case
The list of media folders that may be served publicly now refuses an archival master or retouched work copy written in any letter case. Before, an entry such as
image/ORIGINALorimage/Originalin the public qualities setting was not recognised as a master; on storage that ignores case (macOS disks, Windows/SMB network shares) that folder is the same asimage/original, so the full-size master files could be served to anonymous visitors. The same applies to a renamed master tier configured by your installation. If you set public qualities by hand, the refused entry is now logged at startup, and this installation's media rules are rewritten automatically at the next restart or login. If you run a separate publication server with rules generated bybun run media:publication-host-rules, generate them again and reload that server's Apache or nginx: those rules do not update themselves. -
Uploaded script files under the media folder answer "not found" on Apache
A server script file (
.py,.sh,.cgi, …) placed under the media folder was already never executed nor served, but Apache refused it with "forbidden", which confirms to anyone probing that the file exists. Apache now answers "not found", like nginx already did, in every protection mode and on a separate publication host; withoutmod_rewriteit still refuses the file. The.htaccessregenerates on its own after the update; re-render a publication host's rule files withscripts/media_publication_host_rules.ts. -
Working files under the media folder are never served, on Apache or nginx
Soft-deleted, temporary, import and CSV working files kept next to the media (
.deleted,.temp,.tmp,.import,.csv) are now refused with "not found" by both web servers, in every protection mode and on a separate publication host. Before, the generated nginx rules did not refuse them at all: a working file of a published record whose name followed the media naming pattern was served to anonymous visitors. Apache already refused them, but answered "forbidden" (confirming the file exists) and only in lower case, so on storage that ignores case (macOS disks, Windows/SMB shares).TMPwas served. The media rule files regenerate on their own after the update; on nginx, reload it afterwards (nginx -t && nginx -s reload), and re-render a publication host's rule files withscripts/media_publication_host_rules.ts. -
Every AI request now counts against a daily budget per user, and generated answers need their own permission. (action needed)
Until now any logged-in user could run the assistant, ask for generated answers and run semantic searches without any limit, so one session could exhaust an installation's model budget (or its local GPU). Every request that calls a model is now checked against the user's daily budget before the model is called: assistant runs and model tokens, semantic-search queries, and vision calls of the identification tool. When a budget is used up the request is refused with a message saying when it resets (midnight UTC). Nobody is exempt, administrators and root included. The four budgets are
DEDALO_AI_USER_DAILY_RUNS(50),DEDALO_AI_USER_DAILY_TOKENS(1000000),DEDALO_AI_USER_DAILY_EMBED_QUERIES(2000) andDEDALO_AI_USER_DAILY_VISION(50) — see the configuration reference. The day's usage of every user is listed in the new AI usage section under Administration.Action needed: generated answers over the collection now require the Generated answers tool permission (
tool_rag). Run Register tools after the update to add it, then grant it in the profile editor to the profiles that should use generated answers. Semantic search does not need it.Wire contract:
WC-2026-10-01-ai-spend-budget. -
The assistant no longer proposes changes its own apply step would refuse.
In write mode the assistant proposes a change plan that a person confirms before it runs. The check made before the plan was shown was weaker than the one made when it runs: some plans that named a record outside the user's projects, a field the user may not edit, or a read-only section were shown as valid and then failed when applied. Plans are now checked by the same permission rules that apply when they run, so what the person confirms is what the user is allowed to do.
Wire contract:
WC-2026-10-01-change-plan-write-door. -
Removing a linked record from a portal now checks the portal field and the record's projects.
Removing a linked record from a portal (the unlink button, and the "delete index" of the indexation tool) used to check only the section's permission. A profile that could edit the section but only read the portal field could still unlink from it, a record outside the user's projects could be changed, and a user manager could unlink their own profile, active or administrator flag. Removing a link now requires write access to that portal field and to the record itself (its projects), checked before anything is read or locked; a user manager can no longer unlink their own profile, active or administrator flag; and a record id of 0 or below is refused for every user, administrators included.
Wire contract:
WC-2026-10-01-delete-locator-write-door. -
Asking a vision model for proposals, or identifying a photograph with an external encoder, now requires the identification tool permission.
Proposals from a vision model and image identification through an external service call a paid model and may send the object's photograph off the server. Any user who could read the section could start them. They now require the user's profile to include the identification tool; without it the request is refused before any model is called. Matching by record, proposals voted by similar records and a locally run image encoder cost nothing and are unchanged. Grant the identification tool to the profiles that should use the vision source.
Wire contract:
WC-2026-10-01-identify-vision-grant. -
Searches through a related field only reach the sections that field links to, and that the user may read
A search that follows a related-record field (a portal or autocomplete column) checked the user's permission on the section named in the search, then read whichever section each stored link pointed to. Between sections that share a table and a field — a section and its virtual twin — a user with access to one could match values stored in the other.
A search now follows a related field only into the sections that field is configured to link to, and only into those the user may read, each under its own record restrictions. Links that point outside the field's configured sections — left over from an earlier configuration or an import — no longer match in searches, for administrators too; they already showed nothing on screen. The data is untouched: adding the section back to the field's configuration makes them searchable again.
Wire contract:
WC-2026-10-01-search-hop-configured-targets. -
The site builder's Claude Code agent no longer loads configuration from the site's own files, and refuses a Claude Code that cannot be told not to. (action needed)
Claude Code reads hooks, MCP servers, skills and settings from the project it works in and from its home directory. In a site builder workspace both are written by the agent itself (and by the site's build scripts), so a file planted in one turn ran as a shell command in the next, although the agent is denied a shell. Each Claude Code turn now loads its settings only from the site builder (no user, project or local source; only the site builder's own MCP server), and the site brief (AGENTS.md) is handed to the agent by the site builder instead of being read from the workspace. Action needed: the installed Claude Code must list
--setting-sources,--settingsand--strict-mcp-configinclaude --help(verified on 2.1.286). The site builder checks this at start and before every turn; an older Claude Code is reported in the start log and every turn is refused until it is upgraded. In the site builder tool these refusals now read "cannot run its agent safely on this server" (an administrator must act) or "busy with this site" (try again in a moment) instead of a generic error.Wire contract:
WC-2026-10-01-site-builder-confinement-codes. -
The site builder's agent can no longer choose its own agent program, read another site's activity, or stall a turn with a planted brief. (action needed)
Five gaps around the site builder's confined agent are closed.
- The agent program is chosen by the site builder. A site's agent program (Claude Code, OpenCode, …) was read from the site's own
site.json, which the agent can edit. An agent could switch its next session to another program and plant a plugin that program loads. That bypassed every restriction placed on Claude Code. The choice made when the site is created is now kept in the site builder's private state, outside every site's folder, where no agent run can rename or remove it. A site created before this update gets its record when the site builder starts, from the program itssite.jsonnamed at that moment. From then onsite.jsondecides nothing. A site with no record is refused ("no driver record") and never falls back to the instance's default program (agent.driver): restart the site builder, or start the session naming its program. - One site's agent can no longer watch another's. Each agent run already hid other sites' processes. It could still read the system's process-accounting files (
/sys/fs/cgroup), which show another site's runs, when they started and how much they used. Every agent unit now hides that directory. Action needed: runprovision applyafter updating. Until the units are re-rendered, the site builder finds them different from what it expects and refuses every agent run. - A planted brief no longer stalls the site. The site brief (
AGENTS.md) is in a folder the agent writes. Replacing it with a pipe, a huge file or a file containing a NUL byte could hang every later turn or make every turn fail. The brief is now read without blocking, only up to its size limit, and refused with a clear message when it is not an ordinary text file. The same holds for every file the site builder reads or writes in a site's folder: a pipe planted atsite.jsonor at the agent's connection settings is refused at once instead of stalling it. A turn's setup now also counts against the turn's time limit, and stopping a session reaches it. - An unusable Claude Code is reported, not hidden. A Claude Code binary the site builder cannot run (missing, not executable, or under
/home, which the site builder's service cannot see) was a generic, retryable error. It is now refused with "cannot run its agent safely on this server", naming the binary. A Claude Code check that fails once on a busy server, for example by timing out, is asked again at the next turn. It no longer refuses every turn until the site builder restarts. - Unsaved agent work is no longer lost to a failed save. After each turn the site builder saves the agent's work as a restore point. When that save failed for any reason other than the site builder shutting down, or when the restart's own recovery save failed or had to wait, the work was left unsaved and never retried. The next turn then saved it under its own name. Any save that did not happen is now retried at every start of the site builder until it succeeds.
- The agent program is chosen by the site builder. A site's agent program (Claude Code, OpenCode, …) was read from the site's own
-
A site's AI turn no longer runs commands planted in the site's git settings, and the site builder says why it refuses a run. (action needed)
Eight gaps around the site builder's confined agent are closed.
- A turn no longer runs commands planted in the site's git settings. Claude Code runs
gititself when a turn starts. It switches off git's hooks for that, but not its content filters. A build script, a git hook or the agent itself could add a filter to the site's.git/config, and the next turn then ran that filter's command as the site's user, with the AI provider's key and the museum connection. Setting git's own environment variables does not help: Claude Code removes them. Each turn now runs with the site's.gitfolder hidden, so nogitthe turn starts finds a repository. Action needed: runprovision applyafter updating. Until the units are re-rendered, the site builder finds them different from what it expects and refuses every turn. A site whose folder has no.gitis now refused a turn, with a message that names the missing folder. - The site builder reads systemd's answers correctly on Ubuntu 24.04 and Debian 12. systemd prints some settings, such as
TemporaryFileSystem=, as one line per entry. The site builder kept only the last line, so on those systems it would have refused every agent run as "not what this daemon expects". It now reads every line. - "systemd cannot say" never frees a site. When systemd does not answer a question about a run (a timeout, a D-Bus or polkit error, an answer with a value missing), the site stays held until systemd answers. It is never treated as finished or idle.
- A refused stop is named. When systemd refuses to stop a run, for example because the polkit rule is missing or polkitd is not running, the refusal and the site's "unavailable" message now quote systemd's answer and point at the polkit rule.
- A host that cannot confine runs says so at start. The site builder now checks the host when it starts: the systemd version, each site's user and groups, and the files a run starts from. If something is wrong, it writes the reason to its log at start, instead of failing the first request. It still starts, and refuses each run until the host is fixed.
- Session records and site folders are checked before a run. A session's saved settings are used only if they name that same session and site, and only from folders the site builder created itself. A site folder that has been replaced by a link is refused before any run starts.
- A broken turn setup is reported at start, not at the first turn. The git settings file root renders for every turn, and the systemd units each site's runs use, are now checked when the site builder starts and before a session is accepted. A missing or altered settings file, or an extra drop-in on a unit, is written to the log at start and refused before any work is reserved. A site whose
.gitis a link or not a folder is refused a turn, with a message naming it. - One site's damaged session folder no longer stops the others. When one site's session folder was replaced by a link, the start-up sweep stopped for every site: interrupted sessions were not marked and their work was not committed. The sweep now skips that site, logs why, and goes on with the others.
- A turn no longer runs commands planted in the site's git settings. Claude Code runs
-
The site builder runs each site's AI agent as that site's own system user, from units that root installs. systemd 248 is now enough, and
provision applymust run before the updated daemon starts. (action needed)Until now, every site of a museum ran its AI turns, builds and
gitcommands as one agent user, so one site's run could read or change another site's agent state. The daemon also asked systemd to start those runs itself, which the previous release had to stop allowing (see the entry on the narrowed polkit rule).Now each declared site has its own system user,
dedalo-a-<instance>_<n>, with a private group that only that user and the service user belong to.provision applycreates them and never reuses a number. For each site and each kind of run (turn, build,git), root installs a socket and a service template. The daemon only connects to that socket, and systemd starts the run as the site's user, which the daemon cannot choose. The polkit rule now lets the service user stop or kill those runs and nothing else. What systemd enforces:- A site never has two runs at once, and runs of different sites run as different users.
- Each run gets only its own site's workspace and its own HOME. A build cannot read the turn's
~/.claude, andgitgets no HOME. - A turn or build reaches the network through its site's egress directory under
/run/dedalo-sites-agents/<instance>/egress/. Root creates this directory at every boot from a rendered/etc/tmpfiles.d/file, and the run sees it read-only. - A run of a site whose units differ from what the daemon expects (for example, a hand-added drop-in) is refused, naming the setting. This includes a drop-in that stops a run without killing its last processes, or one that makes systemd open or mount a file for the run as root.
- A run counts as finished only when systemd reports none of its processes left, so the site's next run never starts beside a survivor of the last one.
- Stopping or restarting the daemon also stops that museum's run sockets, which then accept no new run. Starting the daemon starts them again.
Other changes:
- The daemon starts no new run once it is shutting down. A turn whose final commit was refused that way is committed when the daemon next starts.
- The oldest supported systemd is now 248. polkit must be 0.106 or newer, because the stop rule is a JavaScript rules file, and
provision applyrefuses an older one. Supported hosts are Ubuntu 24.04 or newer, Debian 12 or newer, and RHEL 9 or newer. Ubuntu 22.04 is not supported: its polkit 0.105 ignores the rule. Server and minimal installs often have no polkit at all; install it first (apt install polkitdon Debian and Ubuntu,dnf install polkiton RHEL). On systemd 257 or newer each run also gets its own process namespace. - A site's user may belong only to the instance group and its own private group.
provision applyrefuses a site user that any other group lists as a member, and the daemon refuses every run of a site user that has any other group, because a run gets every group of its user. - The
opencodeagent is refused on a host where runs are confined (AGENT_CONFINEMENT=systemd_scope, every provisioned host): it loads configuration and plugins from files a run can write, so a planted plugin would run as the site's user. Useclaude_codethere. - When the daemon is not running,
provision applyclears its failed state before starting it. A daemon that the updated code stopped in a restart loop can then be started. - If the daemon crashes on systemd older than 254, systemd does not stop its runs. The daemon stops them, or keeps their site unavailable, when it starts again.
provision applyrefuses a site user whose uid, or a private group whose gid, belongs to any other account or group on the host. It also refuses any other account whose primary group is a site's private group, and it refuses when the system id range in/etc/login.defshas no room left. The daemon checks the same things before every run.- Creating a site is refused (503) before anything is written when the daemon cannot run that site's
gityet, for example afterprovision applyadded the site but before the daemon restarted. - An agent run that was left running (for example, after the daemon was killed) is stopped before its site runs again. If it will not stop, that site stays unavailable until it does.
- A turn's own
gitnever treats the workspace itself as a repository. Root installs<state dir>/agents/turn.gitconfig(safe.bareRepository = explicit, nothing else), and every turn sees it as/etc/gitconfig, read-only. A turn is refused, naming the file, when that file is missing or says anything else, and when the workspace root contains aHEADentry. Remove a strayHEADfrom the workspace root to run the site again. - The daemon checks that it may stop its sites' runs at start and before each run, by asking systemd to stop a run that does not exist. Without the polkit rule, or without a running polkitd, every run is refused with a message that names the rule, instead of failing at the first interruption.
- A run is refused when its site's user cannot reach the runtime, the shim or the Claude Code binary (
CLAUDE_CODE_BIN), for example because a directory on the path is closed to it. The message names the directory. - The daemon's
.builder/directory in each workspace is now0710, so a turn, which runs as the site's user, can open the MCP configuration it is given there. The site's user still cannot list or change anything in it. An existing.builder/is changed at the site's next turn.
Action needed: as root, run
provision applyfor every instance before the updated daemon starts, or in the same maintenance window as the code update. This includes an update installed from within the application.- The daemon no longer starts while its environment still sets
AGENT_USER,AGENT_HOMEorSYSTEMD_RUN_BIN.provision applyremoves those keys and writesAGENT_IDENTITIES,AGENT_SOCKET_DIR,AGENT_STATE_ROOTandAGENT_IDENTITY_EPOCH. It writesSYSTEMCTL_BINonly when the declaration namesagent.systemctl_bin. Otherwise the daemon uses/usr/bin/systemctl, so declare it only on a host wheresystemctlis somewhere else. Do not add the key to the rendered environment file by hand: the nextprovision applyremoves it. - The first run of
provision applystops the daemon, gives each site's files that the old agent user wrote to the site's new user, installs the units, and starts the daemon again. - It also opens to the instance group the files and directories in each existing workspace that are owned by the service user, except
.builder/. Sites created before 2026-09-05 have these: back then turns andgitran as the service user and left.gitclosed to the group, so the site's new user could not commit. The service user keeps owning them. - It locks the old agent user without deleting it.
- It moves the old shared agent HOME aside, next to itself, as
<home>.retired-<date>, owned by root and closed to everyone else (0700). Nothing from it is copied to the new users. - Once, after the update, a conversation cannot resume its earlier context: its next turn starts a new agent session.
- Adding a site later also needs
provision apply, which restarts the daemon.
-
The thesaurus term picker grants link mode only from a profile, never from a blanket rule.
The term picker opens a thesaurus in link mode only for a user who may edit the field that asked for it. That edit right was read without asking where it came from, so a field under the editing-preset section — which every user may edit through a built-in rule, bounded only to their own presets — counted as a link-mode grant for every user. Link mode now requires edit permission granted by the user's profile (or the root account); otherwise the thesaurus opens in ordinary browse mode.
-
Translation, imports, cache rebuilds, uploads and bulk reverts now check permissions on every record and field they write.
Several tools checked a user's permission on a section and field but not on the specific record they then wrote, so the rule that keeps a user from changing parts of their own account (for example their own profile) did not apply there. Automatic translation, the poster-frame tool, the cache rebuild, file and CSV/MARC21/Zotero imports, the fields an upload fills in automatically, and the bulk revert of a process now check each record and field exactly as the edit form does. A field or row the user may not change is reported and left untouched; CSV imports now need permission on every imported column, including the creation and modification metadata columns.
Wire contract:
WC-2026-10-01-write-door-delegations. -
The assistant can no longer search or count records of a section the user may not read.
The assistant's search, count and find-or-create tools applied the user's projects but not the section permission, so a user whose profile did not grant a section could still list and count its records through the assistant. They now refuse such a section, exactly as the record list does.
Wire contract:
WC-2026-09-30-mcp-search-section-grant. -
Only profiles granted the assistant tool can use the assistant. (action needed)
With the assistant enabled on the server (
DEDALO_AGENT_HTTP_ENABLED), any logged-in user could run it, even if their profile did not include the assistant tool. The assistant now requires the profile to granttool_assistant, for global administrators too; only the root account holds every tool. Action needed: in the profile editor, grant the assistant tool to the profiles whose users should keep using it.Wire contract:
WC-2026-09-30-agent-tool-grant. -
Translation, transcription and RDF-import fetches now connect to the address the SSRF guard vetted (DNS rebinding closed); network failures report a typed reason.
The guard used to check the server's address and then let the connection look the name up again, so a hostile DNS server could answer "public" to the check and "this machine" or "the internal network" to the connection. The connection now goes to the address that was checked, with the real name kept for the certificate and the
Hostheader, for the translation and transcription services and for every RDF URI a cataloguer imports. Failures are typed instead of carrying the runtime's own error text: the RDF import reports the fixed sentence "The outbound request could not be completed" for each URI that failed (see the RDF import tool reference), while translation and transcription report a short message naming the reason, such ashop connect failed (timeout)orredirect refused (HTTP 302), never an address. A translation or transcription request (a POST) that may already have reached the server is never re-sent to the server's other address, so a failed transcription request cannot start a second job; an RDF-import fetch (a GET, safe to repeat) may be retried on the next address.Wire contract:
WC-2026-09-30-guarded-text-pinned-typed-transport. -
Posterframes, audio streams and clip downloads now respect the component's own permission and the user's projects.
The audiovisual and 3D media actions (create or delete a posterframe, attach a 3D snapshot, read an audiovisual file's streams, cut and download a clip) used to check only the section's permission. A profile that was explicitly denied the audiovisual component could still use them, and any record id could be reached even outside the user's projects. They now check the section, the component itself and the record's project, in that order, before they look at the file. A user who can see a record's video in the player can still download its clips as before.
Wire contract:
WC-2026-09-30-media-pair-scope. -
Ontology identifiers are checked on every read and write, and the database now refuses malformed ones.
An ontology node's identifiers (its tipo, its parent, its model, its TLD, and the target of a component alias) are used by the search engine to build its queries. Until now a malformed value stored in the ontology table (for example an alias pointing at a tipo that contains quotes or spaces) could reach a search query unchecked. Now every identifier must be letters followed by digits (a TLD: two or more lowercase letters), no longer than its database column: an alias with a malformed target is refused as an invalid ontology node, the search engine checks the alias target again before using it, the ontology write doors refuse a malformed node, an archive restore refuses one before writing anything, and the ontology recovery file leaves such rows out and names them. The update adds six checks to the
dd_ontologytable that refuse any malformed identifier from then on. An installation that already holds malformed rows still updates normally: the checks start in a "not yet validated" state, and the reconcileontology_identifiers(maintenance area, reconcile status, orbun run scripts/reconcile.ts) lists those rows and what it would do with each. Applying it rebuilds each row from its ontology source where one exists, deletes the rows that cannot be rebuilt (every deleted row is listed in full in the report and in the server log, even if the final validation fails), and then validates the checks. Until it is applied, reordering a malformed node is refused with a message that names the check and the reconcile to run. When an ontology source record holds a malformed reference (a parent, model or related-term pointer), the rebuild now drops that reference from the node and names the record in its message, instead of storing the malformed value or failing the whole TLD.Wire contract:
WC-2026-09-30-ontology-identifier-grammar. -
Stored password hashes are no longer sent to the browser.
Until now, anyone able to open a user record received the stored password hash of that account (and, for accounts not yet migrated from v6, the reversible legacy value) — material an attacker could try to crack offline. Every screen and API answer now shows a fixed mask (
****************) instead: it says only that a password is set. Logins, password changes and imports are unaffected.Wire contract:
WC-2026-09-30-password-hash-never-served. -
Site builder agent turns and builds run in a private network namespace and reach the outside only by hostname, through the daemon's egress gate; the Publication API key no longer reaches the agent, and AGENT_EGRESS_ALLOW is refused.
A confined agent turn used to be allowed "any" address with loopback and the private ranges denied. systemd's address filter lets the allow list win over the deny list, so that turn could in fact reach the database, the engine, the local network and a cloud host's metadata service. Every confined run (a turn, a build step, a git command) now runs in its own private network namespace with
/runhidden. Loopback, the LAN, the metadata service and the host's own sockets do not exist inside it. A turn or a build reaches the outside only through its site's socket directory, served by the site-builder daemon: an HTTPS proxy that connects only to the hostnames that run may use, on port 443, and refuses any name that resolves to a non-public address. It forwards nothing until the connection's TLS handshake names that same hostname, so a hostname behind a shared CDN is not a way to other sites on that CDN. A git command gets no network at all. The database socket directories some distributions keep outside/run(RHEL's MariaDB uses/var/lib/mysql/mysql.sock) are hidden from every run too, and each run gets its own/dev/shminstead of the host's shared one. A run cannot reach another site's socket directory: only its own site's is mounted, and runs of different sites run as different users, so a concurrent run cannot be reached through/proceither. Each blocked destination is written as one line in the session or build log. A run on a host that silently ignores the namespace setting is refused before anything starts.The hostnames are:
- Claude Code turns:
api.anthropic.com. - opencode/pi turns: the hosts named in
AGENT_PROVIDER_HOSTS. With none named, such a turn is refused, naming the key. - Builds: the hosts in
BUILD_REGISTRY_HOSTS(defaultregistry.npmjs.org).
On a provisioned host these come from the declaration's
agent.provider_hosts/agent.registry_hosts. The Publication API key now stays with the daemon, which adds it on its side of the agent's MCP connection; it is no longer written into the site workspace.What changes for an operator:
- A non-empty
AGENT_EGRESS_ALLOWstops the daemon at boot, with a message naming its replacements. - A model served on loopback or the museum's LAN can no longer be used by a turn.
- Builds can no longer reach anything on loopback or the LAN.
- A command-line tool that ignores the standard proxy environment variables has no network.
- A tool that tunnels anything but TLS naming the host it asked for (plain HTTP over port 443, Encrypted Client Hello, a handshake naming two hosts) is disconnected, with a line in the log.
- A run may hold at most 128 connections through the daemon at once. One more is refused, with a line in the log.
- An opencode turn installs its provider's package from
registry.npmjs.orgon first use. Name that host inagent.provider_hoststoo. - The daemon refuses to start any confined run (503, naming the cause) when:
- the host's systemd is older than 248, or its version cannot be read;
- its socket (
LISTEN_SOCKET) or the agent socket directory is not under/run; - it cannot read its own network namespace;
- the site has no agent user of its own on the host (see the entry on per-site agent users);
- the site builder or its bun can be changed by any site's agent user, or cannot be read or run by it (a directory above them that such a user owns counts as one it can change, whatever its permissions);
- the site builder or its bun lives under
/home,/root,/run,/tmpor/var/tmp. - A site builder and bun owned by the engine's own user, as the documented install lays them out, are accepted.
- Claude Code turns:
-
The site builder can no longer start its agent units through polkit (action needed)
The polkit rule the site-builder provisioner installs (
/etc/polkit-1/rules.d/49-dedalo-site-<instance>-agent.rules) allowed the site builder's service user to start any systemd unit whose name began with the instance's agent prefix. polkit is told the unit's name, but not which user the unit will run as. On systemd 257 or newer that meant the service user could start a unit with that name as root, so the site builder's daemon was effectively root on the host.The rule now allows only stop and kill, and only on the runs of the museum's declared sites. The daemon no longer starts any unit. A run is started by systemd from units that root installs for each site, as that site's own user (see the entry on per-site agent users, in this same release). No per-run file is written for systemd to read as root.
Action needed: run the site-builder provisioner (
provision apply) on every host. It installs the narrowed rule together with the per-site units. -
Transcription actions check the audiovisual component and the user's projects before they touch a recording.
Building the audio file for transcription, sending a recording to the transcription server, checking its status and building subtitles checked only the section's permission, and two of them skipped the check when a field was missing from the request. They now check the section, the audiovisual component (or the transcription field they write) and the record's project first, before anything is read, sent or written; every transcription path asks the same thing of the recording — permission to consult it — and write access only to the transcription field it fills, so a transcriber gets the same answer whichever engine they choose, and a user with no access to the recording gets none of them. A transcription that finishes after the user lost access to the record, or after their account was deactivated or deleted, is no longer saved. Checking a server transcription's progress now reaches only the checking user's own job on that recording, and answers its progress alone: it no longer accepts a guessed job number, and it never returns the finished text, which the server saves into the record itself.
Wire contract:
WC-2026-09-30-transcription-record-tipo. -
Every write through a tool, the assistant or the record doors now asks the same four questions, in the same order.
A write names a section, often a component and a record. Every door that performs one — the record save, duplicate and delete, the tools, the tag delete of a transcription, the assistant's write tools — now answers through one shared rule: is the record id a real id, does the profile grant the section, does it grant that component of that record (with the rule that a user cannot raise their own profile, developer flag or username), and is the record inside the user's projects. Before, some tools asked only part of it: a global administrator with write access to user passwords could reach the root account's record through a tool, a record id like
1.5orabcwas accepted, and a user could change their own profile assignment through a tool that the record editor refused. These requests are now refused. A section that is read-only by design (Activity, the Time Machine) is also refused to every tool, importer and record door that would create, overwrite or delete its records, for administrators too. A CSV import row that names a record id still free when the file was read, but taken by another user's new record while the import ran, no longer writes into that record as if it were the import's own: the row is now checked as a change to that existing record (its projects, and the rule on a user's own account) and reported as updated, not created. The same holds for a file import whose file name names a record id: if the id was still free when the import started but another user's new record took it during the import, the file is now checked as a write to that existing record (its projects) instead of being treated as the import's own new record, and is refused when that record is outside the user's projects. When the assistant is asked to find a record or create it, it now checks every field it would fill before creating anything, and a refused fill no longer leaves an empty record behind. The one exception kept is the record editor's save, and the deletion of a tag in a text field, which is an edit of that same field: as before, they ask the field's grant only, so a user can still edit a linked record's fields through a portal they may edit; your review of that exception is invited in the wire-contract entry.Wire contract:
WC-2026-09-30-write-door. -
Outbound fetches now refuse every IPv6 route to an internal address. (action needed)
When the server fetches a URL on a user's behalf (an RDF import, an external catalogue lookup, a translation or transcription service, a harvest), it first checks that the address is on the public internet, so a user cannot point it at your internal network or at the cloud metadata endpoint. Until now several IPv6 forms passed that check although they lead to an internal address: the NAT64 prefix
64:ff9b::/96(on an IPv6-only host this reaches169.254.169.254), 6to42002::/16, the IPv4-compatible and IPv4-translated forms, the old site-local range, multicast, Teredo, and any address carrying a zone (%eth0). The documentation ranges (192.0.2.0/24,198.51.100.0/24,203.0.113.0/24,2001:db8::/32) were also accepted.Now all of them are refused. An IPv6 address is accepted only inside the global unicast space. One that carries an IPv4 address through the IPv4-mapped form or NAT64 is judged by that IPv4 address, so on an IPv6-only install public sites stay reachable. 6to4 and Teredo are tunnels and are refused whole.
Action needed on an IPv6-only server behind a NAT64 translator that uses its own prefix (a network-specific one, or one taken from the local-use block
64:ff9b:1::/48or a unique-local range): IPv4 sites are unreachable from it until you declare the translator's prefix, exactly as it is configured, in the new settingDEDALO_NAT64_PREFIXES(for example2001:db8:64::/96or64:ff9b:1::/96). Addresses inside it are then judged by the IPv4 address they reach. The server also asks the network for its NAT64 prefix on its own, but uses the answer only to refuse more, never to allow more. Nothing to configure anywhere else.With
DEDALO_TRANSCRIBER_ALLOW_PRIVATE_HOSTSon, an on-premise transcription server may still not sit on IPv4 link-local (169.254.0.0/16) or on another cloud's metadata address (100.100.100.200,192.0.0.192, and the IPv6 metadata servers of AWS,fd00:ec2::254, and Google Compute Engine,fd20:ce::254), in any spelling — through a local-use NAT64 address (64:ff9b:1::/48) included.Action needed if an address allowlist (
DEDALO_INSTALL_ALLOWED_IPS,DEDALO_ERROR_REPORT_ALLOWED_IPS) has an IPv4 part with a leading zero (127.0.0.01,010.0.0.1): such an entry is no longer read as a number and matches no client, because some systems read it as octal — so the installer or the error report refuses that client until you rewrite the entry without leading zeros. The install allowlist line the server logs when it starts names such an entry as ignored. An entry now matches its address in every spelling:2001:db8:0::1matches a client reported as2001:db8::1,127.0.0.1(or theloopbacktoken) matches::ffff:7f00:1, the form a dual-stack listener may report, and a block written in the IPv4-mapped form (::ffff:10.0.0.0/104or::ffff:a00:0/104) is the IPv4 block it spells (10.0.0.0/8).
Changed
-
The guided publication-host install now proposes the v1 database connection it finds on the host.
provision initasks how the v1 Publication API reaches MariaDB: through its unix socket or over TCP. Until now the proposed answer was always the socket, even on a host without a local MariaDB. Init now looks for a local MariaDB socket (/run/mysqld/mysqld.sockon Debian and Ubuntu,/var/lib/mysql/mysql.sockon RHEL, Rocky and Alma). It proposes that socket when one exists, and otherwise TCP to127.0.0.1:3306, saying whether anything listens there. The question is still yours to answer. For TCP the proposed host is127.0.0.1, neverlocalhost, because v1's database driver readslocalhostas "use the socket" whatever the port. See Publication host agent. -
New installations are connected to the official update server and always have the Languages thesaurus; a server restarted by systemd or Docker must now declare
DEDALO_SUPERVISED=true. (action needed)The command-line installer, the browser wizard and
install.shnow work from one install plan, so the same answers give the same configuration whichever one you use (installer reference).- Update servers are written by default. Installs used to leave
ONTOLOGY_SERVERSandCODE_SERVERSout of/private/.env, which meant no ontology or code updates were ever offered. The installers now write the official Dédalo server (v7.master.dedalo.dev) into both. For an air-gapped installation, answer no to the update-server question, or pass--no-update-serversto the command-line installer. Both keys are then written as[], and you can add them later. Re-running the installer does not overwrite update servers you already set. An earlier air-gapped[]is not kept, though: answering yes on a re-run switches the installation back to the official server. - Languages is always active. The Languages thesaurus (
lg) is switched on together with the database on every install. It is no longer a choice and is never imported, because its terms already ship with the installation database. - Default working languages: English and Spanish. All three installers default to
lg-engandlg-spa. The wizard used to pre-tick the whole language list; the other languages are still offered, but they are optional and you tick them yourself. - One default set of optional thesauri. All three installers start from the same default: today that is Spain (
es). The wizard no longer pre-ticks France. The command-line installer used to install no thesaurus by default and now installs this set; use--hierarchies noneto skip it. A thesaurus that fails to install now stops the install before anything is sealed. Unknown command-line flags (including the old--yes) are refused, and the database host defaults tolocalhostinstead of/tmp. - Supervision must be declared (action needed). The code update panel only replaces the code tree when something will restart the server afterwards. It used to guess this from systemd's own variables. Those variables are also inherited by terminal shells in desktop sessions, so an unsupervised
bun run startcould look supervised: the update would install the new code, stop the server and leave it stopped. The guess is gone. Now the code update runs only when the process manager declaresDEDALO_SUPERVISED=true, and the shipped systemd units, compose stacks and thedev/dev:server/start:supervisedscripts all do. If your systemd unit or compose file was written before this release, addEnvironment=DEDALO_SUPERVISED=trueto the unit's[Service]section, orDEDALO_SUPERVISED: "true"to thededaloservice'senvironment:. Setting it in/private/.envhas no effect, because every launch method reads that file, including the unsupervisedbun run start(updating the code).
Wire contract:
WC-2026-10-08-install-plan-update-servers-core-lg. - Update servers are written by default. Installs used to leave
-
"
provision checknow refuses a publication host where a service could not read or run its own code, and anengine_groupthat is one of the instance's own groups."Until now, a home directory left at Ubuntu's
0750, an agent copy that only root could read, or a site Bun the v2 account could not execute passedcheckandapply, and the service failed later with Permission denied.checknow judges every path a service runs from with that service's own user and groups: the agent's code and every directory above it, the site's Bun for the agent and the v2 API, the binary that checks v1 releases, and the way down to the state root for the agent, v2 and v1. Each refusal names the account, the path and thechmodthat fixes it. On one machine it also refuses anengine_groupthat is the agent's own group, or the group of the v1 or v2 accounts: the agent's socket would then be closed to the work system. Whether the work system's user is in the declared group is still proved by the socket request of step 7.checkreads only the mode bits, so a host that grants access through an ACL alone is now refused: give the access with the mode instead. See Publication host agent. -
The Ontology area can now be opened by global administrators whose profile grants it, not only by root.
Until now only the root account could see the Ontology area in the menu or open it. It now also opens for a global administrator whose profile grants the Ontology area. Both are needed: a global administrator without that permission does not see it, and neither does a user who has the permission but is not a global administrator. Inside the area, the profile decides what the administrator sees: each ontology (for example
dd,rscor a local one) appears only if the profile grants read access to it, and its records and fields follow the usual read and edit permissions. Root still sees and edits everything. To give someone access, grant the Ontology area and the ontologies they should work on in their profile.Wire contract:
WC-2026-10-07-ontology-area-admin-grant. -
Preparing the publication host agent now installs exactly the locked dependency versions, and only the ones it runs with.
bun run hostagent:installnow runs a frozen, production-only install: it uses the committed lock file exactly, stops if the lock and the package list disagree, and leaves out the development tools, so they no longer reach the publication server, andprovision checkrefuses an agent copy whosenode_modules/holds one. Contributors who run the agent's test suite prepare the tree withbun run hostagent:install:devinstead. See Publication host agent. -
"
provision checkno longer reads as an error when it lists the changes to make."A first
provision checkon a new publication host lists the changesapplywill make. It used to exit with code 1, sobun runprinted error: script "provision" exited with code 1 under a perfectly normal report. It now exits 0 and ends with run 'apply' to make them. Scripts that need to know whether changes are pending pass--exit-code, which keeps the old code 1. Refusals (3) and failures (4) are unchanged. See Publication host agent. -
The in-browser AI runtime is updated to transformers.js 4.3.0, running on the exact ONNX Runtime build it was made for.
Browser-side transcription, translation and background removal run on transformers.js, which is now 4.3.0 (WebGPU on Safari 26 and later, plus fixes to Whisper's progress reporting). Its ONNX Runtime is now exactly the build that release was made and tested with (
onnxruntime-web1.31.0-dev.20260914). Before, the installed ONNX Runtime was a different version from the one the bundle was built against. That combination worked, but nobody had tested it. The two versions are now checked against each other on every build and always update together. Nothing to do on update. Models you already downloaded keep working. -
The code-update panel now leads with its verdict and the update button; the reference facts fold away.
The Update code maintenance panel used to list every fact at once: installation details, all eleven readiness checks, the last update and every restore point. It now shows the readiness verdict and only the checks that need attention (refusals, warnings, a pending backup waiver), followed by the server picker and the update button. This installation, All checks, Last code update and Restore points are folded on a one-line summary (version and build type, per-state counts, the last update's outcome, the number and date of restore points) and open with a click; each fold remembers whether you left it open in this browser. A last update that is still pending or was rolled back opens by itself. The developer-builds warning is shown once that option is ticked.
-
The maintenance Unit test area only offers the JS test runner and the test-table reset where they can work.
Open JS unit test is shown only on a development server (
DEDALO_DEV_MODE) whose dev dependencies are installed. A production install (the default Docker image, or one kept current by the code updater) does not have the browser test libraries, so the runner page used to open and fail with unreadable MIME-type errors; the panel now says which libraries are missing instead. Truncate test table and Create new empty test record is shown only on a development server, and the server refuses it elsewhere (maintenance.dev_mode_required). Run long process stays available everywhere.Wire contract:
WC-2026-10-01-unit-test-widget-dev-gate. -
Hitting a database limit now shows a clear "try again" error, and maintenance runs on its own database connections.
When a database statement runs past
DB_STATEMENT_TIMEOUT_MS, or a request waits pastDB_POOL_ACQUIRE_TIMEOUT_MSfor a free database connection, the user now sees a "took longer than the server allows" or "the server is busy, try again" message instead of a generic server error. Maintenance no longer runs under the statement limit: the long maintenance-area actions (rebuilds, VACUUM and REINDEX, bulk transforms, imports) and data updates use a separate set of database connections without it, sized by the newDB_MAINTENANCE_POOL_MAX(default 2 per process). Count it TWICE in your connection budget, plus 2: each engine process may holdDB_POOL_MAX + 2 × DB_MAINTENANCE_POOL_MAX + 2connections (16 with the defaults), because the index-rebuild lane keeps its own idle connections and a stop or an update verdict opens up to 2 short-lived ones of their own (see the database settings). The other maintenance-area actions keep the limit and never wait behind a long one, and the dataframe integrity scan now stops a batch that runs past its time budget instead of running on. When the server stops, it cancels only its own running maintenance statements: another installation sharing the same PostgreSQL server is never touched. The defaults of the three limits are unchanged (0, off);60000,30000and5000are the recommended production values, and the database settings page lists the long operations to measure on a large installation before you set the statement limit. A concurrent index rebuild, plain VACUUM or ANALYZE that is still running when the server stops is left to finish on the database instead of being cancelled, because a cancelled concurrent rebuild leaves a broken index behind (the blocking forms — theREINDEX TABLEandVACUUM FULLof "Re-create db assets" — wait at most 5 seconds for their table like any other maintenance step, are retried a few times, and are cancelled at shutdown, which undoes them cleanly; before, such a statement queued behind a long backup kept every later reader of the table waiting for the whole backup); and "Optimize tables" now first removes any such broken index an earlier interrupted rebuild left on the tables it optimizes. A long maintenance action still gives up after 5 seconds of waiting for a table another operation is using, so it never holds up the users reading that table: the step it was on is undone, steps it had already finished are kept, and the action reports "try again in a moment" (or, for the search-store rebuild, an error line for that store). The bulk transforms (move TLD, move locator, move to portal, move to table, move language) now apply each definition file all-or-nothing: a file that waits too long is retried a few times and, if it still cannot finish, is left completely unapplied and named in the report. Before, a file could stop part-way with some tables already changed, leaving a section's records split between the old and the new tipo. Rebuilding the database constraints, triggers or indexes is now all-or-nothing for each table: if the new constraint, trigger or index cannot be created (including after that 5-second wait), the old one is kept instead of being left removed, and the action lists the failure by constraint, trigger or index, table and database error code (the full database message is in the server log). An index is now built next to the old one, which keeps serving searches until the new one takes its place, so the table stays readable during the rebuild.Wire contract:
WC-2026-09-30-db-typed-503. -
The official update server for v7 installations is v7.master.dedalo.dev.
Code and ontology updates are now split by version. v7 installations update from
https://v7.master.dedalo.dev/dedalo/core/api/v1/json/— the address the configuration examples forCODE_SERVERSandONTOLOGY_SERVERSnow show. v6 installations keep updating from their own server exactly as before; a v7 update server answers only v7 installations.For an installation that serves updates to others, the suggested layout keeps the release archives in
/srv/dedalo/code(DEDALO_CODE_FILES_DIR) and the ontology files in/srv/dedalo/ontology(ONTOLOGY_DATA_IO_DIR), next to the media in/srv/dedalo/media. Nothing changes for an installation that leaves these settings unset. -
A published code release is now built from a release tag (
vX.Y.Z); developer builds come frommaster.On a code server, Serve Code's Build release button used to archive the tip of the
masterbranch, and Build developer release whatever other branch the server had checked out. Now a published release is always a tagged version: Build release archives the newestvX.Y.Ztag of this engine in the build checkout (prerelease tags such as betas are not releases, earlier-enginev6tags are never candidates, and a tag whose version file disagrees with its name is refused), and Build developer release archives the tip ofmaster— the latest integrated code, before its release. The branch the server has checked out no longer matters.What to do on a code server: tag each release commit (
git tag v7.0.1, pushed to the remote) and fetch tags into the build checkout (git fetch --tags) before building; until a tag exists the panel reports that nothing can be published. While v7 is in beta this is the expected state: installations receive v7 code only as developer builds. See Updating code.Wire contract:
WC-2026-09-29-code-release-channel-refs. -
Building and serving code releases now has its own maintenance panel, Serve Code.
The Update code panel used to hold two jobs: installing a new release on this installation, and — on a code server — building releases from git and serving them to others. The second job is now its own panel, Serve Code, shown only on a code server (
IS_A_CODE_SERVER=true) or the development installation. Update code keeps installing, restoring and deleting restore points.For scripts that build releases through the API: send the build request to
serve_codeinstead ofupdate_code(same action name,build_version_from_git_master, same options).Wire contract:
WC-2026-09-28-maintenance-serve-code-widget. -
Serving your ontology to other installations now has its own maintenance panel, Serve Ontology.
The Update Ontology panel used to do two jobs: download an ontology from a master server, and — folded away at the bottom — report whether this installation can serve its own ontology to others. The two are now separate panels. Update Ontology only downloads; the new Serve Ontology panel shows the three
../private/.envsettings that decide serving (IS_AN_ONTOLOGY_SERVER,ONTOLOGY_SERVER_CODE,DEDALO_CORS_ALLOWED_ORIGINS), the lines to add, and the address other installations must register.Wire contract:
WC-2026-09-28-maintenance-serve-ontology-widget.
Added
-
A publication host on another machine installs from one kit file built on the work host.
Installing a publication host on a second machine no longer means copying five entries of the work checkout by hand. On the work host,
bun run hostagent:pack -- --draft <draft.json>builds one archive — the agent's code with its production dependencies (installed for the kit, without the tests), the draft and the installer, with a list of every file's checksum — and prints its sha256. The same checkout and draft always give the same file. The kit carries no password and no token: those are typed or created on the publication host. There,sh install.sh <instance> --kit <file> --kit-sha256 <sha256>refuses a kit whose sha256 is not the one the work host printed, then refuses an altered, extra or missing file before any of its code runs. A draft the guided install would refuse is refused when the kit is built. Installing from a checkout (--source) is unchanged. See the kit. -
After an install from a kit converges,
provision initoffers to remove the kit file it was given.install.sh --kitnow tells init which kit file it was given and the sha256 it verified. When the install converged, init asks whether to remove that file (the default is no); without a terminal,-- --yesremoves it. Only that file is removed, and only while it still has the verified sha256: a replaced file or a link is left in place and named. Re-runs need no kit, and the work host can always build it again. See Publication host agent. -
Two-machine publication hosts pair from one sealed, passphrase-protected file.
At the end of a guided install on a publication host reached over TLS,
provision initnow writes one encrypted pairing package — the engine fragment, the agent's token and the engine's TLS bundle — readable by root only, and shows its one-time passphrase once on the terminal (it is stored nowhere, and without a terminal no package is written). On the work host,dedalo:pair-publication-host add <name> --package <file>asks for the passphrase without echo (or reads it with--passphrase-stdin), opens the package in memory and runs the same checks and the same live proof as before. A wrong passphrase or an altered file is refused before anything is contacted. Delete both copies of the package afterwards;--decide pair.package=againwrites a new one. Pairing with the three loose files is unchanged, and a host on the same machine still pairs directly. See Pairing. -
"
provision initreports the sealed pairing package as stale once the work host has used it, and removes it on request."The sealed pairing package init writes on a two-machine install holds the agent's token and the work system's TLS key. Once the publication host's agent has recorded a command from the work host after the package was written, every later init run (and
--dry-run) reports the package as stale and offers to remove it — the default on a terminal,--decide pair.package=removewithout one, which also removes it earlier. Init removes only the file it wrote and records the removal in its journal; a later run reports the package as gone. See Publication host agent. -
A new publication host can be drafted, kitted and paired from the Publication hosts panel.
As the Dédalo root user, Maintenance → Publication hosts → New publication host now makes the draft for a new publication host: type the site's domain, choose one machine or two, and the panel proposes the instance, the accounts, the service and free ports, which you can change before saving. The draft is checked with the publication agent's own rules and against the other instances on the same machine, and a refusal names the field. For two machines the panel builds the install kit from the release the code updater verified, shows its sha256 and lets you download it; then, once the publication host is installed, you upload its sealed pairing package and type the passphrase there. The package must belong to that draft — its instance and the agent address inside it — and the agent must prove it live before anything is stored; the passphrase and the package are never kept. The command-line paths are unchanged. See New publication host.
Wire contract:
WC-2026-10-09-publication-host-panel-setup. -
A publication host whose declaration drops the Publication API v1 now has its v1 pool, log rotation and v1 files removed by
provision apply.Re-declaring an existing v1+v2 publication host as v2-only used to leave its v1 FPM pool, the pool's log rotation, the v1 API releases and configuration (with its database credentials) and the pool's own directory behind.
provision applynow keeps a record of what it provisioned (provisioned.jsonin the instance's configuration directory) and removes what the declaration no longer has: the pool through the FPM configtest and a reload (a pool that was the install's only one is put back and apply stops), the rotation, and both v1 trees, after the agent restarted.provision checklists each removal first. Only files that still carry the provisioner's own stamp for that instance, and trees exactly as it left them, are removed; anything edited, unstamped, foreign or with another owner stops the run and is named. The v1 SELinux rules go with them; the v1 account stays. A pool that moves to another FPM version is retired the same way. See Publication host agent. -
A publication host can now serve the Publication API v2 only, without the Publication API v1.
The Publication API v1 is legacy: it is needed only by websites built for Dédalo v6. A publication host's declaration without a
v1block now installs a v2-only site, the recommended shape for a new site: no v1 account, no v1 configuration file and no v1 runtime on the host, and the guided install (provision init) neither looks for nor asks about any of them. The draft chooses with"apis": "v2_only"or"v1_and_v2", or by the presence of its ownv1block, and a v2-only site names its distribution family insite.os_family. The work system never pushes a v1 release to such a host, and its Publication hosts panel shows the v1 row as Not served, which is not a fault. Existing hosts that declare v1 are unchanged. See the publication host install.Wire contract:
WC-2026-10-09-publication-host-v2-only-site. -
The backup panel now says when backups are manual.
Dédalo never makes database backups on its own: a nightly job installed with the operating system does (see Backups). When no scheduled backup of the database has ever reached the backup directory, the Make backup panel in Maintenance now shows No scheduler, backups are manual., with a link to the backup documentation. Before, it showed only the age of the newest backup, which looked like automatic backups that had stopped.
Wire contract:
WC-2026-10-08-make-backup-scheduled-evidence. -
On an nginx publication host, the media map is now pushed by Apply media rules, and shared safely by every instance on the host.
nginx's media rules need a small map in the server's
http {}block (it checks the media login cookie and decides how SVG files are served). That map may be defined only once per server, but a publication host can carry several Dédalo instances, and until now it had to be placed and kept current by hand. On a publication host whose declaration setsweb.nginx_map: conf_d, Apply media rules now sends this work system's map to the agent before the media rules. The agent never writes the file nginx loads: it hands its part to a small service that runs as root on the host, which checks every instance's part, merges them into one file, tests the configuration and reloads nginx. When every instance runs the same Dédalo version and media folder, the file is exactly the map Dédalo writes for a work host. The publication hosts panel shows the map's state for each host: the map expected and the map applied, the shared host map and how many instances it serves; a host whose map is out of date counts as drift. The host refuses, and the panel says why: a map that is not in the expected form, an instance whose media folders changed since its map was first accepted (the earlier map stays untilprovision applyis run for that instance), or another instance with a newer map than the host's renderer understands (the shared map is left as it is untilprovision applyis run for that newer instance).provision initsetsweb.nginx_map: conf_dwhen nginx includesconf.dinsidehttp {}(the Debian and RHEL default), and moves a map placed earlier by hand into the shared one after you confirm. A host whose map is placed by hand (web.nginx_map: none, the default of a hand-written declaration) is not touched, and an agent too old to know the shared map is refused with a request to update it. Every configuration test and reload on a publication host, by the agent or by root, now waits for the others, so two instances can no longer reload the same web server at the same time; if the shared lock is missing, runprovision applyfor the instance. -
"A publication host can now be installed with one guided command,
provision init, on Debian, Ubuntu 24.04 and 26.04, and RHEL, Rocky or Alma 9 and 10 with SELinux; the Publication API v1 runs in a FastCGI pool of its own." (action needed)sh …/publication/host_agent/deploy/install.sh <instance> --source <checkout> --draft <file>looks at the publication host, compares it with what the instance needs, prints what is already right, what it will change (with the exact commands and diffs) and what needs your decision, and changes nothing until you confirm. It creates the missing accounts, gives the site's home to root, downloads and verifies the site's Bun against a hash table taken from Bun's signed checksums, installs the agent's code, writes the declaration, provisions, adds two lines to the site's virtual host (with a backup, a configuration test and an automatic roll back), asks for the API database password on the terminal only, checks that the agent answers and pairs it with the work system on one machine (on two machines it prints the pairing commands to run on the work host). It never installs a package, never edits a FastCGI pool of yours, never sets an SELinux boolean or edits one of your files without a typed answer, and never runs on Ubuntu 22.04 (its polkit cannot read the agent's rules) or on RHEL, Rocky or Alma 8 (systemd 239 and kernel 4.18 are below what the units and Bun need:install.shrefuses EL 8 before it downloads Bun). Whoever can write the source it is given can become root through it: give it only a checkout you trust.On RHEL, Rocky and Alma with SELinux,
provision applynow labels the instance's own paths and the v2 port, and the guided install asks before widening any SELinux boolean. A shared media directory is labelled for the web server only with your consent, kept in the declaration asmedia.selinux_label: true(the guided install writes it when you answeract). A typed secret (the database passwords, the v1 web user code) may not contain a space,',\or$. The units need systemd 247 or newer:provision applyrefuses an older one.The site's web server logs stay outside its home: in the home layout
provision applycreates/var/log/apache2/<domain>/(RHEL/var/log/httpd/<domain>/, nginx/var/log/nginx/<domain>/), owned by root, and the rotation file/etc/logrotate.d/dedalo_<instance>_web(the distribution's own rotation does not reach a directory per site); point the virtual host's logs there. On Ubuntu 26.04 a log under/homestops Apache from starting, and the guided install says so. The guided install reads the sudo policy the installedsudouses (/etc/sudoers-rsunder sudo-rs, when it exists), accepts a polkit that the system bus starts on demand, and asks fore2fsprogswhenchattris missing.The Publication API v1 now runs in its own FastCGI process pool,
dedalo_<instance>_v1, under its own account, which the provisioner writes when the declaration names the newsiteblock, with the site's web include beside it. A hand-runprovision applynow waits for a runningprovision initof the same instance, andprovision checkanswers exit 5 (busy) instead of drift while one runs.Action needed on an existing publication host:
- create the host group every agent's service now runs with, once per host:
groupadd --system dedalo_pubhost. Until thenprovision applyrefuses and prints that line; - a declaration whose
v1.useriswww-data,apache,nginx,wwwornobody(the old page allowed the web server's user with the web server's own module) is now refused: create an account for v1 alone, give it the v1 configuration file (chown <account>,chmod 0400), declare it and thesiteblock, and runapply. See Publication host agent and its manual steps 2, 3 and 9.
- create the host group every agent's service now runs with, once per host:
-
The Publication hosts panel now shows in red a publication server whose Bun differs from the version the work system pins.
Each host in Maintenance › Publication hosts has a new Bun version row, with the expected version (this work system's
.bun-version) and the version the host reports side by side. It is green only when they are exactly equal, red on any difference (for example1.4.1 != 1.4.2), and unknown when the host cannot be reached or proved. The install guide now gives each site on a publication server its own Bun, installed by root in/home/<site>/.bun/at the pinned version, so each site can be upgraded on its own, and suggests naming each instance after its site's domain (my-hosts.org→my_hosts_org). See Publication host agent.Wire contract:
WC-2026-10-03-publication-hosts-widget. -
Several publication hosts on one server are checked for isolation. (action needed)
provision checkandprovision applynow read the other publication-host declarations on the server. They refuse an instance that shares a user (agent, v1 or v2), the v2 group, unit or port, a listening port, or a directory with another. Before, a shared user let one instance change the other's media rules and API releases, and a shared port only failed when the service started. When a runtime path (Bun, the v1 API's runtime) or the agent's directory is a link, the refusal now prints the real path to declare. The new section Several instances on one server lists what each instance needs of its own.The installation page now declares the instance before creating its accounts, and gives the exact commands.
provision checknames each missing account with its declaration field and the command, in the order to run them. The new section Lay out each site in its home directory puts each site's state root beside its document root (/home/<site>/dedalo, with the home owned by root) and shows the virtual host that maps the APIs into the site.The examples'
v2.health_urlwas…/dedalo/publication/server_api/v2/health, which answers 404 under the v2 API's defaultBASE_PATH, so every v2 release would fail its health check. It is nowhttp://127.0.0.1:<port>/health, which answers whatever prefix the API is published under; use that form in your declaration.Action needed: the declaration's
web.groupis replaced byv1.user, the user the Publication API v1 runs as: with one process pool per site, that site's pool user (the pools may share the web server's group). Write"web": {"server": …, "unit": …}and"v1": {"user": …}. The v1 configuration file inshared/must now be owned by that user and readable by it alone (chmod 0400): installing a v1 release is refused withshared_config_exposedotherwise. The agent, v1 and v2 users must be three different accounts. -
A paired publication server now gets the Publication APIs matching the work system's version, can keep a verified copy of the published media, and is checked from the public side.
After every confirmed code update or code restore (a rollback to an earlier version), the work system sends the matching Publication API releases to each paired publication server, exactly as the update verified them. A database restore does not change the code and sends nothing; the panel shows any lag and Push API releases realigns it. A file changed on disk since then stops the push and is named in the panel. In copy mode the publication server keeps its own copy of the published media, only public qualities of published records. Unpublishing makes the files answer "not found" as soon as the publication server accepts the unmark (until it does, for example while it cannot be reached, the panel shows the deletion as pending and the files stay public), then deletes them, and the deletion counts as done only once the server's file list confirms it. The maintenance panel can also check each publication server through its public address: a published file must load and an unpublished one must not, after every rules change and on a schedule. On a copy server, where the unpublished file is normally absent, a "not found" proves nothing and the check says unknown, never a pass. See Publication host agent.
-
The publication hosts panel shows each copy-mode host's media copy, and root can bring one host back in sync on demand.
For a publication server that keeps its own copy of the published media, the publication hosts panel now has a Media copy row. It reads OK when the copy matches what the work system publishes. It reads pending while files are still being copied or removed, and red when an unpublished file has not been confirmed deleted from the public server after one check period (ten minutes), or when the server stopped being a copy server while it still held files. The engine checks and repairs every copy server by itself every ten minutes, and right after a data restore. The root user can run it for one server at once with Reconcile media copy. Applying it by hand, here or from the Reconcile panel, is reserved to root. A server that cannot be reached is reported as a failure, never as done, and its pending deletions are completed on the next check. A copy server is now protected by the same media rules as a server that mounts the shared media (Apply media rules works for it too), so an unpublished record stops being served before its files are deleted.
Wire contract:
WC-2026-10-03-publication-hosts-widget. -
A separate publication server can now run a small agent that the work system pairs with, to install the Publication APIs, apply media rules and report status, without root access.
Institutions whose public website runs on its own server, or on its own hostname on the same server, can install the publication host agent there. It accepts a fixed list of requests from the work system and nothing else: report status, check the media mount, apply the web server's media rules (keeping the previous rules if the new ones fail the configuration test), and install or roll back a Publication API release (the previous release keeps serving when a new one is not healthy). The work system connects over mutual TLS, or over a local socket on a single server, and proves the pairing with a fingerprint. The agent runs as its own user with two narrow grants (the web server's configuration test; and reloading the web server, restarting the v2 API, and starting or stopping a scratch copy of the v2 API on a local port to test a new release), and the publication server never needs to download packages. See Publication host agent.
-
A separate publication server can now serve published media from shared storage, with its own access rules.
Institutions whose public website runs on its own server, reading the same media storage as the work system (read-only), can now generate media rules for that server:
bun run media:publication-host-rules --root <mount>(Apache, or nginx with--server nginx). The publication server then serves only the files of published records, in the public quality folders, and never accepts the work system's login cookie. Originals are never served. See A separate publication server with shared media storage in the media protection manual page. -
The maintenance panel now shows each paired publication server, and can apply its media rules or roll back a Publication API release.
A new Publication hosts panel in Maintenance, in the Publication group, lists every publication server the work system is paired with. For each one it shows whether the server answers, whether the pairing still matches, its media mode and mount, whether its installed media rules are the ones the work system would generate now, and which Publication API releases are current. The root user can apply the media rules, check the media mount, roll back a Publication API release, edit a server's public address, quality folders and probe files, or remove a server; other administrators see the panel read-only, without the servers' network addresses. Servers are added only on the work server's command line (
bun run dedalo:pair-publication-host), run as the user that runs Dédalo, from the files the publication server's provisioner writes; the token is given in the fragment, a private file or standard input, never as an argument. The pairing is proved before anything is saved, and the work system never sends its token to a server whose pairing fingerprint does not match. The Media access control panel links to it. See Publication host agent.Wire contract:
WC-2026-10-03-publication-hosts-widget.
Fixed
-
Maintenance → Build database version can rebuild the install seed again.
The Build install version button refused with "not runnable on this engine", pointing at a maintenance dashboard that no longer exists. It now rebuilds
install/db/dedalo_install.pgsql.gz, the database every fresh install starts from, out of the running database: the core ontology, the languages and the hierarchy registry (all inactive) are kept, user data is left out, and the root user, the General project and the Admin/User profiles are written fresh. The work happens in a scratch database the button creates and drops; your database is only read. See the seed. -
On an nginx publication host, a media map that stops nginx at its reload is now rolled back and nginx restarted, and the panel counts a map that is not loaded as a red check.
The root service that renders the shared nginx media map (
dedalo-pubhost-map) tests every new map before nginx reloads it. On SELinux hosts nginx can still stop on the reload itself, after a test that passed. Until now the service then only reported the failure and left nginx down, with every site on that server. It now watches nginx for five seconds after the reload. If nginx is down, the service puts back the map nginx had loaded (or removes a first one), tests it, restarts nginx and checks that it runs. The push is reported as failed and the panel keeps showing the map that is actually loaded. In Maintenance › Publication hosts the map's state is now a check of its own, Host media map. It is red when the host's agent is too old for the shared map, when the host refused this work system's map, or when this work system's map is not the one nginx serves. It is green when that map is loaded or when the map is placed by hand. Before, a map that was not loaded was painted red, but it was not counted with the other checks. See Publication host agent.Wire contract:
WC-2026-10-03-publication-hosts-widget. -
The Publication API v1 error log on a publication host is now rotated.
Each site's v1 API writes its errors to
/var/lib/dedalo_publication_host/<instance>/v1/log/error.log. Until now nothing rotated that file, so it grew until the disk was full.provision applynow writes/etc/logrotate.d/dedalo_<instance>_v1for every site, in either layout. The file rotates daily and keeps 14 compressed copies. The rotation runs as the v1 user, because that account owns the directory and root never renames files in a directory another account can change. Runprovision apply(orprovision init) once for each site to get it. See Publication host agent. -
Container installs take their nightly backups again, and the full-stack install no longer stops at "directories".
Since 2026-08-30 the
backupsvolume of both compose stacks came up owned by root while the engine and thebackupservice run as an unprivileged user. Two things followed. The documented command-line install of the full stack (docker-compose.yml) stopped withinstall failed: /backups/db. And the nightlybackupservice wrote nothing at all. Because it could not write its own failure marker either,docker compose psstill showed it as healthy.The image now creates that directory with the right owner. Rebuild the image (
docker compose build, thendocker compose up -d, adding-f docker-compose.simple.yml --env-file .dedalo.envon the simple stack). An existingbackupsvolume is still empty, so it takes the right owner by itself; no manual step is needed. Thebackupservice now reports unhealthy whenever it cannot write to/backups. The simple stack also declares which stores each run must produce, so a store that never ran is reported as a failure, as on the full stack. After updating, check that/backups/LAST_OKappears the morning after. See Docker backups. -
"
./install.shruns on macOS again (and wherever Docker's storage lives in a VM)."On macOS, and on Docker Desktop under WSL or with a remote Docker host,
./install.shstopped silently right after its banner. Its free-disk check used a Linux-only option. The check is now portable. Where the free space cannot be measured from the host, the check is skipped and the install continues. See Simple install. -
When the install wizard refuses your browser, it now names the address it saw — and
./install.sh --wizardasks who may open it.Until it is finished, the wizard answers only the addresses in
DEDALO_INSTALL_ALLOWED_IPS. Behind the stack's proxy the engine sees an address few people would guess (Docker Desktop:192.168.65.1)../install.sh --wizardstarted the stack without naming anyone, so every wizard install was refused, with no hint of what to allow. The refusal now says "The installer is not allowed from this address (192.168.65.1)", and the engine log names it too../install.sh --wizardasks which addresses may open the wizard; it suggests the private network ranges. See Simple install.Wire contract:
WC-2026-10-08-install-ip-denied-names-address. -
The browser install wizard's first screen no longer fails with "An unexpected error occurred" in containers.
On a machine with no database yet, such as any container, the wizard's first call tried to read the database for the names of the configured languages and failed. The wizard now names them from the installer's own language list. See Simple install.
-
The browser install wizard no longer fails at the database step with
function "f_unaccent" already exists.After Save config, the restarted engine ran its schema upgrades on the still-empty database, and the seed restore then collided with them, leaving a half-built database. This affected every browser-wizard install, on every platform. Those upgrades now wait until the install is sealed, as on the command-line installer. Finish restarts the engine once more, so the sealed instance starts with all of them applied; the page reloads by itself a few seconds later.
-
"The publication-host pairing command now names a command you can run, and says why it cannot read a copied file."
When the pairing command refused because of the user running it, its message suggested
sudo -u <engine user> bun run …. Pasted as is, the shell read<engine user>as a file redirection, and with the user filled in,sudocould not find a barebun. The message now prints the full command: the checkout to run it from, the user (by its id), and the Bun that ran it, by its full path. A token file or engine bundle copied as root, which the Dédalo user cannot read, used to end in unexpected failure (Error), exit 4. It is now named like the fragment already was, could not be read (EACCES), exit 3. The engine fragment thatprovision applywrites no longer claims that no release reads its keys: the pairing command reads exactly those keys. The nextprovision checklists the fragment as a change, andapplyrewrites it. See Publication host agent. -
The auction-URL and journal-URL import tools now declare the sections they belong to.
The two import tools are registered for their own sections only (
numisdata4for auction URLs;rsc205andrsc3for journal URLs), so the restriction no longer depends on their server code loading. Run Register tools after the update to apply it. -
Ontologies re-installed by an ontology update keep their typology and name instead of moving to "Others".
After an ontology update, ontologies such as
ddortchappeared in the profile permissions tree under the Others typology with only their bare tld as name (dd), although their registry record says otherwise (fordd: Core, "Dédalo | dd"). An update now keeps the typology and name from the ontology's registry record. To repair an installation already affected, rebuild the ontology's main node from the Ontology tool (the registry records themselves were always correct). -
A tool whose code fails to load no longer appears in every section.
When a tool's server code failed to load (for example a missing dependency), its "only in these sections" rule was skipped and the tool appeared in every section. Such a tool is now hidden, and the server log names the load failure.
-
Code updates no longer refuse over a file an older release shipped and a newer one removed.
An update from the Update code panel refused with "Unknown entries at the code-tree root …
.vscode" on installs that had taken an older release: the file was shipped by Dédalo itself, then removed from later releases, and the updater could not tell it from a file the administrator had added — nor could it be cleared without shell access. Each update now records the top-level entries of the release it installs, so the next update moves a retired release file into the backup instead of refusing, and the panel's Code-tree root entries check reports only files nobody shipped. An install updated before this release still refuses once over such a file: remove it by hand that one time (what makes an update refuse).Wire contract:
WC-2026-10-07-update-code-root-entries-stamp. -
Publishing no longer fails creating a MariaDB integer column sized by
varchar.A
field_intdiffusion node whose properties carriedvarchar(for example"varchar": 1024) was created asINT(1024), which MariaDB refuses, and the whole publication run stopped with "An unexpected error stopped the diffusion run". The two sizing properties are separate again:varcharsizes text columns only andlengthsizes integer columns only. The integer column is now created asINT(8)unlesslengthsays otherwise. -
A publication table too wide for MariaDB is refused by name before publishing starts.
MariaDB limits a table row to 65,535 bytes, and every
VARCHAR(n)column reserves 4 × n bytes of it whatever it holds. A table with many widevarcharfields could not be created, and the publication stopped with "An unexpected error stopped the diffusion run", for every table of the element and not just the wide one. The element's plan now refuses such a table before anything is published, naming the table, its width and its widest columns. To fix it, change those fields tofield_text(add"index": "BTREE"to keep the same index) or reduce theirvarchar. -
Publication host provisioning works with Apache on Debian and Ubuntu. (action needed)
provision checkrefused every Debian or Ubuntu Apache host with web.configtest_bin '/usr/sbin/apachectl' is a symlink, because the provisioner used one fixed path for every system. It now picks the configuration-test command found on the host:apache2ctlon Debian and Ubuntu,apachectlon RHEL,nginxfor nginx. The agent's settings file now names that command. Action needed for an agent provisioned before this change: after copying the new agent code, runbun run provision apply <instance>again. Until you do, the agent refuses to start because the setting is missing. If the agent restarted repeatedly before you ran it, systemd may have stopped retrying: runsystemctl reset-failed dedalo-publication-host-<instance>andapplyagain. See Publication host agent. -
The Docker image can now write AVIF, so
.avifalternative versions of images work out of the box.The Docker image's ImageMagick could read AVIF but not write it: the Debian package it is built on ships the AVIF decoder only. An installation that lists
avifinDEDALO_IMAGE_ALTERNATIVE_EXTENSIONStherefore had those alternative versions refused on every upload. The image now includes the AVIF encoder (libheif-plugin-aomenc). Docker installations get it with the next image build; on a host install, add the same package to have AVIF versions written. -
Interlaced videos are now deinterlaced when their web versions are built.
Every video quality was meant to be deinterlaced, but the encoder was given the deinterlace filter and the colour-correction filter as two separate options, and ffmpeg keeps only the last one. The deinterlace step was silently skipped, so video recorded interlaced (most analogue and DV tape transfers) got web versions with visible combing on movement. Both encoding passes now receive one combined filter, and interlaced video is deinterlaced. Progressive video is left untouched: only frames marked as interlaced are processed.
Existing versions are not rebuilt automatically. To fix a video already in the archive, rebuild its qualities with the media versions tool; the original file is never changed.
-
The audio encoder for video and audio derivatives is chosen from what the configured ffmpeg can actually encode, and a failed encode names its error.
The AAC encoder used for video and audio derivatives (
libfdk_aac, thenaac) is now read from the configured ffmpeg's own encoder list (ffmpeg -encoders) instead of its build flags, which could name an encoder the binary does not have. The answer is remembered per ffmpeg binary, so a different ffmpeg resolved by the same server is asked again rather than handed the first one's answer — and the same holds for ImageMagick's "can this format be written" check. A probe that cannot run is no longer remembered for the life of the server. A failing second encoding pass or audio extraction now reports ffmpeg's error line instead of the tail of its banner and progress output; the encoded files are byte-identical. See the media engine. -
The profile permissions tree opens in a fraction of a second instead of several seconds.
Opening a profile record rebuilt the whole permissions tree (every area, section and field of the ontology — some 13,000 entries) on every visit, with several database queries per entry: 6–7 seconds on a real installation. The tree is now built from one ontology query and kept per interface language and per set of granted areas, so the first opening takes well under a second and later ones are immediate. It is rebuilt automatically after any ontology change and after any change to a profile or a user's profile assignment, so it never shows outdated structure or another user's areas.
Siblings that share the same ontology order number now always appear in the same order (by creation), where before their relative order could vary between servers.
-
Published files survive a power cut, and deleting a record no longer races a running publication into the same directory.
Two gaps in file publications (Markdown, XML, RDF, CSV, JSON) are closed:
- A power cut no longer leaves a "completed" run with broken files. A run recorded each batch, and finally its completed state, in the database while the files it had written could still be only in the operating system's memory. A power cut (or a kernel crash) could then leave empty or missing record files, a deleted record's file back in place, or a truncated CSV/JSON export — behind a job that said completed and could no longer be resumed. Every file, archive and merged document is now forced to disk, and its directory with it, before the run records it.
- Deleting a record waits its turn on file targets too. When a record is deleted, its published file is removed under the same per-target hold the publication runs use. It could otherwise be removed just before a running batch wrote it again (the deleted record reappeared on the public site), or while a run was building its archive. If a run is writing that directory at the moment of the deletion, the removal stays pending and the retry queue completes it, as it already did for publication databases.
Wire contract:
WC-2026-09-30-diffusion-run-ledger,WC-2026-09-30-diffusion-target-fence. -
Resetting a hierarchy to its seed can no longer delete it without restoring it.
"Reset to seed" (Add hierarchy) and the installer's hierarchy step now apply each hierarchy all-or-nothing. The reset used to delete the hierarchy's terms first and load the seed in a separate step: if the seed then failed to load, the hierarchy was left empty — every edit and addition gone and the seed not restored. A models file that failed to load, or a failed update of the record counter, was ignored and the hierarchy reported as imported. Now the delete, the terms, the models and the counter are one database transaction: if any part fails, nothing changes and the hierarchy is reported as failed with the reason.
-
Local ontology overrides now apply to the node they point at, and change only what they state.
A record in the local ontology (
localontology0) overrides a shared node through its Overwrite field. Before, any link in the record counted as an override (so its parent was overridden too), and re-parsing the node could move it to thelocalontologynamespace, erase its other translations, drop its layout CSS and make it translatable. Now only the Overwrite field links an override; the node's TLD, translatable flag, order and model flag stay as shared; the term merges per language, and each property the override fills (CSS included) replaces the shared one whole while the others are kept — set a property tonullto remove it. Local records are no longer parsed as nodes of their own. See Overriding shared ontology nodes.Wire contract:
WC-2026-10-01-ontology-overwrite-scoped. -
The Publication server API maintenance panel shows its "Open Swagger UI" buttons again.
The panel (Maintenance → Publication → Publication server API) never showed the buttons that open the interactive documentation of the publication server API v1, because
API_WEB_USER_CODE_MULTIPLEwas not read. It is a configuration key again: list each publication database and its API code, e.g.API_WEB_USER_CODE_MULTIPLE=[{"db_name":"web_my_entity","code":"my_api_code"}], optionally withapi_uiwhen the API runs on another server — see the configuration reference. A v6 configuration migrated with the config migrator now carries the value across; the empty placeholder entry of a stock v6 configuration is left out, so a migrated install does not report a dropped entry on every start. -
Creating a site twice at the same moment can no longer overwrite or delete the first site.
When two requests created a site with the same name at nearly the same time, the second one could pass its checks while the first was still being set up. It then wrote its own settings over the finished site, and if anything later failed, it deleted the whole site folder, including the first site's work. The site folder is now claimed by exactly one request: the second request is refused with "a site with this name already exists", and the first site is left untouched.
A site folder that exists but has no
site.json(left by a create that was interrupted, or by a site whose settings file was removed) is no longer reused. Creating a site with that name is refused with the reasonworkspace_exists, and nothing in the folder is changed. An administrator must inspect the folder and remove it before the name can be used. -
Deleting a site while an agent session or a build is running is refused, and no longer blocks the site name.
A site could be deleted while an agent session was still working on it. The session kept writing its own history, which re-created an empty folder with the site's name. Creating a site with that name was then refused with the reason
workspace_existsuntil an administrator removed the folder by hand. Every restart of the site builder also retried a recovery that could never succeed for that folder.Now a delete is refused while a session, a build or a repository operation is running on the site. The response names what is running (for example
session_running), and nothing is removed. Stop the session or wait for the build, then delete again. Sessions and builds can no longer re-create a deleted site's folder. At startup, the site builder ignores folders left by the old behaviour, so it no longer retries their recovery. Remove those folders by hand to free the name. -
Site builder state survives a power cut, and a damaged driver record is reported at startup.
The site builder's own state (each site's driver record, session and build records,
site.json) was written atomically but not forced to disk. After a power cut or kernel crash, a newly written driver record could come back empty. The site then refused every session that did not name its driver, on every restart, and nothing explained why until a session was attempted. These files, and the directories they are created in, are now forced to disk before the write is reported done. A driver record that is present but unreadable is now named in the startup log with the steps to fix it. It is left as found and never rebuilt fromsite.json, which the agent can edit. -
A second Site Builder started by hand no longer stops the running service's agent runs.
When the Site Builder daemon was started a second time for an instance that was already running (for example, by hand as the service user while debugging), the second process stopped the running service's agent turns and marked its sessions interrupted. Only after that did it notice the instance was already served and exit.
The daemon now checks first. If the instance's socket or port already answers, or (with systemd confinement) systemd says another process is the service's main process, the second start exits with one line saying why. It stops nothing and writes nothing. Start the service with
systemctl, not by hand. -
Administrators' toolbars now show only the tools their profile grants.
Global administrators (other than root) saw every installed tool in their toolbars, even tools their profile does not grant — and clicking one was then refused. The toolbar and every tool door now follow the same rule: a tool is available when the user's profile grants it (or it is always active); only the root account holds every tool. An administrator who asks for a tool their profile does not grant now gets "not authorized" rather than "unknown tool".
Wire contract:
WC-2026-10-01-tool-grant-one-decision. -
Rebuilding a user's activity statistics can no longer lose them half-way.
"Rebuild user stats" (Database info) used to delete a user's daily statistics first and then recompute and save them day by day, each step on its own. A failure part-way — a database error, a server restart — left that user's statistics deleted or half rebuilt. Now the activity log is read first and the old statistics are replaced in one transaction: if the rebuild fails, the user's previous statistics are kept and the error names the user (and the users already rebuilt before it). A day whose statistics record could not be created is no longer skipped silently. The rebuild still recomputes only from the activity log that exists, so statistics older than the log are still lost when it succeeds.
-
A database backup counts only once it has been read back completely.
The code updater requires a recent database backup, and the update panel shows whether there is one. Both used a quick check that reads only the start of a dump, so a dump that had stopped part way through still counted as a backup, and a code update could go ahead with no usable way back. Now a backup counts only after PostgreSQL has read it back from beginning to end; a dump that is cut short is named in the refusal ("did not verify (truncated)"), and the next older complete backup is used if there is one. The read happens once per backup file and never makes the server unresponsive: while it is running the panel shows the backup as "verifying" instead of guessing. A read that does not finish in time proves nothing, so that backup does not count either; on slow backup storage raise the new setting
DEDALO_BACKUP_VERIFY_SECONDS_PER_GB(default 60 seconds per gigabyte), which the refusal names. A file named like a Dédalo backup (.custom.backup) that does not even start like a PostgreSQL dump — for example one left full of zeros by a crash — no longer counts as a backup either. Only a problem in the file itself marks a backup as broken: if the read is interrupted, the backup disk reports an error, or the server's PostgreSQL tools are older than the dump, the backup does not count for now but is read again next time instead of being written off — and a backup that an earlier version wrote off for one of those reasons is read again once. This holds whatever language the server's system runs in: PostgreSQL's messages were misread when they came out translated (for example on a server set to Spanish), which could write off a good backup. Stopping a code update while its backup is being checked, or before it replaces the code, now really stops it: nothing is installed and the server is not restarted. The backup line of the update panel is checked only for the superuser, the only account that can run an update. If the backup cannot be checked at all (for example the server's PostgreSQL tools cannot be started), a data update now warns that the backup could not be checked instead of refusing to run.Wire contract:
WC-2026-09-30-backup-freshness-deep-async. -
A database backup that is still being written no longer appears as a backup.
Before, a dump started from the maintenance area was written straight under its final backup name, so for the whole time it ran the backup list showed an unfinished file, and pressing the button twice in the same second could throw away the dump that was already running. Now a dump is written under a temporary
.partname and receives its backup name only after it has finished successfully and — where the server can check it — has been read back completely (a finished dump that could not be checked — no time, a disk error, the check interrupted — is named but reported as "not verified", never thrown away); the list shows only finished backups, and a second press while one is running is simply skipped. A dump that fails is kept as.failedfor you to inspect (a later failure under the same name becomes.failed.1, and so on — nothing is overwritten), and never looks like a backup. While the finished dump is being read back, the progress panel keeps showing it as running (it used to report a successful backup as interrupted), and you can stop a running backup from the panel. A.partfile left behind by a server that restarted mid-dump is never deleted: after 24 hours the next backup reads it back and, if it is complete, gives it its backup name; if the file itself is broken it is kept as.orphanedfor you to inspect, and if it simply could not be read this time it is left where it is and read again next time (the nightly backup job follows the same rule). The progress of a backup now belongs to the user who started it. An empty.partis left alone, because it may belong to a dump that is still waiting to start.Wire contract:
WC-2026-09-30-backup-part-promotion. -
A publication run that crashes, is cancelled or meets another run now publishes exactly what it should — and large archives no longer exhaust memory.
Four defects of the publication (diffusion) runs are closed:
- Resume after a crash. A run that was interrupted and resumed used to lose
the records its primary records link to, and rebuilt the downloadable archive
(
diffusion_md.zip, the merged RDF/XML document and its zip) from only the part it did after the restart. Each run now keeps a ledger of what it has queued and published, committed with every batch: a resumed run publishes byte for byte what an uninterrupted run would, and its report still says Partial success for problems met before the crash. A run left by a version before this one restarts from the beginning. A cancelled run no longer rewrites the published archive from its partial work. A linked record that was unpublished while a run was stopped is not published again when the run is resumed: the run checks each linked record's publication state when it reaches it. - One writer per target. Two runs, a record deletion and the maintenance
repairs could write the same publication database or directory at the same
moment, and a run whose job had been taken over could keep writing. Each
target is now written by one batch at a time — a waiting run shows
Waiting for the publication target (busy)… — and a record deleted while its
batch waited is removed from the public site instead of published. A long
step (adding a column to a large published table, a language sweep) keeps
its hold on the target for as long as it runs, and a batch that takes longer
than about 20 seconds no longer makes a healthy run look stopped (it used to
be restarted, and could end failed after its retries). Deleting records while a run
holds their target no longer waits: the unpublish is queued and retried.
Deletions never hold each other up: two users deleting records published
in the same database both have them removed from the public site at once.
DB_POOL_MAXmust be at least 2 for a publication run to start. The media-file allowlist is covered too: Rebuild media index and the startup repair wait for a run that is publishing, so the media of a record that was just published no longer disappears from the public site until the next repair. They wait for at most two minutes and hold nothing while they wait: a run or a deletion on one publication database is never held up because a run is busy on another. When the wait runs out, Rebuild media index names the database it could not repair, and the startup repair is reported as not applied. - Another user's publication. Pressing Publish on an element and section another user is already publishing used to show you that user's run as if it were yours. You now get a clear "the publication target is busy" message; pressing Publish again on your own running request still reconnects to it.
- Archives in bounded memory. Zip archives and merged documents are now built from disk one file at a time, with unchanged bytes, instead of loading the whole publication into memory at the last step. Two files with the same name in one archive are refused instead of one silently replacing the other. A file removed while its archive or merged document is being built (a record unpublished at that moment) is left out and named in the run's report, instead of the whole run failing — including when every file of a Markdown run is gone, which now just produces no archive. A file removed after the archive started reading it is archived whole.
Wire contract:
WC-2026-09-30-diffusion-run-ledger,WC-2026-09-30-diffusion-target-fence,WC-2026-09-30-diffusion-attach-scope,WC-2026-09-30-diffusion-zip-streamed. - Resume after a crash. A run that was interrupted and resumed used to lose
the records its primary records link to, and rebuilt the downloadable archive
(
-
Duplicating a record now files the history of a transliterable or IRI field in the language it was saved in.
When a record was duplicated, a field that keeps per-language versions beside a base value (a transliterable field) or an IRI field got its history row in the language-neutral lane, next to an empty extra row, while a normal save of the same field files it in the working language. The Time Machine of the copy therefore listed the change under the wrong language. The copy's history now lands in the working language, exactly where a save puts it, and the empty extra row is gone. The rule that decides which language a history row belongs to is now one rule shared by every door that writes history.
Wire contract:
WC-2026-09-27-bulk-revert-undo-log. -
Regenerating the media cache no longer undoes an upload made while it runs.
The "Update cache" tool (media components) and the media files repair (
scripts/media_repair_files_info.ts, and thefiles_infoentry of the reconcile tools) rebuild files and then record which files a record has. They used to record that from what they had read at the start, so a file a curator uploaded to the same record while they ran — and its original file name — was silently undone. They now record it from the record as it stands at that moment, so the curator's upload is kept. "Update cache" also reports records deleted while it ran (and rows that stayed locked) instead of counting them as regenerated. The repair now also fixes a record whose media list names another record's files (what a failed duplicate could leave), and reports records it could not write instead of counting them as repaired. It judges each media item on its own: an item whose files are not on this server keeps its record of them (unless you allow shrinking), even when another item of the same field is repaired, and a file named some other way (for example by an image id) is never mistaken for another record's. Both tools now give up on a record another user is holding after a few seconds, report it, and go on with the next one, instead of waiting indefinitely.Wire contract:
WC-2026-09-30-media-key-locked-transform. -
A move_* data transform can be stopped, and only one runs at a time.
Running a move transform for real (Move TLD, Move locator, Move to portal, Move to table, Move lang with
dry_run: false) used to happen inside the web request. Nothing could stop it except restarting the server. It kept every record it had changed locked until the end of each definition file. If it was sent again it waited behind itself and then reported a failure while the first run carried on unseen. Now the transform runs as a background process that answers at once, reports its progress in the maintenance panel and has no time limit. Stopping it cancels the definition file it is working on and undoes that file completely; the files after it are reported as not run. A second transform started while one is running is refused ("Another move_* transform is running") instead of waiting. The maintenance panel can now actually run a transform: until now the five move widgets only ever sent a dry run, so no button reached the real run. The submit button now runs a preview (dry run); when the preview ends without errors an Execute button appears, which asks for confirmation and runs exactly the files that were previewed (change the selection and it asks you to preview again). The preview also runs in the background, with the same progress panel and Stop button. A run that fails — a definition file undone, a file refused because another transform is running, an unknown outcome — now ends as an error in the panel, with the reason; before, the panel said "Process completed" and the failure was visible only in the raw report. If a definition file fails at the moment its changes are being saved (a lost database connection, a server shutdown), the report now says what the database actually did with it: applied, undone, or, when that cannot be read back, an unknown outcome that stops the run and asks you to check the data before running that file again. Before, every failed file was reported as undone, and running a Move locator file again after it had in fact been applied moved its locators twice.Wire contract:
WC-2026-09-30-move-transform-execute-job. -
A failed ontology update now says why, inside the Update ontology panel
When fetching the master's file list or importing the ontology failed, the panel showed nothing — the error only reached the browser console. The panel now shows the failure in place: the error, the server's explanation (for example which address was refused) and the request id to find it in the server log.
-
The Update ontology panel scrolls to its result
The result of an ontology update — success, warnings or a failure — appeared below the submit button, out of sight unless the admin scrolled down, so the panel looked idle. It now scrolls to the version change while the import runs, and then to the outcome, as the Update code panel already does.
-
Updating the ontology works from every configured master, not only the first
When
ONTOLOGY_SERVERSlisted several masters sharing the same access code, choosing any of them except the first failed every file withDownload failed … (origin mismatch: <chosen> != <first>). The engine now identifies the chosen master by its address, so each listed server updates from itself. A server address that is not inONTOLOGY_SERVERSis still refused before anything is downloaded. -
A site builder turn whose egress gate fails to close now still ends, instead of leaving the session running forever.
When the daemon could not remove a turn's egress sockets (for example, the host refused the unlink), the turn's remaining cleanup was skipped: the driver's MCP configuration stayed in the workspace and the session never left the running state. Each cleanup step now runs on its own. The turn ends normally, and the failure is written as an
[egress]line in the session log. The same holds for a build or git step: its gate failing to close no longer replaces the step's own result, and the[egress]line goes to the build log. A run refused after its gate opened (for example, an environment value with a control character) now reports that refusal, not the error from closing the gate. -
A data update now applies completely or not at all, and two updates can no longer run at once.
Before, each step of a data update was saved as soon as it ran: a failing step, a stopped update or a server restart left the earlier steps applied while the installation still reported the old data version, and running the update again applied them twice. Now the whole update — every step and the new version number — is saved in one piece. If anything goes wrong, or the update is stopped, nothing of it is kept and the report ends with "Rolled back: no statement of this run persisted"; after a restart, simply run the update again. A second update started while one is running, or an update that was already applied from another window, is refused. Stopping the update job now also stops the statement it was running, even while the server is busy. A statement stopped by a server shutdown is reported as an interruption, not as an error in the update's SQL, and the update log marks every run that was saved with a
COMMITTEDline. The installed data version shown in the panel is the final word: if the update log cannot be written (a full disk, say), the update still completes and reports its real outcome. Every required step must stay checked: the update refuses a selection that leaves one out, because the new version number would otherwise claim work that was never done. If the connection to the database is lost at the very end, the report says whether the update was saved after all — as the database itself records it, not as the version number happens to read — or asks you to reload the panel when that cannot be read; it never claims a rollback it cannot confirm. Long updates are no longer cut short by the database statement limit or by the background-job time limit, and a step waiting for a busy table waits only briefly and retries, so ordinary work on that table is not held up behind it. An update step can no longer end the update's own database transaction half-way (aCOMMITinside a step is refused before it reaches the database); should that ever happen anyway, the report says the update was partially applied and was not recorded as done, instead of claiming nothing was kept.Wire contract:
WC-2026-09-30-update-engine-atomic. -
Error reports now include logged client errors
The "Report a problem" tool now attaches errors the client caught and logged (
console.error), not only uncaught ones, so a report of real breakage no longer says "0 errors". Only a short message and stack are kept; repeats are counted, not duplicated. -
An update server with no public host now says so, instead of sending download links that point at localhost.
When an ontology or code update server had no
DEDALO_HOSTset (or set it tolocalhost), it still answered other installations, but every download link in its answer pointed athttp://localhost. The installation being updated rightly refused them, with an "origin mismatch" error that seemed to blame its own setup.The server now refuses those requests itself, and its message names the setting to fix: set
DEDALO_HOST(andDEDALO_PROTOCOL) on the update server. Requests from the same machine are still served, so local development setups keep working.Wire contract:
WC-2026-09-30-update-manifest-local-origin-refusal. -
Time machine restore no longer fails on installs whose outbound host allowlist is empty.
Before, restoring any value from the time machine was refused when
DEDALO_EXTERNAL_ALLOWED_HOSTSwas empty — which is the default. The error named a section you had not touched (on a standard install,test3) and its external catalogue host (Zenon), because the engine checked the allowlist while merely reading an external service binding, even though a restore never contacts that service.Now restores work with no change to your
.env. The allowlist still guards every request the server sends out: a request to a host that is not listed is refused before any connection is opened. Where a host is not allowed, what you see changes in three places: an external search notice now names the real service and says the host is blocked, and an external value in a record and an export's degradation report name the real service, all instead of reporting an unknown, misconfigured source.Wire contract:
WC-2026-09-27-external-allowlist-at-door-only. -
The multi-instance Apache example now sets an empty
DocumentRootand the entry redirects.The Apache virtual host in Multiple instances had no
DocumentRoot, so a vhost copied from it inherited the server-wide one (often/var/www/html) and served its contents on any path it did not route. It now points at an empty directory, like the single-instance reference, and carries the302redirects from/,/dedalo/and/dedalo/core/to the login page. Check each existing Dédalo vhost for aDocumentRootline. -
The developer information bar is shown to developers and root again, on any server.
The information strip at the top of the interface — engine version, build, database and runtime — is a developer surface. It had become tied to
DEDALO_DEV_MODE, so on an installation that did not set that key it was hidden even from a logged-in developer, and setting it inprivate/.envdid nothing because the container environment takes precedence. That is what a Docker installation saw: no developer bar, whatever the.envsaid.The bar now follows the logged-in user, as the application itself did before the TypeScript rewrite: a user flagged as a developer in their record sees it, and root (superuser) always counts as a developer.
DEDALO_DEV_MODEkeeps its own meaning as the server posture — it selects the no-cache boot, the readable client libraries and the dev-only libraries the browser test harness needs — but it no longer decides who sees developer surfaces.Debug-only surfaces (
SHOW_DEBUG) are now shown to root alone, as before the rewrite; other developers keep the developer surfaces but not the debug ones, and non-developers see neither, even on a development server.The main navigation bar is unchanged; only the extra information strip and the developer-only shortcuts are affected.
For developers
Security
-
The RDF import no longer shows the internal address a refused link resolved to.
When an RDF link was refused because its host leads into the institution's own network, the per-link error list of
get_rdf_datarepeated the server's log text, which names the internal address the host resolved to. Each failed link is now reported as{uri, error}, whereerroris the same error body a failed request carries: a fixed, public sentence and a code (security.ssrf_blocked), never the address. The import screen shows one line per failed link, as before, now in the user's language when that error has a translated label.Wire contract:
WC-2026-09-29-rdf-per-uri-error-wire-body.
Changed
-
The test suite now runs its diffusion gates against its own MariaDB server, never an installation's.
Before, the gates that publish to MariaDB used whatever server the machine's configuration named. On a developer machine they created and dropped tables in a real publication database. On a machine without that server they skipped silently and reported green.
Now
bun teststarts a MariaDB server of its own for each test lane, under../private/test_mariadb/<suite database>. It listens on a private unix socket and never on the network. The gates check a marker on that server before they write, and they fail loudly rather than skip when it is missing. The gates never write to an installation's databases. CI runs each of these gates on its own and checks that the rows that gate is declared to write really changed on the suite's server during its run. It also runs every other unit and parity test that can reach the MariaDB connection code, directly or through other modules, with the suite's server up. It fails if any of them connects to the server without first passing the suite's check, or if any of them never really ran. These runs come last in CI's database tier, after the parity tests, so they cannot change the data the earlier stages measure. A check fails if they are ever moved earlier. The tier stops the server when it exits. One gap remains until the engine itself is fixed: while a lane's server is not running, a test that opens a MariaDB connection without going through the suite's own check can still make a login attempt against the machine's default MariaDB server. It cannot write there, since the attempt uses the suite's own user.Removing a lane's server is safe against a run that is still using it. The lane's directory is either fully there, with its marker, or gone, both when it is created and when it is removed. A test waiting to use a removed lane is told so instead of carrying on. A removal that was killed halfway is finished by the next one. A MariaDB command that hangs is killed after a deadline, so it cannot block the lane for everyone else, and a test waiting for the lane waits long enough to report the real problem instead of "lane busy". A server that does not start in time is stopped, not left running. A server that answers with an error is reported and never restarted. Stopping or sweeping a lane also stops any stray MariaDB server or installer still running on that lane's data directory, for example one left behind by a killed run, and waits until it has really exited; if a process survives, the command fails and names it. The shard runner (
bun run test:shard, and itsbun run test:shard:sweep) removes the servers of its shard lanes too, and reports a lane it could not remove with the real error, refusing to run or exiting with an error for it, instead of claiming the lane was not the suite's.What this means for you: running the full suite on a development machine now needs the MariaDB server binaries (
mariadbd,mariadb-install-dbandmariadb; on macOS,brew install mariadb). Without them the MariaDB gates fail and name what is missing. Stop a lane's server withbun run scripts/ci/suite_mariadb.ts stop. To remove it together with its data, usebun run scripts/ci/suite_mariadb.ts sweep.
Added
-
Tool authors can read other sites through
harvestFetch, a harvesting door that obeys robots.txt and paces its requests.A tool that imports from another institution's site (an auction catalogue, a journal's OAI endpoint, a publisher's PDF) can now call
harvestFetchinstead of building its own fetch layer. For every hop of a redirect chain it re-checks the address and your host policy, refuses a switch from https to http, and asks the site'srobots.txt— for images, PDFs and POSTs too. It sends one request at a time per origin, for the whole installation, at least three seconds apart, or at the site'sCrawl-delayorRetry-Aftercapped at one minute. It connects to the address it checked, bounds each hop by a total and an idle timeout and a byte ceiling, and can refuse an unexpected media type before downloading it. Its refusals carry their own codes (harvest.refused,harvest.robots_disallowed,harvest.robots_unavailable,harvest.too_large,harvest.unexpected_type), which name the site and the reason, so a cataloguer learns why a URL was not fetched. See Fetching from other sites.
Fixed
-
A save whose value is not shaped like the field's data is now refused instead of stored.
The save API accepted a value in any shape and answered success: a bare text sent to a translatable text field was stored beside the other languages, and the next save in another language silently removed it; a number sent as text, an empty
nullitem or a non-list replacement were stored or emptied the field the same way. Such a save is now refused withrequest.invalid_data, before anything is written. Each item must be an object —{value: "…"}for a text field,{value: 12}for a number,{start: {…}}for a date,{iri: "…", title: "…"}for a link — and a replacement (set_data) must be a list of them. The application's own editors already send these shapes. The CSV/JSON importer now converts numbers written as text in a number column ("55") and numbers in a text column, and refuses a number cell it cannot read instead of storing it. Emptying a date field in the record editor now removes the stored date; before, it left an empty placeholder in the record. Records that still hold numbers stored as text by earlier versions keep working with the cache-update and propagate-data tools: those tools convert such numbers when they save the record again. The RDF importer converts a value mapped to a number field (a Nomisma weight, diameter or axis) into a number, and reports one that is not a number (number_unparsed) instead of sending it.Wire contract:
WC-2026-10-03-save-refuses-malformed-value-shape. -
The client-library version table in the manual is generated from the pins, so it can no longer go stale.
Client library versions listed outdated versions for five npm-tracked libraries (three, geoman, turf, highlight.js, mocha). Its npm table is now generated from
package.jsonand the client-library registry (bun run libs:gen) and checked byte for byte by a gate, so a version bump that leaves the page behind fails the build. The vendored table stays hand-written and is checked againstvendor/vendor_manifest.json, as before. -
A refused save no longer leaves an empty record behind.
A save to a record that does not exist yet creates the record first. When the change itself was then refused (for example, removing a value the field does not hold), the answer was a failure, but the new empty record stayed, the section's id counter had moved to its id, and the activity log recorded its creation. A refused save now leaves nothing behind: no record, no counter move, no history or activity entry. The answer to the request is unchanged. A save run inside a larger operation (an import row, for example) leaves that choice to the operation, which rolls the row back as before.
-
A relation search with an unreadable value now fails with an error instead of quietly matching every record.
Relation fields (selects, portals, autocompletes…) take their operator — is empty, has a value, is different — in
q_operator, and their value as a record link. When a request put something else in the value, such as the operator itself ("q":"!*") or plain text, the condition was silently dropped, and the search and its count covered the whole section: records with no author counted every record. Such a request is now refused with arequest.invaliderror that namesq_operator. The searches the interface builds were never affected.Wire contract:
WC-2026-09-23-relation-q-is-a-locator.
Wire contract — 106 entries
WC-2026-08-24-install-ip-gate-fail-closedWC-2026-08-24-media-auth-session-scopedWC-2026-08-24-media-protection-default-closedWC-2026-08-24-media-svg-response-headersWC-2026-08-24-proxy-trust-and-password-costWC-2026-08-25-update-code-restore-pointWC-2026-08-25-update-code-waive-backupWC-2026-08-27-csv-ingest-refusalsWC-2026-08-27-duplicate-reminted-dataframe-targetsWC-2026-08-27-tm-lang-slice-restore-mergeWC-2026-08-27-write-language-provenanceWC-2026-08-28-export-gate-b-refuses-unresolvable-segmentWC-2026-08-28-idempotency-keyWC-2026-08-28-login-active-accountWC-2026-08-28-root-self-service-writeWC-2026-08-28-search-path-acl-every-hopWC-2026-08-29-tm-apply-value-confirm-labelWC-2026-08-30-remove-requires-item-idWC-2026-08-30-section-id-counter-is-a-high-water-markWC-2026-08-31-client-reads-three-fields-the-engine-never-emitsWC-2026-08-31-import-code-lookup-refuses-a-truncated-candidate-setWC-2026-08-31-time-machine-history-belongs-to-a-generationWC-2026-09-01-children-recursive-searchWC-2026-09-01-ts-object-svg-urlWC-2026-09-03-bulk-revert-skipped-typed-entriesWC-2026-09-03-duplicate-record-new-activity-rowWC-2026-09-03-info-stored-value-never-servedWC-2026-09-03-maintenance-reconcile-status-widgetWC-2026-09-03-monovalue-insert-replacesWC-2026-09-03-read-door-component-aclWC-2026-09-04-client-limit-boundWC-2026-09-04-committed-third-party-bundles-leave-the-client-treeWC-2026-09-04-context-render-classWC-2026-09-05-children-order-one-ruleWC-2026-09-05-diffusion-lease-epoch-fenceWC-2026-09-05-multi-locator-order-is-deterministicWC-2026-09-05-preauth-intake-boundsWC-2026-09-05-search-terms-are-literalWC-2026-09-06-dataframe-delete-policy-on-slotWC-2026-09-23-relation-q-is-a-locatorWC-2026-09-24-external-foreign-target-and-verbatim-idWC-2026-09-24-external-record-4xx-is-not-foundWC-2026-09-24-external-record-field-setWC-2026-09-24-multi-section-search-identity-dedupWC-2026-09-24-tool-export-server-built-artifactsWC-2026-09-27-bulk-revert-undo-logWC-2026-09-27-csv-import-append-modeWC-2026-09-27-external-allowlist-at-door-onlyWC-2026-09-28-maintenance-serve-code-widgetWC-2026-09-28-maintenance-serve-ontology-widgetWC-2026-09-29-code-release-channel-refsWC-2026-09-29-dataframe-hard-delete-retiredWC-2026-09-29-number-not-equalWC-2026-09-29-rdf-per-uri-error-wire-bodyWC-2026-09-29-search-deep-leaf-mixed-ruleWC-2026-09-29-search-where-parts-parenthesizedWC-2026-09-29-select-family-mode-datalistWC-2026-09-29-tm-preview-frame-children-as-ofWC-2026-09-30-agent-tool-grantWC-2026-09-30-backup-freshness-deep-asyncWC-2026-09-30-backup-part-promotionWC-2026-09-30-db-typed-503WC-2026-09-30-diffusion-attach-scopeWC-2026-09-30-diffusion-run-ledgerWC-2026-09-30-diffusion-target-fenceWC-2026-09-30-diffusion-zip-streamedWC-2026-09-30-guarded-text-pinned-typed-transportWC-2026-09-30-mcp-search-section-grantWC-2026-09-30-media-key-locked-transformWC-2026-09-30-media-pair-scopeWC-2026-09-30-move-transform-execute-jobWC-2026-09-30-ontology-identifier-grammarWC-2026-09-30-password-hash-never-servedWC-2026-09-30-password-policy-enforcedWC-2026-09-30-record-write-obligation-ledgerWC-2026-09-30-search-root-step-aclWC-2026-09-30-transcription-record-tipoWC-2026-09-30-update-engine-atomicWC-2026-09-30-update-manifest-local-origin-refusalWC-2026-09-30-write-doorWC-2026-10-01-ai-spend-budgetWC-2026-10-01-change-plan-write-doorWC-2026-10-01-delete-locator-write-doorWC-2026-10-01-identify-vision-grantWC-2026-10-01-ontology-overwrite-scopedWC-2026-10-01-rdf-harvest-doorWC-2026-10-01-rdf-ontology-importWC-2026-10-01-relation-search-display-pathsWC-2026-10-01-search-hop-configured-targetsWC-2026-10-01-site-builder-confinement-codesWC-2026-10-01-tool-grant-one-decisionWC-2026-10-01-unit-test-widget-dev-gateWC-2026-10-01-write-door-delegationsWC-2026-10-02-relation-children-write-throughWC-2026-10-02-select-lang-missing-entryWC-2026-10-03-publication-hosts-widgetWC-2026-10-03-save-refuses-malformed-value-shapeWC-2026-10-06-consultation-only-no-section-toolsWC-2026-10-07-acquisition-tools-ts-onlyWC-2026-10-07-ontology-area-admin-grantWC-2026-10-07-update-code-root-entries-stampWC-2026-10-08-install-ip-denied-names-addressWC-2026-10-08-install-plan-update-servers-core-lgWC-2026-10-08-make-backup-scheduled-evidenceWC-2026-10-09-publication-host-panel-setupWC-2026-10-09-publication-host-v2-only-site
7.0.0-beta.4 — 2026-08-24
534 commits: git log --no-merges v7.0.0-beta.3..v7.0.0-beta.4
For users
Fixed
-
A blocked pop-up now says so instead of breaking the action that opened it.
Opening a tool, a record, an ontology page or a related-records list in a new window failed silently when the browser refused the pop-up: no window appeared and the action behind it stopped half way, with nothing on screen to explain why. The refusal is now reported to the user, and open related records no longer claims success for a list nobody saw.
-
Editing one field and then correcting a typo in another no longer loses the first edit.
A long text field commits its change half a second after you stop typing. If, inside that window, you typed a character in any other field and deleted it again, the page concluded that nothing was unsaved: closing the tab or moving to another record then discarded the text field's edit with no warning, no save and nothing in the console.
The page tracked unsaved work as a single yes/no answer for the whole screen, and every field was allowed to write it. A field returning to its stored value answered no on behalf of every other field, including ones still holding work. Each field now reports only for itself, and the page answers yes while any of them still has something unsaved — so the save-before-leaving sweep and the confirmation prompt both run when they should. A test now holds that a field may only ever retire its own answer.
-
A missing language list no longer disables language selection for the rest of the session.
The list of languages is fetched once and kept for the whole page. A failed fetch was kept in exactly the same way: the failure itself was stored as if it were the list, every later request for languages was answered with it, and nothing ever tried again. A map field would then fail outright and the rich-text editor would silently load in the wrong language. Only a usable list is kept now; anything else leaves nothing behind, so the next request for languages asks the server again.
-
A field whose save comes back without its record keeps its value instead of emptying itself.
When the answer to a save did not carry the record back — the save itself having succeeded — the field threw away everything it knew about what it was editing: the value on screen, and the copy it keeps to decide whether anything has changed since. From that point the field looked perfectly normal but had nothing left to save, every later edit was reported as a change whether or not it was one, and the only notice of it was a console line that appeared solely with developer mode on.
The field now keeps what it was editing, records that it is out of step with the server, and says so unconditionally. While it is in that state it also stops trusting its own record of what the server holds: its next save is always sent, rather than being skipped as nothing has changed — so the edit the server never confirmed is retried instead of being quietly dropped. Tests hold all of this, including that the retry really goes out.
-
A request to a slow server can no longer wait forever.
While a request is in flight the page asks the server whether it is still alive, so that a slow answer is not mistaken for a dead server. That check removed the request's time limit instead of extending it: a server that answered the check and then stopped responding left the page waiting indefinitely — the awaiting for busy server notice dismissed itself, the panel stayed empty, and no error was ever raised. A busy server now buys the request more time, and the notice stays visible for as long as the wait it describes.
Long operations keep their own limit. The extra time is added to whatever is left of the original allowance rather than replacing it, so a database backup still gets its full hour.
-
Text you have typed but not yet confirmed is now protected when you reload or close the tab.
Typing into a field and reloading the page dropped the typing with no warning: no prompt, no save, nothing in the console. It happened in most field types — plain text, dates, numbers, e-mail, passwords, coordinates — while long text fields were unaffected, which is what made the loss look arbitrary.
The page only counted a field as unsaved once the field had confirmed its value, and most fields confirm on leaving the field. Reloading or closing a tab never leaves the field, so the page believed there was nothing to protect. Unconfirmed typing now counts from the first keystroke, and leaving the page forces the field you are in to confirm first — so a real edit raises the browser's leave-page warning and can still be saved, while typing you have already undone raises nothing.
Only fields that hold record data in edit mode are watched: search boxes, list cells and the lookup box inside a related-records field are not your unsaved work and never trigger the warning. A test now types into every kind of field, for every component, and fails if any of them can be reloaded away unnoticed.
-
Logging in no longer asks for the credentials twice.
The login screen rendered the login panel twice — the form itself, dimmed, with a second one overlaid on top of it — so entering the user and password once appeared to do nothing and the operator had to type them again.
The second panel was the re-login overlay, which exists to recover a session that died under a working page. The activity tray mounts with the page, and it mounted on the login screen too: its first act is to read the caller's own background work, which for a caller who has not logged in yet is correctly refused with
not_logged— and anynot_loggedraised the overlay. The tray now mounts only for a logged-in page and asks nothing while anonymous, and the overlay refuses to open on a page that never had a session: it is a re-login, never a second login. A session that expires mid-use still gets the modal, which is the case it was built for.
For administrators
Security
-
The activity panel of the area dashboard renders user names as text.
That panel lists the most active users by the name recorded in the users section. The name was inserted into the page as markup, so a name containing HTML was interpreted by the browser of everyone who opened the dashboard instead of being displayed. Names — and the counts beside them — are now written as text, as the chart legend on the same panel already did.
Removed
-
The ontology parser's Reconcile button.
The tool offered two writes onto the runtime ontology — an incremental Reconcile that applied only what had drifted, and Regenerate, which wipes the hierarchy's runtime nodes and re-derives them. Reconcile's whole justification was that it never left the ontology momentarily empty, and that stopped being true of Regenerate the day the rebuild moved inside a single transaction: readers keep seeing the current ontology until the new one commits, and a failure rolls back. What was left was a strictly weaker write offered beside the stronger one, with nothing to tell an operator which to press — and a second code path to keep in step with the ontology parser forever.
Regenerate is now the one write onto the runtime tree, and Refresh status still answers "what drifted" without changing anything. Ordering a node no longer prompts the question the button existed to ask: reordering genuinely changes every sibling that moved, so "only what drifted" was the whole hierarchy anyway.
Changed
-
Rebuilding an ontology no longer refreshes the export files, and is ~20× faster for it.
Regenerate used to rebuild the LLM map for the whole installation afterwards, whichever single hierarchy you had ticked. That map is one of the files other installations download, and a rebuild refreshes none of its companions — so keeping it alone current never made the published set coherent, while costing 21 of the 22 seconds an operator spent waiting. The files are refreshed by Export, which publishes all of them together.
If you rebuilt in order to publish
Press Export afterwards. That was already true of the ontology definition files; it is now true of the LLM map too.
This is also where the
dropped sqo target …lines in the server log during a rebuild came from — the whole-install map walk, reporting components that point at ontologies this installation has registered but never imported. They still appear on an export, and in normal editing of those components, which is where they are actionable.
Fixed
-
The Export hierarchy maintenance panel opens again.
Selecting it in the maintenance area refused with "The 'export_hierarchy' panel is not available on this engine" and rendered nothing — which also put its working Sync hierarchy active status action out of reach, since the whole card dies with the failed value load. The panel now reports that hierarchy EXPORTING is not offered by this engine (it wrote install dump files) and renders the sync form, which is the operation it actually has.
-
The five migration panels (Move TLD, Move locator, Move to portal, Move to table, Move lang) show their definition files again.
Each opened with no explanation text and an EMPTY file list, so there was nothing to select and the transform could not be run at all. The engine had been serving the body and the file list all along — the panel simply never asked for it.
Both bugs are the same invariant broken from opposite sides: a panel's value load is a PAIR (the widget serves a value, the panel asks for it) and either half alone is a broken panel. A gate now checks the pairing in both directions for every maintenance widget, so a half-wired panel fails the test suite instead of an operator's browser.
-
Any component offering "every ontology" as a search target took 42 ms to configure — on every single request.
Resolving such a component's search configuration enriches each target section it offers, and asked the database twice per section whether it has a new record and a delete record button — four times over for a section with neither. On an installation carrying 205 ontologies that is ~800 queries to draw one autocomplete, repeated on every page that shows it, and nothing about the answers changes between requests.
The lookup is now remembered alongside the other ontology reads, so it costs 0.64 ms. Authoring a new button still shows up immediately — the memory is dropped whenever the ontology is written, like every other ontology cache. Building the LLM map, which resolves 9 408 such fields, went 21.7 s → 1.2 s on the same installation, producing a byte-for-byte identical file.
-
The active-tools registry was re-queried once per rendered row.
The lookup takes no arguments — it is the same query every time — and a media-icon widget runs it per row, so a ten-row page ran it ten times for 43 ms. It is now read once and cleared whenever the tools registry (
dd1324), the install config or a user profile is written, through the existing tool-cache invalidation.Out-of-band registry edits need a cache clear
Activating or deactivating a tool through the interface invalidates the cache immediately. Editing
matrix_toolsdirectly in the database does not — use the maintenance area's cache-clear action, or restart. -
A list page re-read the same record once per widget path.
The info widgets ask for one component at a time, and each ask fetched a WHOLE matrix row — so a
component_statedeclaring eight paths over the same related record fetched that record eight times, and a media-icon widget on the same page fetched it again. On a ten-row page of an oral-history section that was 145 record reads for 27 distinct records — 81% pure repetition, and 328 ms of the 371 ms the request spent in the database. The waste grew with both the page size and the archive, which is why the page kept getting slower without anything changing.A record is now fetched once per read, and the page-level batch loaders hand their results to the same store, so a component asking for a record the page already loaded costs no round trip. Same page: 3 record reads, and the count no longer grows with the page size. The list read went 421 ms → 66 ms, and an edit read of the same section 107 ms → 56 ms. Responses are byte-for-byte identical — this is purely the number of round trips.
Wire contract — 114 entries
WC-2026-08-03-chunked-upload-identityWC-2026-08-03-cors-allowed-originsWC-2026-08-03-service-dropzone-folded-into-service-uploadWC-2026-08-04-import-derivative-failure-counts-as-importedWC-2026-08-04-media-versions-panel-wireWC-2026-08-05-external-api-config-publicationWC-2026-08-05-external-derived-emissionWC-2026-08-05-external-entry-normalisationWC-2026-08-05-external-first-record-reduceWC-2026-08-05-external-request-field-unionWC-2026-08-05-external-source-statusWC-2026-08-05-multi-engine-ddo-expansionWC-2026-08-06-external-client-renderWC-2026-08-06-external-search-requestWC-2026-08-06-external-write-refusalWC-2026-08-06-observer-degraded-seed-refuses-shrinkWC-2026-08-06-observer-equivalence-class-targetsWC-2026-08-06-observer-grow-only-failsafe-retiredWC-2026-08-06-observer-removal-propagationWC-2026-08-07-breakdown-total-orderWC-2026-08-07-media-alternate-version-builderWC-2026-08-07-retouched-tier-is-a-masterWC-2026-08-08-csp-connect-src-update-mastersWC-2026-08-08-geolocation-emptiness-explicitWC-2026-08-08-media-thumb-handlerWC-2026-08-09-autocomplete-hi-ancestor-searchWC-2026-08-09-av-fragment-failure-reasonWC-2026-08-09-changes-in-locators-integrityWC-2026-08-09-code-manifest-target-dedupeWC-2026-08-09-dataframe-import-id-key-identityWC-2026-08-09-date-search-uncovered-mode-throwsWC-2026-08-09-ddo-map-per-entry-sanitizationWC-2026-08-09-diffusion-degradation-and-loud-ddo-fnsWC-2026-08-09-export-raw-dataframe-own-columnWC-2026-08-09-image-svg-envelope-preservedWC-2026-08-09-indexation-grid-read-grant-scopeWC-2026-08-09-info-legacy-stored-value-fallthroughWC-2026-08-09-ingest-companions-and-human-filenameWC-2026-08-09-media-index-target-selectionWC-2026-08-09-media-restore-no-overwriteWC-2026-08-09-media-spawn-integrity-and-livenessWC-2026-08-09-media-tree-boot-provisioningWC-2026-08-09-media-tree-configurable-folders-and-boot-budgetWC-2026-08-09-move-lang-collision-refusedWC-2026-08-09-move-to-table-self-move-refusedWC-2026-08-09-ontology-tld-derivedWC-2026-08-09-ontology-update-partial-restoreWC-2026-08-09-portalize-portal-mergeWC-2026-08-09-provider-message-truncation-boundaryWC-2026-08-09-record-birth-defaultsWC-2026-08-09-simple-schema-changes-pre-importWC-2026-08-09-time-machine-restore-replays-paired-dataframe-framesWC-2026-08-09-tm-date-search-yearless-q-dropsWC-2026-08-09-users-section-record-scopeWC-2026-08-10-maintenance-catalog-fail-softWC-2026-08-10-section-id-int-canonicalWC-2026-08-11-diffusion-computed-inverse-hopsWC-2026-08-11-diffusion-uninstalled-package-skipWC-2026-08-11-regenerate-drops-llm-map-post-stepWC-2026-08-11-vector-tag-badgesWC-2026-08-12-authorization-denial-tokenWC-2026-08-12-filter-records-enforcedWC-2026-08-12-indexation-tool-buttonsWC-2026-08-12-media-job-visibilityWC-2026-08-12-user-activity-full-historyWC-2026-08-13-maintenance-ai-models-widgetWC-2026-08-13-transcription-status-panelWC-2026-08-14-llm-map-target-sections-pluralWC-2026-08-14-model-section-registry-branchWC-2026-08-14-non-section-target-refusedWC-2026-08-14-relation-insert-accepts-batchWC-2026-08-14-relation-insert-target-validationWC-2026-08-14-relation-model-target-in-sqoWC-2026-08-14-target-mode-retiredWC-2026-08-14-thesaurus-picker-caller-declaredWC-2026-08-14-tm-cells-obey-list-emit-policyWC-2026-08-14-tm-ddo-mode-retiredWC-2026-08-14-tm-permission-floorWC-2026-08-14-tm-scope-server-ownedWC-2026-08-15-code-manifest-sha256-and-snake-keysWC-2026-08-15-diffusion-connection-status-ok-messageWC-2026-08-15-diffusion-job-result-recordWC-2026-08-15-error-envelope-v2WC-2026-08-15-error-status-is-a-channelWC-2026-08-15-external-degradation-is-a-noticeWC-2026-08-15-http-layer-error-envelopeWC-2026-08-15-identify-decline-codesWC-2026-08-15-install-refusals-typedWC-2026-08-15-mcp-error-code-alignmentWC-2026-08-15-stream-error-framesWC-2026-08-15-tool-response-envelope-v2WC-2026-08-16-error-envelope-compat-removalWC-2026-08-16-sqo-session-not-persisted-on-session-save-falseWC-2026-08-16-tm-tool-note-valueWC-2026-08-17-list-row-is-indexableWC-2026-08-17-list-row-selectability-declaredWC-2026-08-18-dashboard-total-non-record-storeWC-2026-08-19-rqo-body-csrf-tokenWC-2026-08-19-test-tld-replayWC-2026-08-19-test-tld-replay-search-groupWC-2026-08-19-tool-lang-translator-engine-type-and-browser-transformerWC-2026-08-19-tools-gates-test-tldWC-2026-08-21-files-cache-finish-messageWC-2026-08-22-preauth-language-cookieWC-2026-08-23-build-provenanceWC-2026-08-23-dedalo-files-post-harvest-censusWC-2026-08-23-diffusion-langs-widgets-derivedWC-2026-08-23-maintenance-widget-label-caseWC-2026-08-23-projects-default-langs-derivedWC-2026-08-23-test6228-clone-recutWC-2026-08-23-tool-export-register-labelsWC-2026-08-23-update-mode-clean-onlyWC-2026-08-23-user-tools-post-harvest-censusWC-2026-08-24-update-code-dev-channel
7.0.0-beta.3 — 2026-08-03
440 commits: git log --no-merges v7.0.0-beta.2..v7.0.0-beta.3
Action needed when you update
- Media URLs honor
DEDALO_MEDIA_WEB_BASE
For users
Changed
-
The state widget's edit view was restyled.
It now follows the maintenance widget-kit language: card surface, uppercase header, hairline row rules, tabular numerals, and a meter per percentage (full at 100%, muted empty track at 0%). Every colour goes through a theme token, so the breakdown panel is no longer a white slab in dark mode. Percentages are keyboard reachable, and the panel stays open while the pointer travels into it.
-
Append-only logs restrict sort and search dimensions
Arbitrary component sorts on the append-only log are disallowed: every
dd542column issortable:falseexcept When (dd547), whose order maps to the directsection_idcolumn (append-only ⇒ insertion order). The edit-mode search FIELDS panel now omits the shared section-info group (dd196) for Activity (dd542) and Time Machine (dd15), where that editorial metadata is meaningless as a search dimension.Wire contract:
WC-044,WC-045.
Added
-
Pre-expiry warning.
The client warns before a session dies, so unsaved work can be committed instead of the next click failing. Lead time is
SESSION_WARNING_SECONDS(default 300;0disables it). The boot payload ships only the absolute deadline — the idle window restarts on every request, so the client re-arms a local timer from its own activity beat. -
Assistant grounding tools
The AI assistant's loop gains section/facet scoping on
dedalo_semantic_searchand the newdedalo_retrieve_passages(exact chunk-level passages for cited answers, deduplicated across languages). The external-model egress gate now also classifies every deep-resolution CONTRIBUTOR section of a hit — text a group document pulled from a forbidden section can never reach an external model through a public host record. -
Semantic search in the client
A "Search by meaning" quick input in the section-list toolbar and a semantic block in the search panel (composes AND with the structured filter). Ranked hits pin the list via
filter_by_locatorsplus the new{"mode":"locator_position"}SQO order entry, so relevance order survives pagination, counts and exports; an SQO-derived pinned chip makes the state visible and clearable. Sections with several embed groups get a facet selector (dd_rag_api embed_groups). Search presets store the LIVE natural-language query and re-run it on Apply.Wire contract:
WC-047.
Fixed
-
A row with no data collapsed the layout.
The cells of an empty situation/state column were not rendered at all, so the next row's label was auto-placed into the hole and the labels marched across the columns. Empty cells are now drawn, and read
—. -
The state breakdown panel contradicted its own cell.
A source record that saved nothing contributes 0 to the average but emits no
detailitem, so a record with two linked resources and one value showed a hover panel readingtotal : 50%beside a cell reading25%— two numbers, both labelled total. The panel now lists one row per source record (the empty ones included, as explicit0%) under a single ruled-off total that always equals the cell. It also dropped every row after the first on a non-translatable leaf, and clipped long option names to one letter per line. -
An expired session raises the re-login modal instead of a network error and blank widgets.
The client's whole recovery path keys on the error code
not_logged, which the server never emitted (it put the human message inerrors[]), and the client's retry wrapper treated the 401 as a transport failure and threw before the response could be read. Expiry now raises the re-login modal in place, and pending saves replay onlogin_successful. -
Images, video, PDFs and 3D files no longer 404 for anyone logged in longer than a day.
Until this fix they did, while the application itself looked completely healthy. The media cookie was minted only at login with a fixed 24-hour
Max-Agewhile the session refreshed on every request; since the web server enforces media access, losing the cookie removed every media file with no other symptom. The reverse leak is closed too — a cookie minted just before logout stayed a valid media credential for up to 48 hours with no session behind it. -
Search preset leaked fields from the wrong section
The search panel restores your in-progress filter from a per-user temp editing preset (
dd655) plus named presets (dd623). These were keyed by the search's caller section (self.section_tipo) instead of the section actually being searched (self.target_section_tipo, the same source the field list uses). The two diverge whenever the searched section differs from the host section — an ontology/thesaurus browser, or a relation/portal/autocomplete picker opened from inside another section — so a filter built from one section's fields was stored under a different section's key and later surfaced there (e.g. anontologytype0filter appearing in the Activitydd542search panel). Presets are now scoped by the searched section (client/dedalo/core/search/js/preset_scope.jspreset_scope_tipo), and the section-key match is exact (q_operator: '==') so a tipo that is a substring of another can no longer bleed. Guarded bytest/unit/search_preset_scope.test.ts.
For administrators
Changed
-
The media access cookie now lives exactly as long as the session.
dedalo_media_authis re-issued on any authenticated request whose value is missing or stale, withMax-Age= the session idle window. Media protection · login. -
Session expiry retuned.
The idle timeout drops from 12 h to 1 h (
SESSION_TTL_SECONDS) and the absolute cap from 30 days to 12 h (SESSION_ABSOLUTE_TTL_SECONDS). An unattended browser is the threat the idle window exists for, and because the client polls in the background the idle clock alone never expires anything — the cap is what actually ends a working day. Both clocks were already enforced on read; only the defaults moved. Long-running work is unaffected: background imports keep their requesting user on the job record and diffusion re-derives the enqueuing principal at run time, so a publication run or a massive import survives its owner's logout and reattaches after re-login. -
Activity (
dd542) list survives 30M+ rowsOn a 32.9M-row / 85 GB
matrix_activitya header sort or a deep page used to full-scan the whole table (>60 s at every page). Three wire-identical internal rewrites (same rows, same order, same paginated envelope): the structural sort key (section_id/id) is emitted index-aligned with noNULLS LAST, so the default newest-first list is index-served (>60 s → ~11 ms); deep and last pages use a late-row-lookup + order-flip on the flattened unique-key path (>5 s → ~64 ms); and the bare-browse total is served from a save-event-invalidated cache. Ordered-search SQL is flattened to an inlineORDER BY … LIMITwhenever the target table carries the full unique(section_id, section_tipo)key.Wire contract:
WC-044,WC-046. -
Media URLs honor
DEDALO_MEDIA_WEB_BASE(action needed)Every client media URL is now built on a configurable base (
config.media.webBase), so an install serving media from a different origin than the app can emit correct absolute URLs; unset means the previous same-origin relative default. Distinct fromDEDALO_MEDIA_EXPORT_BASE, which continues to root export cells only (unset means unresolved, never guessed). That key is the rename ofDEDALO_MEDIA_BASE_URL: the two names were indistinguishable while the values differ only in audience, so the pair now says who each one serves — the old spelling is retired and refuses the boot until the line is renamed. Its value is normalized likewebBase(trailing slash stripped), and it takes the media directory too (https://host/dedalo/media), because both bases are prefixed to the same media-root relative path. See config reference.Wire contract:
WC-042. -
Optimize tables prunes redundant indexes on the two logs.
The Optimize tables action of the Database-info maintenance widget prunes dead/redundant indexes on the two logs by a single-source-of-truth policy (never a constraint or a proven-used index); ~7.9 GB reclaimed on the reference install.
-
tool_update_cachenow requires an explicit scopeThe silent whole-section fallback is removed: a missing or malformed
sqofails closed withinvalid_request, and the client sends a deep clone of the caller list's livesqo, so a run's scope is exactly the scope the list displays. The confirm dialog carries the record and component counts; the media regenerate path rebuilds only files that are missing (instead of re-encoding everything), mints add800bulk-process record per run, and suppresses Time Machine for the run's re-saves.Wire contract:
WC-043.
Added
-
The full 16-code activity WHAT map is emitted
Eight event emitters were added (LOG IN / LOG OUT / NEW / UPLOAD COMPLETE / DELETE FILE / RECOVER SECTION / RECOVER COMPONENT / NEW VERSION), so the
user_activitycharts are no longer limited to the four previously-instrumented events. Login failures are recorded too (throttle lockouts and maintenance refusals included). Known gap, ledgered: the projects dimension (relation.dd550) is not written by the engine, so activity filtered by project shows nothing from the current era.Wire contract:
WC-040. -
Agent-built public websites
A wholly TS-native subsystem lets users build public sites over the published data by talking to a coding agent: a standalone daemon (
publication/site_builder/, isolated like the publication API), the proxy tooltool_sitebuilder(a three-pane workspace), and asite_builder_statusmaintenance widget that probes the daemon and hosts the launcher. No counterpart existed in the previous engine. Docs: Site builder · internals.Wire contract:
WC-035. -
RAG embed-groups descriptor
What a section vectorizes is now AUTHORED in its
section_mapnode:properties.rag.embedis an array of named groups, each an exact request_configddo_map. A group is one vector document per (record, data language) — the facet unit (a person'sprofessionvsfiliation; a transcription with its own chunking) — stored underrag:<group>. Resolution reuses the section-read machinery (emitDdoData), so DEEP relation resolution works: a coin type's card can embed its mint's name, resolved through the relation. Virtual sections select their own maps (the section_map read is virtual-aware) — the earlier per-component boolean opt-in, which could not differentiate virtual siblings and indexed no text at all for virtual sections, is retired. Documentation: RAG & semantic search · cookbook R1. -
RAG ops
The drain cron + embedding-sidecar runbook (
engineering/PRODUCTION.md§11); Ollama's native/api/embedmatches the sidecar contract directly.
Fixed
-
Expired sessions are garbage-collected when they hit the absolute cap.
The sweeper matched only the idle clock, so a session kept warm by a polling client past the cap was refused on every request and its row kept forever.
-
Long text fields are embedded in full, not as their list preview.
Text-area values embedded through list-mode ddos were truncated to the 130-character list preview (a 2.1 MB transcription embedded as 154 chars). Ddo
modeinrag.embedmaps is now honored verbatim when explicit and defaults to full-value resolution for literals when absent.
For developers
Changed
-
sanitizeClientSqocapsfilter_by_locatorsat 1000 pins.sanitizeClientSqoclampsfilter_by_locatorsto 1000 pins (loud log). -
Semantic indexing no longer depends on who saved the record.
Index-time resolution always runs under a system scope with explicit data languages — a record's vectors never depend on which user's save triggered the re-index (guarded by
rag_index_scope_tripwire). -
Scoped semantic searches can no longer be starved by a dominant section.
Retrieval scope is pushed down into the vector-store legs (a dominant section can no longer starve scoped searches into empty results).
Added
-
itemson thestatewidget'stotalitemsThe source-record count the total is averaged over, i.e. its own divisor. TS-only and additive; wire contract
WC-2026-08-03-state-widget-total-source-count. Shape and rationale. -
A generic Stop wire for background jobs
dd_utils_api::stop_processis registered (the copied client's Stop button always posted it but no handler existed), owner-gated and job-scoped; the abort reaches handlers as a per-jobAbortSignal, andupdate_cachechecks it per record and returns a partial summary.Wire contract:
WC-043.
Wire contract — 50 entries
WC-035WC-040WC-041WC-042WC-043WC-044WC-045WC-046WC-047WC-048WC-049WC-050WC-051WC-052WC-053WC-054WC-055WC-056WC-057WC-058WC-059WC-060WC-061WC-062WC-063WC-064WC-065WC-066WC-067WC-068WC-069WC-071WC-072WC-073WC-074WC-075WC-076WC-077WC-078WC-079WC-080WC-081WC-2026-08-02-index-tag-legibilityWC-2026-08-02-observer-cascade-bounded-flagWC-2026-08-02-observer-references-limit-not-honouredWC-2026-08-02-observer-relay-writes-nothingWC-2026-08-02-observer-subscription-registry-activationWC-2026-08-02-register-tools-registry-stateWC-2026-08-02-tool-string-lang-fallbackWC-2026-08-03-state-widget-total-source-count
7.0.0-beta.2 — 2026-07-18
274 commits: git log --no-merges v7.0.0-beta.2
For users
Added
-
Login forgot password flow is live
The recovery actions
request_password_reset/confirm_password_resetare implemented natively (src/core/security/password_reset.ts+ a nodemailer SMTP mailer), with pending codes in the session store.requestalways returns{result:true, reset_id}(anti-enumeration); a successful reset evicts the user's existing sessions. TLS peer verification is never disableable (pin a private CA viaNODE_EXTRA_CA_CERTS). Config: catalog domainmailer(DEDALO_SMTP_*,DEDALO_PWRESET_*). User guide: Password recovery.Wire contract:
WC-039.
Fixed
-
Special-table component search restored
component_jsongained a search builder (Activity's Datadd551and every JSON component were previously unsearchable), and the Time Machine table (dd15), which stores each component in a flat physical column, gained a component conformer so its clauses are honored instead of returning all rows. Text matching on these paths is accent- and case-insensitive (a safe superset that never hides a match).Wire contract:
WC-037. -
Time-machine date search now filters
The When (
dd547) search in Activity (dd542) sent a structured object that the builder had been stringifying to"[object Object]"and dropping, so every date search ran unfiltered; object-q is now normalized. Directional operators are implemented for the special-table date path: each typed value defines a precision-sized half-open period and the operator picks the boundary (>2026→ strictly after 2026), where the frozen engine left them all falling through to a range equality.Wire contract:
WC-036.
For administrators
Changed
-
IP→country resolution moved server-side and offline
The
ip_apidescriptor is removed frompage_globals; the client no longer has each browser fetch a third-party geolocation service. Resolution is now the native GeoIP subsystem (src/core/geoip/, DB-IP Country Lite) behind the same-originget_ip_countryaction. Config keys:DEDALO_GEOIP_*(the oldIP_APIkey is dropped).Wire contract:
WC-038.
Added
-
Staged cutover levers
DEDALO_DIFFUSION_NATIVE(flips the byte-identical client from the old engine to the main API) andDEDALO_DIFFUSION_NATIVE_ELEMENTS(per-element routing); native in-process MariaDB delete propagation with the old engine socket kept as transition fallback. -
Native diffusion engine
The publication pipeline (publish, not just delete/status) now runs natively in the TS work server: dd1190 →
PublicationPlancompiler, streaming resolver (recursive ddo-chain walk, publication gate, linked-record frontier), the 33-fn parser registry (23 runtime + 10 compile-absorbed), 5-rung language projection, format writers for SQL/Socrata/CSV/JSON/Markdown/RDF/XML, a durable Postgres job queue (dedalo_ts_diffusion_jobs) with spawned runner processes, checkpointed crash-resume (byte-equivalent), and the completedd_diffusion_apiclient action set — the copiedtool_diffusionclient works with zero edits. 228 tests across 16 suites; oracle spot-check against old-engine-published rows. Documentation: diffusion/native_engine.md; specengineering/DIFFUSION_SPEC.md.
For developers
Changed
-
Label catalog cleanup
The master key census went 686 → 413: 28 renames to English keys, 240 proven-unused removals, and 21 single-tool keys migrated into their tools' own
register.jsonlabels (edited withtool_dd_label). Theget_labelwire shape is unchanged; only the key set.Wire contract:
WC-034. -
Program strings are now repo-owned label catalogs
The application's buttons, menus, dialogs and error text are served by committed files (
src/core/labels/master.json= the complete key set with source strings;src/core/labels/catalog/lg-<code>.json= per-language translations) merged into theget_labeldictionary bysrc/core/labels/catalog.ts. Labels ride code deploys, not ontology updates: a key ships in the same commit as the code that references it. The served dictionary always carries the full master key set (previously a lang file missing a key servedundefined). The prior model —dd_ontologymodel='label'(dd383) rows rebuilt into generated JS lang files — is retired: those rows are inert and the generated lang files are deleted. New invariant gate:labels_tripwire. See Internationalization → Program strings.Wire contract:
WC-033. -
The diffusion documentation describes only the native engine.
The legacy pre-rewrite + external-engine diffusion documentation was removed: the TS server is a new version built from scratch, and its docs describe only the native engine. Deleted pages:
diffusion/dd_diffusion_api_and_bun.md,diffusion/engine_internals.md,diffusion/diffusion_config_properties.md,diffusion/diffusion_multiple_databases.md,api/diffusion_api_documentation.mdand the wholeapi/diffusion/directory (README.md,architecture.md,data_model.md,endpoints.md).core/system/diffusion.md rewritten as the lean conceptual overview of native diffusion; diffusion/diffusion_markdown.md rewritten for the native Markdown writer; diffusion/diffusion_data_flow.md cleansed of old-architecture wording and re-anchored to diffusion/native_engine.md, now titled The diffusion engine — the single technical reference.
Wire contract — 39 entries
WC-001WC-002WC-003WC-004WC-005WC-006WC-007WC-008WC-009WC-010WC-011WC-012WC-013WC-014WC-015WC-016WC-017WC-018WC-019WC-020WC-021WC-022WC-023WC-024WC-025WC-026WC-027WC-028WC-029WC-030WC-031WC-032WC-033WC-034WC-036WC-037WC-038WC-039WC-2026-07-09-installer-diagnostics-grid