Give workspace names, herdr ids and commands their own opaque types #429

Open
opened 2026-08-21 13:06:44 +00:00 by mph · 4 comments
Owner

Problem

The terminal workspace port addresses everything with bare String. Three
different things share that one type, and the compiler cannot tell them apart.

  1. The workspace name — iw's <project>-<key>, e.g. iw-cli-IW-422. tmux
    accepts this directly as a session name.
  2. The herdr workspace id — what herdr actually accepts. herdr addresses a
    workspace by id only and does not accept a label, which is why
    HerdrLookup exists: it turns a checkout path or a label into an id.
  3. The command to run inside a workspace.

Two consequences, both already met in IW-422:

  • RunInWorkspace.apply(workspace: String, command: String) takes two Strings.
    Transposing them compiles cleanly and fails only at runtime — it would run the
    workspace name as a command, inside a workspace named after a command.
  • Passing a label where herdr wants an id is a silent type-correct mistake. The
    issue documented this trap and worked around it in HerdrLookup; the type
    system does not enforce it.

Proposal

Introduce opaque types in core/model/ and thread them through the port.

opaque type WorkspaceName = String
opaque type HerdrWorkspaceId = String
opaque type Command = String

The design content is not the typing — it is deciding where each type starts and
stops. Specifically: at which boundary a WorkspaceName becomes a
HerdrWorkspaceId, whether HerdrWorkspaceId belongs in model/ or beside the
herdr adapter, and where the raw String is recovered for os.proc.

Estimated blast radius (measured 2026-08-21)

Where Sites
New opaque types in core/model/ ~15 lines
TerminalWorkspaceOps signatures 12 methods
LiveTmuxOps, LiveHerdrOps, UnavailableWorkspaceOps ~62 name references
FakeTerminalWorkspaceOps ~39
Tmux.scala argv builders ~23, mostly .value at the process boundary
Herdr.scala ~3
Test files 13

Roughly half a day. The work is mechanical and compiler-driven — it does not
compile until every boundary is converted, so nothing is silent.

Origin

Raised while triaging LESSONS.md for IW-422, from the entry on narrowing
SessionHooks to the capability it calls. Naming the capability
(RunInWorkspace, commit 69b2c64) restored the parameter roles in the type but
left both parameters as String. This issue closes the remaining gap.

## Problem The terminal workspace port addresses everything with bare `String`. Three different things share that one type, and the compiler cannot tell them apart. 1. **The workspace name** — iw's `<project>-<key>`, e.g. `iw-cli-IW-422`. tmux accepts this directly as a session name. 2. **The herdr workspace id** — what herdr actually accepts. herdr addresses a workspace **by id only and does not accept a label**, which is why `HerdrLookup` exists: it turns a checkout path or a label into an id. 3. **The command** to run inside a workspace. Two consequences, both already met in IW-422: - `RunInWorkspace.apply(workspace: String, command: String)` takes two Strings. Transposing them compiles cleanly and fails only at runtime — it would run the workspace name as a command, inside a workspace named after a command. - Passing a label where herdr wants an id is a silent type-correct mistake. The issue documented this trap and worked around it in `HerdrLookup`; the type system does not enforce it. ## Proposal Introduce opaque types in `core/model/` and thread them through the port. ```scala opaque type WorkspaceName = String opaque type HerdrWorkspaceId = String opaque type Command = String ``` The design content is not the typing — it is deciding where each type starts and stops. Specifically: at which boundary a `WorkspaceName` becomes a `HerdrWorkspaceId`, whether `HerdrWorkspaceId` belongs in `model/` or beside the herdr adapter, and where the raw `String` is recovered for `os.proc`. ## Estimated blast radius (measured 2026-08-21) | Where | Sites | |---|---| | New opaque types in `core/model/` | ~15 lines | | `TerminalWorkspaceOps` signatures | 12 methods | | `LiveTmuxOps`, `LiveHerdrOps`, `UnavailableWorkspaceOps` | ~62 `name` references | | `FakeTerminalWorkspaceOps` | ~39 | | `Tmux.scala` argv builders | ~23, mostly `.value` at the process boundary | | `Herdr.scala` | ~3 | | Test files | 13 | Roughly half a day. The work is mechanical and compiler-driven — it does not compile until every boundary is converted, so nothing is silent. ## Origin Raised while triaging LESSONS.md for IW-422, from the entry on narrowing `SessionHooks` to the capability it calls. Naming the capability (`RunInWorkspace`, commit 69b2c64) restored the parameter roles in the type but left both parameters as `String`. This issue closes the remaining gap.
Author
Owner

IW-476 phase 2 created a new instance of this fault, found independently by the composition and scala3 reviewers.

The new call site. TerminalWorkspaceOps.create widened from (name, workDir) to (name: String, label: String, workDir: os.Path) (core/commands/CommandEnv.scala:403-407). Two same-typed, adjacent parameters with non-interchangeable meanings now sit next to each other, so create(label, name, workDir) compiles silently.

It fails differently per backend, which makes it worse than the RunInWorkspace case. LiveTmuxOps reads name and discards label; LiveHerdrOps reads label and discards name. A transposed call therefore misbehaves in one way under tmux and another under herdr, and every automated tier runs against tmux only.

