ADR-090 — propagate_state MCP verb (anchor-and-sync workflow)¶
Status · Draft (impl shipped 2026-05-10; flips to Accepted on darkroom validation) Date · 2026-05-10 TA anchor · /contracts/mcp-tools · /components/synthesizer · /constraints/single-process Related RFC · RFC-037 (closes; LR-Sync-parity discipline) Related ADRs · ADR-002 (SET-replace semantics), ADR-018 (per-image snapshot store), ADR-082 (modversion drift), ADR-084..087 (mask + retouch)
Context¶
The photographer-workflow survey surfaced anchor-and-sync as a genre-spanning move (4/6 wedding photographers cite it; landscape series, wildlife bursts, multi-shot product sets all echo it). The pattern: nail post-processing on one anchor image, then propagate that state to a list of related target images. Lightroom's Sync does this; chemigram had no analog. Full deliberation in RFC-037; this ADR captures the closing decision.
Decision¶
Add a single MCP verb propagate_state(source_image_id, target_image_ids, *, exclude_ops?, include_per_image?, label?) that copies the source image's history entries to every target image, with framing-bound ops auto-excluded by default. (The core Python function takes source_workspace/target_workspaces Workspace objects directly; the MCP wrapper resolves image_ids to workspaces.)
Inheritance discipline: inherit everything by default, exclude framing-bound ops. Same shape as Lightroom Sync — settings that depend on per-image content or coordinates don't propagate.
Default framing-bound exclusions:
- ashift, crop, retouch, lens (operation-level — coordinate-specific or per-camera)
- Drawn-mask-bound entries (gradient / ellipse / rectangle / path — coordinates are image-specific)
Parametric range masks (color-range / luminance-range) DO propagate — they're content-relative, not coordinate-bound.
Atomic semantics: every target validates first; any modversion mismatch / missing target / empty-history source / cap-exceeded aborts the entire batch. No target receives partial state.
Cap: MAX_TARGETS_PER_CALL = 200. Generous-but-finite; covers wedding lighting groups (~80-100 max), bird bursts (~30), product variants (~20).
CLI: chemigram propagate-state <source_id> --to <id1> --to <id2> ... [--exclude-op <name>...] [--include-per-image] [--label <label>] (positional source; repeatable --to per target; repeatable --exclude-op for fine-grained opt-outs).
Rationale¶
- LR-parity is the right discipline. Photographers' mental model for cross-image sync was shaped by Lightroom; matching that discipline reduces friction. The framing-bound exclusion list is what LR Sync excludes too.
- Inherit-everything-by-default beats predefined scopes ("wb_only", "color_only", etc). The early RFC framing proposed scope presets; survey feedback reframed this — photographers want the inverse: "everything except framing-specific moves." Predefined scopes would be a leaky enumeration.
- Atomic-synchronous is fine for v1. 200 targets × ~5s = ~17min worst case; the chemigram single-process constraint (TA/constraints/single-process) means async job tracking is a substantial scope expansion. Revisit if photographer feedback shows real wait pain.
- Hard-reject on modversion drift. Mirrors RFC-007 / ADR-082's discipline. Silent partial-propagation would mask real format bugs.
Alternatives considered¶
- Author an L2 look on the fly from the anchor and apply it. Rejected as primary path — produces permanent vocabulary artifacts for ephemeral session decisions; scope control awkward; engineering cost high. A future enhancement could combine: photographer applies propagated state, then optionally promotes to a personal-pack L2 look.
- Extend
apply_primitive --stdinto accept N primitives. Rejected — derived semantics need scope, atomicity, op-log surfaces that--stdindoesn't expose. - Use chemigram's existing branch/merge model. Rejected — branches are per-image; cross-image state replication isn't what they're for.
- Background-job model with polling. Rejected for v1 — async job tracking is substantial scope expansion. Atomic-synchronous suffices today.
- Per-target customization at propagation time (e.g., apply with N% strength scaling per image). Deferred — would conflate RFC-035 with this verb; a future per-target-overrides extension is separable.
Consequences¶
Positive: - Closes the highest-recurrence cross-genre workflow gap not yet addressed. - One snapshot per target with explicit propagation label; the source's hash is captured for traceability. - Composes with the full RFC-024/025/029 mask architecture — drawn masks excluded, parametric masks propagate. - Future "re-propagate from source's HEAD" recipe becomes possible from the captured source-hash.
Negative:
- +1 MCP verb. Adds to the agent's surface area; mitigated by clear semantics (LR-Sync analog).
- Drawn-mask exclusion is opinionated; some photographers may want them propagated. The default favors right-thing-by-default; the include_per_image opt-in preserves flexibility.
- Multi-modversion targets hard-reject. Documented; offers clear feedback rather than silent-bad-render.
- Cap of 200 may bite extreme batches (multi-camera cinematic shoots). Revisitable if real workflows hit it.
Implementation notes¶
- New module:
src/chemigram/core/propagate.pywithpropagate_state()core function +FRAMING_BOUND_OPS = frozenset({"ashift", "crop", "retouch", "lens"})+_is_drawn_mask_bound()helper that decodes blendop_params mask_mode field. propagate_stateround-trips: each source HistoryEntry converts to PluginEntry, gets bundled into a synthetic DtstyleEntry, then routes throughsynthesize_xmpagainst each target's baseline (reuses the existing SET-replace / Path B Add discipline).- CLI:
propagate-statecommand insrc/chemigram/cli/commands/edit.py. - MCP:
propagate_statetool registered insrc/chemigram/mcp/tools/vocab_edit.py. - Tests: 13 new in
tests/unit/core/test_propagate.pycovering source-state read, framing-bound exclusion, drawn-mask detection, atomic batch semantics, modversion-drift rejection, cap enforcement.
Resolved RFC-037 open questions¶
- Scope presets (
"wb_only"/"color_only") — abandoned in favor of inherit-everything-with-framing-bound-exclusions. Predefined scope enumerations would be leaky; the LR-Sync discipline of "everything that's portable" is cleaner. - Op-log entry shape —
{op: "propagate_state", source_image: "...", source_hash: "...", n_ops: N, label: "..."}. Source hash anchors the propagation for traceability. - Targets with unrelated edits already — SET-replace per ADR-002; propagated ops replace matching ops on the target, non-matching target ops persist. Verified against wedding-burst use case.
- CLI shape — explicit list via
--targets <id1>,<id2>,...; stdin support is a follow-up if photographer feedback warrants. - Render-validation feedback — no for v1 (would slow large batches). Photographer-driven render-preview remains the validation tool.