settings: every writer commits the one serializer's bytes through the one byte-exact helper

The seam claimed "one spelling on disk whichever surface wrote it", and two of the
three writers still committed with `Path.write_text`, which translates \n to \r\n on
Windows. On the 3-OS matrix the config saver and the packaged bootstrap saver therefore
emitted CRLF while the owner endpoint (through `atomic_write_json` -> `write_text_atomic`)
emitted LF for the same document, and no byte comparison run on a single leg could see it.

All three now commit `serialize_settings()` output through `utils.write_text_atomic`,
which is byte-exact on every platform. The owner writer stops re-deriving the same JSON
text inside `atomic_write_json` and calls the serializer itself, so "one serializer" is
the code rather than two spellings pinned equal. The config saver keeps its OSError
fallback, now byte-exact too.

Three properties come with the shared helper rather than being added: the temp sibling
carries the atomic signature the stale-temp sweep recognises (`settings.tmp` never did),
the existing permission bits survive the replace (a 0600 settings.json stayed 0600 only
by luck before), and the pytest live-data guard on `_atomic_overwrite` now covers this
surface as well.

The packaged bootstrap saver additionally takes the settings write guards on the path it
actually writes: it went through neither the snapshot pin nor the live-data guard, so a
bootstrap save was the one settings write that could land under a strict benchmark pin.
This commit is contained in:
Ouroboros 2026-09-02 09:08:33 +00:00
parent 5e0e2f8eac
commit 9eb25e253b
4 changed files with 33 additions and 27 deletions

View file

@ -728,11 +728,14 @@ def normalize_settings_raw(raw: dict) -> dict:
def serialize_settings(settings: dict) -> str:
"""THE bytes a settings document is persisted as, for every writer that persists one.
``ouroboros.utils.atomic_write_json`` produces exactly this text, which is what lets the
owner-endpoint writer keep its atomic helper while the config saver and the packaged
bootstrap saver produce byte-identical output through the same function (pinned by
tests/test_settings_read_seam.py). Without one serializer the writers disagreed on
``ensure_ascii`` alone, so the same document had two spellings on disk."""
Every writer of this document puts exactly ``serialize_settings(document).encode("utf-8")``
on disk on EVERY platform, because every one of them commits through
``ouroboros.utils.write_text_atomic``, which does not translate newlines. Both halves are
load-bearing: without one serializer the writers disagreed on ``ensure_ascii``, and with one
serializer but a text-mode ``Path.write_text`` two of the three would still have written
CRLF on Windows for the LF the third wrote. Pinned by tests/test_settings_read_seam.py on
the bytes AND on the mechanism, because the byte comparison alone cannot see a difference
that only exists on another platform."""
return json.dumps(settings, ensure_ascii=False, indent=2)
@ -849,13 +852,11 @@ def save_settings(
f"OUROBOROS_RUNTIME_MODE elevation refused: "
f"{baseline_mode!r} -> {new_mode!r}.{hint}"
)
from ouroboros.utils import write_text_atomic
try:
from ouroboros.utils import replace_atomic
tmp = SETTINGS_PATH.with_suffix(".tmp")
tmp.write_text(serialize_settings(settings), encoding="utf-8")
replace_atomic(str(tmp), str(SETTINGS_PATH))
except OSError:
SETTINGS_PATH.write_text(serialize_settings(settings), encoding="utf-8")
write_text_atomic(SETTINGS_PATH, serialize_settings(settings))
except OSError: # a filesystem that cannot rename a sibling: write in place, same bytes
SETTINGS_PATH.write_bytes(serialize_settings(settings).encode("utf-8"))
finally:
_release_settings_lock(fd)

View file

@ -20,7 +20,7 @@ re-implemented (or silently skipped) per call site:
about to overwrite — not against a read taken before a multi-second daemon
call. A refusal aborts with ``SettingsPreconditionFailed`` and writes
nothing.
3. **The commit boundary is visible.** Once ``atomic_write_json`` returns, the
3. **The commit boundary is visible.** Once ``write_text_atomic`` returns, the
bytes ARE on disk; a failure in a LATER step (env projection, supervisor
start, hot-reload side effects) must be reported as its own fact, never as
"nothing was saved" (BIBLE P1). ``CommitBoundary`` carries that distinction
@ -49,7 +49,7 @@ from ouroboros.config import SETTINGS_DEFAULTS as _SETTINGS_DEFAULTS
from ouroboros.context_mode_compat import normalize_context_mode_compat
from ouroboros.gateway._helpers import json_error, request_drive_root
from ouroboros.settings_integrity import SettingsIntegrityError, read_settings_json_verified
from ouroboros.utils import append_jsonl, atomic_write_json, utc_now_iso
from ouroboros.utils import append_jsonl, utc_now_iso, write_text_atomic
log = logging.getLogger(__name__)
@ -253,7 +253,7 @@ def _owner_write_settings(
"""Write owner-controlled settings without applying the runtime-mode ratchet.
Skipping that ONE ratchet is the whole reason this writer exists; everything else comes from
``config.prepare_settings_for_persist``, the single point both persisting writers pass through.
``config.prepare_settings_for_persist``, the single point every persisting writer passes through.
An endpoint that genuinely authors a disk-authored key (context mode, safety mode, the false
compatibility tombstone) must name it in ``authored_keys`` — otherwise a POST about an unrelated key would
author a mode decision out of the defaults merge that ``_owner_read_settings_raw`` performs.
@ -343,7 +343,7 @@ def _owner_update_settings(
dict(proposed), authored_keys=authored_keys,
allow_context_lowering=allow_context_lowering,
allow_safety_lowering=allow_safety_lowering)
atomic_write_json(_config.SETTINGS_PATH, to_write, trailing_newline=False)
write_text_atomic(_config.SETTINGS_PATH, _config.serialize_settings(to_write))
if boundary is not None:
boundary.commit()
finally:

