Skip to main content
← Back to list
01Issue
BugOpenSwamp CLIPublic
AssigneesNone

Relationships

#2290 cycle-override approvals cannot be recorded on Windows: gateId is interpolated raw into the data-record path

Opened by mtaylor · 9/18/2026

Summary

approve gateId=cycle-override:<stage> cannot be recorded on Windows. The approval record's instance name embeds the gate id verbatim, and the resulting directory name contains a :, which is not legal in an NTFS path component. The call fails while creating the record's directory.

This does not depend on a user-chosen gate name. The software-factory cycle-override protocol requires gate ids of the form cycle-override:<stage>, so every valid cycle-override approval necessarily contains the Windows-invalid : character. Cycle-override approval recording is therefore structurally unusable on Windows in the affected version, rather than failing only for certain custom gate names.

Ordinary human-approval gate ids are unaffected in practice, since they conventionally contain no path-hostile characters.

Observed behaviour

Minimal reproduction, using a throwaway factory rather than any real run:

  1. Create a factory whose stages use the default maxCycles (5).
  2. Drive a work item until one stage's entries reach the limit. status then reports the transition as cycleLimitBlocked and prints the documented remedy.
  3. Run exactly what it prints:
swamp model method run <factory> approve \
  --input workItem=<ref> \
  --input gateId=cycle-override:planning \
  --input actor=user:<who>

Result:

Error: The directory name is invalid. (os error 267):
mkdir '<repo>\.swamp\data\@swamp\software-factory\<id>\approval-<ref>-cycle-override:planning'

The : in the final path component is the offending character.

The failure is atomic and deterministic: no approval record is written, run state is unchanged, and the work item remains parked. Retrying produces the same result.

Expected behaviour

The override is recorded, and the cycle-limit-blocked transition becomes available for one further entry.

Implementation evidence

All quoted from the installed @swamp/software-factory 2026.06.24.1.

models/_lib/run_names.ts builds the instance name with the gate id uninterpolated:

export function approvalInstance(slug: string, gateId: string): string {
  return `${APPROVAL_PREFIX}${slug}-${gateId}`;
}

The slug beside it has already been sanitized. workItemSlug() in the same file maps anything outside [A-Za-z0-9._-] to -, trims leading/trailing - and ., truncates to 48 characters, and — when that mapping was lossy — appends an 8-hex-digit suffix from a hash seeded with 0x811c9dc5 and multiplied by 0x01000193 (FNV-1a constants), so that two distinct refs cannot collide after sanitization. The gate-id component receives none of this.

The colon is not avoidable by the caller. models/_lib/definition_schema.ts defines CYCLE_OVERRIDE_PREFIX = "cycle-override:", and models/software_factory.ts accepts an override only when decision.gateId.startsWith(CYCLE_OVERRIDE_PREFIX), deriving the target stage from the remainder. Any gate id the engine will accept as an override therefore contains the character NTFS rejects.

Approval lookup does not recover the gate id from the record name. In models/_lib/buildRunView (models/_lib/run_data.ts), records are bucketed by the name prefix approval-<slug>- and then keyed by the gate id decoded from the record payload:

view.approvals.set(records[0].gateId, records);

and approve writes gateId: decision.gateId into that payload.

Operational impact

A run that exhausts maxCycles can become parked at a human decision point with a valid cycle-override authorization that cannot be recorded on Windows.

Of the documented remedies for a stage at its limit:

  • grant the override — fails as above;
  • take an escalation/abort transition — available only if the definition happens to declare one;
  • reset — destroys run history and re-stamps startedAt, which era-filters existing artifacts, evidence and approvals.

Two things that look like workarounds are not supported recovery mechanisms, in both cases correctly:

  • recording the blocked stage's subject artifact from the stage the run is parked in is rejected — Artifact '<name>' is not declared on stage '<other-stage>';
  • resolve_findings clears a findings-clear gate on the forward transition only; it cannot return a run to an earlier stage, so it cannot reach a stage whose entry is what the override was needed for.

Not verified here: behaviour on filesystems that permit : in a path component. The failure is a filesystem constraint on the generated name, so non-Windows hosts are expected to be unaffected, but this report does not test that.

Suggested fix

One option, offered as a suggestion rather than the only correct implementation: apply the same record-name sanitization strategy already used for the work-item component to the gate-id component in approvalInstance().

Compatibility reasoning, from the source quoted above:

  • gate ids requiring no sanitization map to themselves, so their record names are unchanged;
  • cycle-override ids become filesystem-safe;
  • approval semantics are unaffected, because evaluation keys on the gateId stored in the record payload rather than on the instance name;
  • on Windows there can be no existing :-bearing records to orphan, since creating one is exactly what fails.

Two caveats worth weighing, both derived from the code above rather than observed:

  • A naive substitution (for example, replacing : with -) could map two distinct gate ids onto one record name. Because buildRunView groups versions by instance name and then keys the result on records[0].gateId, a collision would merge unrelated approvals into a single chain attributed to whichever gate id sorted first. Mirroring workItemSlug()'s lossy-then-suffix approach avoids this; a plain character swap does not.
  • On hosts where : is legal, cycle-override: records already exist. Those keep loading correctly, since bucketing uses only the approval-<slug>- prefix and keying uses the payload. But a new grant for a gate id that already has records would be written under the new name, producing two buckets for one gate id; as view.approvals.set() is keyed by gate id, one bucket would overwrite the other in the view. Worth considering whether existing records need migrating, or whether the read path should merge buckets sharing a gate id.
  • #1796 reports cycle-override grants being deduplicated so that, from the second grant on, the allowance does not increment. That is a separate defect in the same escape hatch: it concerns grants that are successfully recorded, whereas this report concerns a grant that cannot be recorded at all. Its reproduction presupposes a first grant succeeding, which suggests it was observed on a platform where this bug does not apply.
  • #1923 reports cycle-override approvals being evaluated latest-only, so earlier overrides are ignored. Also distinct, and also downstream of recording.

Observed on

  • swamp 20260914.163154.0-sha.0bc3d215
  • @swamp/software-factory 2026.06.24.1
  • Windows 11 Pro 10.0.26200 (NTFS)
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED

Open

9/18/2026, 8:50:45 PM

No activity in this phase yet.

03Sludge Pulse

Sign in to post a ripple.