Skip to content

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

  • Every AI request now counts against daily limits set per user and per profile, and generated answers need their own permission.
  • The browser installer is gone — install with the terminal installer; a server with no configuration now refuses to start.
  • SELinux lets the web server reach the Dédalo engine only, and no backup or rollback step runs as root on files the service user can change.
  • The terminal installer sets up the whole host: production, Docker and trial install in one run.
  • The site builder's OpenCode driver loads only the configuration the site builder states.
  • A site builder agent turn can no longer leave configuration in its home directory for the next turn to read.
  • Thesauri are offered from one exported manifest, hierarchy.json, and nothing but declared dependencies is pre-selected.
  • The PostgreSQL socket you give the installer is now the one the server connects through (DB_SOCKET).
  • Docker installations now pull a published, signed Dédalo image, or build it locally if you prefer, and record the choice in .dedalo.env.
  • Installations now choose their domain ontologies (Oral history by default) and install what each one declares it depends on; the install database carries only the core ontologies and no test data.
  • "The Publication API v1 on a publication host answers again: its configuration names the release that runs."
  • 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.
  • 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 apply must 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

  • Logging back in as another user no longer saves the previous user's pending edit.

    When a session expires while you are working, Dédalo shows a login box over the page so you can sign in again without losing your work. Before, if a different user signed in on that box, the edit that was waiting to be saved was sent anyway — under the new user's name, so the Time Machine and the "modified by" stamp named the wrong person. Now a pending save, or a page section waiting to load, is resumed only when the same user signs back in. If someone else signs in, nothing is sent: the page reloads, and until it does nothing can be saved, deleted, moved or uploaded from it — only signing in again is allowed. Your unsaved edit stays marked as unsaved. The decision of who signed in is made in one place only, and the login box just passes the answer on, so the check cannot be weakened by changing one copy of it.

    The same now holds when the other user signs in somewhere else — in another tab or window of the same browser. Before, the next save from your page was quietly retried with the other user's session and recorded under their name. Now every answer from the server says whose session it is, your page keeps working only with your own session, and an answer from someone else's makes the page reload instead of sending anything. A lock you held on a field is no longer released on the other user's behalf when such a page closes, either.

    Wire contract: WC-2026-10-11-session-user-beside-csrf-token.

  • Rich text is cleaned by a real HTML parser when it is saved.

    Text fields with formatting are cleaned of anything that could run a script when they are saved. That cleaning used to pattern-match the text, which missed some disguised forms (for example an encoded javascript: link or an event handler written without a space) and sometimes changed ordinary text that merely mentioned such words. It now reads the text the way a browser does: disguised forms are caught, and text and formatting that are not dangerous come back exactly as written. Embedded SVG, MathML, frames, noscript, title and textarea blocks are now removed together with their content, HTML comments are removed, an attribute written twice on one tag is kept once with its first value (the one a browser uses), and a tag left unfinished at the very end of a value is dropped, as a browser would drop it. The cleaning is repeated on its own result until nothing changes, so removing one dangerous piece can never join the text around it into a new one. Dropping it takes the same short time however long the unfinished part is; in the rare value where the end cannot be found that way, the whole value is kept as plain visible text rather than lost. Values saved before this update are not changed until they are saved again.

    Wire contract: WC-2026-10-11-rich-text-sanitizer-tokenizer.

  • 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 user activity chart names actions in your language.

    The Activity by type chart of a user's activity (Login, Save, Edit, Upload complete…) now takes its action names from the application's translated labels, in the language you are using, instead of from ontology terms. Saved statistics from earlier versions are read as before and add up with new ones under the same action. The seven activity-only names (login, logout, upload complete, recover section, recover component, stats, new version) are new labels, translated in every shipped language.

    Wire contract: WC-2026-10-12-user-activity-what-labels.

  • Searching a thesaurus branch with all its descendants costs the same however large the branch.

    Expanding a term to all its descendants (the "with children" search, fixed filters, the tree) used to ask the database once per term of the branch — about 1,650 requests for a branch of 1,200 terms. The whole branch is now read in one request, in one consistent snapshot, with the same result. Sibling terms that share an internal number across two sections are now always listed in the same order (by section) instead of an order that could change between reads.

    Wire contract: WC-2026-10-11-children-recursive-one-statement.

  • The site builder now says why it refused, and which refusals clear by themselves.

    Before, most refusals of the site builder tool read the same: "The site builder rejected the request". A site name already taken, a build still running, a site with no webspace, a server full of running agent turns and a missing confirmation all showed that one sentence, and none of them was marked as something to retry. Now each refusal has its own message: the name is taken, a folder for that name is still on the server, the domain belongs to another site, there is nothing to publish yet, the site has used its disk space, the server has reached its site limit, and so on. A refusal that clears by itself (another task is running on the site, too many agent turns at once) is reported as temporary, so you can try again in a moment. Permission refusals say that the site builder needs developer or administrator permission, or that the agent session belongs to another user.

    For API consumers: the tool's errors are new site_builder.* codes (listed in the wire contract entry), the busy family moves from site_builder.rejected (400) to site_builder.busy (503, retryable), and no site-builder error carries a free-text message any more — the code and its label are the message.

    Wire contract: WC-2026-10-11-site-builder-reason-codes.

  • A failed agent turn in the site builder now says what went wrong, in your language.

    Before, when an agent turn of the site builder failed, the chat showed the site builder service's own internal sentence — sometimes a server path, a user id or a raw program error — in English whatever your language, and nothing said whether trying again could help. Now each failure has its own message: the turn ran out of time, it was stopped (by you, or because the service was shutting down), the server could not prepare it, the agent stopped with an error (its own output is shown apart, as the agent's), the session history could not be read, or one of the refusals the site builder already explains (the site is busy, the server cannot run the agent safely, …). The details an administrator needs stay in the site builder's log.

    For API consumers: an error record of the session_stream event stream is now {type:'error', error:{code, category, message, label_key, retryable}, agent_output?}, and the stream's event: error frames carry the same error body — never free text. New codes: site_builder.turn_timeout, site_builder.turn_interrupted, site_builder.turn_setup_failed, site_builder.agent_failed, site_builder.replay_failed.

    Wire contract: WC-2026-10-11-site-builder-turn-error-frames.

  • 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": true in its properties.

  • 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 like abcd or 1234. Existing passwords keep working; the rules apply when a password is changed. A program that sets passwords through the API receives a validation.password_policy error 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_request was missing from the standard wiring); wire_tool now 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

  • The search group operator (AND / OR) now shows a "Search operator" tooltip on hover.

    When you hover the AND / OR button at the top of a search group, a tooltip now reads Search operator, so the control is identifiable without guessing from its colour alone.

  • 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 under http:// and https:// 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

  • The projects picker follows a user's project assignments at once.

    When an administrator changed the projects a user belongs to, that user's Projects picker (and the project names shown in lists) kept offering the old set — a revoked project stayed selectable and a newly granted one stayed hidden — until an unrelated change cleared the server's memory. The picker is now keyed by the user's current projects, so the next read shows exactly the new set.

  • No change can place a thesaurus term below itself any more — not two tree moves made at the same moment, nor an edit, import or restore — and two people changing a term's broader terms at the same time no longer erase each other's change.

    Before, two drag-and-drop moves in the thesaurus tree made at the same moment, each harmless on its own, could together place a term under one of its own narrower terms. The hierarchy then contained a loop: the term showed up under itself and its list of ancestors could not be resolved. Now the second move sees the first and is refused with The move would create a cycle in the tree, and nothing is stored. The same refusal now answers when the tree's add button would create a term that a leftover link already names as a broader term of one of its new ancestors; before, that failed with a generic error. Duplicating a term is refused the same way when such a leftover link would make the copy one of its own broader terms; before, the copy was stored with the loop. The same check now guards every other way a broader term is stored: saving the broader-term field in the edit form (or its narrower-terms field), the API and assistant field writes, CSV import, propagating a value to many records, and restoring a value, a whole record or a deleted record from the history. Each refuses a change that would close a loop with the same message and stores nothing. A bulk revert from the history keeps going when one of its changes would close a loop: that change is left as it is and listed among the skipped ones with the reason tree.cycle; every skipped change that failed now names the reason as a code. An existing loop can still be repaired: removing the link that closes it, or adding a broader term that does not touch it, is accepted.

    Changes made at the same moment no longer get lost either. Before, if two people moved the same term (one with several broader terms), or one moved it while another edited its broader-term field or its position among its siblings, the change saved last could silently undo the first. Now the second change waits for the first and builds on it. See the thesaurus tree.

    Wire contract: WC-2026-10-11-parent-link-cycle-every-door.

  • The PDF extractor shows extracted text exactly as the PDF holds it, and its HTML mode is cleaned on the server.

    In text mode the extractor used to read the extracted text as if it were web markup, so anything between angle brackets disappeared from the preview — an epigraphic edition such as IMP CAES<A>R became IMP CAESR, and that was what "Select text" copied into the record. Now the preview shows the characters exactly as the PDF holds them. In HTML mode the page markers are now added on the server, using the page offset set on the PDF field, and the result is cleaned by the same sanitizer that cleans rich text on save before the preview shows it; a PDF with no page markers no longer produces an empty preview. HTML mode also works again with current versions of the PDF tools on the server: an option they do not accept made every HTML extraction fail.

    Wire contract: WC-2026-10-11-pdf-extractor-render-class.

  • Moving, adding and reordering terms in the thesaurus tree is recorded in each term's history and updates its "modified by" and "modified date".

    Before, a change made in the thesaurus tree itself — dragging a term under another broader term, creating a narrower term with the tree's add button, or dragging terms into a new order — was stored without a trace: the term's history showed nothing, and its modified by and modified date still named the previous edit. Such a change could therefore be neither seen nor undone from the history.

    Now each of these changes is recorded like an edit made in the form. The moved term's history shows the move as one change (its broader term before and after), the new position among its siblings is recorded too, and every term whose position changed shows who changed it and when. See the thesaurus tree.

    Wire contract: WC-2026-10-11-parent-link-cycle-every-door.

  • The Time Machine history of a Hierarchies record opens again instead of failing with an internal error.

    Opening the history of a record whose section has a field that takes its choices from another field's value (in Hierarchies: General term and General term model) answered an internal error instead of the list of changes. The history now lists those columns like any other, offering the same choices as the record itself.

  • 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_id and 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 --apply to 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 http to https). 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 .rdf appended still work: the tool tries that form when the first answer is not RDF/XML. The tool now also reads each site's robots.txt and 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_incomplete counter and a media.operation_failed line 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_failed counter 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: true flag. 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-nolan entry), 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

  • Every AI request now counts against daily limits set per user and per profile, 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 limits before the model is called: assistant runs, model tokens, semantic-search queries, and vision calls of the identification tool. When a limit is used up the request is refused with a message saying when it resets (midnight UTC). Nobody is exempt, administrators and root included.

    The limits are set in the AI usage group of each profile (Administration → Profiles) and, to override the profile for one person, of each user (Administration → Users). Each of the four limits is resolved on its own: the user's value when it is filled in, otherwise the profile's, otherwise 0. A limit of 0 refuses every request of that kind; there is no "unlimited" value. A user cannot raise their own limit: on their own user record the AI limits are read-only, whatever their profile allows (administrators with full access excepted). The day's usage of every user is listed in the AI usage section under Administration. See AI spend limits.

    Action needed: a fresh database is required for this beta: databases created by an earlier beta hold engine-written ontology rows that this version no longer uses, and they are not migrated. On the new installation, set the AI limits on the profiles (or users) that should use AI features, since all limits start empty and every AI request is refused until then. Generated answers over the collection also require the Generated answers tool permission (tool_rag): run Register tools after the update, 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, WC-2026-10-12-ddengine-removed.

  • SELinux lets the web server reach the Dédalo engine only, and no backup or rollback step runs as root on files the service user can change. (action needed)

    On RHEL-family hosts the installer's SELinux module now gives the engine a domain of its own (dedalo_engine_t, entered when systemd starts the pinned runtime, which is labelled dedalo_engine_exec_t). The web server may connect to that domain only. Before, the rule named the generic service domain, so the web server could also connect to the sockets of every other service started from /opt or /usr/local. The engine keeps the rights it had. The installer relabels the runtime after loading the module, checks the label, and refuses a first boot whose engine runs in any other domain.

    The reference units in deploy/ for a host set up by hand no longer run anything as root that the service user can change, and they now describe the host by role rather than by fixed paths. The service user owns an instance directory of your choosing, for example /home/my_domain.org/dedalo or /opt/dedalo/app. It holds the code, the private directory and the backups, and the directory above it can stay root-owned. A root-owned bin directory outside it, such as /opt/dedalo/bin or /usr/local/lib/dedalo/my_domain.org/bin, holds the pinned runtime and every script a root-started unit runs. The units name the two directories and the service user through the placeholders INSTANCE_DIR, ROOT_BIN_DIR and DEDALO_USER, so one set of files serves any layout. The site-builder store (store 5) has moved out of dedalo-backup.service into its own root unit, dedalo-backup-site-builder.service, which reads no .env file and writes to /var/backups/dedalo-site-builder. The backup failure alarm now runs as the service user. The code-update rollback unit moves the code trees as the service user and runs only its restart as root, and it and the watchdog name the code-update backup root (--backup-root INSTANCE_DIR/backups/code) instead of reading it from .env.

    In-app code updates work on such a host: the update renames the code only inside the instance directory. When the service user cannot write that directory, the update is now refused before anything is downloaded, with a message naming the directory. Before, it downloaded and tested the release and then failed when it tried to move the code. A host set up by hand with the earlier layout, whose code sits directly in a root-owned /opt/dedalo, must move its code, private directory and backups into an instance directory and render the units again (Upgrading › A host set up by hand).

  • The site builder's OpenCode driver loads only the configuration the site builder states. (action needed)

    The OpenCode agent used to read configuration from the site's working folder and from its home directory — project settings, plugins (which are code it runs), agent definitions, commands and skills — files a previous turn or a site build could have written. Now every OpenCode turn runs with those sources switched off and with the site builder's own configuration (its connection to the Dédalo publication server, its permission rules, and the site's brief as the one instruction file) passed directly; the site builder no longer writes an opencode.json file into the site's folder. Before it runs any turn, the site builder checks that the installed OpenCode honours these switches — and that it can see each of those sources when they are not switched off, so the check proves something; an OpenCode that fails it is reported as unavailable and its turns are refused. OpenCode 1.18.32 passes. When the check could not be completed at that moment (a question timed out), the turn is refused as "busy, try again" rather than as a defect of OpenCode, and the check runs again on the next attempt.

    Action needed: OPENCODE_ENV may now carry provider credentials only — OPENCODE_API_KEY, OPENCODE_AUTH_CONTENT, or a key named like ANTHROPIC_API_KEY, …_AUTH_TOKEN, …_ACCESS_KEY_ID, …_SECRET_ACCESS_KEY or …_SESSION_TOKEN. Any other key (a switch, a proxy setting, HOME, XDG_*, PATH, a configuration path, a permission override) stops the site builder at start, naming the key: remove it. OpenCode remains unavailable on hosts that confine agent turns (AGENT_CONFINEMENT=systemd_scope). The site builder also states OpenCode's credentials itself for every turn, so a login saved with opencode auth login is no longer read: put the credential in OPENCODE_ENV instead. OPENCODE_AUTH_CONTENT may hold api and oauth entries only — a wellknown entry makes OpenCode download and apply a remote configuration on every turn, so it stops the site builder at start. For the same reason an OpenCode console login is refused: while OpenCode's database for the site builder's turns holds a console account (opencode console login), OpenCode applies that organisation's remote configuration on every turn, and no setting turns this off, so every OpenCode turn is refused, naming the opencode console logout command to run. The site builder now also turns off OpenCode's automatic updates, model catalogue downloads, language-server downloads and session sharing on every turn.

  • A site builder agent turn can no longer leave configuration in its home directory for the next turn to read. (action needed)

    Each site's agent turns used to run with a home directory the agent's own account could write, kept from one turn to the next. Programs a turn runs read their settings from there (git, Bun, the agent command-line tools), so one turn could leave a setting that every later turn of that site would read — including one that reopens the site's repository to the agent. The turn's home directory now belongs to root: only its state/ folder is the agent's, and the agent's settings and caches are pointed there or at a read-only, empty folder.

    Action needed: run provision apply on each site builder host after updating. It stops the site builder once, rebuilds every site's turn home directory, carries the agent's own session state (its conversation history, account record and login — nothing else) into the new state/ folder, and moves everything else that stood there, including the agent's own settings, skills and hooks, into the retired/ folder of the agent state directory (closed to root; nothing is deleted). A later provision apply repairs, the same way, any turn home directory the site builder refuses. Until provision apply has run, every agent turn is refused (confinement_unavailable, naming the home directory that is not in its new shape). On a development host running the site builder unconfined (AGENT_CONFINEMENT=none), AGENT_STATE_ROOT is now required: set it to an absolute path in the instance's .env before updating, or every agent turn is refused (confinement_unavailable; the site builder says so when it starts). Each turn there now gets a fresh home directory, and the agent's state lives in <AGENT_STATE_ROOT>/unconfined/turn/state: the site builder moves the Claude Code login, account record and conversation history there once, the first time it starts after the update (it never overwrites what is already there).

  • Root's writes under a directory another account controls now land only in the exact directory expected.

    Two places that provision apply and provision init write to sit under a directory that root does not fully control. The first is a site's web log directory: on Ubuntu, /var/log is writable by the syslog group. The second is the API configuration files in the state tree's shared/ 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. For shared/ that means its exact owner, group and mode. For the web server's log directory it means the owner, group, mode and identity that provision check observed. 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 the host.sudo item. 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 check and provision apply build 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 next apply grants 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 next check or apply, run chown root:root and chmod go-w on each declaration and keep it in /etc/dedalo_publication_host/. provision render is 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/ORIGINAL or image/Original in 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 as image/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 by bun 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; without mod_rewrite it still refuses the file. The .htaccess regenerates on its own after the update; re-render a publication host's rule files with scripts/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) .TMP was 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 with scripts/media_publication_host_rules.ts.

  • 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, --settings and --strict-mcp-config in claude --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 its site.json named at that moment. From then on site.json decides 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: run provision apply after 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 at site.json or 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.
  • 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 git itself 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 .git folder hidden, so no git the turn starts finds a repository. Action needed: run provision apply after 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 .git is 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 .git is 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.
  • 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 apply must run before the updated daemon starts. (action needed)

    Until now, every site of a museum ran its AI turns, builds and git commands 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 apply creates 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, and git gets 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 apply refuses 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 polkitd on Debian and Ubuntu, dnf install polkit on 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 apply refuses 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 opencode agent 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. Use claude_code there.
    • When the daemon is not running, provision apply clears 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 apply refuses 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.defs has 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 git yet, for example after provision apply added 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 git never 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 a HEAD entry. Remove a stray HEAD from 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 now 0710, 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 apply for 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_HOME or SYSTEMD_RUN_BIN. provision apply removes those keys and writes AGENT_IDENTITIES, AGENT_SOCKET_DIR, AGENT_STATE_ROOT and AGENT_IDENTITY_EPOCH. It writes SYSTEMCTL_BIN only when the declaration names agent.systemctl_bin. Otherwise the daemon uses /usr/bin/systemctl, so declare it only on a host where systemctl is somewhere else. Do not add the key to the rendered environment file by hand: the next provision apply removes it.
    • The first run of provision apply stops 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 and git ran as the service user and left .git closed 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.

    See the site builder internals.

  • 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 grant tool_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 Host header, 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 as hop connect failed (timeout) or redirect 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_ontology table 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 reconcile ontology_identifiers (maintenance area, reconcile status, or bun 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 /run hidden. 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/shm instead 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 /proc either. 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 (default registry.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_ALLOW stops 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.org on first use. Name that host in agent.provider_hosts too.
    • 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, /tmp or /var/tmp.
    • A site builder and bun owned by the engine's own user, as the documented install lays them out, are accepted.

    See the site builder internals.

  • 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.5 or abc was 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 reaches 169.254.169.254), 6to4 2002::/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::/48 or 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 setting DEDALO_NAT64_PREFIXES (for example 2001:db8:64::/96 or 64: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_HOSTS on, 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 the address allowlist (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 error report refuses that client until you rewrite the entry without leading zeros. An entry now matches its address in every spelling: 2001:db8:0::1 matches a client reported as 2001:db8::1, 127.0.0.1 (or the loopback token) 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/104 or ::ffff:a00:0/104) is the IPv4 block it spells (10.0.0.0/8).

Removed

  • The browser installer is gone — install with the terminal installer; a server with no configuration now refuses to start. (action needed)

    Every installation route — production, Docker, trial, developer — now runs the terminal installer (./install.sh, or bun run dedalo:install). The browser wizard, its unauthenticated install API and the DEDALO_INSTALL_ALLOWED_IPS and DEDALO_INSTALL_NO_RESTART settings are removed; delete those lines from your .env and compose overrides. A server started without configuration no longer serves a setup page: it writes one line to its log ("not installed: … run the installer", or "restore .env from backup" on an installed instance whose .env is gone) and exits with code 78, which the shipped systemd units do not restart. An interrupted install, or an interrupted re-run on a live instance, keeps the server from starting until it is finished with the installer's --resume. Hosts installed with an earlier dedalo-ts@.service template keep it until it is replaced: update the template from the release so the unit stops on code 78. Adding thesauri after install is unchanged (Maintenance › Import hierarchies). See Installer reference.

    Wire contract: WC-2026-10-12-install-surface-removed, WC-2026-10-08-install-ip-denied-names-address.

Changed

  • Large exports no longer load the whole selection into the server's memory.

    An export now reads its records in pages from one consistent snapshot of the selection, instead of loading every record id before writing the first line, so a very large export uses the same memory as a small one. Two limits are now visible: the in-page (non-streamed) export answer is refused with export.format_limit above DEDALO_SEARCH_CLIENT_MAX_LIMIT records — the streamed and background exports serve the same selection — and each server process runs at most a quarter of DB_POOL_MAX exports at once (at least one), answering export.busy (retry shortly) to an export asked for directly beyond that, while a background export waits for its turn instead of failing. A server configured with DB_POOL_MAX=1 cannot run exports; give it at least 2. A streamed export whose reader stops reading for 15 minutes is closed with export.consumer_idle, which frees its database connection; an export that keeps being read is never cut, however slow.

    Wire contract: WC-2026-10-11-export-buffered-cap-and-admission.

  • Thesauri are offered from one exported manifest, hierarchy.json, and nothing but declared dependencies is pre-selected. (action needed)

    The thesauri a release offers at install time, and in Maintenance › Install hierarchies, are now listed in one file, install/import/hierarchy/hierarchy.json. It is exported from a master installation with the new Export hierarchy.json button of Maintenance › Export hierarchy. It replaces the three hand-written files hierarchies.json, hierarchies_typologies.json and hierarchies_to_install.json, which are deleted. Each entry describes the thesaurus completely (name, typology, default language, real section, scope note, declared dependencies) and lists its data files with their sha256 checksums.

    • Every listed thesaurus is offered, with or without data files. One without data files is installed as an empty thesaurus, ready for your editors.
    • Data files are verified before anything is written. A missing file, or one whose checksum does not match, refuses that thesaurus, and the install reports it. A malformed hierarchy.json is refused as a whole rather than offering a shorter list.
    • An entry must fit this installation before its terms are copied. If its language or typology record does not exist here, or its real section is not a section here, the thesaurus is refused and nothing is imported. An install that was interrupted after the import is finished by the next run. If a thesaurus was installed empty and a later release ships its terms, the install says to use Reset to seed; it does not skip it silently.
    • A thesaurus's own dependencies are suggested with it. Ticking a thesaurus ticks the thesauri its entry declares; you can untick any of them. A thesaurus dependency never stops an install: one you leave out, or one hierarchy.json has no entry for, gives a warning that it is strongly recommended and can be installed later. Only a declared mandatory ontology this installation lacks stops the import, and the installer says why.
    • Only declared dependencies are pre-selected. The installers no longer pre-tick Spain or any other thesaurus. Every declared thesaurus is ticked — a mandatory one marked strongly recommended, never locked — and nothing else is selected. Both installers suggest importing your own country's toponymy (for example --hierarchies np for an installation in Nepal); --list-hierarchies prints the codes.
    • A missing data file is no longer an error, because the thesaurus is created empty.
    • The data export takes an explicit list only. The * (every active hierarchy) and all (the whole table) forms are gone. Each tipo must be the thesaurus or model section (<tld>1 or <tld>2) of an active hierarchy. Languages (lg1, lg2) is refused, because its terms ship in the database seed. Export hierarchy.json skips a malformed registry record and names it, and still writes the file for every other thesaurus.
    • Thesaurus names in the installers appear in your interface language.
    • The install seed now carries only the Languages registry record. Every other thesaurus's record is written from its hierarchy.json entry the first time it is activated.

    Action needed if you added your own hierarchies to install/import/hierarchy/ and listed them in the old hierarchies.json: add their entries to hierarchy.json, copied from the source installation's exported hierarchy.json. See Install new hierarchies.

    Wire contract: WC-2026-10-10-hierarchy-json-manifest.

  • Ontology dependencies are declared in the master's Dependencies field, say whether an ontology or a thesaurus is needed, and can be optional.

    An ontology master now declares what each ontology needs in the Dependencies field (hierarchy60) of its Ontologies main record. This replaces the Required ontologies field from the previous build. That field is removed automatically from every installation at its next start. Its values are not converted, so a master that filled it must declare them again in Dependencies.

    Each dependency names a TLD and says whether its ontology or its thesaurus is needed, and whether it is mandatory.

    • Installing. The installer always installs mandatory ontologies. It offers every other dependency already ticked, and you may untick it, on its screen or with --decline-dependencies. A thesaurus never blocks an install: a mandatory thesaurus is a strong recommendation, marked strongly recommended on the screen. Leaving it out, or a release that does not ship it, gives a warning that names it and says it can be installed later from Maintenance › Install hierarchies. You can also keep a thesaurus of your own under another name.
    • Updating. An update reports each missing mandatory dependency under Import warnings, together with how to provide it. It never installs one on its own.

    The ontology registry also keeps local decisions through imports and rebuilds. A domain ontology you switched off stays off, and its project filter is kept. Each ontology is filed under the installation's own structure language, not a fixed one.

    See Declaring what an ontology requires.

    Wire contract: WC-2026-10-10-ontology-dependencies-hierarchy60.

  • Installations now choose their domain ontologies (Oral history by default) and install what each one declares it depends on; the install database carries only the core ontologies and no test data. (action needed)

    Until now every installation got the same ontologies, whatever it was for: the install database carried Oral history (oh) and the developers' test ontology, and nothing asked which heritage domains the institution catalogues. The installer now asks (installer reference).

    • The install database is core-only. It carries the core ontologies (dd, rsc, ontology, ontologytype, hierarchy, lg) and nothing else. The developers' test ontology and the test3 playground records are no longer installed; they exist only in the developers' test database.
    • At least one domain ontology is installed. The default is Oral history (oh). It is built into the release, so it installs without a network. Any other ontology the update server offers can be chosen by its code. For example, tch (Tangible cultural heritage) is the general inventory model for objects and collections. The interactive installer has an Ontologies screen. The command line takes --ontologies oh,tch and lists the server's catalog with --list-ontologies. install.sh asks one more question.
    • Dependencies come with the choice. An ontology server now publishes, for each ontology, the ontologies it needs. The installer installs them too, before the ontology that needs them, and says so ("tch also installs: …"). An older server that does not publish them gets a clear warning: the chosen ontology is installed alone, and nothing is guessed. After the import, the installer checks that every node of the installed ontologies can find the nodes it refers to. Anything missing is reported as a warning that names the ontology to add. On an ontology master, an editor declares the dependencies in the Dependencies field of each ontology's record (Ontology › Ontologies main), and they are published with the next export (updating the ontology); see the separate note on that field for mandatory and optional dependencies and required thesauri. Importing an ontology (at install time or with the update panel) keeps its declaration in that field, so a master that imports its ontologies from another master publishes them too.
    • Fully offline installs. --ontology-source <dir|archive> installs from a directory (or a .tar/.tar.gz/.tgz) laid out like an ontology server's export. An air-gapped install (--no-update-servers) is offered only the built-in oh.
    • Nothing is half-installed. The ontology files are downloaded and checked before the database is touched. If an import fails, the install stops and is not sealed. Recreate the database and run the installer again.
    • ACTIVE_ONTOLOGY_TLDS is written by the installer (the core, the chosen ontologies and their dependencies), so the ontology update panel refreshes exactly what the installation carries. When the key is unset, the engine's fallback is now the core list alone: utoponymy and nexus are no longer in it. Action needed: if your /private/.env has no ACTIVE_ONTOLOGY_TLDS and your installation uses utoponymy or nexus, add the key with them (for example ACTIVE_ONTOLOGY_TLDS=["dd","rsc","ontology","ontologytype","hierarchy","lg","oh","utoponymy","nexus"]), or the update panel stops refreshing them.
    • On a development server, the maintenance Unit test area's Truncate test table and Create new empty test record now refuses on a database that has no test ontology (maintenance.action_refused). That is any installation's database. The message names the developers' test database as the place where the playground lives.

    Wire contract: WC-2026-10-09-ontology-manifest-dependencies, WC-2026-10-09-install-domain-ontologies, WC-2026-10-01-unit-test-widget-dev-gate.

  • "On a publication host with fapolicyd, the trust of the agent, its Bun and every Publication API release it installs is now kept by the host itself: no trust lines to run by hand, and a rollback is trusted too."

    Before, the guided install stopped at host.fapolicyd until you ran printed fapolicyd-cli lines for the site's Bun and the agent's code, again after every code update, and the API releases the agent installed later were not covered at all. Now, wherever fapolicyd is installed, provision apply writes one trust file per instance (/etc/fapolicyd/trust.d/dedalo_<instance>) and installs a root service, dedalo-pubhost-trust-<instance>, that the agent starts after every release install and before every rollback. The service works out what to trust from the instance's declaration (the Bun, the agent's code, the current and the previous release of each API it serves); the agent cannot name a file. A release it cannot verify is refused with trust_failed and the previous release keeps serving. Two new init items warn about fapolicyd's own settings and print the lines that change them: host.fapolicyd_integrity when integrity would let a file changed after it was trusted still run (integrity = sha256), and host.fapolicyd_mounts when allow_filesystem_mark = 0, fapolicyd's default, under which it never sees what the agent and the API services open at all. install.sh still stops, with the one line to run, when fapolicyd denies the Bun it starts. A dedalo trust file left by the old hand-run lines is no longer needed. See Publication host agent.

  • "On RHEL, Rocky and Alma, the v2 API of a site installed in its home now uses the publication host's own SELinux type, as in the system layout."

    With SELinux, the v2 API tree of a site installed in its home (/home/<domain>) was labelled data_home_t, a home type the web server can read when httpd_read_user_content is on. It is now labelled dedalo_publication_v2_t, the one type of the publication host's own policy module dedalo_publication_host, in both layouts: systemd may only read it and the web server may not read it under any boolean. provision apply (or init) installs the module on every SELinux host. On a site already installed in its home, the next run re-types its own rule in place and relabels the tree, before v2 starts again; nothing is to be done by hand. A rule of yours on the same path is refused, never changed. Run provision apply (or init) again on such a site. See Publication host agent.

  • The guided publication-host install now proposes the v1 database connection it finds on the host.

    provision init asks 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.sock on Debian and Ubuntu, /var/lib/mysql/mysql.sock on RHEL, Rocky and Alma). It proposes that socket when one exists, and otherwise TCP to 127.0.0.1:3306, saying whether anything listens there. The question is still yours to answer. For TCP the proposed host is 127.0.0.1, never localhost, because v1's database driver reads localhost as "use the socket" whatever the port. See Publication host agent.

  • "install.sh now asks you to trust its Bun in the instance's own fapolicyd file, and says when to remove it."

    On a first install with fapolicyd running, install.sh stops until the Bun it starts is trusted. The line it printed wrote a shared dedalo trust file, which the guide elsewhere tells you to remove, and kept a stale entry for every instance installed that way. It now writes /etc/fapolicyd/trust.d/dedalo_init_<instance>, and prints the line that removes it once init converged; the installed Bun is then trusted by the instance's own trust file.

  • The publication host's guided install is now proven on Ubuntu 26.04, including the agent's one root permission under its new sudo.

    The guided install of a publication host (install.sh, provision init) now passes its full drill on a real Ubuntu 26.04 machine with AppArmor enforcing: Apache and nginx, the Publication API v1 runtime, systemd 259 and polkit 127. Ubuntu 26.04 replaces sudo with sudo-rs; the drill now also checks, on every system, that the agent's single root permission (testing the web server configuration before a reload) works through the machine's own sudo, from the agent's own service, and allows nothing else. Ubuntu 26.04's sandboxed apache2 service serves the site with its logs in /var/log/apache2/<domain>. No AppArmor profile confines the web servers, the v1 runtime pool, polkit or the agents, so no AppArmor change is needed. See the publication host install guide.

  • A publication host on its own machine is now proven on Ubuntu 26.04, from the panel's kit to the first publication.

    A work system and a separate publication host, both on real Ubuntu 26.04 machines with AppArmor enforcing, went through the whole two-machine path with no change needed: the kit built in the Publication hosts panel, the guided install from it, pairing from the command line and from the panel, a token rotation and a new pairing, then the media rules, the media probe, pushing the Publication APIs (v2 alone, and v1 with v2 on a second site), rolling them back and pushing again. The firewall rule from the guide, Ubuntu 26.04's sudo-rs and its sandboxed apache2 service on both machines, and the Publication API v1 runtime on version 8.5 all work as they are. See the publication host install guide.

  • 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)

    Every way of answering the installer — its screens, its flags, an answers file — now works 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_SERVERS and CODE_SERVERS out 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-servers to 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. The installer defaults to lg-eng and lg-spa; the other languages are still offered, but they are optional and you tick them yourself.
    • One default set of optional thesauri. Every way of answering starts from the same default: today that is Spain (es). The command-line installer used to install no thesaurus by default and now installs this set; use --hierarchies none to 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 to localhost instead 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 start could 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 declares DEDALO_SUPERVISED=true, and the shipped systemd units, compose stacks and the dev / dev:server / start:supervised scripts all do. If your systemd unit or compose file was written before this release, add Environment=DEDALO_SUPERVISED=true to the unit's [Service] section, or DEDALO_SUPERVISED: "true" to the dedalo service's environment:. Setting it in /private/.env has no effect, because every launch method reads that file, including the unsupervised bun run start (updating the code).

    Wire contract: WC-2026-10-08-install-plan-update-servers-core-lg.

  • "provision check now refuses a publication host where a service could not read or run its own code, and an engine_group that 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 passed check and apply, and the service failed later with Permission denied. check now 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 the chmod that fixes it. On one machine it also refuses an engine_group that 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. check reads 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, rsc or 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:install now 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, and provision check refuses an agent copy whose node_modules/ holds one. Contributors who run the agent's test suite prepare the tree with bun run hostagent:install:dev instead. See Publication host agent.

  • "provision check no longer reads as an error when it lists the changes to make."

    A first provision check on a new publication host lists the changes apply will make. It used to exit with code 1, so bun run printed 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-web 1.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 past DB_POOL_ACQUIRE_TIMEOUT_MS for 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 new DB_MAINTENANCE_POOL_MAX (default 2 per process). Count it TWICE in your connection budget, plus 2: each engine process may hold DB_POOL_MAX + 2 × DB_MAINTENANCE_POOL_MAX + 2 connections (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, 30000 and 5000 are 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 — the REINDEX TABLE and VACUUM FULL of "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 for CODE_SERVERS and ONTOLOGY_SERVERS now 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 from master.

    On a code server, Serve Code's Build release button used to archive the tip of the master branch, 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 newest vX.Y.Z tag of this engine in the build checkout (prerelease tags such as betas are not releases, earlier-engine v6 tags are never candidates, and a tag whose version file disagrees with its name is refused), and Build developer release archives the tip of master — 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_code instead of update_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/.env settings 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 Production instance can be installed by hand again, step by step, exactly as the installer does it.

    Install by hand is the Production route without the installer: one section per host step, in the installer's order, with the commands that step runs and the full text of every file it writes (the systemd drop-ins, the nginx or Apache site, the media map, the host registry entry). The engine part — the database seed, the ontologies, the thesauri, the root password — runs through the installer itself, as the service user, with --engine-only. A host built this way has the same layout, units, site and backups as one the installer built, and later installer runs on the same host see it. Each section of Production install links its hand version and back. A host set up by hand now points to this page; hosts already running the single deploy/dedalo-ts.service unit set keep their instructions in its last section.

  • A production install can live in any folder and run as an existing account.

    The Production route's Identity screen now asks, after the instance name, for the install folder (default /home/ded_<instance>, any absolute path — for example /home/my_domain.org/dedalo), the service user (default ded_<instance>) and the service group (default: the user's own). The same answers are the flags --install-dir, --service-user, --service-group and the answers-file keys install_dir, service_user, service_group (production only). A new account is created; an existing account — a hosting account — is used as it is and is never created, changed or deleted by the installer, --abort included. The folder's parent is never changed. The database role is the service user (peer authentication), and the backups default to <install folder>/backups. The System check refuses another instance's user, group or folder (and an account in another instance's group), root and system accounts, the packages' accounts (postgres, the web server's), privileged groups, a folder the installer may not use (an OS directory or another account's is never taken over), and a backup dir that already exists; the Review shows what will be CREATED and what is USED AS IS. The systemd templates now name no user or path: each instance's drop-ins carry them, and a template without its drop-in runs nothing. See The install folder and the service account.

  • The terminal installer sets up the whole host: production, Docker and trial install in one run. (action needed)

    A production install used to be a fifteen-step manual procedure. Now sudo sh install.sh --route production --instance <name> (or the interactive installer, choosing Production) does it as resumable steps: packages on the newest Ubuntu, Debian and RHEL-family releases (an older one is refused with the supported list), the instance user, PostgreSQL 18 with a password-less peer role, ONE site file for the instance (nginx or Apache — never another site or the global configuration), Let's Encrypt or your own certificate (checked against the address and expiry), the systemd units, SELinux on RHEL, a first boot, the health watchdog (armed only after the first boot) and a first backup verified store by store. Every command is shown on the Review before it runs; --resume continues after a failure or an interruption, --abort undoes only what the install created. The firewall and the web server's modules are checked and told, never changed.

    The Docker and trial routes now install from the terminal too — on Linux, or on a Mac with Docker Desktop: the image is verified (cosign, and its version label), .dedalo.env and compose.override.yaml are written, the engine steps run inside the container with their secrets on standard input, and the stack is started and probed.

    nginx serves a root-owned, validated COPY of the media access rules the engine generates (never the instance-writable file itself). After changing the media access mode, refresh it with the command the installer prints at the end of the install. --abort asks for --confirm-database before it undoes anything that touches the database.

    install.sh is now only the bootstrap — it verifies the release and the pinned Bun, then starts the installer with your arguments. It no longer installs Docker, asks questions or runs compose itself, and --wizard is retired. See Production host steps.

  • The installer asks its questions in the terminal, checks each answer as you give it, and shows the whole plan before it installs anything.

    Before, installing from a terminal meant writing every answer as a flag (or an answers file) and finding out at the end which one was wrong; the step-by-step guidance, the connection checks and the plan preview lived only in the browser wizard.

    Now bun run scripts/install.ts with no answer flags asks the questions itself, one step at a time: the kind of installation (production with a named instance, Docker, a Docker trial without media protection, or a developer checkout), a system check, the database, the institution, the ontologies and thesauri (what they declare pre-ticked — ticking one ticks what it declares — untick to decline), the languages, publication, mail and the administrator password. Every default and every refusal is the same one a flag-driven run uses. The database must answer before you go on; the MariaDB and SMTP connections are checked (the mail check can send a real test message), and you can keep a failed one by typing yes. Passwords are never shown and are typed twice; the root password is checked rule by rule. The Review lists what will be installed and the equivalent command line (without passwords), then you type install, go back with edit <n>, or save the answers with write <path> for a later unattended run. --plain (also chosen automatically for TERM=dumb, a redirected output or a small terminal) gives the same questions one per line, with no colour or screen drawing, for screen readers and serial consoles.

    Other changes for administrators: the new --route and --instance answers change a few defaults (on a production route the database and role are dedalo_<instance>, the media root /srv/dedalo/<instance>/media); the publication database now defaults to web_dedalo (web_<instance> on production) instead of being required; a run at a terminal without --execute asks for a missing root password instead of failing; --write-answers <file> saves the answers of any run; --list-langs prints the language offer and --list-hierarchies now includes what each thesaurus declares. A run whose standard input is not a terminal and that carries no answers is refused and points to --answers. A typed quit exits 0, Ctrl-C exits 130. A check whose answers change later (a new instance name, another route) runs again before install is offered. On the Docker routes the terminal installer writes the answers but does not install from the host yet; the production web server, TLS and systemd steps are still done by hand. Details: Installer reference.

  • The terminal installer draws full-screen at a capable terminal, and can abort an interrupted install

    Run at a terminal of at least 80×24, bun run scripts/install.ts now shows the installer full-screen: a rail of the route's steps, filtered lists for the ontologies, thesauri and languages, passwords drawn as dots with the root password's rules checked as you type, checks shown in place, the Review with the equivalent command line, the install's steps with their times, and a Done screen that shows the generated salt once. The questions, defaults, rules and the install are exactly the plain mode's; --plain, a small or dumb terminal, or a machine without the terminal library's native build get the plain mode (one line says why). Whatever way it ends, the terminal is restored. When an interrupted install is found, both modes now offer abort next to resume and quit (the database name typed back). See the full-screen installer.

  • The command-line installer checks every answer before it writes anything, and an interrupted install can be resumed.

    Before, the command-line installer (scripts/install.ts, which install.sh drives) found a bad answer only at the step that first used it: a root password that broke the password policy was refused after the .env had been written, the database restored and the ontologies imported, and an entity such as 1x or a port such as 70000 was not refused at all. An install that stopped part-way left a half-built database, and the only way on was to drop it and start again.

    Now every answer is checked first, and nothing is written until all of them pass and the target database is confirmed to exist, be empty and be owned by the database user the install connects as (no other install can be running against it). That covers the entity name, ports, time zone, locale, paths, SMTP encryption, media access mode, the diffusion database and user, and the root password against the policy. The answers can also come from a file (--answers answers.json, the plan's answer names in a dedalo.install.answers/1 envelope). Passwords should come from the DEDALO_INSTALL_* environment variables or --secrets-stdin, never from the command line: the --*-password flags still work but now warn, because ps shows them to every user. An answers file that contains a password is refused. No password is written to the installer's record or printed, and the generated DEDALO_SALT_STRING is printed once, at the end.

    Every run is now recorded in <private>/install/ (a plan file and a state file, kept as the install's record). If an install stops, whether from a failure, Ctrl-C or a killed process, run bun run scripts/install.ts --resume. It checks each step that already completed, does not repeat finished work, and asks again only for the passwords a remaining step still needs. To take the install back, run --abort --confirm-database <db>. That empties the database (the database itself is kept) and restores the previous .env, but a sealed install is never undone. The database restore is now a single transaction that also marks the database as this install's, so an interrupted restore leaves the database empty rather than half-filled, and neither --resume nor --abort ever adopts or empties a database the install did not fill (for example, a backup restored into it in the meantime). Backups of .env that existed before the install are never removed. Running the installer again over an existing record is refused and points you to --resume or --abort. Details: Installer reference.

  • Docker installations now pull a published, signed Dédalo image, or build it locally if you prefer, and record the choice in .dedalo.env. (action needed)

    Until now every Docker installation built its own image from the checkout. Dédalo now publishes the engine image, signed, to its own registry and to mirrors — GitHub Container Registry and Docker Hub — and you choose where your installation takes it from (where the image comes from):

    • One of Dédalo's registries. The registry table says which ones publish today; the registry Dédalo runs itself is the primary, the other two are mirrors of the same images. A registry that is not yet available is never offered. Images are published per release, so until the first stable release is published, the installer builds locally.
    • A registry of your own, holding copies of the published images (how to mirror them).
    • A local build from the checkout, with -f deploy/compose.build.yml (a local build).

    ./install.sh asks the question, offers the registries that publish your checkout's version first, and builds locally when none does. With cosign installed it checks the image's signature (checking the signature). The answer is written to .dedalo.env as five keys — DEDALO_COMPOSE_FILE, DEDALO_IMAGE, DEDALO_VERSION, DEDALO_IMAGE_MODE, DEDALO_IMAGE_VERIFY (the keys) — and every later update takes the image from the same place. The compose files no longer build: they run DEDALO_IMAGE:DEDALO_VERSION. Updating is now one command, ./deploy/dedalo-image-update.sh --version <version>, which takes a database backup, gets the image, re-pins DEDALO_VERSION and rolls back on its own if the new version does not come up healthy within the health timeout (15 minutes by default, so long boot migrations are waited for) (upgrading); with signature checking on, it also refuses a signed image that is not the version it asked for. Going back after an update that succeeded is a short manual procedure (rolling back by hand). Its old --mode, --image and --tag flags are gone.

    Action for an existing Docker installation. Add these lines to .dedalo.env (on the full stack, create the file with your three POSTGRES_* values as well), with the version your checkout declares:

    DEDALO_COMPOSE_FILE=docker-compose.simple.yml
    DEDALO_IMAGE=localhost/dedalo
    DEDALO_VERSION=7.0.1
    DEDALO_IMAGE_MODE=build
    DEDALO_IMAGE_VERIFY=none
    

    Then build once under the new name and recreate the stack:

    docker compose -f docker-compose.simple.yml -f deploy/compose.build.yml --env-file .dedalo.env build dedalo
    docker compose -f docker-compose.simple.yml --env-file .dedalo.env up -d
    

    (on the full stack, docker-compose.yml instead of docker-compose.simple.yml, in the file and in the commands). The first update of such an installation needs --skip-version-check. See an installation from before the image pin.

  • 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 init offers to remove the kit file it was given.

    install.sh --kit now 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, -- --yes removes 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 init now 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=again writes a new one. Pairing with the three loose files is unchanged, and a host on the same machine still pairs directly. See Pairing.

  • "provision init reports 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=remove without 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 apply now keeps a record of what it provisioned (provisioned.json in 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 check lists 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 v1 block 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 own v1 block, and a v2-only site names its distribution family in site.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.

  • On Docker installations, the Update code panel now shows how to install each release, and can hand the update to an optional host updater.

    On a Docker installation the code lives inside the image, so the panel cannot replace it in place. Until now it only said Update blocked. It now shows an Image updates block: where this installation's image comes from (pulled from a registry, and whether that registry is one of Dédalo's, or built on the host), and, for the release you select, the exact command to run on the Docker host, ./deploy/dedalo-image-update.sh --version <version>. The in-container readiness checks are still listed, folded, because they describe the in-place update this installation does not use.

    You can also install the optional host updater on the Docker host (sudo ./deploy/dedalo-image-updater.sh install-units). It is off by default. With it installed, the panel offers Request this update: the host updater picks the request up within about a minute, runs the same command with its backup, health check and rollback, and the panel shows the outcome. A request needs the superuser and maintenance mode, and can only name a release on the normal upgrade path. The registry, pull or build, and the signature check are decided on the host, from .dedalo.env. The engine is never given control of Docker. See the host updater.

    Wire contract: WC-2026-10-09-update-code-image-channel.

  • The Update code panel shows each release's notes before you install it.

    When you check for available updates, each published release now lists what it changes: its Action needed items and one line per change, the same ones this change log publishes for that release. You can read what an update will do before you choose it, on every installation. Developer builds and releases cut before this change have no notes. See updating the code.

    Wire contract: WC-2026-10-09-code-manifest-release-notes.

  • 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 sets web.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 until provision apply is 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 until provision apply is run for that newer instance). provision init sets web.nginx_map: conf_d when nginx includes conf.d inside http {} (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, run provision apply for 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.sh refuses 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 apply now 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 as media.selinux_label: true (the guided install writes it when you answer act). 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 apply refuses an older one.

    The site's web server logs stay outside its home: in the home layout provision apply creates /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 /home stops Apache from starting, and the guided install says so. The guided install reads the sudo policy the installed sudo uses (/etc/sudoers-rs under sudo-rs, when it exists), accepts a polkit that the system bus starts on demand, and asks for e2fsprogs when chattr is 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 new site block, with the site's web include beside it. A hand-run provision apply now waits for a running provision init of the same instance, and provision check answers 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 then provision apply refuses and prints that line;
    • a declaration whose v1.user is www-data, apache, nginx, www or nobody (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 the site block, and run apply. See Publication host agent and its manual steps 2, 3 and 9.
  • 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 example 1.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 check and provision apply now 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 check names 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_url was …/dedalo/publication/server_api/v2/health, which answers 404 under the v2 API's default BASE_PATH, so every v2 release would fail its health check. It is now http://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.group is replaced by v1.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 in shared/ must now be owned by that user and readable by it alone (chmod 0400): installing a v1 release is refused with shared_config_exposed otherwise. 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

  • The installer no longer leaves Bun's cache in an existing hosting account's home.

    On the Production route, the commands the installer runs with Bun as the service user (the dependency install, the engine steps, the migration check) now set HOME to the install folder on their command line (runuser … -- /usr/bin/env HOME=<install folder> …). runuser resets HOME to the account's own home, so with an existing hosting account whose home is not the install folder, Bun's caches were written there. The Review screen shows the new form, and Install by hand uses the same commands.

  • The update-ontology warning no longer claims your local ontology will be deleted.

    The Update ontology panel and its confirmation dialog warned that the update would "delete the actual ontology" and that "you will lose all changes made to the local Ontology". That is not what the process does: it replaces the ontology of the TLDs you select (the common/shared ontologies) with the master's version and re-processes them. Your local ontology — custom TLDs and localontology overrides — is a different namespace and is kept, re-applied automatically when the updated nodes are processed. The panel note, the lead text, the submit label and the confirmation dialog now say so, in every interface language (the confirmation dialog was previously an untranslated English string; it is now a translated label). The only way the local ontology is replaced is if you explicitly add localontology to the list of ontologies to update — keep it out.

    Wire contract: WC-2026-10-10-update-ontology-confirm-text.

  • The PostgreSQL socket you give the installer is now the one the server connects through (DB_SOCKET). (action needed)

    The installer's socket answer (--db-socket) was used by the installer's own connection test and then written as DEDALO_SOCKET_CONN, a key the server never read: an installation reachable only through the socket passed the install and then connected to DB_HOST instead. The answer is now written as the new key DB_SOCKET, and one rule decides the route for everything that connects to the database: the server, the installer's test, the nightly backup and the maintenance tools. When DB_SOCKET is set it wins over DB_HOST, and a socket that does not exist fails loudly instead of quietly falling back to localhost. An existing .env that holds DEDALO_SOCKET_CONN keeps working: it is now read as the old spelling of DB_SOCKET, so check that the directory it names is where your PostgreSQL socket really is before updating. A re-run of the installer writes the value once, under the new name. If you use the nightly backup unit, update deploy/dedalo-backup.service from the release so its database backup connects the same way.

  • On Docker, the browser client now always matches the engine that is running.

    The proxy used to serve the browser client straight from the checkout on the host, while the engine ran from its image. When the two came from different versions — a checkout at one release behind an image of another — the client and the engine no longer understood each other, and pages broke in ways that looked like engine bugs. Now the engine publishes the client of its own image into a client volume each time it starts, and the proxy serves it from there. An update or a rollback therefore moves the client together with the engine. No action is needed: the new compose files declare the volume. If you keep your own compose file, give the dedalo service DEDALO_CLIENT_PUBLISH_DIR: /srv/dedalo/client with the client volume mounted there, and mount the same volume read-only in nginx instead of ./client (the files).

  • The installation guides now install the watchdog and rollback scripts, tell existing servers how to declare supervision, and give RHEL a complete systemd unit.

    The non-Docker installation and update pages were brought in line with the installer and the shipped units.

    • Watchdog and rollback scripts. The shipped watchdog and rollback units run two scripts from /opt/dedalo/bin/, outside the code tree. No page said to install them, so a server set up from the manual alone had a watchdog failing with status=203/EXEC every 30 seconds and no automatic rollback of a failed code update. Production step 10 now installs them, and Upgrading refreshes them after each update.
    • Existing servers and supervision. Upgrading has a new step that checks the installed unit for Environment=DEDALO_SUPERVISED=true, SuccessExitStatus=75 and Restart=always, and adds what is missing with a drop-in. Troubleshooting explains the No supervisor declared refusal. The installer's last line now names the supervised ways to start the server instead of bun run start, which cannot take code updates.
    • RHEL. The RHEL-based systems page (since folded into Production install) gives a complete dedalo-ts.service ordered after postgresql-18.service. The shipped unit names postgresql.service, which does not exist there. The page also runs the installer the same way as the production guide, and keeps DEDALO_PG_BIN_PATH in .env.
    • Several instances. The multi-instance template unit now carries KillMode=process, the wider start limit, the rollback hook and the watchdog. The watchdog script takes --restart-unit / --rollback-unit, so a templated instance can point it at its own units. Each site is installed with --socket, --media-path and --media-access-mode instead of appending keys by hand.
    • Corrections. The backup set is five stores (site-builder instances included). Without --media-path the media root defaults to ../private/media. The installer keeps every .env key it does not own, including keys added before the install. An air-gapped installation can update its ontology from local files (updating the ontology). STRUCTURE_FROM_SERVER and DEDALO_SOURCE_VERSION_LOCAL_DIR are documented as having no effect. The four language keys are documented as required, with no default. The configuration pages point at install/sample.env in the code tree for the current list of keys: ../private/sample.env is the installer's copy and a code update does not refresh it. The developer quickstart lists the supervised start scripts and the second-instance script.
    • Key names in .env. The installer now writes every key under the name the configuration reference uses (DB_NAME, DB_HOST, DB_USER, DB_PASSWORD, DB_PORT, ENTITY, PROJECTS_DEFAULT_LANGS, APPLICATION_LANG, DATA_LANG) instead of the older fallback spellings (DEDALO_DATABASE_CONN, DEDALO_ENTITY, …). Before, a DB_HOST you added by hand silently won over the answer you had just given the installer. A re-run over an existing .env replaces the old spelling with the new one, so no value is left under two names. The engine and the backup scripts still accept the old spellings, so an existing .env keeps working unchanged.
  • The install database is compiled from the repository and proven by a fresh install before it ships.

    Maintenance → Build database version → Build install version refused with "not runnable on this engine", pointing at a maintenance dashboard that no longer exists. It now compiles install/db/dedalo_install.pgsql.gz — the database every fresh install starts from — from files in the repository only (the schema and its migrations, the core ontology release packages, the languages and hierarchy registry files, the default accounts), never from an installation's database, so it cannot carry one installation's data or mistakes into every new install. Before the file is replaced the result installs itself in a scratch database and is checked; the same command is bun run seed:build. The seed ships with a manifest that records what it was built from. See the seed.

  • "Probe media on a new copy-mode publication host no longer reports a problem before anything is published."

    On a copy host, the marker directory .publication/pub/ is created by the agent with the first published record. Until then the probe reported it as missing, so the first probe after pairing said found 1 problem(s). An absent marker directory is now zero published records on a copy host; on a shared host it is still a problem.

  • "The guided publication-host install now runs on RHEL 9 with SELinux enforcing and fapolicyd: the polkit rules directory, the SELinux tools and fapolicyd's trust are judged as EL ships them."

    The first run of the EL install drill on a real RHEL 9.8 machine (SELinux enforcing) found three things the guided install got wrong there. provision apply refused every instance because EL's polkit package owns /etc/polkit-1/rules.d by the polkit daemon's account: that directory, as the package ships it, is now accepted (any other owner is still refused). init reported the SELinux tools missing because /usr/sbin/restorecon is a link to setfiles on EL: a link to a program now counts. With fapolicyd active, an untrusted Bun may not even read the code it runs: install.sh now stops before the hand-over with the one line that trusts its Bun, and init's host.fapolicyd prints the lines for the site's Bun and the agent's code (re-runnable: an existing entry is updated). See Publication host agent.

  • "On RHEL, Rocky and Alma with SELinux, the Publication API v2 of a site installed in its home now starts: systemd may read its settings and its release links."

    The first API v2 release pushed in the EL install drill never started. Under the home layout the v2 directory kept the home's own SELinux type, which systemd may not read, so neither the v2 settings file (v2.env) nor the links to the current release and to the release being tested could be followed: every install was refused (scratch_start_failed). provision apply now labels <home>/dedalo/publication_api/v2 dedalo_publication_v2_t, the publication host's own SELinux type, which systemd may only read and the web server may not read. The v2 services also start their entry with bun src/index.ts instead of bun run src/index.ts, which left links in the service's private /tmp that systemd could not remove at every stop (an SELinux denial each time). Run provision apply (or init) again on such a site. See Publication host agent.

  • "The guided install of a publication host now gives a working command for the newer AppStream runtime of the v1 API on RHEL, Rocky and Alma 10."

    On EL 10 the guided install said that no dnf command installs a version of the v1 API's runtime other than 8.3 as the system one, and offered Remi only. RHEL 10.2 ships 8.4 in AppStream as alternative packages with the same files as the default ones. When the declared version is not installed, host.fpm_install now prints the dnf install line for those 8.4 packages (the FastCGI server and the command-line interpreter). When an older system version is already installed, the command has --allowerasing, and the item says that every pool of the system FastCGI server then runs the new version. Remi and the default 8.3 are still offered. The guided install has now been tested on RHEL 10.2 with SELinux enforcing, as well as RHEL 9.8. See Publication host agent.

  • "On a publication host with fapolicyd's allow_filesystem_mark = 1, the agent keeps its right to reload the web server and start its services."

    With allow_filesystem_mark = 1, the setting the guided install recommends, fapolicyd also checks the sandboxed polkit service, and polkit could no longer load the agent's rule: Apply media rules failed with reload_failed and every API push with trust_failed, while the agent logged Interactive authentication required. The instance's trust file now lists the agent's polkit rule, and provision apply restarts polkit after each trust update, so a rule that failed to load earlier is loaded again. The same setting stops the distribution's own polkit rules from loading, for every program on the host: Publication host agent shows how to check for it and the lines that trust those rules. A failed start of the trust service now says that polkit refused it, instead of a bare exited 1.

  • 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 host's guided install now configures the running web server when both Apache and nginx are installed.

    On a machine with both Apache and nginx installed, where only one of them runs, the guided install (install.sh, provision init) proposed Apache whatever ran and then found no site to attach to, so the install stopped asking for a manual step. It now proposes the web server that is running, and when you choose the other one, it looks at that server's sites before going on. Measured on Ubuntu 24.04, which keeps a stopped, disabled Apache listed. See the publication host install guide.

  • "When provision apply is refused and you answer manual, the guided install no longer goes on with the steps that need it."

    Answering manual to a refused provision.apply (for example a copy media root whose parent directory does not exist) let init run the steps that depend on it, which then failed (the API configuration with ENOENT, exit 4, "a change failed"). Those steps are now left out, listed under still to do with what they wait for, and init ends with exit 3 naming them: fix the refusal, then run init again.

  • "The guided install no longer refuses when you keep the default answer of an optional question."

    Answering skip to an optional question (the default for move the vhost logs, asked whenever the site's virtual host logs into the distribution's log directory, as Ubuntu's stock virtual host does; or keeping a stale pairing package) made init stop with exit 3, skipping leaves required items undone: init.keep_ref, before it changed anything. An optional question now holds nothing back: init installs the host and leaves only that item as it is.

  • "Pairing from the sealed package: the guide puts the copy where the Dédalo user can read it, and an unreadable copy says why."

    The guide told you to carry the .pairing file to /root/, which the Dédalo user cannot pass (0550 on RHEL, Rocky and Alma, 0700 on Debian and Ubuntu), so the pairing command answered could not be read (EACCES). The guide now uses /opt/dedalo/pairing/, and the command's refusal for an unreadable file says that every directory above it must be passable too. The guide also shows the firewall rule that lets only the work host reach the agent's port, on firewalld and ufw: Publication host agent.

  • "On RHEL, Rocky and Alma, a publication host in the system layout now starts its v2 API: the provisioner installs its own small SELinux policy module."

    With SELinux enforcing, an instance installed in the system layout (/srv and /opt) left its v2 API tree with /srv's own type, which systemd may not read: the v2 service could not start. provision apply now writes and installs the policy module dedalo_publication_host (one per host, a stamped CIL file, installed with semodule, no compiler needed). It defines one type, dedalo_publication_v2_t, which systemd may read and the web server may not, and labels the v2 tree with it before any service starts. The guided install lists it as selinux.v2_policy. A module of that name that the provisioner did not install is refused, never replaced, and the module is removed when no instance on the host needs it any more. A first system-layout install also creates the shared directory /srv/dedalo_publication_host (it was refused when that directory did not exist). See Publication host agent.

  • "Rotating a publication host's token now restarts its agent."

    After you remove credentials/SERVICE_TOKEN, provision apply (and a re-run of install.sh) mints a new token and now restarts the running agent, which reads its token only when it starts. Before, the agent kept the old token: the guided install failed its own health check (publishes another pairing fingerprint, exit 4) and pairing again answered pairing_mismatch until the agent was restarted by hand. The manual restart step is gone from Rotating the token.

  • "Upgrading a publication host's agent from a new kit no longer leaves the agent stopped on a host with fapolicyd."

    When the new agent code was the only change, the guided install restarted the agent before fapolicyd trusted the new files, and the agent stopped with EPERM reading …/src/index.ts. On a host with fapolicyd, a run that installs new agent code or a new Bun now always runs provision apply first, which trusts them, and only then restarts the agent. provision apply also clears a service that stopped after too many failed starts, so a run that fixed the cause brings it back instead of failing with Start request repeated too quickly.

  • "The Publication API v1 on a publication host answers again: its configuration names the release that runs." (action needed)

    The v1 configuration lives in publication_api/v1/shared/ and is linked into every release, but the sample it is made from found the API's files from its own location. On a publication host every v1 request therefore failed while answering 200, and the v1 error log said Class "manager" not found. The guided install now writes the API_ROOT line that names the release of the script that runs. Action needed on a publication host already serving v1: in the v1 API configuration file in publication_api/v1/shared/ (the one step 5 of the install guide creates), replace the API_ROOT line with define('API_ROOT', dirname(get_included_files()[0], 2));, keeping the file's owner and mode (see Create the API configuration files).

  • 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 apply now writes /etc/logrotate.d/dedalo_<instance>_v1 for 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. Run provision apply (or provision init) once for each site to get it. See Publication host agent.

  • "Publication API v1 releases install on a publication host with fapolicyd's allow_filesystem_mark = 1."

    The agent checks the syntax of every v1 file before a release goes live. It did so on a copy fapolicyd did not trust yet, so with allow_filesystem_mark = 1 every v1 release was refused (Could not open input file). The check now reads the release in its final place, after fapolicyd trusts it; a release that fails it is removed and the previous one keeps serving.

  • Container installs take their nightly backups again, and the full-stack install no longer stops at "directories".

    Since 2026-08-30 the backups volume of both compose stacks came up owned by root while the engine and the backup service run as an unprivileged user. Two things followed. The documented command-line install of the full stack (docker-compose.yml) stopped with install failed: /backups/db. And the nightly backup service wrote nothing at all. Because it could not write its own failure marker either, docker compose ps still showed it as healthy.

    The image now creates that directory with the right owner. Rebuild the image (docker compose build, then docker compose up -d, adding -f docker-compose.simple.yml --env-file .dedalo.env on the simple stack). An existing backups volume is still empty, so it takes the right owner by itself; no manual step is needed. The backup service 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_OK appears the morning after. See Docker backups.

  • "./install.sh runs 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.sh stopped 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.

  • "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, sudo could not find a bare bun. 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 that provision apply writes no longer claims that no release reads its keys: the pairing command reads exactly those keys. The next provision check lists the fragment as a change, and apply rewrites 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 (numisdata4 for auction URLs; rsc205 and rsc3 for 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 dd or tch appeared in the profile permissions tree under the Others typology with only their bare tld as name (dd), although their registry record says otherwise (for dd: 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_int diffusion node whose properties carried varchar (for example "varchar": 1024) was created as INT(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: varchar sizes text columns only and length sizes integer columns only. The integer column is now created as INT(8) unless length says 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 wide varchar fields 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 to field_text (add "index": "BTREE" to keep the same index) or reduce their varchar.

  • Publication host provisioning works with Apache on Debian and Ubuntu. (action needed)

    provision check refused 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: apache2ctl on Debian and Ubuntu, apachectl on RHEL, nginx for 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, run bun 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: run systemctl reset-failed dedalo-publication-host-<instance> and apply again. See Publication host agent.

  • The Docker image can now write AVIF, so .avif alternative 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 avif in DEDALO_IMAGE_ALTERNATIVE_EXTENSIONS therefore 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, then aac) 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 the localontology namespace, 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 to null to 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_MULTIPLE was 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 with api_ui when 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 reason workspace_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_exists until 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 from site.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 .part name 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 .failed for 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 .part file 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 .orphaned for 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 .part is 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_MAX must 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.

  • 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 the files_info entry 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_SERVERS listed several masters sharing the same access code, choosing any of them except the first failed every file with Download 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 in ONTOLOGY_SERVERS is 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 COMMITTED line. 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 (a COMMIT inside 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_HOST set (or set it to localhost), it still answered other installations, but every download link in its answer pointed at http://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 (and DEDALO_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_HOSTS was 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 DocumentRoot and 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 the 302 redirects from /, /dedalo/ and /dedalo/core/ to the login page. Check each existing Dédalo vhost for a DocumentRoot line.

  • 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 in private/.env did nothing because the container environment takes precedence. That is what a Docker installation saw: no developer bar, whatever the .env said.

    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_MODE keeps 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_data repeated the server's log text, which names the internal address the host resolved to. Each failed link is now reported as {uri, error}, where error is 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

  • Related-record lists answer one bounded page, and say so when they cut it.

    The Referencias list (read with source.action get_relation_list), the tools' related-records listing (related_search) and the thesaurus indexations grid (get_indexation_grid) used to read an absent limit or limit: 0 as "every record", and answered a malformed limit or offset ('all', 1e21) with a server error. Every client window is now bounded by DEDALO_SEARCH_CLIENT_MAX_LIMIT: an absent, zero or out-of-range limit becomes the ceiling, an offset past the end returns an empty page, and the answer carries a search.page_clamped notice naming the window asked for and the one served — for an absent limit, only when a record the caller may see lies past the page (records outside the caller's sections or projects never decide it). An indexation cell embedded in a record read is bounded the same way. The same bound applies to every API request at the parse door. A non-administrator's page may hold fewer rows than the limit while more pages exist (rows outside the caller's projects are dropped after paging): page by offset until an empty page.

    Wire contract: WC-2026-10-11-relation-reads-client-page.

  • 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 test starts 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 its bun 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-db and mariadb; on macOS, brew install mariadb). Without them the MariaDB gates fail and name what is missing. Stop a lane's server with bun run scripts/ci/suite_mariadb.ts stop. To remove it together with its data, use bun run scripts/ci/suite_mariadb.ts sweep.

Added

  • Each release is published as a signed multi-architecture image, with the same digest on every Dédalo registry.

    A release tag vX.Y.Z now also produces the engine image X.Y.Z, for linux/amd64 and linux/arm64. It is built once from the same git archive as the code server's release zip, signed keylessly with Sigstore by the release workflow, and copied with its signature to every registry that engineering/image_registries.json lists as provisioned. Each copy is checked to carry the same digest. A published release tag is never rebuilt or overwritten. Developer images (X.Y.Z-dev) are built only on demand, from master. There is no latest tag. To check that an image is genuine, verify it against the release workflow's identity, as shown in checking the signature. A registry that is not provisioned, or whose credentials are missing, is skipped by name, never silently.

  • 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 harvestFetch instead 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's robots.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's Crawl-delay or Retry-After capped 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 null item or a non-list replacement were stored or emptied the field the same way. Such a save is now refused with request.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.json and 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 against vendor/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 a request.invalid error that names q_operator. The searches the interface builds were never affected.

    Wire contract: WC-2026-09-23-relation-q-is-a-locator.

Wire contract — 125 entries
  • WC-2026-08-24-install-ip-gate-fail-closed
  • WC-2026-08-24-media-auth-session-scoped
  • WC-2026-08-24-media-protection-default-closed
  • WC-2026-08-24-media-svg-response-headers
  • WC-2026-08-24-proxy-trust-and-password-cost
  • WC-2026-08-25-update-code-restore-point
  • WC-2026-08-25-update-code-waive-backup
  • WC-2026-08-27-csv-ingest-refusals
  • WC-2026-08-27-duplicate-reminted-dataframe-targets
  • WC-2026-08-27-tm-lang-slice-restore-merge
  • WC-2026-08-27-write-language-provenance
  • WC-2026-08-28-export-gate-b-refuses-unresolvable-segment
  • WC-2026-08-28-idempotency-key
  • WC-2026-08-28-login-active-account
  • WC-2026-08-28-root-self-service-write
  • WC-2026-08-28-search-path-acl-every-hop
  • WC-2026-08-29-tm-apply-value-confirm-label
  • WC-2026-08-30-remove-requires-item-id
  • WC-2026-08-30-section-id-counter-is-a-high-water-mark
  • WC-2026-08-31-client-reads-three-fields-the-engine-never-emits
  • WC-2026-08-31-import-code-lookup-refuses-a-truncated-candidate-set
  • WC-2026-08-31-time-machine-history-belongs-to-a-generation
  • WC-2026-09-01-children-recursive-search
  • WC-2026-09-01-ts-object-svg-url
  • WC-2026-09-03-bulk-revert-skipped-typed-entries
  • WC-2026-09-03-duplicate-record-new-activity-row
  • WC-2026-09-03-info-stored-value-never-served
  • WC-2026-09-03-maintenance-reconcile-status-widget
  • WC-2026-09-03-monovalue-insert-replaces
  • WC-2026-09-03-read-door-component-acl
  • WC-2026-09-04-client-limit-bound
  • WC-2026-09-04-committed-third-party-bundles-leave-the-client-tree
  • WC-2026-09-04-context-render-class
  • WC-2026-09-05-children-order-one-rule
  • WC-2026-09-05-diffusion-lease-epoch-fence
  • WC-2026-09-05-multi-locator-order-is-deterministic
  • WC-2026-09-05-preauth-intake-bounds
  • WC-2026-09-05-search-terms-are-literal
  • WC-2026-09-06-dataframe-delete-policy-on-slot
  • WC-2026-09-23-relation-q-is-a-locator
  • WC-2026-09-24-external-foreign-target-and-verbatim-id
  • WC-2026-09-24-external-record-4xx-is-not-found
  • WC-2026-09-24-external-record-field-set
  • WC-2026-09-24-multi-section-search-identity-dedup
  • WC-2026-09-24-tool-export-server-built-artifacts
  • WC-2026-09-27-bulk-revert-undo-log
  • WC-2026-09-27-csv-import-append-mode
  • WC-2026-09-27-external-allowlist-at-door-only
  • WC-2026-09-28-maintenance-serve-code-widget
  • WC-2026-09-28-maintenance-serve-ontology-widget
  • WC-2026-09-29-code-release-channel-refs
  • WC-2026-09-29-dataframe-hard-delete-retired
  • WC-2026-09-29-number-not-equal
  • WC-2026-09-29-rdf-per-uri-error-wire-body
  • WC-2026-09-29-search-deep-leaf-mixed-rule
  • WC-2026-09-29-search-where-parts-parenthesized
  • WC-2026-09-29-select-family-mode-datalist
  • WC-2026-09-29-tm-preview-frame-children-as-of
  • WC-2026-09-30-agent-tool-grant
  • WC-2026-09-30-backup-freshness-deep-async
  • WC-2026-09-30-backup-part-promotion
  • WC-2026-09-30-db-typed-503
  • WC-2026-09-30-diffusion-attach-scope
  • WC-2026-09-30-diffusion-run-ledger
  • WC-2026-09-30-diffusion-target-fence
  • WC-2026-09-30-diffusion-zip-streamed
  • WC-2026-09-30-guarded-text-pinned-typed-transport
  • WC-2026-09-30-mcp-search-section-grant
  • WC-2026-09-30-media-key-locked-transform
  • WC-2026-09-30-media-pair-scope
  • WC-2026-09-30-move-transform-execute-job
  • WC-2026-09-30-ontology-identifier-grammar
  • WC-2026-09-30-password-hash-never-served
  • WC-2026-09-30-password-policy-enforced
  • WC-2026-09-30-record-write-obligation-ledger
  • WC-2026-09-30-search-root-step-acl
  • WC-2026-09-30-transcription-record-tipo
  • WC-2026-09-30-update-engine-atomic
  • WC-2026-09-30-update-manifest-local-origin-refusal
  • WC-2026-09-30-write-door
  • WC-2026-10-01-ai-spend-budget
  • WC-2026-10-01-change-plan-write-door
  • WC-2026-10-01-delete-locator-write-door
  • WC-2026-10-01-identify-vision-grant
  • WC-2026-10-01-ontology-overwrite-scoped
  • WC-2026-10-01-rdf-harvest-door
  • WC-2026-10-01-rdf-ontology-import
  • WC-2026-10-01-relation-search-display-paths
  • WC-2026-10-01-search-hop-configured-targets
  • WC-2026-10-01-site-builder-confinement-codes
  • WC-2026-10-01-tool-grant-one-decision
  • WC-2026-10-01-unit-test-widget-dev-gate
  • WC-2026-10-01-write-door-delegations
  • WC-2026-10-02-relation-children-write-through
  • WC-2026-10-02-select-lang-missing-entry
  • WC-2026-10-03-publication-hosts-widget
  • WC-2026-10-03-save-refuses-malformed-value-shape
  • WC-2026-10-06-consultation-only-no-section-tools
  • WC-2026-10-07-acquisition-tools-ts-only
  • WC-2026-10-07-ontology-area-admin-grant
  • WC-2026-10-07-update-code-root-entries-stamp
  • WC-2026-10-08-install-ip-denied-names-address
  • WC-2026-10-08-install-plan-update-servers-core-lg
  • WC-2026-10-08-make-backup-scheduled-evidence
  • WC-2026-10-09-code-manifest-release-notes
  • WC-2026-10-09-install-domain-ontologies
  • WC-2026-10-09-ontology-manifest-dependencies
  • WC-2026-10-09-publication-host-panel-setup
  • WC-2026-10-09-publication-host-v2-only-site
  • WC-2026-10-09-update-code-image-channel
  • WC-2026-10-10-hierarchy-json-manifest
  • WC-2026-10-10-ontology-dependencies-hierarchy60
  • WC-2026-10-10-update-ontology-confirm-text
  • WC-2026-10-11-children-recursive-one-statement
  • WC-2026-10-11-export-buffered-cap-and-admission
  • WC-2026-10-11-parent-link-cycle-every-door
  • WC-2026-10-11-pdf-extractor-render-class
  • WC-2026-10-11-relation-reads-client-page
  • WC-2026-10-11-rich-text-sanitizer-tokenizer
  • WC-2026-10-11-session-user-beside-csrf-token
  • WC-2026-10-11-site-builder-reason-codes
  • WC-2026-10-11-site-builder-turn-error-frames
  • WC-2026-10-12-ddengine-removed
  • WC-2026-10-12-install-surface-removed
  • WC-2026-10-12-user-activity-what-labels

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 any not_logged raised 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_tools directly 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_state declaring 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-identity
  • WC-2026-08-03-cors-allowed-origins
  • WC-2026-08-03-service-dropzone-folded-into-service-upload
  • WC-2026-08-04-import-derivative-failure-counts-as-imported
  • WC-2026-08-04-media-versions-panel-wire
  • WC-2026-08-05-external-api-config-publication
  • WC-2026-08-05-external-derived-emission
  • WC-2026-08-05-external-entry-normalisation
  • WC-2026-08-05-external-first-record-reduce
  • WC-2026-08-05-external-request-field-union
  • WC-2026-08-05-external-source-status
  • WC-2026-08-05-multi-engine-ddo-expansion
  • WC-2026-08-06-external-client-render
  • WC-2026-08-06-external-search-request
  • WC-2026-08-06-external-write-refusal
  • WC-2026-08-06-observer-degraded-seed-refuses-shrink
  • WC-2026-08-06-observer-equivalence-class-targets
  • WC-2026-08-06-observer-grow-only-failsafe-retired
  • WC-2026-08-06-observer-removal-propagation
  • WC-2026-08-07-breakdown-total-order
  • WC-2026-08-07-media-alternate-version-builder
  • WC-2026-08-07-retouched-tier-is-a-master
  • WC-2026-08-08-csp-connect-src-update-masters
  • WC-2026-08-08-geolocation-emptiness-explicit
  • WC-2026-08-08-media-thumb-handler
  • WC-2026-08-09-autocomplete-hi-ancestor-search
  • WC-2026-08-09-av-fragment-failure-reason
  • WC-2026-08-09-changes-in-locators-integrity
  • WC-2026-08-09-code-manifest-target-dedupe
  • WC-2026-08-09-dataframe-import-id-key-identity
  • WC-2026-08-09-date-search-uncovered-mode-throws
  • WC-2026-08-09-ddo-map-per-entry-sanitization
  • WC-2026-08-09-diffusion-degradation-and-loud-ddo-fns
  • WC-2026-08-09-export-raw-dataframe-own-column
  • WC-2026-08-09-image-svg-envelope-preserved
  • WC-2026-08-09-indexation-grid-read-grant-scope
  • WC-2026-08-09-info-legacy-stored-value-fallthrough
  • WC-2026-08-09-ingest-companions-and-human-filename
  • WC-2026-08-09-media-index-target-selection
  • WC-2026-08-09-media-restore-no-overwrite
  • WC-2026-08-09-media-spawn-integrity-and-liveness
  • WC-2026-08-09-media-tree-boot-provisioning
  • WC-2026-08-09-media-tree-configurable-folders-and-boot-budget
  • WC-2026-08-09-move-lang-collision-refused
  • WC-2026-08-09-move-to-table-self-move-refused
  • WC-2026-08-09-ontology-tld-derived
  • WC-2026-08-09-ontology-update-partial-restore
  • WC-2026-08-09-portalize-portal-merge
  • WC-2026-08-09-provider-message-truncation-boundary
  • WC-2026-08-09-record-birth-defaults
  • WC-2026-08-09-simple-schema-changes-pre-import
  • WC-2026-08-09-time-machine-restore-replays-paired-dataframe-frames
  • WC-2026-08-09-tm-date-search-yearless-q-drops
  • WC-2026-08-09-users-section-record-scope
  • WC-2026-08-10-maintenance-catalog-fail-soft
  • WC-2026-08-10-section-id-int-canonical
  • WC-2026-08-11-diffusion-computed-inverse-hops
  • WC-2026-08-11-diffusion-uninstalled-package-skip
  • WC-2026-08-11-regenerate-drops-llm-map-post-step
  • WC-2026-08-11-vector-tag-badges
  • WC-2026-08-12-authorization-denial-token
  • WC-2026-08-12-filter-records-enforced
  • WC-2026-08-12-indexation-tool-buttons
  • WC-2026-08-12-media-job-visibility
  • WC-2026-08-12-user-activity-full-history
  • WC-2026-08-13-maintenance-ai-models-widget
  • WC-2026-08-13-transcription-status-panel
  • WC-2026-08-14-llm-map-target-sections-plural
  • WC-2026-08-14-model-section-registry-branch
  • WC-2026-08-14-non-section-target-refused
  • WC-2026-08-14-relation-insert-accepts-batch
  • WC-2026-08-14-relation-insert-target-validation
  • WC-2026-08-14-relation-model-target-in-sqo
  • WC-2026-08-14-target-mode-retired
  • WC-2026-08-14-thesaurus-picker-caller-declared
  • WC-2026-08-14-tm-cells-obey-list-emit-policy
  • WC-2026-08-14-tm-ddo-mode-retired
  • WC-2026-08-14-tm-permission-floor
  • WC-2026-08-14-tm-scope-server-owned
  • WC-2026-08-15-code-manifest-sha256-and-snake-keys
  • WC-2026-08-15-diffusion-connection-status-ok-message
  • WC-2026-08-15-diffusion-job-result-record
  • WC-2026-08-15-error-envelope-v2
  • WC-2026-08-15-error-status-is-a-channel
  • WC-2026-08-15-external-degradation-is-a-notice
  • WC-2026-08-15-http-layer-error-envelope
  • WC-2026-08-15-identify-decline-codes
  • WC-2026-08-15-install-refusals-typed
  • WC-2026-08-15-mcp-error-code-alignment
  • WC-2026-08-15-stream-error-frames
  • WC-2026-08-15-tool-response-envelope-v2
  • WC-2026-08-16-error-envelope-compat-removal
  • WC-2026-08-16-sqo-session-not-persisted-on-session-save-false
  • WC-2026-08-16-tm-tool-note-value
  • WC-2026-08-17-list-row-is-indexable
  • WC-2026-08-17-list-row-selectability-declared
  • WC-2026-08-18-dashboard-total-non-record-store
  • WC-2026-08-19-rqo-body-csrf-token
  • WC-2026-08-19-test-tld-replay
  • WC-2026-08-19-test-tld-replay-search-group
  • WC-2026-08-19-tool-lang-translator-engine-type-and-browser-transformer
  • WC-2026-08-19-tools-gates-test-tld
  • WC-2026-08-21-files-cache-finish-message
  • WC-2026-08-22-preauth-language-cookie
  • WC-2026-08-23-build-provenance
  • WC-2026-08-23-dedalo-files-post-harvest-census
  • WC-2026-08-23-diffusion-langs-widgets-derived
  • WC-2026-08-23-maintenance-widget-label-case
  • WC-2026-08-23-projects-default-langs-derived
  • WC-2026-08-23-test6228-clone-recut
  • WC-2026-08-23-tool-export-register-labels
  • WC-2026-08-23-update-mode-clean-only
  • WC-2026-08-23-user-tools-post-harvest-census
  • WC-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 dd542 column is sortable:false except When (dd547), whose order maps to the direct section_id column (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; 0 disables 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_search and the new dedalo_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_locators plus 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 detail item, so a record with two linked resources and one value showed a hover panel reading total : 50% beside a cell reading 25% — two numbers, both labelled total. The panel now lists one row per source record (the empty ones included, as explicit 0%) 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 in errors[]), 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 on login_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-Age while 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. an ontologytype0 filter appearing in the Activity dd542 search panel). Presets are now scoped by the searched section (client/dedalo/core/search/js/preset_scope.js preset_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 by test/unit/search_preset_scope.test.ts.

For administrators

Changed

  • The media access cookie now lives exactly as long as the session.

    dedalo_media_auth is re-issued on any authenticated request whose value is missing or stale, with Max-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+ rows

    On a 32.9M-row / 85 GB matrix_activity a 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 no NULLS 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 inline ORDER BY … LIMIT whenever 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 from DEDALO_MEDIA_EXPORT_BASE, which continues to root export cells only (unset means unresolved, never guessed). That key is the rename of DEDALO_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 like webBase (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_cache now requires an explicit scope

    The silent whole-section fallback is removed: a missing or malformed sqo fails closed with invalid_request, and the client sends a deep clone of the caller list's live sqo, 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 a dd800 bulk-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_activity charts 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 tool tool_sitebuilder (a three-pane workspace), and a site_builder_status maintenance 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_map node: properties.rag.embed is an array of named groups, each an exact request_config ddo_map. A group is one vector document per (record, data language) — the facet unit (a person's profession vs filiation; a transcription with its own chunking) — stored under rag:<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/embed matches 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 mode in rag.embed maps is now honored verbatim when explicit and defaults to full-value resolution for literals when absent.

For developers

Changed

  • sanitizeClientSqo caps filter_by_locators at 1000 pins.

    sanitizeClientSqo clamps filter_by_locators to 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

  • items on the state widget's total items

    The 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_process is 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-job AbortSignal, and update_cache checks it per record and returns a partial summary.

    Wire contract: WC-043.

Wire contract — 50 entries
  • WC-035
  • WC-040
  • WC-041
  • WC-042
  • WC-043
  • WC-044
  • WC-045
  • WC-046
  • WC-047
  • WC-048
  • WC-049
  • WC-050
  • WC-051
  • WC-052
  • WC-053
  • WC-054
  • WC-055
  • WC-056
  • WC-057
  • WC-058
  • WC-059
  • WC-060
  • WC-061
  • WC-062
  • WC-063
  • WC-064
  • WC-065
  • WC-066
  • WC-067
  • WC-068
  • WC-069
  • WC-071
  • WC-072
  • WC-073
  • WC-074
  • WC-075
  • WC-076
  • WC-077
  • WC-078
  • WC-079
  • WC-080
  • WC-081
  • WC-2026-08-02-index-tag-legibility
  • WC-2026-08-02-observer-cascade-bounded-flag
  • WC-2026-08-02-observer-references-limit-not-honoured
  • WC-2026-08-02-observer-relay-writes-nothing
  • WC-2026-08-02-observer-subscription-registry-activation
  • WC-2026-08-02-register-tools-registry-state
  • WC-2026-08-02-tool-string-lang-fallback
  • WC-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_reset are implemented natively (src/core/security/password_reset.ts + a nodemailer SMTP mailer), with pending codes in the session store. request always 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 via NODE_EXTRA_CA_CERTS). Config: catalog domain mailer (DEDALO_SMTP_*, DEDALO_PWRESET_*). User guide: Password recovery.

    Wire contract: WC-039.

