The install engine (developer reference)
See also: Installing Dédalo (operator guide) · Installer reference · Development · Extending Dédalo
This page documents how the install engine works internally (DEC-19). For the
step-by-step operator instructions, see the
install guide and the
installer reference; this page is for developers
working on src/core/install/ and the install boot path.
The engine
One engine under src/core/install/, driven by ONE installer in the terminal:
scripts/install.ts (npm dedalo:install; install.sh is only its bootstrap),
whose front ends — the full-screen UI, the plain line mode, flags and an answers
file — all build the same plan. The executor (executor.ts, its loop in
step_loop.ts) runs the plan's steps through the ONE step table (steps.ts
INSTALL_STEPS, each run being the step_runs.ts STEP_RUNS entry) with
accept / verify / undo and a checkpoint per step, which is what makes --resume
and --abort possible. There is no install API: the browser front end and its
step router were removed on 2026-10-12
(engineering/wire_contract/WC-2026-10-12-install-surface-removed.md), and a
server on a box that is not installed refuses to start
(Fail-closed boot).
The install plan (one module, every front end)
install_plan.ts is the ONE place that turns raw answers into an install:
buildInstallPlan(raw, {salt, priorEnv, ontologyCatalog}) normalizes them
(normalizeInstallAnswers — every default lives here and nowhere else), and
returns the .env sections in file order, the keys the plan owns, the ordered
step ids, the optional thesauri, the domain-ontology choice (ontologies, its
ontologySource, the deps-first ontologyRequest and the
activeOntologyTlds it writes), notes, warnings and errors. It is PURE: it
imports only lang_catalog.ts, hierarchy_meta.ts and ontology_choice.ts,
never config.ts, the database or the network, because the CLI imports it
before any configuration exists. A catalog beyond the built-in oh is resolved
by the CALLER (ontology_catalog.ts, which may fetch) and handed in; when the
choice needs one and none was given, the plan reports a programming error rather
than fetching.
- The CLI maps argv to the SAME raw record the screens and an answers file
produce (
answersFromCliArgs, tableINSTALL_CLI_FLAGS— the parser carries no defaults, and an unknown flag or a value flag without a value is an error), seeds the process environment fromcliBootEnv(plan)throughseedProcessEnv(src/config/env.ts— the installer is the one writer ofprocess.envoutside the loader), then runsplan.stepswith an exhaustive switch.--planprints the plan as one JSON line and touches nothing;--list-ontologiesprintsdescribeOntologyCatalogof the selected source and exits. The prior.envis read once throughprior_env.ts readPriorEnv()(the same readerconfig_persist.tsuses), so a printed plan honours a preserved custom server list. persist_config(config_persist.ts) builds the plan from the answers (with the prior.envvalues) and rendersplan.env, refusinginstall.invalid_inputonplan.errorsbefore any write; it marks the server stateterminal_installing, which keeps a server from starting untilinstall_finishseals it.- The steps (
INSTALL_STEP_IDS) are the keys of the one step table:test_db_connection,test_diffusion_connection(diffusion only),test_mailer_connection(mailer only),persist_config,check_directories,stage_ontologies,install_db_from_default_file,install_ontologies,set_root_pw,install_hierarchies(always — possibly with an empty list),register_tools(unless skipped),install_finish. The pre-flight and the closing login check are the installer's own affordances, not plan steps. - Update servers. The plan writes
ONTOLOGY_SERVERSandCODE_SERVERS(raw JSON) —OFFICIAL_ONTOLOGY_SERVER/OFFICIAL_CODE_SERVERby default,[]forupdate_servers: 'none'. With the official choice, a key whose prior.envvalue is a non-empty list is left out of the plan, so the persist step carries the operator's line (mirrors included) verbatim; a prior[](an earlier air-gapped answer) or an unparseable value is replaced by the official entry. - Ontologies. The plan writes
ACTIVE_ONTOLOGY_TLDS(raw JSON, OWNED — rewritten on every run) =CORE_ONTOLOGY_TLDSfollowed by the install order of the chosen domain ontologies and the ontologies they declare (main: ontology35; declined optional ones left out). See Domain ontologies below. - Never in the plan:
DEDALO_SUPERVISED. Supervision is declared by the process manager (unitEnvironment=, composeenvironment:, the supervisedbun runscripts); the engine reads it from the process environment only (src/core/update/supervision.ts).
install_plan_parity_tripwire holds the front ends to this: the same answers
through CLI argv, as a record and as an answers file yield the same plan, a spawned
--plan equals the in-process plan, and two persisted .env files differ only in
the generated secret. For the ontologies it also builds a local fixture source and
checks that the spawned --list-ontologies prints exactly
describeOntologyCatalog of it, and that the ACTIVE_ONTOLOGY_TLDS written
maps back (ontologyRequestFromActive) to the very request the plan stages.
| Step | Module | Notes |
|---|---|---|
| pre-flight | init_test.ts, server_info.ts |
init_test.result gates the run; server_info feeds the probes' host facts (probes.ts) |
test_db_connection |
db_probe.ts |
psql SELECT 1 on POSTED creds; falls back to the postgres DB to tell "missing DB" from "auth wrong" |
test_diffusion_connection |
db_probe.ts → diffusion/api/ facade |
one-shot MariaDB probe (facade-only, boundary rule) |
persist_config |
config_persist.ts |
atomic .env write (0600, backup-on-overwrite, preserve-or-generate secrets) + state |
check_directories |
directories.ts |
private/sessions/cache/media/backups, write+unlink probe |
| (catalog, before the plan) | ontology_catalog.ts |
resolveOntologyCatalog — the caller resolves the catalog of the selected source (the official/configured server, a local --ontology-source, or the offline view) and hands it to the plan |
stage_ontologies |
ontology_install.ts |
copy/download + gunzip + COPY-sanity of every chosen file into the staging dir, staged.json with sha256s; the DB is never touched. The request is the plan's ontologyRequest |
install_db_from_default_file |
db_restore.ts |
empty-DB gate → gunzip → psql -f the core-only seed (already migrated) → ensureSearchStores() → activateCoreHierarchies() (activates lg, see below) |
install_ontologies |
ontology_install.ts |
sha re-check → stageOntologyFiles → importStagedOntologyFiles (the update panel's shared lower layer) → one re-derive pass → reference verification (warnings) → staging dir removed |
set_root_pw |
root_pw.ts |
Argon2id, direct UPDATE matrix_users section_id -1 dd133 |
install_hierarchies |
hierarchy_import.ts |
optional thesauri only: \copy into matrix_hierarchy + counter consolidate + activation; a core TLD is activation-only, an empty list is a no-op |
register_tools |
register_tools.ts |
reuses core/tools/register.ts importTools({dryRun:false}) |
install_finish |
finish.ts |
seal guard (root + password exist) → install_status='sealed' |
Domain ontologies
The seed carries only the core (src/core/ontology/core_tlds.ts
CORE_ONTOLOGY_TLDS — dd, rsc, ontology, ontologytype, hierarchy, lg,
the ONE home of that list). Every install adds ≥ 1 domain ontology. The modules,
leaf first:
| Module | Role |
|---|---|
src/core/install/ontology_choice.ts |
PURE, config-free. The answer (normalizeOntologyChoice: default DEFAULT_DOMAIN_ONTOLOGIES = oh, core dropped with a note, none refused), the built-in catalog (vendoredOntologyCatalog: oh only — VENDORED_DOMAIN_TLDS — its dependencies READ from the vendored ontology.json entry, absent = not declared), the merge (precedence local > vendored > server), the closure (closeOntologyChoice: main: ontology35 dependencies depth-first, main: hierarchy1 ones into the thesaurus set; a mandatory ontology always, an optional one and EVERY thesaurus unless declined_dependencies — a declined mandatory thesaurus is a warning, recommendedThesaurusWarning), the request, activeOntologyTldsOf, the round trip from a written list (ontologyRequestFromActive) and the ONE view (describeOntologyCatalog) both front ends show |
src/core/install/ontology_catalog.ts |
resolveOntologyCatalog(source, {allowedServers}) — the one place a source is read: none → built-in only; local → a directory or an extracted archive, its ontology.json version must equal this engine's major.minor; server → fetchOntologyManifest. Refuses install.invalid_input (operator path) or install.step_failed (server) |
src/core/install/ontology_archive.ts |
extractOntologyArchive — a pure tar walk (no tar binary): regular files named ontology.json / <tld>.copy.gz at the root or under one top directory, through confinedPath; links, devices, absolute or .. paths, > 256 entries or > 512 MiB refused |
src/core/install/hierarchy_dependencies.ts |
withThesaurusDependencies() — closes a thesaurus choice over the thesauri the manifest entries declare (Maintenance › Install hierarchies), with the plan's own collector |
src/core/install/ontology_install.ts |
the two steps: stageOntologies(request, {stagingDir}) and installOntologies({stagingDir, userId}), plus verifyInstalledOntologyReferences |
src/core/install/prior_env.ts |
readPriorEnv() — the prior .env ({} when absent), for the CLI and config_persist.ts alike |
src/core/ontology/ontology_manifest.ts |
the manifest CLIENT: parseOntologyManifest, readLocalOntologyManifest, fetchOntologyManifest |
src/core/ontology/ontology_references.ts |
the pure reference classifier (below) |
src/core/db/copy_text.ts |
the src-side COPY text codec (decode/encode a field, split a row, find a dump's COPY blocks) |
The closure. closeOntologyChoice(chosen, catalog, policy) walks the DECLARED
main: ontology35 dependencies of the chosen TLDs, transitively, in deps-first
post-order: mandatory ones always, optional ones unless the policy declines them
(declinePolicy(answers.declined_dependencies), each declined one noted). Core
TLDs are never followed (a declaration names core
too — every domain ontology takes its models from dd — but the seed already
has it). A cycle is tolerated (first finish wins; the re-derive pass in
installOntologies settles a node whose model arrived later). An entry with
dependencies: null (an older server) is installed alone under a loud warning
naming it — the installer never computes dependencies. A declared MANDATORY
dependency the source does not offer, and an unknown TLD, are errors before any
write; an unoffered OPTIONAL one is a warning and skipped. A dependency on a core
TLD is never put to the decline policy: the seed always installs
it, so it can be neither declined nor reported as declined (the Ontologies
screen shows it ticked and locked).
The thesaurus set. main: hierarchy1 dependencies are collected by ONE
function, collectHierarchyDependencies (merged per TLD, mandatory wins, core
lg never listed), into {tld, mandatory, dependants} items. The plan collects
them over the vendored core declarations plus the install order's entries. The
seed compiler passes each release entry's declaration through the import door
(releaseDependencies in seed_build.ts; a malformed one refuses the compile),
so the seed's core registry rows hold the same hierarchy60 the plan reads —
install_seed_drift_tripwire checks the committed seed against the release.
The result goes into InstallPlan.hierarchyDependencies,
and adds them to plan.hierarchies (even with --hierarchies none) unless
declined. A thesaurus dependency never blocks an install (owner decision
2026-10-10, WC-2026-10-10-ontology-dependencies-hierarchy60 addendum):
mandatory: true on a main: hierarchy1 item is a STRONG RECOMMENDATION —
pre-ticked like an optional one and declinable; declining it is a WARNING from
ONE function, recommendedThesaurusWarning (dependency, dependants, and
THESAURUS_INSTALL_LATER = Maintenance › Install hierarchies).
hierarchyDependencyPlan checks each against the thesaurus manifest
(offeredHierarchyTlds() — every non-core entry of hierarchy.json, with or
without data files) and answers {install, warnings}: a thesaurus with no
entry is a warning and skipped, mandatory or not. An entry without data files
is installable — it is created empty. The install_hierarchies step imports
exactly plan.hierarchies: the warnings are the plan's, decided before anything
is written, and the step adds nothing. Mandatory ONTOLOGY dependencies keep their
rule (always installed; unoffered → error).
The source. ontologySourceFor(answers, priorEnv) (in the plan): a local
ontology_source wins; else the first entry of ontologyServersFor (a prior
CUSTOM ONTOLOGY_SERVERS list — the same preserve rule as the update-servers
section — or OFFICIAL_ONTOLOGY_SERVER); else none (air-gapped). The manifest
fetch is a server-side fetchBoundedText call behind the named address policy
assertConfiguredMasterUrl(url, allowed): the URL must be EXACTLY one of the
configured masters — operator configuration, never client text. LAN masters are
legitimate, so the public-address guard does not apply. File downloads reuse the
update panel's pinned downloadRemoteOntologyFile; there is no new fetch site.
The two steps. stage_ontologies is the install's only network phase and
runs BEFORE the restore: every file (deps first, plus the source's
matrix_dd.copy.gz when it ships one and ≥ 1 item comes from it) is copied or
downloaded into installOntologyStagingDir()
(<private>/install/ontology_staging/<major.minor>/), gunzipped under the shared
caps and COPY-sanity checked; staged.json records each file's sha256 and the
source (never the access code). install_ontologies runs right after the restore:
it refuses install.state_conflict when staged.json is missing or a file's
sha256 changed, unpacks through the update panel's own Phase-A stager
(stageOntologyFiles, local mode), claims the update's single-flight latch
(claimOntologyImportLatch), and imports through the SHARED LOWER LAYER of
src/core/ontology/ontology_update.ts, importStagedOntologyFiles — recovery
snapshots, the per-file import with auto-restore, the dd_ontology derive and
the cache clears. updateOntology keeps its own shell (TLS, ownership, target,
latch, download, Phase A, schema capture before and after, root info); the
installer calls the layer directly, without flipping IS_AN_ONTOLOGY_SERVER.
The failure contract is install-level all-or-nothing: any import error
refuses install.step_failed; the layer's snapshot restore runs (its known
limits are the update panel's), nothing is sealed, and the operator recreates the
database. The staging directory is removed on success only.
Reference measurement. ontology_references.ts reads the structural fields
only — parent (ontology15), model (ontology6), related nodes (ontology10) —
from dd_ontology rows (referencesOfRows) or raw package lines
(referencesOfCopyRows), and classifies each reference: a PARENT in a TLD the
measured set does not own is a graft (a node hung under another ontology's
node — inert when absent); a relation FROM a node whose model descends from the
diffusion model grouper (DIFFUSION_MODEL_ROOT = dd1226) is diffusion;
everything else is a dependency and must resolve. Tipos embedded in
properties are not measured — a stated limit. The same classifier drives the
post-install warnings (verifyInstalledOntologyReferences), the vendored-closure
gate and the seed contract.
The declaration (server side). Dependencies are Dédalo state on the ontology
master: the master ontology's component_json hierarchy60 Dependencies
(group Relations of hierarchy1, so the Ontologies main ontology35 form
has it too), stored as misc.hierarchy60[0].value on the registry row. Each
item is {tld, main: ontology35|hierarchy1, mandatory}, checked by ONE
normalizer (normalizeOntologyDependencies,
src/core/ontology/ontology_dependencies.ts; the dedupe key is (tld, main)).
getActiveOntologies (src/core/ontology/data_io.ts) exports it as
dependencies ONLY when the row holds the key. Invalid items become census
error lines. activeOntologiesInfo copies it into ontology.json, and the
update manifest serves that file verbatim. An absent key means NOT DECLARED;
[] is a declaration
(engineering/wire_contract/WC-2026-10-10-ontology-dependencies-hierarchy60.md).
The import writes it back through syncMainSectionFromDefinition, the
registry-row law's import door, which on an existing row replaces only the
definition's keys.
The answers (engineering/wire_contract/WC-2026-10-09-install-domain-ontologies.md,
whose browser half is superseded by WC-2026-10-12-install-surface-removed):
ontologies and, since
engineering/wire_contract/WC-2026-10-10-ontology-dependencies-hierarchy60.md,
declined_dependencies (CLI --decline-dependencies); each catalog view entry
carries hierarchy_dependencies, and --plan prints hierarchy_dependencies
and declined_dependencies.
The suite database is built through these doors
bun run test:db:setup (scripts/test_db_setup.ts) builds the suite database
as an installation first: installDbFromSeed() (the restore door, so the core
seed, the search stores and lg), then the default domain
ontology through stageOntologies(defaultOfflineOntologyRequest()) +
installOntologies(), and only then the suite's own fixtures — the marker, the
test TLD (materializeTestTldOntology, which refuses on a database without the
marker; there is no bypass), the canonical test3 records, hierarchies and tools.
No installation receives a test fixture.
Fail-closed boot
config.ts builds and freezes config at import and requireEnv-throws on a
missing required key. There is no install mode (removed 2026-10-12 —
engineering/wire_contract/WC-2026-10-12-install-surface-removed.md); a box that
is not installed REFUSES TO START.
src/config/install_state.ts (depends on env.ts only) answers the question:
REQUIRED_CONFIG_KEYS—ENTITY,DB_NAME,DB_HOST,DB_USER(frozen at four byconfig_docs_tripwire).UNCONFIGURED— true when all four are unset or empty (environment plus.env). The catalog readers then hand out theinstallSentinelvalues, soconfigstill imports on a fresh box (forconfig:gen, the install probe child, and the server's own refusal). The seal is NOT consulted: a sealed box whose.envwas lost must reach the refusal, not throw at import.notInstalledRefusal()— readsinstall_statusraw fromts_state.json(DEDALO_TS_STATE_PATHoverrides) and answers one sentence, ornull: not installed (no.env) → run the installer; a.envnaming none of the four → complete it, do not reinstall; sealed → restore.env; any status other thansealed→--resume. A garbled state file reads as no status.NOT_INSTALLED_EXIT_CODE = 78(EX_CONFIG) — distinct from 75 (the planned restart), 1 (a crash) and 0.
src/boot_refusal.ts is the FIRST import of server.ts: ES modules evaluate in
import order, so when server.ts is the process entry (Bun.main) the refusal
prints [boot] REFUSED — <sentence> (exit 78; supervisors must not restart) and
exits before any sibling import runs — before the session store opens its SQLite
file, before the config build records anything in ts_state.json, before any
listener. startServer() repeats the check for a programmatic caller; a gate that
imports server.ts for handleRequest is not refused. A PARTIAL configuration
(one to three keys) is misconfigured, not unconfigured: the config import throws
the precise missing-key error (exit 1).
The supervisors stop on 78: the systemd units carry
RestartPreventExitStatus=78, deploy/dedalo-ts-watchdog.sh does not start the
restart unit for a unit that failed with 78, and the dev/supervised loops respawn
on 75 only. The planned restart itself (src/core/update/restart.ts
scheduleServerRestart, exit 75) is requested only by a code update or restore.
The installer seals every route before the first server boot (production: the
engine steps run before host_first_boot; docker/trial: the engine runs in
compose run --rm before stack_up; developer: the operator starts the server
after the install). persist_config writes terminal_installing, so a server
started by hand over a half-built install refuses with --resume instead of
serving a login in front of an unfinished database.
Gates: no_install_surface_tripwire (the action table driven session-less; the
real server spawned config-less — exit 78, no listener, no database connection,
no write), install_state_boot (the predicate),
install_restart_supervisor_tripwire (the supervisors).
Languages (mandatory)
config.ts requires four language keys whenever the server is configured
(not UNCONFIGURED): DEDALO_APPLICATION_LANGS (code→label map),
PROJECTS_DEFAULT_LANGS (code array), DEDALO_APPLICATION_LANGS_DEFAULT,
DEDALO_DATA_LANG_DEFAULT (owner rule: a missing/malformed value must refuse
boot). The installer therefore MUST write them, or the post-persist_config
restart crash-loops.
lang_catalog.tsis the single source of truth:INSTALL_LANG_CATALOG(the curated ~10 code→label set) +deriveLangConfig({langs, appLangDefault, dataLangDefault})— PURE (noconfig.tsimport). The picked set drives BOTH the map and the array, so they can never disagree. Absentlangs→INSTALL_DEFAULT_LANG_CODES(lg-eng,lg-spa; the rest of the catalog is optional); an explicit empty set or a default ∉ set →errors(refused).install_plan.tscallsderiveLangConfig(its errors become plan errors, soconfig_persist.tsrefuses on them), and the plan carries the map/array as RAW compactJSON.stringify(NOTenvQuote):parseEnvFilestrips surrounding quotes but does not unescape inner\", so anenvQuote'd JSON value would not round-trip throughJSON.parse. Scalars (*_DEFAULT,APPLICATION_LANG,DATA_LANG,DEDALO_STRUCTURE_LANG) useenvQuote.- The CLI (
scripts/install.ts) presets the lang env vars from the plan'sderiveLangConfigresult (cliBootEnv) BEFORE importing config (with ENTITY/DB set, config would otherwise throw at import) —deriveLangConfigis pure precisely so it can run first. Flags:--langs,--app-lang,--data-lang. - The interactive installer asks for them on its languages screen, pre-ticking
INSTALL_DEFAULT_LANG_CODES; ≥1 is enforced by the plan (deriveLangConfig).
Config / paths
- Key spellings.
env.tsreads each config key by its canonical name and accepts a documented legacyDEDALO_*spelling as a fallback, so an administrator can carry an existing.envover unchanged; the canonical name always wins when both are set. The install plan writes only canonical names, andconfig_persist.tsdrops a prior.env's legacy line for every key the plan writes, so a re-run never leaves one value under two names.DEDALO_SALT_STRINGis generated (or preserved) on write for continuity, but nothing in the server reads it — it is not a password salt and never was (seesrc/core/security/password_hash.ts). DEDALO_PRIVATE_DIRrelocates the whole private tree — bothenv.ts(read) and the installer (write) honor it.installPrivateDir()(paths.ts) adds a write-only test overrideDEDALO_INSTALL_PRIVATE_DIR.- pg client binaries resolve via
pg_bin.ts(configDEDALO_PG_BIN_PATH, then Homebrewpostgresql@NNnewest-first, then PATH — a client older than the server refuses to connect);pg_exec.tsruns psql against an explicit connection descriptor (posted creds for the browser probe; the CLI path).
Core hierarchies (Languages)
hierarchy_meta.ts CORE_HIERARCHIES names the thesauri every install has —
today only lg (Languages). Its terms ship in the seed, in matrix_langs
(the table lg1/lg2 resolve to), so it is never imported: hierarchy.json
lists it for reference with data_files: [], no lg1.copy.gz is ever vendored,
and its registry row is the only one the seed ships. It only needs
activating — flag active, active in thesaurus, link the root — which
activateCoreHierarchies() (hierarchy_activate.ts) does inside
install_db_from_default_file, so every surface that restores the seed (the
installer, test:db:setup) gets it with no separate step. installHierarchies answers
a core TLD with activation only (replace is refused: its terms cannot be
re-imported), and a front end that lists lg gets it dropped with a note.
The thesaurus manifest (hierarchy.json)
The thesauri a release offers are ONE exported file,
install/import/hierarchy/hierarchy.json, written on the master by the
Export hierarchy widget (export_hierarchy_json, census
src/core/ontology/hierarchy_census.ts) and read back through ONE strict reader
(src/core/ontology/hierarchy_manifest_format.ts, a pure leaf both sides
import; readHierarchyManifest() in hierarchy_meta.ts is the installer's
door). Shape: the ontology.json envelope (version, date, entity_id,
entity, entity_label, host — manifestEnvelope() in
src/core/ontology/data_io.ts, shared), typologies [{typology_id, name,
name_data}] (the hierarchy13 records), and active_hierarchies, one entry per
ACTIVE hierarchy1 row: tld (hierarchy6), name/name_data
(hierarchy5), typology_id/typology_name (hierarchy9), lang:
{section_id, label} (hierarchy8 → an lg1 record; the import uses the int
section_id), real_section_tipo (hierarchy109, null = the provisioning
default), active_in_thesaurus (hierarchy125), scope_note_data
(hierarchy61, [] when empty), dependencies (misc.hierarchy60 through the
shared normalizer; absent = not declared) and data_files [{file, sha256}]
(the <tld>1|2.copy.gz files beside it at export time; [] = an empty
thesaurus; always [] for a core hierarchy). A malformed file refuses
(install.manifest_invalid) — it is never read as an empty offer. The census
writes record by record: each entry is checked alone (validateHierarchyEntry,
plus a set real_section_tipo must be a section), and a row that fails is one
Skipped hierarchy1/<id> error line, never a failed export. The data export
admits only an active row's own <tld>1/<tld>2. Contract:
engineering/wire_contract/WC-2026-10-10-hierarchy-json-manifest.md.
- Offer —
offeredHierarchies(): every non-core entry, data or not; the installer's Thesauri screen and the add_hierarchy widget get the same view fromhierarchyChoiceView(lang)({tld, label, typology, has_data, dependencies}—dependencies= the entry's declared non-core thesauri{tld, mandatory}, pre-ticked by the client when the entry is ticked; labels picked fromname_datain the requesting user's language throughontology/manifest_lang_pick.ts). - Pre-selection — ONLY the declared dependencies: the CLI default and the
Thesauri screen's ticked boxes are the plan's
hierarchyDependencies(all ticked, a mandatory one marked strongly recommended — never locked). No toponymy is ever a default; a plan with no toponymy thesaurus (typologyTOPONYMY_TYPOLOGY_ID) carriessuggestions=TOPONYMY_SUGGESTION(install_plan.ts), which the installer prints. - The chosen thesauri's own declarations (
installHierarchies→hierarchy_dependencies.tswithThesaurusDependencies→ontology_choice.tscloseThesaurusChoice) — before anything is written, the batch is checked transitively against the chosen entries'dependencies: NOTHING is added (the front ends pre-tick declared thesauri); a mandatory thesaurus left out, or one with no entry, is a warning inmsg; only a mandatoryontology35not installed refuses the whole batch. The CLI plan runs the same closure with the decline policy — a declared thesaurus is added pre-ticked unless declined (--planshows it). A reset is not closed. - Preflight (
activationBlocker,hierarchy_activate.ts) — for a TLD with no registry row yet, before its terms are copied: thelg1/<lang.section_id>andhierarchy13/<typology_id>records exist, andprovisionBlockeraccepts the entry (a setreal_section_tiponames asection). A refusal writes nothing. - Import (
hierarchy_import.ts) — for each listed data file: exists AND sha256 matches, verified before any write; a failure refuses that TLD with nothing written. No data files = activation only. Without reset, the sections the entry LISTS are counted (classifyListedSections): data present → no copy, but activation still runs (an interrupted run converges); a section holding only the root an empty activation minted while its file carries more → refused naming Reset. - Activation of a new row (
activateHierarchy,hierarchy_activate.ts) — the registry row is written from the entry in one transaction:hierarchy6,hierarchy5,hierarchy9,hierarchy8=lg1/<lang.section_id>,hierarchy109,hierarchy61,misc.hierarchy60; thenensureHierarchyprovisions. An existing row is never re-described.
The install seed
install/db/dedalo_install.pgsql.gz: full matrix/dd_ontology schema, extensions
(btree_gin/pg_trgm/unaccent), functions/indexes, the CORE ontologies only
(CORE_ONTOLOGY_TLDS, ~3,470 dd_ontology rows), the root user (empty password),
the default project and Admin/User profiles, and the Languages terms in
matrix_langs. No domain ontology, no test TLD, no matrix_test row. The
derived search stores (matrix_string_search, matrix_relation_index) are
created but ship EMPTY: the relation-index reader refuses an empty store while
relation data exists, so completeFreshInstall (db_restore.ts) runs
ensureSearchStores() FIRST, before any engine read or write. Optional hierarchy
import files are vendored under install/import/hierarchy/ (<tld>1/<tld>2
.copy.gz files + the manifest hierarchy.json, above).
The seed is compiled, never hand-edited and never built from a database:
bun run seed:build (the same compiler as Maintenance → Build database version →
build_install_version, src/core/install/seed_build.ts) builds it from files in
this repository only, so the same checkout produces the same seed on any machine.
Its sources:
| Part of the seed | Source |
|---|---|
| Schema (tables, functions, triggers, indexes, extensions) | install/db/seed/schema.sql plus every file in install/db/migrations/ |
Core ontology (CORE_ONTOLOGY_TLDS: dd, rsc, ontology, ontologytype, hierarchy, lg — seed_sources.ts reads that one list) and private lists |
the ontology release install/import/ontology/<major.minor>/ — the packages every ontology update downloads — applied through the update's own per-package door |
Languages terms (matrix_langs) |
install/db/seed/matrix_langs.copy.gz |
Hierarchy registry (matrix_hierarchy_main): the CORE rows only (lg), shipped inactive — the restore activates them; every other thesaurus's row is written at activation from its hierarchy.json entry |
install/db/seed/matrix_hierarchy_main.copy.gz |
| Root user (no password), General project, Admin/User profiles | SEED_RECORDS in src/core/install/seed_sources.ts |
| Data version | generated: the engine's version |
Everything else ships empty. No domain ontology is compiled in: oh is the
installer's default answer, installed after the restore from the vendored
install/import/ontology/<major.minor>/oh.copy.gz (see Domain ontologies). The
test TLD and the test3 playground are the suite's: bun run test:db:setup
materializes them (registry row included) after restoring this seed. install/db/seed/dd_ontology_scaffold.copy.gz is the parser's scaffold:
deriving dd_ontology reads the ontology's own definitions, so the compiler starts
from it, derives to a fixpoint and requires every TLD to come out in sync — none of
the scaffold reaches the seed.
The compile runs in scratch databases it creates and marks (dedalo_seed_build_<pid>,
dedalo_seed_verify_<pid>, dropped afterwards), one compile per cluster (an advisory
lock). Before the file is replaced, the result installs itself: it is restored into
the second scratch database through the installer's own restore — search stores,
Languages activation — the boot migrations run over it, and the
core ontology, Languages and root password are checked. It checks what the seed
promises (the core), never a domain ontology. A registry record the activation
would refuse, an active hierarchy, a registry row of a non-core hierarchy, an ontology row of another TLD or an undeclared
extension refuses the compile. The database user needs CREATEDB.
The seed is committed with install/db/dedalo_install.manifest.json: the seed's
digest, its row counts, and a digest of every source file. Changing what an install
is born with is a change to a source, then bun run seed:build, then commit the seed
and the manifest together — a source changed without a recompile is reported STALE.
To ship a newer ontology, export the release packages from the ontology master first.
Front ends (the interactive installer)
The terminal installer has ONE walker and two renderings. install_session.ts
(runInteractiveInstall) walks the screen model (screen_model.ts): the
order, the gating, what a changed answer re-asks, the probes, the kept
secrets, the Review's commands, the unfinished-install offer (resume / abort /
quit). It talks to an InstallPrompter (prompter_contract.ts), which holds
the shared answer rules — what Enter takes (suggested), when - empties a
field, the secret entry flow (secretEntryStep: refused, policy, twice, three
mismatches = refused), a confirm's default — and the structured views a front
end draws (ScreenView before every ask; Welcome, Resume, Probe, Review). The
multi-select model (ticks, declared closure, declines, locks) is
multi_select.ts; the text hygiene every front end applies to untrusted
strings is display_text.ts (sanitizeLine).
- Plain (
ui_plain/prompter.ts): one question per line. - Full screen (
ui/, OpenTUI): the same asks as widgets, answered with the plain mode's values. It holds no install knowledge — no default, no rule, no step table (scripts/install.tshands it the step list throughRunReporter.begin).ui/lifecycle.tsowns every exit path (signals, an uncaught error,fail, Done) and restores the terminal; the process is injected (ProcessPort) so the gates drive the UI on OpenTUI's test renderer without a handler that exits the runner.console_capture.tsroutes every console method into the log ring while the UI is up (the executor's redaction wraps it).
The run is told through run_events.ts: the executor emits a StepEvent per
step phase (start, skipped, adopted, done, failed, interrupted,
undo — every text redacted) and reports its own signal handlers armed or
disarmed (RunIo.signals), so the full screen delegates SIGINT/SIGTERM to the
executor only while it holds them. The headless CLI has no listener and its
transcript is unchanged.
Confinement. @opentui/* is imported only under src/core/install/ui/,
and only tui_loader.ts loads it — by a dynamic import, inside one try
around the import AND the renderer's creation (a missing native build throws
only there). Any failure answers {kind: 'plain', reason} and the CLI goes on
in plain mode. Neither the server's import closure nor the headless installer
reaches it. OpenTUI's own environment switches (OTUI_*, SHOW_CONSOLE — one
appends every raw stdin byte to a file) are cleared by scripts/install.ts
before the UI loads.
The pin. @opentui/core is pinned EXACTLY in package.json
dependencies (the latest stable at the time, 0.6.1): the package is pre-1.0,
so a minor release may change the API. Dependabot proposes bumps; a bump is
taken with the TUI gates green. The lock carries the native build of every
target (darwin arm64/x64, linux arm64/x64, glibc and musl).
The step loop (one loop, two executors)
step_loop.ts is the resumable loop both executors drive: the ENGINE executor
(executor.ts — the engine steps and its database claim) and the HOST executor
(host/host_executor.ts — the production and docker/trial host steps). One
implementation of the rules, so a host step resumes exactly as an engine step
does:
- recorded
done: a step with averifymust still verify, or the run is refused as drift (step <id> was recorded done but its outcome is missing …); a step without one is skipped; - recorded
runningorfailed: averifythat passes means the work landed before the checkpoint — the step is adopted (marked done, skipped) unless it isneverAdopt(the seed restore); otherwise it runs again; - pending: intent → run → created → verify → done, checkpointed around it.
The loop knows nothing of what a step does; the driver's perform runs it. Also
here, shared by both executors: the fault seam (DEDALO_INSTALL_FAULT), the
event emitter, the console redaction and the stopping of children on a signal.
It is config-free — the root process of the production route loads it.
The host executor
host/host_executor.ts runs a host route's plan through the same loop, with the
same state schema, in its own run directory, so two processes never share a
state file and root never writes into a directory the instance user owns:
/var/lib/dedalo-install/<instance>/ on production (0700, root),
<checkout>/.dedalo-install/ on docker and trial. Modes: fresh (refuses an
existing record; production refuses without root, naming the sudo sh install.sh
form), resume (the saved plan and the frozen facts; a step whose commands no
longer equal the ones the run showed is refused) and abort (reverse order,
only what created lists — and once the engine child completed, only the host
steps after it: a live install is never taken apart).
| Module | What it owns |
|---|---|
host/contract.ts |
what a host step is: a PURE commands(ctx) (plan + frozen facts → the exact commands), run, verify, undo, intent, created. Review equals run: the Review lists commands(), the run executes exactly them; an unless probe makes a re-run after a kill converge. Nothing secret is in a command — a secret reaches a child only as stdin: {secrets} |
host/execute.ts |
the one place a command becomes an effect and the one place it becomes a Review line; as: instance\|postgres becomes a runuser -u <user> -- prefix |
host/runner.ts |
the one spawner: absolute binaries only (from the OS row's closed table, never a PATH lookup), a fixed environment (no DEDALO_INSTALL_* reaches a child), secrets on stdin, bounded output and time, every child registered so a signal stops apt, dnf and certbot too. Its recording twin answers argv from a response table and logs every request |
host/fs.ts |
every file a host step touches: atomic writes, mode and owner set by descriptor before the rename, no symlink followed, owners by name from the root's own passwd/group |
host/facts.ts, host/os_support.ts |
the read-only measurement of the machine (OS row, privilege, web server, PostgreSQL, what already exists under this instance's names, SELinux, the firewall) and the supported OS rows — exact ID and major version, newest releases only |
host/layout.ts |
every path and name of one production instance, from the instance name |
host/review.ts |
the Review's host commands: the facts measured now, then every step's commands() for them |
host/host_table.ts |
the exhaustive step-id → definition table (production rows in host/production/, stack rows in host/stack/) and the digest host_preflight freezes |
host/registry.ts |
/var/lib/dedalo-install/instances/<i>.json: the refcounts of the shared resources (template units, a Bun pin, the SELinux module, the nginx media map) |
host/systemd/render.ts |
the template units (deploy/systemd/*@*, only OS-row values substituted) and the per-instance drop-ins; every Environment= value quoted; no root line beside the instance's .env |
host/web/{nginx,apache,site,clash}.ts |
the one site file per instance, the validated media-rule copies and the host-wide map include, and the clash scan (only (file, names, ports) tuples kept from nginx -T / -S) |
host/stack/* |
the docker/trial steps, the compose invocation (-f <stack> -f compose.override.yaml --env-file .dedalo.env, the same argv deploy/dedalo-image-lib.sh emits) and the image verification |
Engine delegation
The host process never runs an engine step itself. host_engine (production)
starts ONE child as the instance user in the instance's own tree —
runuser -u ded_<i> -- <bun> run scripts/install.ts --request-stdin --execute --events-jsonl [--resume] —
and stack_engine runs the same command in a throw-away container
(docker compose … run --rm -T dedalo …). The request (the engine answers — the
host answers minus HOST_ONLY_ANSWER_KEYS, plus engine_only and, on production,
the OS row's pg_bin_path — and the secrets) is ONE JSON object on the child's
standard input. Its log lines and step events come back as JSON lines and are
relayed, redacted; the one-time salt arrives as a show_once frame the front end
shows once and never stores. verify asks the child (--status --json); undo
is the child's own --abort, which refuses a sealed install.
Seams
All read from the process environment only, never from ../private/.env:
DEDALO_INSTALL_FAULT=<step>:before|after— the resume drills' SIGKILL point, in the loop.DEDALO_INSTALL_HOST_ROOT— the HostFs root becomes this directory and the runner becomes the recording runner, in the same move; a root without the.dedalo_install_scratchmarker is refused. In-process gates inject the runner and the fs directly.DEDALO_INSTALL_PROBE_CA_FILE— the public probe's trust anchor, accepted only inside a directory marked.dedalo_install_drill.
Drills
bun run drill:install-host(scripts/install_host_drill.ts, opt-in, never part ofbun test): a privileged systemd container of the newest supported release (--os debian|ubuntu|alma,--web nginx|apache), decoys planted, the bootstrap run as root and killed after every host step in turn, resumed each time, then checked from outside (units, timers,nginx -t,/healthover HTTPS through the drill CA, the decoys byte-equal) and aborted on the live instance.test/unit/install_host_resume_drill.test.tsandinstall_resume_drill.test.ts: the same kill-and-resume over a marked scratch host and over the engine steps.
Gates
test/unit/install_*.test.ts and seed_build_native: the seed compiler and its
content contract (install_seed_contract_native holds the committed seed to it, and
install_seed_manifest_tripwire to its manifest and sources; install_seed_drift_tripwire
holds the compiler's TLDs = CORE_ONTOLOGY_TLDS = the catalog default of
ACTIVE_ONTOLOGY_TLDS and the core's dangling dependency references to
engineering/install_seed_contract.json), the fail-closed boot (install_state_boot,
subprocess imports; no_install_surface_tripwire, the real server spawned), the .env write
contract, the seed restore + Argon2id root pw + login (real scratch DB), the
hierarchy import, the seed drift tripwire, and the full CLI e2e ending in a
verified root login (asserting lg active with zero lg rows in
matrix_hierarchy, and one data-carrying manifest entry installed with its
registry row written from the entry). The thesaurus manifest:
hierarchy_manifest_format (the strict reader, hermetic), hierarchy_census_native
(the export census, on the suite database), install_seed_drift_tripwire (the
vendored hierarchy.json parses, every listed file matches its sha256, every
vendored file is listed, no lg data file, the retired files absent, the seed
registry = the core rows), install_hierarchy_activate_native and
hierarchy_import_atomic_native (a row written from the entry, a bad language
id, a checksum mismatch, an empty thesaurus — each refused or applied with
nothing partial). install_plan_parity_tripwire holds flags, the answers record and the answers file to one plan;
install_core_hierarchy_native proves the core activation needs no import;
install_sh_portability holds the stack disk probe to its POSIX df -Pk form and
install.sh to POSIX sh; install_bootstrap_sh holds the bootstrap's refusals
(kernel, musl, CPU, the release sha256, the Bun hash table) and its exec of the
installer; install_sh_image_source holds the image-source decisions of the shared
host library (deploy/dedalo-image-lib.sh). The ontology half: install_ontology_choice (the closure,
hermetic), install_hierarchy_dependencies_native (the install_hierarchies step never
forces a declared thesaurus, over a real registry row, on the suite database),
ontology_references_native (the classifier and codec, hermetic),
ontology_manifest_native (the manifest client against a loopback stand-in
master, hermetic), ontology_dependencies_native (hierarchy60 → census →
manifest, on the suite database), vendored_ontology_closure_tripwire (the built-in oh is
installable alone over the core seed, and every declared thesaurus dependency
names a hierarchy.json entry, hermetic) and
install_ontology_door_native (both steps driven for real on the suite database
against a loopback master: closure order, undeclared warning, local
directory/archive, preflight refusals with the database untouched, tampered
staging, matrix_dd). Tests never name a real domain ontology's structure: they
build zz TLD packages (src/core/test_data/ontology_package_fixture.ts).
The interactive front ends: install_screen_model_tripwire (the model), the
plain mode's install_plain_parity_tripwire / install_plain_secrets_tripwire,
and the full screen's install_tui_parity_tripwire (driven by key presses over
the same matrix: the same plan, the same asks), install_tui_secrets_tripwire
(no secret on any frame or renderable; the salt only on Done),
install_tui_restore_tripwire (every exit path on a real pseudo-terminal: the
code, every terminal mode undone, stty sane), opentui_confinement_tripwire,
install_tui_frames (the mockup's layout at 104×30, 96×27 and 80×24) and
install_executor_events_native (the step events).
The host routes: install_host_steps_native (every production step over a
scratch host and the recording runner), install_host_render_tripwire (the
rendered units and site, the templates on disk equal to the rendered set, no root
line beside an EnvironmentFile), install_host_selinux_native (what the
dedalo_engine module grants, against the old broad rule as a control),
install_host_privilege_tripwire (what the root process may import and write),
install_host_resume_drill, install_stack_steps_native (the docker and trial
steps). install_step_table_native holds the engine's one step table.