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.jsis the instance. Onbuild()it resolves the calling element asmain_element(for a section caller it loads the section instance itself) and builds the history list as an ordinary dd15sectioninstance inlistmode withview:'tm_list', which renders throughcore/section/js/view_tm_list_section.js(its only override is the Id-cell preview/restore action). Its SQO carriesmode:'tm', so the rows come from the TM table rather than live data: filtered bytipo = section_tipofor a section caller, or by onefilter_by_locatorsentry (section, id, component tipo, lang) for a component caller. The instance'sid_variantnames the main tipo, section and record, so opening the tool on another record builds another list.render_tool_time_machine.jslays 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, followhistory_lang()(tool_time_machine.js):lg-nolanwhen the dd15 context of the one-component history read saystm_main.lang_sliced: false(read_tm.tstmMainLaneLaw— every relation model, whatever itstranslatableflag), 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 thetm_edit_recordevent; the tool's handler (get_component) loads the historical component into the preview pane withdata_source:'tm'and the row'smatrix_id(the mode staysedit;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 theselected_matrix_id, and reveals Apply and save. If the picked row has abulk_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_idis part of every instance key, so the preview never shares an instance with the live "Now" pane or the page.section.jsget_section_recordspasses the caller'smatrix_idanddata_sourceto everysection_recordit creates (always from the caller, never from a locator: the dd15 list entries carry onematrix_idper row).section_recordforwardsmatrix_idto all its children, anddata_source:'tm'only to acomponent_dataframechild (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 livesync_datarefresh. 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_targetshows 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 itsdelete_targetpolicy 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 withrow_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, adata_source:'tm'or amatrix_id). What stays live, and the exact bound, is the law of the wire-contract entryWC-2026-09-29-tm-preview-frame-children-as-of(engineering/wire_contract/). -
Apply and save calls
apply_value(...); Revert the bulk process callsbulk_revert_process(...). Both go throughdata_manager.requesttodd_tools_api; on success in a popped-out window the tool closes itself. Before closing after a bulk revert it showsbulk_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 whenexact !== 'full', and per-code counts ofskipped[]andinexact[]. -
Columns are a server decision. The history list is an ordinary dd15
sectioninstance and sends no column map:src/core/section/list_definitions/time_machine_list.tsderives 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 componentrsc329withview:'text', so the cell shows the annotation's value; the inspector's narrower component-history block emits the same tipo withview:'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_valuereads the TM snapshot formatrix_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 (
mergeRestoredLangSliceviarestoredLaneValue), anlg-nolanrow'slg-nolanitems (none for a translatable main: itslg-nolanrows 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 byfrom_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 (isStaleItemFrameover the union ofreadOtherLangItemIds(coords, [], row)andreadOtherLangItemIds(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 anlg-nolanrow when the frames changed), and no row is written under a slot tipo. Every model,component_iriincluded, 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 anlg-nolanrow), and the frames as of the row. The history list of a lang-sliced main in language X islang IN (X, 'lg-nolan')(read_tm.tslaneClause— 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 inlg-nolanis read in the request's data language (timelineScope). The preview of such a version row shows the base as of it (the read emits notransliterate_value). An unsliced main (every relation model) has ONE lane,lg-nolan, whatever its ontologytranslatableflag and whatever the request language (laneLaw(),src/core/relations/main_lanes.ts— the lane followsisLangSlicedModelonly): its rows are read, previewed and restored aslg-nolanrows (value + frames) whatever their tag, and its restore writeslg-nolan. bulk_revert_processreplays 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-nolanpairs thelg-nolanvalue + 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 islg-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], elseinterleaved_write) and the conflict (live region == the run's last after-image, elsechanged_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; thelg-nolanlane's chain over its value and, apart, over its own frames (only frames whosemain_component_tipois 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; restoringdd199/dd200re-derives theirdata-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'sdelete_targetcascade 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 softdelete_targetwipe gets its wiped keys written back into the surviving row while nothing was written to it since) and reported ininexact[](cascade_undelete), elsecascade_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_dateis 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 itunchanged); 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
preBulkStateinference 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, elsechanged_since_run). The legacy keys of a main with dataframes (one per language tag, a v6 dataframe save'slg-nolanincluded) 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 (preRunRowwith 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 aschanged_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 taggedlg-nolanand holds the main in every language plus the frames: it is read as thelg-nolanlane — itslg-nolanitems and its frames, the other languages' items ignored — so under a translatable main (nolg-nolanvalue) it is a frames-only image (isFramesOnlyImage, the predicateapply_valueand 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 isfailed(v7 beta history, unsupported); a one-row history blanks only a record born in the run, elseno_pre_batch_state. A unit of a main with dataframe slots needs level 2 on every slot it may write (slotTipos, else skippedout_of_scope); a frame half that would change a slot outside that set needs the caller's grant on it (assertSlotGrants, else refusedout_of_scope). Every legacy write is listed ininexact[].
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:
dd1326name =tool_time_machine;dd1327version (2.0.4);dd1328minimum Dédalo version (6.2.5);dd1644developer (Dédalo team).dd799label = "Time machine" (localized across project languages);dd612description = "Access and retrieve versions of change history data".dd1335properties ={ "open_as": "window", "windowFeatures": null }→ the tool opens in its own window.dd1372labels 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'sbulk_revert_summary({0}reverted,{1}unchanged,{2}new bulk id),bulk_revert_not_exact,bulk_revert_skipped_headingandbulk_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.
Related
- tool_propagate_component_data — the batch tool whose runs
bulk_revert_processundoes (it stamps thebulk_process_idthat 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_valueuses 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.