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:
- Create a factory whose stages use the default
maxCycles(5). - Drive a work item until one stage's entries reach the limit.
statusthen reports the transition ascycleLimitBlockedand prints the documented remedy. - 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-stampsstartedAt, 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_findingsclears afindings-cleargate 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
gateIdstored 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. BecausebuildRunViewgroups versions by instance name and then keys the result onrecords[0].gateId, a collision would merge unrelated approvals into a single chain attributed to whichever gate id sorted first. MirroringworkItemSlug()'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 theapproval-<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; asview.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.
Related
- #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-factory2026.06.24.1- Windows 11 Pro 10.0.26200 (NTFS)
Closed
No activity in this phase yet.
stack72 commented 9/25/2026, 9:39:26 PM
We’re closing this issue as we redesign the Software Factory extension from the ground up. We will publish a new extension informed by the great feedback we’ve received from this community, as well as the ways our own software factory has continued to grow and evolve. Thank you for the report and discussion.
Sign in to post a ripple.