Skip to content

writ deploy calls its own dangling symlinks foreign: a file that moved between layers cannot be re-pointed without --conflict=replace #883

Closed

Description

@David-Noble-at-work

Feature: #463 — writ command surface: layers, segments, adoption. Found: 2026-09-10 on Danoble-MBP-A,
executing personal#180.

The event

A file moved between layers. noblefactor-ops#146 made the base ship git-{open,close,reset}-branch (twelve
files); personal#180 then removed personal's copies. Between the two, writ resolved every collision
personal-over-base, so ~/.local/bin/git-close-branch was a symlink into personal's tree. When personal's
main pulled the removal, that symlink's target ceased to exist — and the next writ deploy common noblefactor-ops, whose whole purpose was to re-point the twelve at the base, refused:

Error: refusing to deploy over 12 occupied target(s) not recognized as writ's own unmodified output:
  /Users/david-noble/.local/bin/git-close-branch
  …
use --conflict=replace to archive-and-overwrite them, or --conflict=skip to leave them

Every one of the twelve was a symlink writ itself had created, pointing exactly where writ's run index said
it should. writ called them foreign because they were dangling.

The mechanism

cmd/writ/writ/deploy/deploy.go, occupantIsOurs:

if entry.Action == string(file.Link) {
    resolvedTarget, err := filepath.EvalSymlinks(entry.Target)
    if err != nil {
        return false
    }

EvalSymlinks follows the link and fails with ENOENT when the destination is gone. The failure is read as
"not ours". But a dangling link still says where it points — os.Readlink returns the literal path — and
that path is exactly the recorded source. The identity test does not need the destination to exist; it needs
the link text to match the record.

Why this is writ's own lifecycle, not an edge

"A layer stops shipping a file that another layer now ships" is the ordinary shape of a promotion: personal
→ team → base. Every such promotion passes through this state for as long as it takes the losing layer's
removal to merge and pull. Today that state ends in a refusal that names a flag, and the operator has to know
that --conflict=replace is safe this time because the occupants are dangling — which is the judgement
occupantIsOurs exists to make for them.

Compounding: #853 — --dry-run returns before the conflict pre-flight — meant the dry run showed the
twelve collisions resolving to the base and gave no hint the live run would refuse. The refusal was the first
warning.

What it should do

A linked occupant whose Readlink equals the entry's recorded source (after the same normalisation the
resolved comparison uses) is writ's own output, whether or not the destination currently exists. A dangling
link that points somewhere else stays foreign. The --conflict=stop refusal message should distinguish
"dangling, pointing where we recorded" from "foreign" if it ever lists one.

Recovered by hand with --conflict=replace after a dry run confirmed the twelve were the only occupants;
deploy then re-pointed them and Deployed 275 files.

Ruled 2026-09-23 (schedule #916, lane 3, PR A; waits on #922)

An occupant is writ's own when it matches what the record wrote: the literal link target for a link, the content
digest for a copy. The source is never resolved to decide it -- a link whose referent left the checkout still reads
exactly as the record wrote it, so it is ours, and deploy replaces it. The record pre-flight reads is the lifetime
being replaced (#922).

Landed 2026-09-23 (schedule #916, lane 3, PR A)

readback.Entry.AsRecorded is the one predicate: a link is writ's own when its literal endpoint, absolutized against
the link's own directory, is the recorded source -- resolved or dangling -- and a copy when its content digest is the
recorded one; the source is never resolved. Deploy's pre-flight under stop and reconcile's changed decide by it;
occupantIsOurs is gone. The layer-journey scenario's step 3.3 now redeploys the moved links under the default
policy. Both VMs: a base link whose source was moved to another suffix directory dangled, and a deploy under stop
accepted it and ran; the move undone, every entry linked again.

Activity

  1. David-Noble-at-work commented on Sep 24, 2026

    @David-Noble-at-work
    ContributorAuthor

    Landed in PR #940 (merged 2026-09-24T06:13:59Z). The pull request body carried no closing keyword, so this is closed by hand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area:CLICommand-line interfaceEpic:WritDeploymentwrit deployment: layers, adoption, and driftThread:WritDeploydeploying converges the machinebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions