Skip to content

Refuse a launch whose derived id collides with a different triple - #524

Merged
blooop merged 3 commits into
mainfrom
fix/438-id-collision-refusal
Aug 29, 2026
Merged

Refuse a launch whose derived id collides with a different triple#524
blooop merged 3 commits into
mainfrom
fix/438-id-collision-refusal

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #438.

What was broken

A workspace id is <repo-slug>-<ref-slug>-<suffix>, and only the four-character
suffix makes it injective. The readable half is lossy three ways: the owner is not
in it at all, slug() collapses feature/auth and feature-auth to one string,
and the 47-character cap eats a long ref's tail. Two triples whose ids land on the
same string share both things the id names, because devpod workspace names are
global rather than scoped by repository: one clone directory and one container.

Nothing detected that. The second launch attached to the first one's container and
said nothing, so you opened somebody else's checkout from a command with no reason
to look wrong, and a later dl <ws> rm on either deleted a clone the other still
claimed. The suffix width was the whole of the defence, which the two doc comments
in workspace_id.rs said in as many words. Both are updated here.

The refusal

Workspace id 'devlaunch-release-999999999999999999999911-dq8q' is already held by
'blooop/devlaunch@release/999999999999999999999911630'. Launching
'blooop/devlaunch@release/999999999999999999999911783' under it would put both in
one clone directory and one container, so each would open the other's checkout and
'dl <ws> rm' on either would delete work the other still owns. Rename one of the
two branches and launch it again.

Both triples and the id, because renaming one of the two branches is the only way
past it and the two rows are the same 47 characters in dl --ls. The migration's
voice, and the same hazard it already refuses to walk into at migration time.

place_triple scans the records before devpod is asked anything and before the
cold path can build a directory, so the guard fires ahead of the attach that does
the damage. Reached::Nothing, since a refused launch created nothing.

The three fail-open rules, each with a test

  • A record whose branch is not a legal ref is skipped, not failed on. The old
    derivation coerced unsafe refs instead of rejecting them, so a stored branch is
    not necessarily one WorkspaceId::new accepts; the migration reports such a
    record as unusable and carries on. Swapping the is_ok_and for an expect
    turns the test red.
  • A store that cannot be read means no collision, not an error, on
    recorded_id's stated rule.
  • A launch matching its own record attaches exactly as before.

The warm path is untouched

This is the part worth reviewing. Reading the records on the warm attach path is
exactly what devlaunch#145 took off it, and a_warm_triple_launch_does_no_metadata_io_at_all
caught the first attempt: MetadataStorage::open quarantines an unusable document
to .corrupt, so the guard wrote to the cache on a warm launch.

So ColdMachinery grows recorded, which reads through a new
MetadataStorage::look: no lock, no migration, no config.toml, no clone manager,
no write of any kind. look is a second constructor rather than a flag on open
because all three of open's writes (creating the cache directory, quarantining an
unusable file, backing up one that will not round-trip) exist to protect bytes from
the caller's next save, and a reader has no next save.

NeverCold still panics if anything opens the machinery, so the 30-odd tests that
assert #145 assert exactly what they asserted before, and
a_launch_that_matches_its_own_record_attaches_and_reads_no_machinery adds
opens == 0 on top.

Also: the migration half, from the ticket's own comment

migrate_record's guard against adopting another record's clone read a claimed
set built from the pre-run layout alone. Two schema-2 records deriving one
schema-3 id share a destination that no record pointed at before the run, so the
guard never fired: the first renamed onto it, the second found it existing and
repointed itself, and two records ended up owning one clone. claimed now grows as
destinations are taken, so the second lands in blocked like any other conflict.

The colliding pair is real

release/999999999999999999999911630 and release/999999999999999999999911783
under blooop/devlaunch genuinely derive devlaunch-release-999999999999999999999911-dq8q,
found by searching the shape the module doc records seeing in the wild: long
release refs differing only past the truncation point, so the readable halves match
and only the suffix separates them. both_of_the_colliding_refs_really_do_derive_one_id
is named beside the constants and fails first if a change to the derivation moves
them apart, rather than letting the guard's tests pass against two ids that no
longer collide.

Tests

