Repository navigation
Decide: workspace-id collision & sanitization strategy #55
Description
Activity
- added a parent issue
on Aug 7, 2026 Retargeted by #53 (Rust rewrite: go, with a final Python release first). Still a decision ticket, still open — but under a
goit matters more, not less, and it now has two consumers instead of one.The id-derivation scheme this ticket picks is a parse boundary, and it is the first data-shape decision
devlaunch-corewill encode./constructive-modelingwas already on this ticket; it is now the load-bearing reason the ticket exists, because the type-safety motive is what justified consuming devlaunch as a linked crate at all —wfwill holdWorkspaceas a shared struct, so a workspace id that can collide is a defect that propagates into a second codebase.Two additions to the question:
- The scheme must be decided once and encoded once. Whatever replaces
sanitize_workspace_id(dl.py:392) and_sanitize_branch_dir(workspace_clone.py:41) has to be a single derivation the Rust core owns, not two flatten-separators implementations that happen to agree. - Migration now has a natural boundary. The final Python release is the last version reading the old scheme, so "rename / tolerate-both / document-and-break" can be answered against a known cut-over point rather than an open-ended one — and if the answer is tolerate-both, the core inherits that tolerance, so it needs deciding here rather than discovered there.
- The scheme must be decided once and encoded once. Whatever replaces
breadcrumb: Six defects confirmed by running the real code (not read off the source): non-injective id (
feature/authandfeature-authboth →devlaunch-feature-auth);_sanitize_branch_dirhas five verified preimages offeature-auth(/,-, space,+,:); the 48-char guard atdl.py:447-449disables truncation whenlen(repo_name) >= 47, yielding an 80-char id; truncation is itself collision-generating (two branches sharing a 30-char prefix → byte-identical id);_→-atdl.py:445vs_→ deleted atdl.py:469; and_validate_refguards 2 of 3 paths, missingget_workspace_path:78, structurally because it returns a nakedstrcarrying no evidence.breadcrumb: Decision-grade external evidence — kinisi-robotics/kinisi_ros#9766 is a real incident caused by devlaunch's layout. A downstream
get_instance_idread path components; because devlaunch checks out as<cache>/<org>/<repo>/<branch>, the parent names the repo, so every branch collapsed onto one instance id → shared container name, compose project and image tag. Three consequences for this ticket: (1) the failure mode is silently destructive, not just wrong-attach — the sim reaper keyed on the shared id and force-removed the other checkout's running sidecar, before any visible error; (2) an earlier cut of their fix derived the id from theoriginremote and broke undergit remote renameand under a launcher with no git onPATH— empirical confirmation that impure id derivation has already bitten in this stack, which rejects the stateful hash-on-collision option; (3)KINISI_HOSTNAMEhad no length cap and the real id already sat at 62/64 bytes — devlaunch'ssetup_hostname(dl.py:396) sets the hostname to the workspace id, and downstream stacks prefixes/suffixes on it, so 48 chars is a ceiling others eat into, not a budget to fill.breadcrumb: Root cause generalised: devlaunch's clone-dir leaf is unique only within its parent, so identity is spread across two path levels and any downstream tool keying on one component loses it. Scheme decided —
<repo-slug>-<branch-slug>-<syl6>with a pronounceable-syllable suffix derived algorithmically from a hash of the unsanitized(owner, repo, ref)triple (no wordlist, so nothing has to stay byte-identical across the Python→Rust cut-over). Layout decided — the same self-identifying id names both the devpod workspace and the clone dir, so no single-component heuristic can collapse two branches again. This dissolves the two-derivations defect as a side effect.Answer
The defect is that every distinction lives in prose:
spec_to_workspace_id(str) -> str,_validate_ref(str) -> str,_sanitize_branch_dir(str) -> str. Spec vs id, validated vs raw, within-budget vs not — none of it is in a type. Six defects follow from that, all confirmed by running the code, not by reading it.The six defects
# Defect Verified 1 Derivation is not injective blooop/devlaunch@feature/authand@feature-authboth →devlaunch-feature-auth2 48-char invariant not enforced len(repo_name) >= 47makesmax_branch <= 0, so the guard0 < max_branchatdl.py:447-449skips truncation entirely — a 47-char repo yields an 80-char id3 Truncation is itself collision-generating two branches sharing a 30-char prefix → byte-identical id 4 One rule, two derivations, disagreeing _→-atdl.py:445,_→ deleted atdl.py:469:my_repo→my-repovsmyrepo5 Validate-don't-parse, and it already bit _validate_refguardsworkspace_clone.py:99and:278but notget_workspace_path:78; it raises while_sanitize_branch_dircoerces6 Two identities in one record models.py:44-47—workspace_id: stranddevpod_workspace_id: Optional[str]with an unanswerableNone_sanitize_branch_dirhas five verified preimages offeature-auth:feature/auth,feature-auth,feature auth,feature+auth,feature:auth.Severity is higher than "wrong workspace"
kinisi-robotics/kinisi_ros#9766 is a real incident caused by this layout. A downstream
get_instance_idread path components; because devlaunch checks out as<cache>/<org>/<repo>/<branch>, the parent names the repo, so every branch collapsed onto one instance id — shared container name, compose project and image tag. The first symptom was silent destruction: the sim reaper keyed on the shared id and force-removed the other checkout's running sidecar. The docker name conflict came second. So finding #1's failure mode is destroying another workspace's state, not merely attaching to it.That PR also supplies two facts that shaped this answer:
- An earlier cut of its fix derived the id from the
originremote, and broke undergit remote renameand under a launcher with no git onPATH. Impure id derivation has already bitten in this exact stack — which is why the stateful hash-on-collision option is rejected below. KINISI_HOSTNAMEhad no length cap and the real id already sat at 62/64 bytes.setup_hostname(dl.py:396) sets the container hostname to the workspace id, and downstream stacks prefixes and suffixes on it. 48 chars is a ceiling other tools eat into, not a budget to fill.
The scheme
<repo-slug>-<branch-slug>-<syl3> target cap 38; hard ceiling 48 (devpod) repo never truncated below 20 chars — it is the context that makes the id readable branch absorbs all truncation; segment-aware (drop middle /-segments before truncating) syl3 6 chars: 3 consonant-vowel syllables, 18 bits of sha256 over the UNSANITIZED (owner, repo, ref) triple. Never truncated.Identity lives in
syl3; legibility lives in the slug. Because only the slug is ever truncated, truncation can no longer cause a collision — that dissolves defect #3 and turns the truncation policy into a pure readability choice with no correctness risk.Encoding: 16 consonants (
bdfghjklmnprstvz) × 4 vowels (aeio) = 64 combinations = exactly 6 bits per syllable. Pronounceable and memorable (zovomo,kifumo,leneve) with no wordlist — the two tables are 20 characters total, so cross-language agreement across the Python→Rust cut-over is a one-line test assertion rather than a 2048-word file that must not drift.Why the cap is 38, not 48. Tested against the real names in this repo (branches to 50 chars, repos to 16), a cap of 41 lands the kinisi hostname at exactly 64/64 — zero headroom, so one extra character upstream stops the container booting. 38 keeps 3 chars of margin.
Rejected alternatives: reversible escape encoding (
/→_) is injective but still collapses under truncation, so defect #3 survives; hash-on-collision needs a registry read, making derivation stateful and racy between concurrentdlinvocations — the exact impurity that failed in #9766; a full hash is injective but destroys the stated goal that ids be meaningful indevpod list.Verified against real names — 12/12 unique, every repo name intact, max length 41 at the old cap:
devlaunch-main-zovomo wayfinder-main-hesiro devlaunch-feature-auth-polise devlaunch-feature-auth-nesata <- #1 fixed devlaunch-dependabot-codecov-actio-sifiva devlaunch-dependabot-prek-action-2-balono python-template-dependabot-lib-dep-tonifi lifetime-foc-rig-dependabot-codeco-razota kinisi-ros-ags-devcontainer-toolin-leneve devlaunch-fix-gh-auth-in-devcontai-pedoveSegment-awareness is what makes the dependabot cases legible: naive truncation gives
devlaunch-dependabot-github-actions-, which says nothing about which action; dropping the middle taxonomy segments givesdevlaunch-dependabot-codecov-actio-.The layout: one self-identifying id, both places
The same id names the devpod workspace and the clone-dir leaf. Today the leaf is a bare branch name, unique only within its parent — identity spread across two path levels, so any consumer keying on one component loses it. That is the trap that produced #9766, and it is not limited to custom scripts:
<cache>/blooop/devlaunch/mainand<cache>/blooop/wayfinder/mainpresent the same folder name to the standard devcontainer CLI, VS Code, and anything else deriving names from the path — a cross-repo collision needing no slugification quirk at all.A self-identifying leaf makes every path-keyed consumer correct by construction. This also dissolves defect #4: one derivation, used twice, instead of two that disagree.
The repo name stays in the leaf — it is the context that makes the id readable, and the syllables (hashed over the triple) already make the leaf globally unique without it. The cost is that a downstream rule which re-prefixes the repo name produces it twice; that is fixed on the consumer side, not by stripping context here. Recorded as a coordination note, not work on this map: #9766's "promote the clone dir, keep the repo name in front" rule should skip the prefix when the leaf already carries it.
Ref validation folds in (defect #5)
The new derivation is the parse boundary.
_validate_ref(name: str) -> strreturns a nakedstrcarrying no evidence, indistinguishable from unvalidated input — which is structurally whyget_workspace_path:78was missed. Validate once on the way in and return a type that proves it, and the missing-guard class becomes unrepresentable. Theraise(validate) vscoerce(sanitize) split in one module collapses to one response to bad input.Splitting this into its own ticket was considered and rejected: it touches the same functions and would conflict.
Migration: rename the clones, orphan the containers
Workspaces are
git clones (workspace_clone.py:234), not worktrees —.gitis a directory and the remote points at the.barepath, which does not move. So renaming a clone dir is a losslessmv, and any uncommitted work survives. That is the one thing here that is not cheaply recreatable, so it decides the strategy.- Clone dirs: the final Python release renames old-scheme dirs on sight, skipping
.bare. (This repo's cache has 15+ such dirs underblooop/bencheralone:main,w1,asdf1,test-dotfix, …) - Devpod containers: take new ids; old ones are orphaned, with a one-line notice and a cleanup command — the precedent #9766 set for exactly this situation.
- Tolerate-both was rejected: it requires carrying the colliding flatten forever, including into the Rust core, keeping the collision permanently representable — the opposite of the point. It would also leave old clone dirs non-self-identifying, so the #9766 trap stays open for them.
Detection can be structural — a new-scheme leaf ends in
-plus three consonant-vowel pairs — with the caveat that a branch literally namedfoo-bexozafalse-positives. Whethermetadata.jsoninstead gains an explicit version field stays fog on the map, now sharpened by #56.Not fixed here
Defect #6 (
WorktreeInfocarrying bothworkspace_idanddevpod_workspace_id: Optional[str]) is real but out of this ticket's scope — it is a record-shape question, and with one derivation there is no longer a reason for two fields. Left to the storage work.- An earlier cut of its fix derived the id from the
Question
Branches
feature/xandfeature-xcurrently collide:sanitize_workspace_id(dl.py:392,/→-) and_sanitize_branch_dir(workspace_clone.py:41, non-word→-) both flatten separators, so two distinct branches map to the same workspace id and the same clone directory.Decide the fix strategy:
/distinctly, or hash-suffix on collision)? Devpod workspace ids have a 48-char limit and their own charset rules — the scheme has to fit (note the related truncation bug atdl.py:447-449: repo names ≥ 47 chars skip truncation entirely)._validate_ref, applied on only one of three paths) fold into the same change?Consult
/constructive-modeling— this is a data-shape decision (the id derivation is a parse boundary).