fix: delete methods return last known state data#788
Conversation
When a model's delete method succeeds, the deletion marker (tombstone) now
includes the last known active state attributes merged with the deletion
metadata. Previously, tombstones only contained {deletedAt, deletedByMethod},
which broke data.latest() CEL expressions on workflow re-runs.
This fixes two problems:
- Delete responses now include dataArtifacts with full resource attributes
- data.latest() after deletion still resolves original attributes (e.g.
RouteTableId, CidrBlock), enabling idempotent workflow re-runs
Design decision: enrich the tombstone rather than changing data.latest()
semantics. Merging last known state into the marker avoids a breaking change
to data.latest() behavior while solving both the response and re-run problems.
The flat merge means deletedAt/deletedByMethod could theoretically collide
with resource attribute names, but this is accepted as near-zero risk since
cloud APIs don't use these field names.
Closes #636
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Review: Approve ✅
Clean, well-tested change. No blocking issues.
What I checked
- DDD: Tombstone enrichment logic correctly lives in
DefaultMethodExecutionService(domain service). UsesData.withDeletionMarkerproperly and delegates persistence to the repository. - Import boundary: No violations — both files are internal to
src/domain/models/. - Code style: No
anytypes, license headers present, test naming follows conventions, named exports used. - Test coverage: Three relevant test paths covered — enriched tombstone with last known state, graceful degradation when content is missing, and scoped markers for declared resources only. Mock helper cleanly extended with
contentByNameparameter. - Security:
JSON.parseis wrapped in try/catch, spread of parsed content is safe against prototype pollution in modern JS, and deletion metadata keys (deletedAt,deletedByMethod) overwrite any colliding resource attributes (acknowledged design tradeoff in the PR description).
Suggestion (non-blocking)
The deletion marker handles are pushed to currentHandles after ModelOutput is saved (line 652), so the persisted output won't include the deletion data artifacts in its artifacts.dataArtifacts. The returned in-memory result is correct. This matches the existing pattern for follow-up actions, but worth noting if output replay fidelity matters in the future.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Field collision silently overwrites resource attributes —
method_execution_service.ts:694-698: The flat spread{ ...lastKnownState, deletedAt: ..., deletedByMethod: ... }will silently overwrite any resource attribute nameddeletedAtordeletedByMethod. Some cloud APIs do returndeletedAtfor soft-deleted resources (e.g., GCP resources, some AWS services). If a resource haddeletedAt: "2026-03-19T00:00:00Z"from the cloud provider, the tombstone would replace it with the local deletion timestamp, anddata.latest()would return the wrong value. The PR description acknowledges this tradeoff — just flagging that the collision is more realistic than "theoretical" sincedeletedAtis a common API field name. Consider namespacing under a_tombstonekey if this causes issues in practice. -
TOCTOU between
isDeletedcheck andgetContent—method_execution_service.ts:671-681:data.isDeletedis checked on a snapshot fromfindAllForModel, thengetContentreads the latest version. If a concurrent delete operation writes a tombstone between these two calls,getContentwould return the tombstone content (containingdeletedAt/deletedByMethod). The spread would then merge old deletion metadata into the new tombstone. The end result is still correct (newdeletedAt/deletedByMethodoverride old ones), so this is a benign race, but worth noting.
Low
-
Broad catch swallows I/O errors —
method_execution_service.ts:686: The catch block handles both JSON parse errors and any I/O errors (disk failures, permission errors) identically — silent fallback to empty state. A transient disk error during a real delete would produce a tombstone missing all resource attributes, with no log entry to debug it. Consider at minimum logging a warning. -
Non-object JSON content produces nonsensical spread —
method_execution_service.ts:694: If resource content were a JSON primitive or array (e.g.,"hello"or[1,2,3]), the spread...lastKnownStatewould produce unexpected keys ("0": 1, "1": 2, ...for arrays, nothing for primitives). In practice this can't happen because resources are defined with Zod object schemas, so this is theoretical only.
Verdict
PASS — The change is well-scoped, correctly guarded by the existing !data.isDeleted check, and thoroughly tested (including the graceful degradation path). The field collision risk is acknowledged and acceptable for now. The deletion marker as data handle approach is clean and avoids changes to downstream rendering code.
Summary
Closes #636
When a model's
deletemethod succeeds, the deletion marker (tombstone) now includes the last known active state attributes merged with the deletion metadata. This fixes two problems reported in the issue:Delete responses now include
dataArtifacts— Previously the JSON response had nodatafield, making it impossible to confirm what was deleted without a separate query. Now the response includes the full resource attributes alongsidedeletedAt/deletedByMethod.data.latest()resolves original attributes after deletion — Previously, tombstones only contained{deletedAt, deletedByMethod}, which broke CEL expressions likedata.latest("x", "y").attributes.RouteTableIdon workflow re-runs. Now those expressions resolve correctly, enabling idempotent re-runs of complex delete workflows.Design decisions
Enrich the tombstone, not
data.latest()— We considered makingdata.latest()lifecycle-aware (skip tombstones, return last active version) but rejected it because:data.latest()to detect deletiondeletedAtis still there) and re-runs (original attributes are still there)Flat merge, accepted collision risk — Deletion metadata (
deletedAt,deletedByMethod) is merged into the same JSON object as resource attributes. We considered namespacing (e.g._deleted.at) but accepted the theoretical collision risk since cloud APIs don't use these field names, and the added complexity wasn't justified.Deletion markers as data handles — Rather than adding special-case logic in
run.ts, deletion markers are appended tocurrentHandlesinmethod_execution_service.tsso they flow through the existing data artifact pipeline. This meansrun.ts, the JSON renderer, and all presentation code required zero changes.Before
{ "modelId": "ca9547ff-...", "modelName": "test-volume", "type": "@stack72/digitalocean/volume", "methodName": "delete", "dataArtifacts": [] }After
{ "modelId": "ca9547ff-...", "modelName": "test-volume", "type": "@stack72/digitalocean/volume", "methodName": "delete", "dataArtifacts": [ { "name": "main", "attributes": { "id": "8290fa55-...", "name": "swamp-crud-test-vol", "region": "us-east-1", "status": "active", "deletedAt": "2026-03-20T00:02:15.524Z", "deletedByMethod": "delete" } } ] }Changes
src/domain/models/method_execution_service.tssrc/domain/models/method_execution_service_test.tsTest plan
data.latest()resolution🤖 Generated with Claude Code