Core, flows::launch: both_of_the_colliding_refs_really_do_derive_one_id,
a_launch_whose_derived_id_another_triple_already_holds_is_refused,
a_launch_that_matches_its_own_record_attaches_and_reads_no_machinery,
a_record_whose_branch_is_not_a_legal_ref_does_not_block_a_launch,
a_store_that_cannot_be_read_does_not_refuse_a_launch.
domain::metadata: a_look_reads_a_corrupt_file_as_empty_and_leaves_it_exactly_as_it_found_it,
a_look_at_a_cache_that_is_not_there_creates_nothing,
a_look_reads_the_same_records_an_open_does.
flows::migration: two_records_that_derive_one_destination_do_not_both_end_up_owning_it.
dl::render: an_id_collision_names_both_branches_the_id_and_the_way_out.

Public API

public-api.rest.txt only: LaunchRefusal::IdCollision and its fields,
ColdMachinery::recorded, MetadataStorage::look. Nothing at
devlaunch_core::api, so public-api.api.txt is unchanged. Hand-edited, and
therefore provisional until CI's public-api job agrees: this host has neither a
nightly toolchain nor the pinned cargo-public-api, which docs/development.md says
is the only route there is from here. The partition holds locally; ordering is CI's
to confirm.

Local: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings,
cargo fmt --check and pytest test all green.

🤖 Generated with Claude Code

Summary by Sourcery

Refuse launches that would reuse a workspace ID owned by a different triple and prevent the same collision during metadata migration.

New Features:

  • Refuse launches when a different repository/ref triple already holds the derived workspace ID, reporting both triples and the required remediation.

Bug Fixes:

  • Prevent colliding workspace IDs from silently attaching to the wrong clone and container.
  • Prevent schema 2-to-3 migration from assigning two records to the same destination when their derived IDs collide.

Enhancements:

  • Centralize workspace identity comparison so repository casing and case-sensitive refs follow the same rules as ID derivation.
  • Add side-effect-free metadata lookup for collision checks, preserving warm-launch behavior without locks, migration, or cache writes.
  • Keep collision detection fail-open for unreadable metadata and invalid stored refs, while allowing matching records to attach normally.

Documentation:

  • Document workspace ID collision behavior and the resulting launch refusal.

Tests:

  • Add coverage for real derived-ID collisions, warm-path behavior, fail-open cases, identity casing rules, migration conflicts, and refusal rendering.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 6 days and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR prevents launches from silently sharing a clone directory and DevPod workspace when distinct repository/ref triples derive the same lossy workspace ID, using a read-only metadata lookup before launch, an actionable refusal API and renderer, and a corresponding migration fix that blocks duplicate destinations.

Sequence diagram for refusing a colliding workspace launch

sequenceDiagram
    participant User
    participant Launch
    participant MetadataStorage
    participant DevPod
    User->>Launch: run(owner/repo@branch)
    Launch->>MetadataStorage: recorded()
    MetadataStorage-->>Launch: existing worktree records
    alt different triple holds derived workspace ID
        Launch-->>User: LaunchRefusal::IdCollision
    else no collision or unreadable store
        Launch->>DevPod: attach or launch workspace
        DevPod-->>Launch: session result
        Launch-->>User: launch result
    end
Loading

Flow diagram for derived workspace ID collision handling

flowchart TD
    Start[Derive workspace ID] --> Read[Read recorded worktrees]
    Read --> Available{Different triple holds ID?}
    Available -->|Yes| Refuse[Return IdCollision refusal]
    Refuse --> Explain[Render both triples, shared ID, and rename guidance]
    Available -->|No or records unreadable| Proceed[Continue launch]
    Proceed --> Attach[Attach or create workspace]
Loading

Flow diagram for collision-safe metadata migration

flowchart TD
    Begin[Start migration] --> Seed[Seed claimed paths from existing records]
    Seed --> Record[Process next worktree record]
    Record --> Destination[Derive destination path]
    Destination --> Claimed{Destination already claimed?}
    Claimed -->|Yes| Block[Report blocked; keep record on its own clone]
    Claimed -->|No| Take[Claim destination and update record]
    Block --> More{More records?}
    Take --> More
    More -->|Yes| Record
    More -->|No| Done[Complete migration]
Loading

File-Level Changes

