refactor(facets): single-source facet/convey-config I/O on journal_io primitives
Route the facet domain's file-write mechanics onto the shared
solstone/think/journal_io primitives and close two durability/concurrency
races on config/convey.json. On-disk formats are byte-identical
(ensure_ascii=False preserved throughout — the default facet emoji is
non-ASCII), so this is write-mechanics + locking only.
- facets.py: collapse the three facet.json atomic-writer copies into one
_write_facet_json on atomic_replace; route the action-log append through
append_text; make delete_facet/rename_facet's convey.json read-modify-write
atomic and locked under hold_lock.
- convey/config.py: replace save_convey_config with a locked
locked_modify_convey_config RMW helper (unlocked inner _write_convey_config
to avoid self-deadlock) backing all six config routes + set_selected_facet;
the CLI (facets.py) and web server now share the same config/convey.json.lock,
closing the lost-update window. set_selected_facet stays best-effort
(template-context callers must not fault on contention).
- facet_review_candidates.py: locked_modify_candidates onto hold_lock,
_save_jsonl_rows onto atomic_replace; drop the cross-domain
entities.core.atomic_write import, fcntl, and the dead lock-path helper
(sidecar is now review-candidates.jsonl.lock).
- Add CONVEY_BUSY (503); convey config routes translate LockTimeout to it,
curation facet-candidate handlers reuse ENTITY_BUSY.
- Register convey/config.py as a journal_io owner and add the config/convey.json
row to the AGENTS.md L2 ownership table.
- Tests: multiprocess cross-writer + failure-injection coverage for convey.json,
review-candidates, and facet.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>