RFC-037 — propagate_state MCP verb (anchor-and-sync workflow)¶
Status · Decided (impl shipped 2026-05-10; ADR-090 stays Draft until darkroom validation) TA anchor · /contracts/mcp-tools · /components/synthesizer · /components/versioning Related · RFC-031 (apply_per_region — single-image batched), RFC-032 (named-mask vocabulary), photographer-workflows survey Gap #4 Closes into · ADR-090 (closes; flips to Accepted on darkroom-session sign-off) Closes into · ADR-NNN (pending) Why this is an RFC · Surfaced cleanly by R2 (Wedding/Event) and reinforced by R3 — 4/6 wedding photographers ship anchor-and-sync as a load-bearing technique. chemigram's
apply_primitive --stdinpropagates the same primitive call across N image_ids but does not propagate edit STATE (the resulting XMP / vocabulary entries) from a source image to N targets. Wedding-defining gap. Multiple legitimate API shapes; the right answer needs deliberation: should propagation copy raw vocabulary entries, copy resolved XMP plugins, or replay the op-log? Each costs differently in semantics, atomicity, and downstream state-tracking. Genuinely-open question.
The question¶
Across the 6-genre photographer-workflows survey, the wedding/event genre surfaced anchor-and-sync as a load-bearing technique (4/6 photographers; defining for the genre). The pattern:
- Photographer ingests ~3000 frames from a wedding.
- Culls to ~300 deliverables.
- Edits one anchor image per lighting situation (~5-15 lighting groups: prep / ceremony / reception / portraits / dance).
- Syncs the anchor's edit state to all N images in that lighting group (~30-100 images per group).
- Per-image fine-tuning on outliers.
Step 4 — propagating edit state from one image to N others — is the architectural primitive missing from chemigram. Today's options:
apply_primitive --stdin— propagates the same primitive call across N images. Works for "apply skin_uniformity to these 50 images at strength -0.3" but not for "this image's full edit state (5 primitives + 2 named masks + 1 dodge/burn batch) should appear identically on these 50 images." The state-propagation isn't expressible.- Manually re-apply each step on each image — defeats the purpose; this IS what anchor-and-sync exists to avoid.
- Author an L2 look from the anchor — works conceptually (the photographer's edit becomes a "personal preset"), but L2 looks ship as fixed-value composites and authoring one requires manual op_params extraction. Not a workflow tool.
The question: what's the right MCP-verb shape for "propagate this image's edit state to N target images"?
Use cases (post-survey)¶
The dominant cases from R2:
- Wedding lighting groups (4/6 photographers) — Hunter & Sarah ship 30+ presets per (lens × lighting); Stripling/Flemming/Ronald propagate via Sync+Clipboard; Davidson via anchor + Sync. All want "edit one image, propagate to N similar-light images."
- Per-camera batch (Flemming) — sync color baseline per camera body; multiple-photographer wedding teams need this when bodies differ.
- Per-ISO batch (SLR Lounge) — sync noise reduction per ISO bucket; especially relevant to wildlife (R3) where high-ISO batches recur.
- Time-bucketed batch — every reception sequence shot in 5-minute windows shares lighting; sync within window.
Cross-genre echoes: - Landscape (R1) — repeat trips to the same location may yield similar-light batches; apply the same look across a series. - Wildlife (R3) — bird-photography burst sequences within seconds share exposure / WB / NR settings; anchor-and-sync would let the photographer edit one frame from a 20-frame burst and propagate. - Food/Product (R3) — multi-shot product sets often share lighting and need identical color processing.
Wedding is the genre where this is defining; the others are where it's helpful.
Goals¶
- One agent move = one tool call. "Propagate this image's edit state to these 50 images" is one tool call, not 50.
- Atomic semantics. Either all targets receive the propagated state, or none do (either-or; partial application is a UX failure mode).
- Inherit-everything-by-default. The reference image's edit IS the contract. Whatever the photographer did to nail it — WB, exposure, sigmoid, color grade, masked moves — that's what propagates. No scope-preset menu. Same mental model as Lightroom's Sync function: "sync this edit to these targets" without first picking categories. Predefined scope presets force a "what counts as WB?" taxonomy debate; the photographer's choices ARE the answer.
- Auto-exclude framing-bound ops (the LR-parity discipline). Settings that depend on per-image content / coordinates don't propagate by default: drawn masks (eye ellipse, retouch ellipse), compositional crop, spot retouch (heal/clone), L1 EXIF-bound camera baselines. Opt-in via
include_per_image: truefor the rare tripod-fixed series case. - Optional fine-grained opt-out via
exclude_ops: list[str]for the rare "everything except" case (e.g., "inherit everything but keep each target's individual exposure"). Default empty. - Composable with named masks (RFC-032). Named-mask references propagate cleanly because they're abstract (e.g.,
mask_skin_regionresolves per-image at apply time). Drawn masks are caught by goal 4. - Composable with
apply_per_region(RFC-031). Batched per-region calls in the anchor's history propagate as batched per-region calls on each target. - One snapshot per target image. Each target's snapshot history shows a clean "propagated from
" entry. - Versioning-friendly. The propagation respects the per-image content-addressed snapshot store (ADR-018).
Constraints¶
- TA/contracts/mcp-tools — adding a verb requires affirmative justification. The narrow MCP surface is a feature.
- TA/components/synthesizer — propagating state means re-applying the source's vocabulary entries against each target's baseline. The synthesizer already does this for single-image apply; multi-image apply just orchestrates N calls.
- TA/components/versioning — each target gets one new snapshot per propagation. The op-log entry references the source image's hash for traceability.
- ADR-002 (SET-replace semantics) — propagating state replaces conflicting modules in each target. Existing target state for non-overlapping ops is preserved.
- ADR-051 (same-module collision) —
multi_priorityallocation per propagated module follows the same rules asapply_per_region(Path B in synthesize_xmp).
Proposed approach¶
Add a single MCP verb: propagate_state(source_image_id, target_image_ids, *, exclude_ops?, include_per_image?, label?) → results.
Where:
- source_image_id — the anchor image.
- target_image_ids — list of image_ids; soft cap at 200 (enough for typical wedding lighting group; well above bird-burst range).
- exclude_ops — optional list of operation names to skip (default [], i.e., inherit everything). For the rare "everything except include_per_image — optional boolean (default false) to override the framing-bound auto-exclusion (drawn masks, spot retouch, crop, L1 baselines). Use for tripod-fixed series.
- label — optional snapshot label per target.
- Returns: {results: [{image_id, snapshot_hash, applied_ops}], n_succeeded, n_failed}.
Resolution algorithm¶
- Read source state. Load the source image's current XMP via
current_xmp(source_workspace). - Filter ops — by default keep everything; auto-exclude the framing-bound op set (drawn-mask-bound entries, retouch, crop, L1) unless
include_per_image=True. Drop any op explicitly listed inexclude_ops. - For each target image:
a. Load the target's current XMP as baseline.
b. Apply the filtered ops in source's order. Same module + same multi_priority replaces (per ADR-002 SET semantics); different multi_priority appends (Path B).
c. Atomic check — if any op fails (modversion mismatch, parameter validation), abort the entire batch (no targets receive partial state).
d. Snapshot per target with the supplied or default label
"propagated from <source_image_id> [<n_ops> ops]". - Return aggregate result with per-target snapshot hashes.
Atomicity discipline¶
The validation phase walks ALL targets first; only after all validations pass does any apply happen. Failures hard-reject:
- Source image has no current XMP (no anchor to propagate from).
- Source XMP's history is empty (nothing to propagate).
- Any target image not found.
- Any target's modversion conflicts with the source's (rare; surfaces stale-pack drift).
- Filter (auto-exclusions + caller-supplied
exclude_ops) produces empty op set. - N targets exceeds soft cap (200).
Default framing-bound exclusions (the LR-parity discipline)¶
Same discipline as Lightroom's Sync — settings that depend on per-image content / coordinates don't propagate. By default, propagation excludes:
- Drawn-form mask history entries (
dt_formmasks have coordinate-specific geometry — not portable). - Spot retouch entries (heal/clone per RFC-025 / ADR-087; location-specific).
- Compositional crop (per-image framing).
- L1 EXIF-bound camera baselines (per-camera; usually consistent in batch but propagating across mixed-camera sets produces wrong color science).
Override: include_per_image=True for the rare case where the photographer explicitly wants to propagate framing-bound moves across a fixed-camera-tripod sequence (architectural still life, brackets, time-lapse).
Alternatives considered¶
Author an L2 look on the fly from the anchor and apply it. The "anchor becomes a personal preset" framing. Rejected as the primary path because:
1. L2 looks today are fixed-value .dtstyle files + manifest entries; authoring one programmatically from a live edit state is a substantial engineering hit.
2. It produces a permanent vocabulary artifact for what's often an ephemeral session decision (the photographer doesn't want every wedding lighting group becoming a permanent L2 look in their pack).
3. The scope-control story is awkward — L2 looks are all-or-nothing; can't wb_only-apply an L2 look without splitting it.
A future enhancement could combine: photographer applies propagated state, then optionally promotes the propagated state to a personal-pack L2 look. That's separable and worth a follow-up RFC if Phase 2 evidence warrants.
Extend apply_primitive --stdin to accept N primitives. Already exists for stdin batches of one primitive across N images. Extending to N primitives means deriving the primitive list from somewhere — the natural place is the source image's XMP, which is exactly what propagate_state proposes. Rejected as a CLI extension because the semantic surface needs more than --stdin exposes (scope, atomicity, op-log).
Use chemigram's existing branch / merge model (ADR-018). The per-image snapshot store has branches; could each target image have a "propagated" branch tracking the source? Rejected because branches are per-image, not cross-image. Cross-image state-replication isn't what branches are for.
Background-job model — propagate asynchronously, return job ID, photographer polls for completion. Tempting for large batches (200 images × 5-second-each propagation = 17 minutes). Rejected for v1 because chemigram is single-process (TA/constraints/single-process); async job tracking is a substantial scope expansion. Atomic-synchronous is fine for v1; revisit if photographer feedback shows real wait pain.
Per-target customization at propagation time (e.g., apply with N% strength scaling per image). Tempting but conflates RFC-035 (parametric L2 strength) with this RFC. Defer to RFC-035 + per-target parameter overrides as a follow-up.
Trade-offs¶
- +1 MCP verb. Acceptable; closes the highest-recurrence cross-genre workflow gap not yet addressed.
- Soft cap at 200 targets. Wedding lighting groups typically max out at ~80-100 images per group; bird bursts at ~30. 200 is generous-but-finite; revisitable if real workflows hit it.
- Drawn-mask exclusion is opinionated. Some photographers might want them propagated. The default favors the right-thing-by-default; the opt-in flag preserves flexibility.
- Multi-modversion targets. If source uses colorequal mv4 and target's XMP references mv3, propagation surfaces a real drift. Hard-reject is the right discipline (per RFC-007 / ADR-082); offers the photographer clear feedback rather than silent-bad-render.
Open questions¶
- Scope preset definitions. What ops fall into
"wb_only"(just temperature?) vs"color_only"(temperature + colorequal + colorbalancergb?) vs"tone_only"(sigmoid + tone_eq + exposure?)? Propose: documented invocabulary-patterns.md;"all"is the default. - Op-log entry shape. How does each target's op-log record the propagation? Propose:
{op: "propagate_state", source_image: "...", source_hash: "...", n_ops: N, scope: "..."}. The source's hash anchors the propagation for traceability; a future "re-propagate from source's HEAD" recipe becomes possible. - What happens when targets have unrelated edits already? The default is SET-replace (per ADR-002) — propagated ops replace any matching ops on the target; non-matching ops on the target persist. Worth confirming this is the right discipline for wedding workflow specifically.
- CLI surface shape.
chemigram propagate-state <source> --to <id1> <id2> ... --scope <...>? Or via stdin:chemigram propagate-state <source> --stdin --scope all? Propose: support both — explicit list for small batches, stdin for typical wedding scale. - Render-validation feedback. Should
propagate_staterender-preview a few targets after propagation to surface obvious failures? Propose: no for v1 (would slow large batches; the existing render-preview tool is photographer-driven). Document the rendering recommendation invocabulary-patterns.md.
How this closes¶
One ADR:
- ADR-NNN —
propagate_stateMCP verb + scope semantics + atomic discipline — formalizes the verb signature, the scope preset definitions, the drawn-mask exclusion discipline, the op-log structured payload, the soft cap, and the multi-modversion-targets policy.
Implementation effort estimate: 3-5 days. Single core function (synth-from-source), single MCP verb, one CLI command, ~15-20 unit tests + integration tests for multi-image scenarios. Visual-review checkpoint validates wedding-scale scenarios (50-100 image lighting group propagation).
Links¶
- TA/contracts/mcp-tools (this is the modification surface)
- TA/components/synthesizer (re-uses existing synth path per target)
- TA/components/versioning (snapshot per target; op-log entry references source hash)
- Related: RFC-031 (apply_per_region — single-image batched; this RFC is multi-image batched), RFC-032 (named masks propagate cleanly), RFC-035 (per-target strength scaling — future extension)
- Source:
docs/photographer-workflows-survey.mdGap #4 — wedding-defining (4/6); cross-genre echoes in landscape series, wildlife bursts, multi-shot product sets - Validation gate:
docs/guides/darkroom-session-debt.mdwill get a new item once this RFC's implementation lands — wedding-scale propagation visual review