Change Details Files
Add a pre-launch collision guard for derived workspace IDs.
  • Scan recorded worktree triples and IDs before any DevPod interaction or cold-path setup.
  • Refuse only when a different triple occupies or derives the same ID.
  • Expose collision details through the public refusal type and render an actionable message.
  • Preserve warm-attach behavior and fail open for unreadable metadata or invalid stored refs.
rust/devlaunch-core/src/flows/launch.rs
rust/dl/src/cold.rs
rust/dl/src/launch.rs
rust/dl/src/render.rs
rust/devlaunch-core/public-api.rest.txt
Introduce a side-effect-free metadata lookup path for warm launches.
  • Add MetadataStorage::look with a non-mutating load mode.
  • Ensure missing, corrupt, or unreadable metadata is treated as an empty lookup without creating, quarantining, backing up, or locking files.
  • Reuse already-open records on the cold path to avoid duplicate reads.
rust/devlaunch-core/src/domain/metadata.rs
rust/dl/src/cold.rs
Close the equivalent collision hole during schema migration.
  • Grow the claimed-destination set as migration records are processed.
  • Block later records that derive a destination already claimed earlier in the same migration.
rust/devlaunch-core/src/flows/migration.rs
Add regression coverage and document the changed collision guarantees.
  • Pin a real pair of distinct refs that derive one ID and test refusal, self-match, invalid-ref, unreadable-store, and no-DevPod cases.
  • Test side-effect-free metadata lookup and migration blocking.
  • Update workspace ID documentation and the changelog.
rust/devlaunch-core/src/flows/launch.rs
rust/devlaunch-core/src/domain/metadata.rs
rust/devlaunch-core/src/flows/migration.rs
rust/devlaunch-core/src/domain/workspace_id.rs
rust/dl/src/render.rs
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#438 Detect a derived workspace ID collision with a different owner/repository/branch triple before any attach, clone, or devpod operation, and refuse the launch while identifying both triples and the shared ID.
#438 Preserve safe launch behavior in edge cases: matching records must not be treated as collisions, records with invalid branches must be skipped, unreadable metadata must fail open, and warm launches must avoid metadata writes and cold-path machinery.
#438 Complete the associated user-facing and maintenance updates, including refusal rendering, public API snapshots and development documentation, updating collision-related comments, and preventing the analogous migration collision from producing two owners of one destination. The PR implements the refusal rendering, updates the workspace ID comments, and fixes the migration case, but it does not show the required update to the snapshot section in docs/development.md. The public API file is also explicitly hand-edited and described as provisional rather than regenerated with the documented tooling, so the snapshot/documentation requirements are not fully completed.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@blooop
blooop force-pushed the fix/438-id-collision-refusal branch from fd46f65 to 4a27f9a Compare August 29, 2026 19:56
@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto af9f5a8 (head 4a27f9a). Two notes on the rebase, since both were traps:

The CHANGELOG. Main cut 0.25.0 mid-branch, so the conflict resolves plausibly and wrongly by keeping both sides in order, which files this change inside a release that does not contain it. The entry is in [Unreleased]'s ### Fixed (line 98), above ## [0.25.0] - 2026-08-28 (line 137). Same trap ea4c4c6 just documented.

Why CI never ran on the first push. The PR was CONFLICTING, so GitHub could not build the refs/pull/524/merge ref and no pull_request run was created at all. Not a skipped run, not a pending one, none. It is now MERGEABLE.

The public-API snapshot was re-derived on top of main's own snapshot change (456f917), not hand-merged: the diff against main is exactly the ten rows this branch adds and nothing else.

Local on the rebased head: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check and pytest test (607 passed) all green.

Also smoke-tested the release binary against a scratch XDG_CACHE_HOME seeded with a record for the first branch:

$ dl 'blooop/devlaunch@release/999999999999999999999911783' -- true
Workspace id 'devlaunch-release-999999999999999999999911-dq8q' is already held by
'blooop/devlaunch@release/999999999999999999999911630'. ...
exit 1