View file

@ -211,10 +211,10 @@ def _hidden_run(command: Sequence[str], **kwargs: object) -> subprocess.Complete
def _save_settings(path: pathlib.Path, settings: dict) -> None:
"""The packaged bootstrap's settings writer — same prologue, same bytes.
It is a THIRD writer only in the sense that it owns its own path and its own
atomic rename; what it persists goes through the one persistence prologue (which
proves the owner-only context/safety ratchets against the value on disk) and the
one serializer, so a bootstrap save cannot be the save that skips them. The path
It is a THIRD writer only in the sense that it owns its own path; what it persists
goes through the one persistence prologue (which proves the owner-only context/safety
ratchets against the value on disk), the one serializer and the one byte-exact commit
helper, so a bootstrap save cannot be the save that skips them. The path
it writes is the path the prologue reads: the packaged runtime resolves its data
dir to ``~/Ouroboros/data``, which is exactly what ``config`` resolves in a
process that was given no path overrides — pinned by
@ -223,14 +223,19 @@ def _save_settings(path: pathlib.Path, settings: dict) -> None:
ignores the environment by design (the inner CLI child is handed the packaged
paths explicitly), while the prologue proves against ``config.SETTINGS_PATH``,
which honours one. Dormant today — ``BootstrapContext.save_settings`` has no
caller — and disclosed rather than papered over."""
from ouroboros.config import prepare_settings_for_persist, serialize_settings
from ouroboros.utils import replace_atomic
caller — and disclosed rather than papered over.
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(serialize_settings(prepare_settings_for_persist(dict(settings))), encoding="utf-8")
replace_atomic(tmp, path)
The write GUARDS are taken on the path this saver actually writes, not on
``config.SETTINGS_PATH``: under a pinned benchmark snapshot, or from pytest against the
live install, a bootstrap save must refuse for the same reason the other two writers
refuse. Passing its own path is what keeps that honest while the disclosed override
split above exists."""
from ouroboros.config import prepare_settings_for_persist, serialize_settings
from ouroboros.settings_integrity import guard_live_settings_write
from ouroboros.utils import write_text_atomic
guard_live_settings_write(path, pathlib.Path.home())
write_text_atomic(path, serialize_settings(prepare_settings_for_persist(dict(settings))))
def _run_inner_cli(runtime: PackagedRuntime, args: Sequence[str]) -> int:

View file

@ -335,7 +335,7 @@ def test_runtime_config_strict_snapshot_cannot_be_mutated_by_owner_writer(
nonlocal wrote
wrote = True
monkeypatch.setattr(owner_settings, "atomic_write_json", record_write)
monkeypatch.setattr(owner_settings, "write_text_atomic", record_write)
with pytest.raises(config.SettingsIntegrityError, match="immutable"):
owner_settings._owner_write_settings({"OUROBOROS_MODEL": "wrong/model"})
assert not wrote