Skip to content

api::workspace_delete is the delete without the unsaved-work guard #395

Closed

Description

@blooop

Finding

api::workspace_delete — the frozen wf surface — is the delete without the unsaved-work guard.

Removal is three exported functions that the caller must sequence correctly:

function site params
unsaved_work_in flows/lifecycle.rs:868 6
guard_removal flows/lifecycle.rs:806 3
workspace_delete flows/lifecycle.rs:972 8, #[allow(clippy::too_many_arguments)]

17 parameters of interface. The sequence — probe for unsaved work, refuse unless --force, then delete — lives in the binary, at dl/src/commands.rs:664-740. dl gets it right. Nothing makes a second caller get it right, and devlaunch-core/src/lib.rs freezes only workspace_delete into api, so a wf that links the promised surface deletes a clone holding uncommitted work without ever asking.

The comment at dl/src/commands.rs:688 calls the guard "the one thing dl refuses on its own account". That is the problem stated as a feature: it is the one thing a caller refuses, and the frozen API has no caller.

Shape

One deep module. remove(context, target, insistence, notices) -> Result<Removal, NotRun>, absorbing target resolution, opening the records once, the unsaved-work probe, the guard, the volume sweep and the delete. Removal::Refused(RemovalRefused) is an arm, so a caller that ignores the guard does not compile.

Two ordering constraints move from prose into the module while this is open:

  • lifecycle.rs:980-988 — volumes must be named before devpod delete, because delete destroys the record holding their names. Currently a comment.
  • The guard must run before the delete, currently a comment plus the binary's control flow.

Cost, deliberately

This changes public-api.api.txt, which CI diffs — a deliberate PR, not a refactor. That is the right weight for a change that decides whether wf can delete unsaved work, and #251 §7 already says an addition to api is a deliberate PR and a removal a breaking change.

Proving it

Red-first, and the red is reachable from the promised surface alone: call api::workspace_delete against a clone with an uncommitted change and watch it go. There is no such test today because the guard is not on that path.

Found by an architecture review.

