Operation executor receipt always reports empty changedSlideIds for in-place content mutations
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.pyin audit branch) - Reproduced in isolated lab container built from image
sha256:e6866086…11b3d1
Steps to reproduce
- Build an isolated Presenton container from the audit image.
- Apply the Stage 3 executor + proposal endpoints (audit branch
audit/r0-persistence). - Seed a 12-slide document; capture snapshot (revision N).
- Submit a single
UpdateSlideoperation (e.g. setspeaker_note) viaPOST /api/v1/ppt/editor/v1/documents/{id}/operationswithbaseRevision=Nand a freshoperationId. - 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:
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:
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: duplicatewith unchanged revision; stale base → 409; mismatched idempotency payload → 409; missing baseRevision → 428. - Proposal flow:
POST /proposals→POST /proposals/{id}/applyreturns receipt withactorSource: "ai", revision 27→28,changedSlideIdspopulated 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.
Source: presenton/presenton