A gap in this issue's current proposal. The three opaque types listed here — WorkspaceName, HerdrWorkspaceId, Command — have no counterpart for the display label. WorktreePath.sessionName addresses the workspace; WorktreePath.workspaceLabel only displays it. Both are strings derived from the same WorktreePath. Distinguishing them needs either a fourth opaque type or, as the reviewers suggested, one WorkspaceIdentity(name, label) value produced by WorktreePath, narrowing create to (identity, workDir).

The pattern is spreading. Start.scala:250-259's createSessionAndRunHooks now takes five positional parameters. Three are derivable from a WorktreePath the caller already holds, and a fourth (key) is one of its fields. Passing worktreePath instead of unpacking it would shrink the signature and remove the same transposition class.

What IW-476 did instead. A partial fix only: the three create call sites (Start.scala:259, Open.scala:119, CaskServer.scala:1283) pass named arguments. That removes the transposition risk where it exists today without changing the port signature the frozen phase context specified. The type-level fix is left to this issue.

Recorded as IW-476 LESSONS.md entry 14.

IW-476 phase 2 created a new instance of this fault, found independently by the composition and scala3 reviewers. **The new call site.** `TerminalWorkspaceOps.create` widened from `(name, workDir)` to `(name: String, label: String, workDir: os.Path)` (`core/commands/CommandEnv.scala:403-407`). Two same-typed, adjacent parameters with non-interchangeable meanings now sit next to each other, so `create(label, name, workDir)` compiles silently. **It fails differently per backend, which makes it worse than the RunInWorkspace case.** `LiveTmuxOps` reads `name` and discards `label`; `LiveHerdrOps` reads `label` and discards `name`. A transposed call therefore misbehaves in one way under tmux and another under herdr, and every automated tier runs against tmux only. **A gap in this issue's current proposal.** The three opaque types listed here — `WorkspaceName`, `HerdrWorkspaceId`, `Command` — have no counterpart for the *display label*. `WorktreePath.sessionName` addresses the workspace; `WorktreePath.workspaceLabel` only displays it. Both are strings derived from the same `WorktreePath`. Distinguishing them needs either a fourth opaque type or, as the reviewers suggested, one `WorkspaceIdentity(name, label)` value produced by `WorktreePath`, narrowing `create` to `(identity, workDir)`. **The pattern is spreading.** `Start.scala:250-259`'s `createSessionAndRunHooks` now takes five positional parameters. Three are derivable from a `WorktreePath` the caller already holds, and a fourth (`key`) is one of its fields. Passing `worktreePath` instead of unpacking it would shrink the signature and remove the same transposition class. **What IW-476 did instead.** A partial fix only: the three `create` call sites (`Start.scala:259`, `Open.scala:119`, `CaskServer.scala:1283`) pass named arguments. That removes the transposition risk where it exists today without changing the port signature the frozen phase context specified. The type-level fix is left to this issue. Recorded as IW-476 LESSONS.md entry 14.
Member

Intake: agent-ready
Workflow: waterfall
Reason: all gates passed
Analysis: project-management/issues/IW-429/analysis.md on branch IW-429

Intake: agent-ready Workflow: waterfall Reason: all gates passed Analysis: project-management/issues/IW-429/analysis.md on branch IW-429
Member

Implement: needs-human
Reason: batch-implement exit 1 — the kanon plugin command did not compile against this branch's iw-cli core, so no phase ran. ForgeCatchUp.scala:143 expects Either[String, …] from fetchPullRequestForBranch, and core now returns Either[TrackerError, …] (since #778/#780). There is no batch-implement.log; these are the lines of the command output:

[warn]  Using directives detected in multiple files:
- /home/mph/.local/share/iw/plugins/kanon/lib/deps.scala:1:1-7:41
It is recommended to keep them centralized in the /home/mph/Devel/iw/iw-cli-IW-429/core/project.scala file.

[error] /home/mph/.local/share/iw/plugins/kanon/lib/batch/ForgeCatchUp.scala:143:7
[error] Found:    Either[iw.core.model.TrackerError, Option[batch.ForgeState]]
[error] Required: Either[String, Option[batch.ForgeState]]
Compilation failed
Implement: needs-human Reason: batch-implement exit 1 — the kanon plugin command did not compile against this branch's iw-cli core, so no phase ran. `ForgeCatchUp.scala:143` expects `Either[String, …]` from `fetchPullRequestForBranch`, and core now returns `Either[TrackerError, …]` (since #778/#780). There is no batch-implement.log; these are the lines of the command output: ``` [warn] Using directives detected in multiple files: - /home/mph/.local/share/iw/plugins/kanon/lib/deps.scala:1:1-7:41 It is recommended to keep them centralized in the /home/mph/Devel/iw/iw-cli-IW-429/core/project.scala file. [error] /home/mph/.local/share/iw/plugins/kanon/lib/batch/ForgeCatchUp.scala:143:7 [error] Found: Either[iw.core.model.TrackerError, Option[batch.ForgeState]] [error] Required: Either[String, Option[batch.ForgeState]] Compilation failed ```
Member

Release: the implement loop failed this issue for a reason outside it. The kanon plugin did not compile against iw-cli main, because the tracker port now returns TrackerError (iw-cli #778). The plugin fix is dev-docs #484, and iw-cli 0.22.1 carries the core change. The issue goes back to the implement loop.

Release: the implement loop failed this issue for a reason outside it. The kanon plugin did not compile against iw-cli main, because the tracker port now returns TrackerError (iw-cli #778). The plugin fix is dev-docs #484, and iw-cli 0.22.1 carries the core change. The issue goes back to the implement loop.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/iw-cli#429
No description provided.