Activity

  1. added
    bugSomething isn't working
    and removed
    bugSomething isn't working
    on Aug 24, 2026
  2. self-assigned this
    on Aug 24, 2026
  3. blooop commented on Aug 24, 2026

    @blooop
    OwnerAuthor

    agent-decided (constructive modeling): shape (i), with the seam one step in from where the ticket drew it. Fold the three exported functions into one lifecycle::workspace_remove whose outcome sum carries the refusal as an arm; guard_removal, unsaved_work_in and the raw delete become pub(crate). The seam is the workspace id, not the target word: absorbing target resolution needs ColdPath in core, which is #340's job on a human-gated map, and a safety defect on the promised surface must not park behind it.

    Why (ii), the Guarded witness, lost

    It is the shape constructive modeling rejects. Guarded::MayRemove becomes a witness only if it is unconstructible outside core — and even then it witnesses that guard_removal ran, not that it ran on a real answer. guard_removal(id, Unsaved::NothingToLose, NotInsisted) mints one from a fabricated probe, and Unsaved is a plain data enum that the listing flow's own tests build by hand (flows/listing.rs:2678). The illegal state — delete a clone holding the only copy of someone's work — stays representable, just spelled differently. That is a comment-enforced correlation wearing a type, which is the smell, not the fix. It also keeps three functions and 17 parameters of interface where one function has nine and no sequencing rule at all.

    Shape (i) makes it a compile error instead: after the fold there is no exported call that deletes without probing, because there is no exported call that deletes.

    Why (iii), document the contract, lost

    The contract is already documented. dl/src/commands.rs:688 names the guard, flows/lifecycle.rs:980-988 names the volume ordering, and the finding is precisely that documentation is the thing the second caller does not read. Free, and fixes nothing.

    The type

    DeleteOutcome is renamed RemoveOutcome and grows one arm — Refused(RemovalRefused) beside DevpodRefused and Deleted. One sum, not two: a fresh Removal alongside DeleteOutcome would be two overlapping types for one answer, and Removal is already taken in this module by purge's tree removal (lifecycle.rs:1363). <Verb>Outcome is the module's own convention — StopOutcome, PruneOutcome, PurgeOutcome.

    workspace_remove, not a re-signatured workspace_delete, for two reasons. Remove is this domain's verb (dl <ws> rm, RemovalRefused, remove_workspace_by_id); delete is devpod's, and it is one step inside the removal. And the split promise file exists so that a diff in it reads as a contract change: a removed row plus an added row is unmistakable, where a changed signature under the same name reads as routine.

    What the fold costs, stated honestly

    17 parameters across three calls become nine in one, and the #[allow(clippy::too_many_arguments)] survives. git genuinely disappears — inside core it is context.git() (flows/listing.rs:108) rather than a parameter — and cache_dir joins. No parameter-bundle type is invented here, on purpose: #340 moves dl's ColdPath/Records into core, and that bundle is the one that collapses clones + storage + cache_dir to a single argument. Two overlapping bundles is exactly the thing worth not doing.

    One behaviour to carry across the fold rather than rediscover: the probe stays conditional on NotInsisted. dl asks only when the guard could refuse, because the probe is a git status and a git log per clone (commands.rs:690). Folding makes "probe unconditionally" a one-line accident that slows every rm -f, so it gets its own test.

    Does api change now — yes, and it is a removal

    api::workspace_delete goes; api::workspace_remove arrives. Keeping both would leave the unguarded delete promised, which is the finding. #251 §7 calls a removal from api a breaking change, and that is the right weight for a change deciding whether wf can destroy the only copy of someone's work.

    api also re-exports what a caller needs to call it and match the answer — Refresh, WorkspaceCloneManager, MetadataStorage, Insistence, Notices, LifecycleNotice, RemoveOutcome, RemovalRefused. Today 5 of workspace_delete's 8 parameter types live outside api, so the promised delete is not callable from the promise; that is the same defect #313 already decided for Launch::new (5 of 7), and the same answer. The red test below is the forcing function: it may name no flows::, domain:: or clients:: path.

    Snapshot mechanics: public-api.api.txt and public-api.rest.txt are hand-edited on this branch — cargo-public-api needs nightly and this devcontainer cannot install it (scripts/public-api-snapshots.sh, header). CI's regenerate-and-diff is authoritative, so budget one round-trip to true the rows up rather than trusting the hand edit, and expect the rest file to move too (the canonical flows::lifecycle:: rows and the derives on the renamed type).

    Ordering against #299

    No collision. #313/#340 reshape Launch, ColdMachinery, Provision and ColdRefused — disjoint from the remove verb. #314 splits lifecycle.rs along its banners, and the guard sits above the // delete banner while the delete sits below it, so the fold should land first: the split then moves one function instead of three across a boundary that no longer separates them. #314 is blocked on #338 in any case. No edge is added into #299's map — this map does not re-decide tickets living there.

    Proving it, red first

    1. The ticket's red, at the promised surface. Through devlaunch_core::api paths only: a recorded clone holding an uncommitted change, Insistence::NotInsisted → RemoveOutcome::Refused, and the fake runner never saw a devpod delete. Red today, because api::workspace_delete deletes it.
    2. Insistence::Insisted removes it anyway and runs no git status/git log — pins the conditional probe against the accident above.
    3. The ordering comment at lifecycle.rs:980-988 becomes an assertion: in the recorded call order, the volume names are read before devpod delete.
    4. The existing guard and probe unit tests (lifecycle.rs:5368-5450) keep running against the now-pub(crate) functions. Nothing there is deleted; the fold changes who may call them, not what they answer.

    Graduates two builds: the fold and the api swap (unblocked, lands before #314), and the parameter collapse onto core's records bundle once #340 lands.

  4. blooop commented on Aug 24, 2026

    @blooop
    OwnerAuthor

    Resolved above; graduated to #410 (the fold and the api swap) and #411 (the parameter collapse, blocked on #340).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions