Configuration write and persist¶
Scope of this diagram. This chart covers the write path for runtime configuration changes — what happens between /rtp config <file> <key>:<value> (or any other config-mutating command) and a durable on-disk YAML file. It is the companion to diagram 09 (09-configuration-load-and-reload.md), which covers the read / reload path; together they cover the full configuration lifecycle. Related-but-separate behavior paths are intentionally out of scope:
- Read-path / reload of the resulting state — see diagram 09 and
CODE_TOUR.md§13. - Per-attempt teleport reads of config values — see diagrams 01 / 08.
/rtpadminsetup-wizard flow — see ADR-038. A wizard is a driver over this lifecycle, not a separate path.- Cross-server propagation of config changes — see
MULTI_SERVER_PLAN.md. Each backend persists locally; cross-backend fan-out is independent work.
Companion documents: target-state spec
docs/dev/CONFIG_COMMAND_SPEC.md, decision ADR-037, implementation strategy ADR-041.
flowchart TD
%% Color legend: green = durable success state (audit record emitted, on-disk YAML matches in-memory), red = failure / rollback (audit record emitted, no on-disk change), blue = in-memory or disk I/O work, yellow = data / decision point
Trigger{{/rtp config write<br/>or LanguageCmd<br/>or wizard-driven mutation}}:::data
Parse[ConfigParameterGrammar.parse<br/>tokens to ParsedInvocation<br/>spec §3.1]:::async
Authorize[ConfigCommandExecutor.authorize<br/>rtp.config.set.section or umbrella<br/>spec §3.2 / §7]:::async
ReloadCheck{ReloadCmd holds<br/>per-file write lock?<br/>spec §4.6}:::data
Validate[ConfigParameterValidator.validateAll<br/>per-parameter + composite invariants<br/>spec §3.3 / §5 / §8.2]:::async
Snapshot[ConfigTransaction.snapshot<br/>capture FactoryValue map<br/>spec §3.4]:::async
ApplyMem[ConfigTransaction.apply<br/>mutate in-memory parser<br/>spec §3.5]:::async
PostApplyValidate[Composite-invariant re-check<br/>after all mutations applied<br/>spec §3.5 / §8.2]:::async
DryRunFork{dryRun set?<br/>spec §3.7}:::data
WriteTemp[AtomicConfigWriter.write<br/>1 serialize to target.yml.tmp<br/>2 fsync temp<br/>3 rename over target.yml<br/>4 best-effort parent fsync<br/>spec §4.2]:::async
TargetedReload[Configs.reloadOne(targetFile)<br/>swap affected parser<br/>per diagram 09]:::async
AuditOk[[ConfigAuditFormatter.format<br/>RTP.log INFO<br/>outcome = COMMITTED<br/>spec §6]]:::success
AuditDry[[ConfigAuditFormatter.format<br/>RTP.log INFO<br/>outcome = DRY_RUN_OK<br/>render diff via config.dryRun.*]]:::success
AuditFail[[ConfigAuditFormatter.format<br/>RTP.log WARNING<br/>outcome = REJECTED or ROLLED_BACK<br/>caller sees config.error.<reasonCode>]]:::fail
Rollback[ConfigTransaction.rollback<br/>restore snapshot<br/>delete temp file<br/>spec §3.7]:::fail
Done([Durable on disk<br/>in-memory matches<br/>downstream readers see new values]):::success
DoneDry([No state change<br/>caller saw preview]):::success
DoneFail([No state change<br/>caller saw error]):::fail
%% --- Happy path
Trigger ==> Parse
Parse ==> Authorize
Authorize ==> ReloadCheck
ReloadCheck -- No --> Validate
Validate ==> Snapshot
Snapshot ==> ApplyMem
ApplyMem ==> PostApplyValidate
PostApplyValidate ==> DryRunFork
DryRunFork -- No --> WriteTemp
WriteTemp ==> TargetedReload
TargetedReload ==> AuditOk
AuditOk ==> Done
%% --- Dry-run path
DryRunFork -- Yes --> Rollback
Rollback -.dry-run path.-> AuditDry
AuditDry ==> DoneDry
%% --- Failure paths
Parse -. parse fail .-> AuditFail
Authorize -. NO_PERMISSION .-> AuditFail
ReloadCheck -- Yes --> AuditFail
Validate -. validation fail .-> AuditFail
PostApplyValidate -. SCHEMA_INVARIANT .-> Rollback
WriteTemp -. PERSIST_IO .-> Rollback
TargetedReload -. reload fail .-> Rollback
Rollback ==> AuditFail
AuditFail ==> DoneFail
classDef success fill:#b7e4b7,stroke:#1f6b1f,stroke-width:2px,color:#0b2a0b;
classDef fail fill:#f4b7b7,stroke:#8a1f1f,color:#3a0b0b;
classDef async fill:#cfe2ff,stroke:#1f4e8a,color:#0b1f3a;
classDef data fill:#fff2b3,stroke:#8a6d1f,color:#3a2f0b;
How to read this chart¶
- Three accepting states:
Done(live commit succeeded),DoneDry(dry-run preview succeeded),DoneFail(any failure). Every invocation ends in exactly one of them, and every accepting state has emitted exactly one audit record. This is the spec §6 / S-004 guarantee made visible. - The seven-stage lifecycle of spec §3 maps 1:1 onto the blue nodes along the happy path: Parse → Authorize → (reload-check) → Validate → Snapshot → Apply (+ post-apply invariant re-check) → Persist (
WriteTemp+TargetedReload) → Audit. The dashed red transitions show where each stage's failure mode routes — all toAuditFail(single record,WARNINGlevel, configurable message). - Rollback is shared. Both
SCHEMA_INVARIANT(caught after the in-memory mutation) andPERSIST_IO(caught after the temp file exists) route throughRollbackbeforeAuditFail—Rollbackrestores the snapshot and deletes the temp file. Validation-stage failures and authorization failures take the dashed direct edge toAuditFail: no snapshot was taken, nothing to roll back. - Dry-run takes the same path through Parse → Validate → Snapshot → Apply → PostApplyValidate, then forks at
DryRunFork. The dry-run branch callsRollback(to restore the in-memory state from the snapshot — there is no temp file to delete) and emits the audit record withoutcome = DRY_RUN_OKand the rendered diff. The caller sees the preview; on-disk state is unchanged. - The atomic-rename pattern is one node (
WriteTemp) because the four substeps (serialize, fsync, rename, parent-fsync) form a single atomic-by-construction unit from the caller's perspective: either the rename happens (durable) or it doesn't (rollback). The substeps live inAtomicConfigWriter; the diagram does not enumerate them to keep the lifecycle visible at one glance. TargetedReloadfailures are rare but possible (a region rebuild throwing during the post-swap step of diagram 09 because the validator chain accepted state that the rebuild then rejects). Such failures still route throughRollback— the temp file has been renamed, so rollback restores the snapshot in memory and rewrites the previous YAML through a secondAtomicConfigWriter.write. This is the only path that performs two atomic renames; it is a graceful-degradation safety net, not a primary path.- The reload-check at
ReloadCheckis a fasttryLock(0)on the per-file write lock (per ADR-041 Concurrency model). A contended write returnsRELOAD_IN_PROGRESSimmediately rather than blocking; the reload is allowed to complete, and the admin retries.
Common repair lenses¶
- "Set a value, no error, but the file didn't change." Either (a)
--dry-runwas set (check the audit record'sdryRunfield), or (b) aPERSIST_IOrollback happened and the failure message was missed in chat — checkLevel.WARNINGaudit lines around the invocation timestamp. - "Wrote successfully, but the next teleport still sees the old value." Expected in-flight behavior: a teleport that started before
TargetedReloadholds a pre-write parser snapshot until it completes (same invariant as diagram 09). Wait one teleport cycle. - "
RELOAD_IN_PROGRESSdespite no/rtp reloadrunning." Some other path is holding the per-file write lock — typically a concurrent/rtp configto the same file, or a wizard step that hasn't released its transaction yet. Audit records pair to invocations; check who holds the lock by theirtargetFilefield. - "Stray
*.yml.tmpfile inplugins/RTP/." A crash betweenWriteTemp's serialize and rename steps leaves one of these. The startup hook (AtomicConfigWriter.cleanupStaleTempFiles, ADR-041 Startup hook) deletes them on next plugin enable and logs atINFOwithreasonCode = STALE_TEMP_FILE. If the file persists across a restart, check filesystem permissions. - "Two commands committed but only one is in the audit log." Spec violation — every invocation emits exactly one record. File a bug; the regression guard is
ReqRtpS004ConfigCommandAuditTest(ADR-041 Test scaffolding). - "Polygon
expand=trueslipped past/rtp reload." Spec gap A7 (composite invariant not yet wired into reload). This is one of the migration-sequencing steps (ADR-041 Migration sequencing step 8); if it lands incomplete, the regression guard isReloadCmdSchemaValidationTest.
Source anchors¶
Will be populated as ADR-041's Migration sequencing steps land:
rtp-core/.../commands/config/internal/AtomicConfigWriter.java— temp + fsync + rename pattern;cleanupStaleTempFilesstartup hook.rtp-core/.../commands/config/internal/ConfigTransaction.java— snapshot, apply, persist, rollback.rtp-core/.../commands/config/internal/ConfigCommandExecutor.java— orchestrates the seven-stage lifecycle.rtp-core/.../commands/config/internal/ConfigParameterValidator.java— per-parameter + composite invariants.rtp-core/.../commands/config/internal/ConfigParameterGrammar.java— single source of truth for parse + tab-complete.rtp-core/.../commands/config/internal/ConfigAuditRecord.java,ConfigAuditFormatter.java— audit emission.rtp-core/.../commands/config/SubConfigCmd.java— delegates to the executor; was the 245-lineonCommandbody.rtp-core/.../commands/reload/ReloadCmd.java,SubReloadCmd.java— schema-checked reload (ADR-041 migration step 8).
Until the implementation lands, this diagram is the contract the implementation is held against; the spec CONFIG_COMMAND_SPEC.md is the long-form prose for the same contract, and ADR-041 is the class-layout commitment that delivers it.