metadata.json came back byte-identical afterwards and no .lock, .corrupt or .bak sibling appeared, which is the property MetadataStorage::look exists for. Launching the first branch against the same cache is not refused and proceeds to its clone as before.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.92961% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.81%. Comparing base (adab656) to head (a504947).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/launch.rs 96.73% 10 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 96.10% <97.92%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 96.10% <97.92%> (+0.02%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Austin Gregg-Smith added 2 commits August 29, 2026 21:35
A workspace id is `<repo-slug>-<ref-slug>-<suffix>`, and only the four
character suffix makes it injective: the owner is not in the readable
half at all, `slug()` collapses `feature/auth` and `feature-auth` to one
string, and a long ref loses its tail to the 47 character cap. Two
triples whose ids land on the same string share both things the id
names, because devpod workspace names are global rather than scoped by
repository: one clone directory and one container.

Nothing detected that. The second launch attached to the first one's
container and said nothing, so the user opened somebody else's checkout
from a command with no reason to look wrong, and a later `dl <ws> rm` on
either one deleted a clone the other still claimed. The suffix width was
the whole of the defence, which the two doc comments in workspace_id.rs
said in as many words.

`place_triple` now scans the records for a record holding the derived id
under a different triple, before devpod is asked anything and before the
cold path can build a directory, and refuses with both triples and the
id. Three fail-open rules, each with a test: a record whose branch is
not a legal ref is skipped rather than failed on (the old derivation
coerced unsafe refs, so a stored branch is not necessarily one), a store
that cannot be read means "no collision" rather than an error, and a
launch matching its own record attaches exactly as before.

The warm path keeps what devlaunch#145 bought it. `ColdMachinery` grows
`recorded`, which reads `metadata.json` through the new
`MetadataStorage::look`: no lock, no migration, no `config.toml`, no
clone manager, and no write of any kind. `look` is a second constructor
rather than a flag on `open` because `open`'s three writes -- creating
the cache directory, quarantining an unusable file, backing up one that
will not round-trip -- all exist to protect bytes from the caller's next
save, and a reader has no next save.

Also closes the migration half named in the ticket's own comment. The
schema 2 to 3 migration built `claimed` from the pre-run layout alone,
so two records deriving one destination both ended up owning it: the
shared path was new, no record had pointed at it before the run, and the
second record found it existing and repointed itself. `claimed` now
grows as destinations are taken, so the second lands in `blocked` like
any other conflict.

The collision the tests are written against is a real one, found by
searching the shape the module doc records seeing in the wild.

Closes #438
The collision guard read "a different triple" as "a different pair of
strings", and the derivation does not. `WorkspaceId::suffix` lowercases
owner and repo before hashing, deliberately: GitHub's owners and repos
are case-insensitive, so `NVIDIA/cuda-samples` and `nvidia/cuda-samples`
are one repository and derive one id, and the alternative is one repo
cloned twice into two containers.

So the second spelling of a repository derived the first spelling's id
and was then refused for holding it. Nothing normalises earlier --
`worktree_key` interpolates the raw strings -- so before the guard
existed the record lookup missed on case and both spellings converged on
the one workspace, which is the designed behaviour. The refusal broke
it, and broke it with a message naming no way out: it says to rename one
of the two branches, and both branches are `main`. The reader is locked
out of a workspace they already have running until they guess the case
they first typed.

`identity_of` is now the one place that rule lives. `suffix` hashes it
and the guard compares it, so the two cannot drift into disagreeing
about which spellings are one workspace -- which is exactly what went
wrong. Owner and repo fold; the ref does not, because git refs really
are case-sensitive and `Main` and `main` can both exist. The `holds_id`
arm that re-derives from a record's triple already went through
`WorkspaceId::new` and so was already right about the id; it was the
comparison beside it that disagreed.

Tests both ways: the other spelling of a repository is not refused, and
a ref differing only in case still is. The second has to stage its
collision, since two refs differing in case hash apart by design.

Also renames `a_warm_triple_launch_does_no_metadata_io_at_all`. Every
assertion in it is about writes and they all still hold, but the name
claimed no I/O when the guard does read the file, and a name that
over-claims invites someone to "fix" the read. It is now
`a_warm_triple_launch_writes_nothing_to_the_cache`, with a line saying
the read is deliberate.
@blooop
blooop force-pushed the fix/438-id-collision-refusal branch from d442aca to 65ea76f Compare August 29, 2026 20:38
Taken from CI's regenerate-and-diff rather than guessed at: one row in
public-api.api.txt and three in public-api.rest.txt, at the positions
the generator puts them.

Worth saying out loud, because the api file is the promised tier:
`ColdMachinery` is re-exported at `devlaunch_core::api`, so adding a
method to it widens the promised contract. It is a *required* method and
not a defaulted one deliberately. A default returning `None` would keep
the trait source-compatible for an outside implementer, and would do it
by silently switching the collision guard off for anyone who did not
override it -- which is exactly the failure this branch already hit once,
when a rebase grafted `NoColdPath`'s body onto `ColdPath` and left the
whole suite green. Nothing outside this workspace implements the trait
today (#266: wayfinder never links devlaunch-core), so the cost is a
recompile and the benefit is that the guard cannot be turned off by
omission.
@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Fixed, pushed, and green at a504947 (all nine required runs, public-api included).

The case defect

You were right, and the fix is the shape you asked for. identity_of in workspace_id.rs is now the single place the rule lives: suffix() hashes it and the guard compares it, so the derivation and the comparison cannot drift into disagreeing about which spellings are one workspace. No second to_lowercase() pair at the comparison site. Owner and repo fold; the ref does not.

Your probe is in as a_repository_spelled_in_another_case_is_the_same_workspace_and_is_not_refused. I confirmed it reproduces your exact output against the old comparison (cuda-samples-main-mut5 and all), then goes green.

Three more tests came out of it:

  • a_ref_that_differs_only_in_case_is_a_different_workspace_and_does_collide — the other half. It has to stage its collision, since Main and main hash apart by design.
  • identity_folds_the_owner_and_the_repo_and_never_the_ref — pins the rule beside it, per AGENTS.md.
  • the_guard_and_the_derivation_agree_about_which_spellings_are_one_workspace — asserts a.value() == b.value() iff a.identity() == b.identity(), which is the property that actually broke.

On your holds_id question: that arm was already case-correct, exactly as you said, because it goes through WorkspaceId::new. It needed no change, and that asymmetry is what made the defect possible.

Something the rebase turned up, worth your attention

Main merged #514 mid-review, which moved ColdPath out of dl/src/cold.rs into core. The rebase resolved plausibly and wrongly: it grafted NoColdPath's recorded body onto ColdPath, so the production implementation returned None and the guard was switched off entirely. The full suite stayed green, because every other test of the guard hands Launch a cold path built for the test.

So there is now a test that runs the real binary against a real metadata.json: a_warm_launch_whose_id_another_triple_holds_is_refused_by_the_real_binary in dl/tests/launch.rs. I verified it is the only one of the 58 that catches the mismerge.

Public API: this widens the promised tier

ColdMachinery is re-exported at devlaunch_core::api, so recorded is a row in public-api.api.txt, not just the rest file. Flagging it rather than burying it: it is a required method, not a defaulted one. A default returning None would keep the trait source-compatible for an outside implementer and would do it by silently disabling the guard for anyone who did not override it, which is the failure above. Nothing outside this workspace implements it today (#266). Say the word if you would rather have the default and I will switch it.

Snapshots came from CI's regenerate-and-diff, not from guesswork.

The non-blocking one

Renamed to a_warm_triple_launch_writes_nothing_to_the_cache, with a paragraph saying the read is deliberate and why. Also updated the citation to it in metadata.rs, which was the last copy of the old name.

Process note

Taken, thank you — the gate I ran requires each of the nine by name, completed and in {success, skipped, neutral}, not a count. Separately: three of the four CI rounds on this branch never started at all, because a CONFLICTING PR has no merge ref and so gets no pull_request run. Not a pending one, not a skipped one, none. Worth knowing that "no checks" and "checks not finished" look identical from gh pr checks.

The CHANGELOG resolution is verified by #528's own scripts/changelog_frozen.py: 65 released sections untouched.

@blooop
blooop merged commit 3e49af6 into main Aug 29, 2026
15 checks passed
@blooop
blooop deleted the fix/438-id-collision-refusal branch August 29, 2026 20:47
blooop added a commit that referenced this pull request Aug 29, 2026
#524 widened api's re-exports (29 promised paths to 40), so the promise file is
813 rows where it was 521 and the moved-row figure is 631, not 395. The
residual is still 39 types, now over six hundred rows.

Note main's checked-in public-api.rest.txt is stale by one row
(MetadataStorage::look) against main's own tree; regenerating here adds it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refuse a launch whose derived id collides with a different triple

1 participant