Fixed

  • Special-table component search restored

    component_json gained a search builder (Activity's Data dd551 and 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_api descriptor is removed from page_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-origin get_ip_country action. Config keys: DEDALO_GEOIP_* (the old IP_API key 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) and DEDALO_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 → PublicationPlan compiler, 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 complete dd_diffusion_api client action set — the copied tool_diffusion client works with zero edits. 228 tests across 16 suites; oracle spot-check against old-engine-published rows. Documentation: diffusion/native_engine.md; spec engineering/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.json labels (edited with tool_dd_label). The get_label wire 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 the get_label dictionary by src/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 served undefined). The prior model — dd_ontology model='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.md and the whole api/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-001
  • WC-002
  • WC-003
  • WC-004
  • WC-005
  • WC-006
  • WC-007
  • WC-008
  • WC-009
  • WC-010
  • WC-011
  • WC-012
  • WC-013
  • WC-014
  • WC-015
  • WC-016
  • WC-017
  • WC-018
  • WC-019
  • WC-020
  • WC-021
  • WC-022
  • WC-023
  • WC-024
  • WC-025
  • WC-026
  • WC-027
  • WC-028
  • WC-029
  • WC-030
  • WC-031
  • WC-032
  • WC-033
  • WC-034
  • WC-036
  • WC-037
  • WC-038
  • WC-039
  • WC-2026-07-09-installer-diagnostics-grid