#912·presenton

Operation executor receipt always reports empty changedSlideIds for in-place content mutations

Author: snobyyou-cmykCreated Sep 6, 2026Updated Sep 6, 2026

Summary

The operational batch executor's receipt always reports changedSlideIds: [] for in-place content mutations, because the post-apply diff compares each ORM slide object to itself.

Environment

  • Base commit: 6bcd0bda4b5f38f39bb0c6ddc401a3ba9fb15473 (presenton/presenton@main at audit time)
  • Component: operational editor executor (services/operation_executor.py in audit branch)
  • Reproduced in isolated lab container built from image sha256:e6866086…11b3d1

Steps to reproduce

  1. Build an isolated Presenton container from the audit image.
  2. Apply the Stage 3 executor + proposal endpoints (audit branch audit/r0-persistence).
  3. Seed a 12-slide document; capture snapshot (revision N).
  4. Submit a single UpdateSlide operation (e.g. set speaker_note) via POST /api/v1/ppt/editor/v1/documents/{id}/operations with baseRevision=N and a fresh operationId.
  5. Read the receipt via GET …/operations/{operationId}.

Expected behavior

receipt.changedSlideIds contains the id of the mutated slide, and the top-level changedSlideIds in the operation result matches.

Actual behavior

Operation applies correctly (revision N → N+1, slide content updated in DB and snapshot), but both the operation result and the persisted receipt report changedSlideIds: [].

Root cause

_apply_operations() mutates the same SlideModel instances that current_slides references. The later diff:

python
changed_slide_ids = [
    slide_id for slide_id in desired_slide_ids
    if slide_id in existing_slide_ids
    and _slide_value(next(s for s in staged_slides if str(s.id) == slide_id))
    != _slide_value(next(s for s in current_slides if str(s.id) == slide_id))
]

compares each slide to itself, so the diff is always empty. Structural ops (insert/duplicate/move) are unaffected because they change n_slides / slide ids, which trips the earlier desired != current check.

Fix (verified in lab)

Snapshot the baseline before applying operations:

python
current = document_snapshot(presentation, current_slides)
baseline_slide_values = {str(slide.id): _slide_value(slide) for slide in current_slides}

and diff staged values against baseline_slide_values.get(slide_id) instead of re-reading from current_slides.

After the fix, the same repro yields changedSlideIds: ["4336250b-…"] matching the mutated slide.

Verification evidence

  • E2E: batch of 4 ops (UpdateSlide/DuplicateSlide/MoveSlide/UpdateMetadata) applied atomically (rev 26→27, 12→13 slides); idempotent retry returns status: duplicate with unchanged revision; stale base → 409; mismatched idempotency payload → 409; missing baseRevision → 428.
  • Proposal flow: POST /proposalsPOST /proposals/{id}/apply returns receipt with actorSource: "ai", revision 27→28, changedSlideIds populated post-fix.
  • Crash test: injected IntegrityError inside the transaction leaves revision and slide count intact.
  • Regressions: R0 two-tab conflict, 503→retry, Stage 2 UI/API persistence — all green (0 failures across 41+ unit tests and E2E suites).

Full evidence bundle and cumulative patch available on request; happy to open a PR with the executor + fix if useful.