Skip to content

tool_time_machine

Audit/history view and reversion of record and component changes over time, reading from the matrix_time_machine (dd15) change log.

What it does / why & when to use it

Dédalo records every user change to a record's data as a row in the matrix_time_machine table (dd15). tool_time_machine is the UI that lets a person browse that history for one element and restore an earlier version. The user picks a past entry, sees the "Now" value side-by-side with the historical value, and — with Apply and save — overwrites the live data from the snapshot. It works on two scopes:

  • a single component (e.g. one date, one input text), restoring just that component's data, and
  • a whole section record (restoring all of the record's components at once, including recovering files that were deleted with it).

It also exposes a third, admin-only operation: reverting a whole bulk process — undoing in one action every change a batch tool (e.g. tool_propagate_component_data) made across many records under one bulk_process_id.

Concrete heritage scenario: a cataloguer notices that the dating of a coin issue was changed last week to the wrong century. They open the time machine on that Date component, see the change list (when / who / what value), select the entry from before the bad edit — the previous value renders in the preview pane — and press Apply and save. The component is restored to the correct dating, and the restore itself is logged as a new activity entry. If instead an admin discovers that a propagate-component-data run mis-set a field across 400 records, they open the tool on any affected record, pick the row carrying that run's bulk_process_id, and Revert the bulk process — every record touched by that run is rolled back to its pre-run value in one operation.

Use it when: someone needs to see the edit history of a record/component, or roll back a mistaken edit or a mistaken batch run. It is not a diff/merge tool and not a general undo stack — it restores a chosen snapshot wholesale into the live row.

How it works (server + client)

The change log lives in the dd15 matrix_time_machine table, accessed server-side through the TS TM read/write helpers (src/core/db/time_machine.ts, src/core/resolve/read_tm.ts — see tm_record). Each TM row carries the element's full data snapshot plus metadata (when dd559, who dd578, what component dd577, section_tipo/section_id, and an optional bulk_process_id). A bulk run also writes HIDDEN rows (tm_role 1, 3, 4: the BEFORE image of each change, a birth marker per created record, the snapshot of a record a run's dataframe cascade emptied, or a revert's D2 deleted) that only bulk_revert_process reads — see tm_record.

Client (tools/tool_time_machine/js/):

  • tool_time_machine.js is the instance. On build() it resolves the calling element as main_element (for a section caller it loads the section instance itself) and builds the history list as an ordinary dd15 section instance in list mode with view:'tm_list', which renders through core/section/js/view_tm_list_section.js (its only override is the Id-cell preview/restore action). Its SQO carries mode:'tm', so the rows come from the TM table rather than live data: filtered by tipo = section_tipo for a section caller, or by one filter_by_locators entry (section, id, component tipo, lang) for a component caller. The instance's id_variant names the main tipo, section and record, so opening the tool on another record builds another list.
  • render_tool_time_machine.js lays out the tool in a two-column grid: a current ("Now") pane, a preview pane, a tool bar (language selector + buttons), and the history list. The language selector, and the language the apply confirm names, follow history_lang() (tool_time_machine.js): lg-nolan when the dd15 context of the one-component history read says tm_main.lang_sliced: false (read_tm.ts tmMainLaneLaw — every relation model, whatever its translatable flag), so an unsliced main shows no selector and its confirm says it is replaced in full with its dataframes; otherwise the main element's own lang. The tool bar and side-by-side panes only render for non-section callers.
  • When the user clicks a list row's preview/eye icon, the list view (core/section/js/view_tm_list_section.js) publishes the tm_edit_record event; the tool's handler (get_component) loads the historical component into the preview pane with data_source:'tm' and the row's matrix_id (the mode stays edit; data_source, not a render mode, is what makes the server read the TM row) (read-only: permissions forced to 1, tools/interface disabled), stores the selected_matrix_id, and reveals Apply and save. If the picked row has a bulk_process_id, a global admin additionally sees Revert the bulk process (others see an "contact an administrator" notice).
  • The preview subtree is keyed by the row. matrix_id is part of every instance key, so the preview never shares an instance with the live "Now" pane or the page. section.js get_section_records passes the caller's matrix_id and data_source to every section_record it creates (always from the caller, never from a locator: the dd15 list entries carry one matrix_id per row). section_record forwards matrix_id to all its children, and data_source:'tm' only to a component_dataframe child (the one child whose coordinates are the TM row's own record); a literal main forwards both to its dataframe the same way (component_common/js/dataframe.js). So the preview's dataframe reads the row, and it never subscribes to the live sync_data refresh. Picking another row destroys the superseded preview deep (delete_dependencies:true, scoped to this tool), subtree included; re-clicking the same row reuses its instance.
  • Frames as of the row. The preview AND the history list show the main's frames, and the values of each frame's children (e.g. a rating on the frame target), as they stood at that row, not live. A language row of a lang-sliced literal main shows the frames it stood with, and a target emptied later by delete_target shows its values from before the wipe. A whole-record row (tipo = section_tipo: a delete snapshot, or an archive restore's snapshot, as listed by a section caller) is read the same way, one main at a time: each framed cell shows the snapshot's own frames, and their children as of the row. For a delete snapshot the bound is the row itself, so the list of deleted records shows each frame target as it stood when the record was deleted, never the wiped state its delete_target policy left after it. The list datum is one bag shared by every row, and the same frame target is emitted once per framed row; the server stamps each copy with row_section_id (the dd15 row id), and the chip (component_dataframe.get_rating) reads the copy of its own row. Every frame chip on a Time Machine surface (the history list and the preview) is read-only: it paints its label and rating colour, but has no add button and never opens the live target (view_default_list_dataframe.is_read_only, true under any caller chain that holds a dd15 list, a data_source:'tm' or a matrix_id). What stays live, and the exact bound, is the law of the wire-contract entry WC-2026-09-29-tm-preview-frame-children-as-of (engineering/wire_contract/).
  • Apply and save calls apply_value(...); Revert the bulk process calls bulk_revert_process(...). Both go through data_manager.request to dd_tools_api; on success in a popped-out window the tool closes itself. Before closing after a bulk revert it shows bulk_revert_summary_message(data) (render_tool_time_machine.js) in an alert: the counts and the revert's own bulk id, a not-exact line when exact !== 'full', and per-code counts of skipped[] and inexact[].

  • Columns are a server decision. The history list is an ordinary dd15 section instance and sends no column map: src/core/section/list_definitions/time_machine_list.ts derives one column set per surface from the SQO's scope. The tool's two surfaces (a component's history, a record's history) emit the annotation component rsc329 with view:'text', so the cell shows the annotation's value; the inspector's narrower component-history block emits the same tipo with view:'note', the icon that opens the annotation editor in a modal.

Server (tools/tool_time_machine/server/{index,tool_time_machine,bulk_revert}.ts):

  • apply_value reads the TM snapshot for matrix_id, then branches on the target's model:
  • section — overwrites the section's stored data; on success it writes its own whole-record history row (tipo = section_tipo, the record as written, in the restore's transaction — so a recovered record's delete snapshot still reads as a delete and keeps its frame children as of the delete), recovers any deleted section media files, and logs a recovery activity. The consumed TM row is kept.
  • component_ — overwrites the component's stored data. A main with dataframe slots is restored under the two lanes, in one transaction: the row's OWN lane is put back — a language row's value merged over the live other languages (mergeRestoredLangSlice via restoredLaneValue), an lg-nolan row's lg-nolan items (none for a translatable main: its lg-nolan rows restore frames only, rowRestoresValue) — and, BEFORE the main write, in every slot of the main the main's OWN frames are replaced by the frame state AS OF the row (readRowLaneState, src/core/tm_record/lane_state.ts: the row itself when it is a frame state — lg-nolan, or a Dédalo v6 row carrying frames — else the newest frame-state row below it — lg-nolan, or a Dédalo v6 row carrying frames; attributed by from_component_tipo). Frames of another main in a shared slot stay live (the preview shows them live too), and a slot key is removed only when nothing is left. A recorded frame whose item existed in any language at the row OR at the frame-state row its frames came from (frameRowId), and that the restored value no longer holds, is not written back (isStaleItemFrame over the union of readOtherLangItemIds(coords, [], row) and readOtherLangItemIds(coords, [], frameRowId) — a frameless v6 language row that dropped the only framed item leaves no orphan); a frame saved before its item comes back. A slot the frame state is silent about was empty then and is emptied (rowSlotTipos). A row under a slot tipo is refused (engine.uncovered_scope): no supported history has one. The restore's own history follows the lanes (recordMainHistory: its lane's row, plus an lg-nolan row when the frames changed), and no row is written under a slot tipo. Every model, component_iri included, splits by one frame predicate (splitComposed), so Dédalo v6 title-only iri items are kept. The preview (section/read.ts) shows the same reconstructed state: the row's lane, the other lane as of the row (the view language for an lg-nolan row), and the frames as of the row. The history list of a lang-sliced main in language X is lang IN (X, 'lg-nolan') (read_tm.ts laneClause — a frame row filed for an undeclared live slot is listed too); an unsliced main's locator loses its lang (timelineScope), so every row of it is listed whatever its tag (a Dédalo v6 save of a relation marked translatable was tagged with the working language and holds the whole value); a non-translatable main that keeps language versions (savesInRequestLang: with_lang_versions, a non-translatable component_iri) asked in lg-nolan is read in the request's data language (timelineScope). The preview of such a version row shows the base as of it (the read emits no transliterate_value). An unsliced main (every relation model) has ONE lane, lg-nolan, whatever its ontology translatable flag and whatever the request language (laneLaw(), src/core/relations/main_lanes.ts — the lane follows isLangSlicedModel only): its rows are read, previewed and restored as lg-nolan rows (value + frames) whatever their tag, and its restore writes lg-nolan.
  • bulk_revert_process replays the run's undo log (WC-2026-09-27-bulk-revert-undo-log):
  • refuses a run still writing (the in-process live-run registry, src/core/tools/bulk_run_registry.ts) and a second concurrent revert of the same run;
  • mints a new bulk-process record (the revert is itself a run, so it is revertible exactly);
  • loads every row of the run, all roles, and groups them into units — a dataframe main with slots is one unit per record, all its lanes together (its language pairs hold a language's value, its lg-nolan pairs the lg-nolan value + every slot's frames); any other lang-sliced key one unit per (section_tipo, section_id, tipo, lang); any other key one per (…, tipo) — an unsliced key's one lane is lg-nolan, whatever tag a row carries;
  • exact path (a unit with BEFORE rows): each unit is all-or-nothing, one transaction, and units are undone NEWEST FIRST; per key it locks the row, counts live == earliest BEFORE as unchanged (decided first), then checks the chain (before[i+1] == after[i], else interleaved_write) and the conflict (live region == the run's last after-image, else changed_since_run), and otherwise restores the earliest BEFORE into the live key (restoreRegion: other languages kept; an absent BEFORE removes the key), recording its own pair (the persisted bytes, re-read) under the new bulk id. A dataframe main's unit is planned PER LANE, newest lane first: a language lane's chain over its value; the lg-nolan lane's chain over its value and, apart, over its own frames (only frames whose main_component_tipo is that main, so a shared slot never couples two mains); a conflict on any refuses the whole unit, and a clean unit writes the main key and every slot key in one write (other mains' frames kept); a frame it puts back must not pair an item of a lane it does not restore that the history knew and that no longer exists (changed_since_run); a unit that leaves the frames alone but removes an item from every language refuses (changed_since_run) when a live frame pairing that item was added after the run (assertNoOrphanedFrames; a frame that stood before the run stays); its scope gate needs write access on the main and every slot it writes; the record's modified stamps (dd197/dd201) are left alone when the run wrote them itself (a CSV import carrying them) — their own units restore them; restoring dd199/dd200 re-derives their data-column twin (inexact: metadata_twin);
  • records born in the run are deleted through the delete door, under the revert's own bulk id, only when safe (every unit clean, every key — the run's own included — still at its birth value, the marker's image, so a later translation of a run key keeps the record; no inverse reference, delete permission), else created_record_kept; cascade targets (dataframe targets a run's delete_target cascade emptied — no policy removes a target row since WC-2026-09-29-dataframe-hard-delete-retired — and born records a revert's D2 deleted, when that revert is reverted) are restored INSIDE the transaction of the unit that re-links them, or whose own record they are (a refused unit leaves the target deleted; a target that cannot come back refuses the unit; a referencing unit's gate authorizes a missing record, otherwise its scope is judged on the restored row; a missing row comes back insert-only, with its birth marker, in one transaction), the locators the cascade stripped from other records coming back as ordinary units (a soft delete_target wipe gets its wiped keys written back into the surviving row while nothing was written to it since) and reported in inexact[] (cascade_undelete), else cascade_delete_not_reverted. A row that is there is judged by its generation (an epoch opened at the address after the delete snapshot means another record was born there — every explicit-id create that re-uses an address opens one; data.created_date is rewritable and never decides): another record at the address refuses the re-linking unit; the same record with a key written since is left as it is and reported at the record, and the unit still runs — judged against the state the revert PRODUCES, never the bare snapshot: the snapshot with every unit of the revert on that record applied by the units' own writers, per language lane (producedStateOf); a covered key whose live value equals that, by its unit's own equality (ProducedKey.same, strict for an exact key), is settled (its unit finds it unchanged); otherwise a key equal to the snapshot passes, a wiped key is put back, and anything else is a write since (a key of a legacy or unplannable unit is never settled); the same record whose every differing key is already at the produced state (a repeat revert) is a silent no-op. A target that refused one unit is retried, and refuses, in every other unit that re-links it. A bulk create at an explicit id where a deleted record lived opens the address's epoch, so an older run's undo log there is never replayed over the new record;
  • legacy path (a run recorded before the undo log): the old preBulkState inference from the rows older than the run's earliest row, every row read as the full state (a slot the pre-run row is silent about is emptied — the frame plan covers the main's live slots (declared or holding its frames) plus every slot the pre-run row or the run's rows name; the conflict check covers every slot the plan writes: per slot, the main's live own frames must equal the run's LAST row, else changed_since_run). The legacy keys of a main with dataframes (one per language tag, a v6 dataframe save's lg-nolan included) are ONE unit: each key restores its MAIN half from its language's own newest pre-run row, and the unit restores, once (frames are language-blind), the FRAME half from the newest visible row of the main in ANY language older than the run's earliest row of the main in any language (preRunRow with that bound — a v6 dataframe save is never a language's own row yet is the newest state of the frames, and another language's run row already carries the run's frames), conflict-checked once against the main's last run row in any language (split per language, one language's unit rewound frames a sibling unit then refused as changed_since_run); the frames of live items of a language the run did not touch stay live, and a frame of another language's item deleted since is not written back (isStaleItemFrame). An unsliced key takes both halves from one row. A v6 dataframe save under a translatable main is tagged lg-nolan and holds the main in every language plus the frames: it is read as the lg-nolan lane — its lg-nolan items and its frames, the other languages' items ignored — so under a translatable main (no lg-nolan value) it is a frames-only image (isFramesOnlyImage, the predicate apply_value and the preview share) — its pre-run state is the main's newest earlier row in any language, the main is left live (its languages are the run's language keys), and its frames come back except those of items deleted since, the ones the history knew at that row that the live main no longer holds (isStaleItemFrame; a frame saved before its item comes back); a run row under a slot tipo is failed (v7 beta history, unsupported); a one-row history blanks only a record born in the run, else no_pre_batch_state. A unit of a main with dataframe slots needs level 2 on every slot it may write (slotTipos, else skipped out_of_scope); a frame half that would change a slot outside that set needs the caller's grant on it (assertSlotGrants, else refused out_of_scope). Every legacy write is listed in inexact[].

Observers fire post-commit per reverted key, then one activity row per key.

Actions & options

apiActions is declared with a declarative gate per action — the framework enforces it before dispatch, before the handler ever runs:

apiActions: {
    apply_value: { permission: 'tipo', minLevel: 2, handler: toolTimeMachineApplyValue },
    bulk_revert_process: { permission: 'section', minLevel: 2, handler: toolTimeMachineBulkRevert },
},
Action Permission gate Reads from options
apply_value declarative: tipo @ level 2 (write) on (section_tipo, tipo). For a section restore tipo === section_tipo, so it is equivalent to the section gate. ✅ Per-record project scope IS enforced: the declarative tipo kind checks the section/tipo write level only, so the handler adds the record check itself — principalCanAccessRecord(section_tipo, section_id, principal) (tool_time_machine.ts, SEC-024 §9.4), skipped only for section_id <= 0 which addresses no record. (This row previously claimed the check was absent; it was already present. Corrected 2026-08-14.) section_tipo, section_id, tipo, lang, matrix_id, caller_dataframe
bulk_revert_process declarative: section @ level 2 (write). Plus imperative per-row re-gate: getPermissions(section_tipo, tipo) >= 2 for every record in the bulk set; a unit of a main with dataframe slots (exact or legacy) also needs level 2 on every slot it may write (slotTipos, else skipped out_of_scope), and a legacy frame half that would change a slot outside that set needs the grant on it (assertSlotGrants, else the unit is refused out_of_scope) (rows the caller cannot write are skipped as a { reason: 'out_of_scope' } entry of data.skipped — counted, never located — not aborting the whole run). ✅ Per-record project scope is enforced PER ROW: the loop calls principalCanAccessRecord(row.section_tipo, row.section_id, principal) (bulk_revert.ts) alongside the write-level re-gate, so an out-of-scope row is skipped rather than reverted. (This row previously claimed the check was absent; corrected 2026-08-14.) section_tipo, section_id, tipo, lang, bulk_process_id, bulk_revert_process_label

Key option meanings:

Option Type Meaning
section_tipo string (req.) Target section. Also the gate scope.
section_id string/int Target record; gated against the user's project scope.
tipo string (req. for apply_value) The element being restored; its model decides the section-vs-component branch. Missing section_tipo/tipo/matrix_id fails closed (invalid_request).
lang string Component language to restore.
matrix_id int (req. for apply_value) The chosen matrix_time_machine row id (the snapshot to restore).
caller_dataframe object Set when the caller is a dataframe element, so the restore targets the right dataframe slice.
bulk_process_id int (req. for bulk_revert_process) The batch run to undo; all rows with this id are reverted.
bulk_revert_process_label string Human label stored on the new revert process record.

Both actions answer the envelope v2 {ok, request_id, data}. bulk_revert_process returns data: {counter, unchanged, bulk_process_id, exact, skipped, inexact} — counter the units written, unchanged the KEYS already at their pre-run value (a unit may add to both), bulk_process_id the revert's OWN new id, exact 'full' | 'partial' | 'none', inexact typed {basis, …coords} entries (legacy_inference, legacy_born_in_run, cascade_undelete, metadata_twin), and skipped typed entries {reason, section_tipo?, tipo?, section_id?, lang?, code?} — reason is a closed vocabulary (out_of_scope, changed_since_run, interleaved_write, created_record_kept, cascade_delete_not_reverted, no_pre_batch_state, no_column, no_lang, failed), the coordinates ride an entry only once the key has passed the caller's scope gate (an out-of-scope key skips its whole unit, uncoordinated), a located failed entry also carries code — the error-registry code of what failed the unit (tree.cycle for a revert that would close a loop in a hierarchy, internal.unexpected for an untyped throw; WC-2026-10-11-parent-link-cycle-every-door), and the words (a refusal's detail, an exception's text) go to the server log with the request id, never on the wire. apply_value on a section restore also returns restore_deleted_section_media_files. Neither action is listed in backgroundRunnable — they run inline (the client gives bulk_revert_process a 180 s timeout).

There is also a lifecycle hook (never inside apiActions):

isAvailable: (context) => context.callerModel !== 'component_relation_children',

component_relation_children has no time-machine data, so the tool hides itself there — this is now declared by the module itself rather than resolved via a core fallback (contrast tool_diffusion, which still needs one; see Server contract).

How it is registered & surfaced

tools/tool_time_machine/register.json is a column-keyed dump (string/relation/misc/… keyed by component tipo — a seeded matrix-row snapshot, not a hand-authored file); importTools() passes it through as-is (see register.json reference). The essentials it carries:

  • dd1326 name = tool_time_machine; dd1327 version (2.0.4); dd1328 minimum Dédalo version (6.2.5); dd1644 developer (Dédalo team).
  • dd799 label = "Time machine" (localized across project languages); dd612 description = "Access and retrieve versions of change history data".
  • dd1335 properties = { "open_as": "window", "windowFeatures": null } → the tool opens in its own window.
  • dd1372 labels supply the localized UI strings used by the client: apply_and_save, recover_section_alert, revert_bulk_process, info_revert_bulk_process, bulk_revert_confirm_msg (with a {0} process-id placeholder), bulk_revert_process_label, apply_value_confirm_msg, and the revert summary's bulk_revert_summary ({0} reverted, {1} unchanged, {2} new bulk id), bulk_revert_not_exact, bulk_revert_skipped_heading and bulk_revert_inexact_heading ({0} count).

Surfacing (in getElementTools, src/core/tools/registry.ts): the tool attaches to record elements — both components and section records — and uses its isAvailable hook as the last word, hiding only on component_relation_children. This register.json does not carry an explicit dd1330 affected_models relation; surfacing is element/availability-driven, and the bulk-revert UI is further restricted to global admins client-side via page_globals.is_global_admin. One rule DOES suppress the toolbar, at the section level: getSectionTools returns [] for a consultation-only section, so the Time Machine section (dd15) — like Activity (dd542) — carries no section toolbar at all (NO_TOOLS_MODELS is unrelated: it covers the component_section_id/component_info component models). The tool still attaches to record elements everywhere else; only dd15's own section toolbar is empty. See tool_export and WC-2026-10-06-consultation-only-no-section-tools.

Examples

Restoring a single component's value (client apply_value, built by tool_time_machine.js and sent through dd_tools_api):

const rqo = {
    dd_api : 'dd_tools_api',
    action : 'tool_request',
    source : create_source(self, 'apply_value'), // → tool_time_machine::apply_value
    options : {
        section_tipo : 'rsc167',  // the record's section
        section_id   : 482,       // the record
        tipo         : 'rsc170',  // the component being restored
        lang         : 'lg-eng',
        matrix_id    : 91237      // the chosen matrix_time_machine row
        // caller_dataframe : {...}  // only when the caller is a dataframe element
    }
}
const response = await data_manager.request({ body: rqo, retries: 1, timeout: 60000 })
// response → { ok:true, request_id:'…', data:true }

Reverting a whole batch run (admin-only bulk_revert_process):

const rqo = {
    dd_api : 'dd_tools_api',
    action : 'tool_request',
    source : create_source(self, 'bulk_revert_process'), // → tool_time_machine::bulk_revert_process
    options : {
        section_tipo              : 'rsc167',
        section_id                : 482,
        tipo                      : 'rsc170',
        lang                      : 'lg-eng',
        bulk_process_id           : 5571,                 // the run to undo
        bulk_revert_process_label : 'Reversed the process with id 5571 > …'
    }
}
const response = await data_manager.request({ body: rqo, retries: 1, timeout: 180000 })
// response → { ok:true, request_id:'…', data:{ counter:12, unchanged:1, bulk_process_id:5602, exact:'partial',
//              skipped:[ { reason:'out_of_scope' }, … ], inexact:[] } }

A section restore is the same apply_value call with tipo === section_tipo (the model resolves to section); on success the response additionally carries restore_deleted_section_media_files.

  • tool_propagate_component_data — the batch tool whose runs bulk_revert_process undoes (it stamps the bulk_process_id that links the change set).
  • tool_export — the only tool allowed on the dd15 time-machine section, enabling time-machine exports; see Exporting data.
  • section_record — the save/restore path apply_value uses for the section branch (including deleted-media recovery).
  • Creating new tools · Server contract — the tool model, apiActions, gates and lifecycle this page builds on.
  • Source: tools/tool_time_machine/server/{index,tool_time_machine,bulk_revert}.ts, tools/tool_time_machine/js/{tool_time_machine,render_tool_time_machine}.js; TM core: src/core/db/time_machine.ts, src/core/resolve/read_tm.ts, src/core/tm_record/{lane_state,frame_as_of}.ts (see tm_record); client list view: core/section/js/view_tm_list_section.js.