Skip to content

Commit cf6fc63

Browse files
author
David Noble
committed
An occupant is writ's own when it matches what the record wrote: a dangling link is re-pointed under stop
readback.Entry.AsRecorded is the one predicate. A link is as recorded when its literal endpoint, absolutized against the link's own directory, is the recorded source, resolved or dangling; a copy when its content digest is the recorded as-deployed digest. The source is never resolved. Deploy's pre-flight under stop and reconcile's changed decide by it, so a link left dangling by a file that moved between layers is writ's own and a plain deploy re-points it; occupantIsOurs, which resolved both sides and read a dangling link as foreign, is gone. The layer-journey scenario's step 3.3 redeploys the moved links under the default policy. The guide and the reconciliation page say what an occupant of writ's own is. Part of #883; lane 3 of #916, PR A. Claude-Session: https://claude.ai/code/session_01SqQVFZz7AdiLZkGoAcUqRW
1 parent b1b96dc commit cf6fc63

9 files changed

Lines changed: 309 additions & 96 deletions

File tree

‎cmd/writ/scenario_layer_journey_test.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,10 +1144,12 @@ func TestWritLayerJourneyScenario_Part3_Move(t *testing.T) {
11441144
t.Fatalf("the dry run now reports the pre-flight — #%d has landed; update this step:\n%s", issueDryRunPreflight, stderr)
11451145
}
11461146

1147-
// 3.3 the redeploy: everything relinked to the new sources, the helper now the base's, no collision
1148-
_, stderr, err := j.deploy(t, []string{"--conflict=replace"})
1147+
// 3.3 the redeploy, under the default policy (#883): the dangling links are what the record wrote, so
1148+
// they are writ's own and are re-pointed; everything relinked to the new sources, the helper now the
1149+
// base's, no collision
1150+
_, stderr, err := j.deploy(t, nil)
11491151
if err != nil {
1150-
t.Fatalf("redeploy failed: %v\n%s", err, stderr)
1152+
t.Fatalf("redeploy under stop refused writ's own dangling links (#883): %v\n%s", err, stderr)
11511153
}
11521154
if got := collisionsIn(stderr); got != 0 {
11531155
t.Fatalf("after the move nothing should collide; %d narrated:\n%s", got, stderr)

‎cmd/writ/writ/deploy/deploy.go‎

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
"errors"
1717
"fmt"
1818
"os"
19-
"path/filepath"
2019
"sort"
2120
"strings"
2221

@@ -27,7 +26,6 @@ import (
2726
"github.com/NobleFactor/devlore-cli/cmd/writ/writ/snapshot"
2827
"github.com/NobleFactor/devlore-cli/cmd/writ/writ/tree"
2928
"github.com/NobleFactor/devlore-cli/pkg/op"
30-
"github.com/NobleFactor/devlore-cli/pkg/op/provider/file"
3129
)
3230

3331
// Config carries the resolved settings for one deploy operation.
@@ -263,9 +261,10 @@ func runGraph(
263261
// layered-enforcement ruling).
264262
//
265263
// Under the default `stop`, every planned target that is occupied on disk is classified through the readback:
266-
// writ's own unmodified outputs — a symlink resolving to its recorded source, or a file whose digest equals the
267-
// run's recorded as-deployed identity — are cleared for replacement (redeploys flow); anything foreign or
268-
// locally modified is a violation, and the deploy refuses listing them and naming the flag. A cleared run (or an
264+
// writ's own unmodified outputs — an occupant that is what the record wrote, [readback.Entry.AsRecorded]: a
265+
// symlink whose literal endpoint is the recorded source, resolved or dangling, or a file whose digest is the
266+
// recorded as-deployed identity — are cleared for replacement (redeploys flow, and a file that moved between
267+
// layers is re-pointed, #883); anything foreign or locally modified is a violation, and the deploy refuses listing them and naming the flag. A cleared run (or an
269268
// explicit `skip` / `replace`) hands the resolved policy to the file provider's write seam, which enforces it
270269
// per target. A missing run index reads as zero knowledge (every occupant is foreign) — first deploys onto a
271270
// clean machine have no occupants, so nothing refuses.
@@ -302,7 +301,7 @@ func preflightConflicts(ctx context.Context, cfg *Config, graphs []*op.Graph) (o
302301
continue
303302
}
304303

305-
if entry, known := inventory.Entries[target]; known && occupantIsOurs(entry) {
304+
if entry, known := inventory.Entries[target]; known && entry.AsRecorded() {
306305
continue
307306
}
308307

@@ -353,41 +352,6 @@ func plannedTargets(graph *op.Graph) map[string]bool {
353352
return targets
354353
}
355354

356-
// occupantIsOurs reports whether an occupied target is writ's own unmodified output.
357-
//
358-
// A linked entry is ours when the on-disk symlink resolves to the entry's recorded source; a copied entry is
359-
// ours when its content digest equals the run's recorded as-deployed identity (step 48). Entries without a
360-
// recorded identity (pre-capture runs) are NOT cleared — indeterminate occupants stay policy-gated.
361-
//
362-
// Parameters:
363-
// - `entry`: the readback inventory entry for the occupied target.
364-
//
365-
// Returns:
366-
// - `bool`: true when the occupant is writ's own unmodified output.
367-
func occupantIsOurs(entry readback.Entry) bool {
368-
369-
if entry.Action == string(file.Link) {
370-
resolvedTarget, err := filepath.EvalSymlinks(entry.Target)
371-
if err != nil {
372-
return false
373-
}
374-
resolvedSource, err := filepath.EvalSymlinks(entry.Source)
375-
if err != nil {
376-
return false
377-
}
378-
return resolvedTarget == resolvedSource
379-
}
380-
381-
if entry.RecordedDigest == "" {
382-
return false
383-
}
384-
current, err := os.ReadFile(entry.Target)
385-
if err != nil {
386-
return false
387-
}
388-
return readback.ContentDigest(current) == entry.RecordedDigest
389-
}
390-
391355
// scopeLabel returns the graph's scope for reporting, or "default" when unscoped.
392356
//
393357
// Parameters:
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright Noble Factor. All rights reserved.
3+
4+
package readback_test
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/NobleFactor/devlore-cli/cmd/writ/writ/readback"
12+
"github.com/NobleFactor/devlore-cli/pkg/op/provider/file"
13+
)
14+
15+
// TestAsRecorded_Link pins #883: a link is as recorded when its literal endpoint is the recorded source, whether or
16+
// not that source exists; a link elsewhere and a non-link are not.
17+
func TestAsRecorded_Link(t *testing.T) {
18+
19+
root := t.TempDir()
20+
source := filepath.Join(root, "layer", "file")
21+
target := filepath.Join(root, "home", ".file")
22+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
23+
t.Fatal(err)
24+
}
25+
entry := readback.Entry{Target: target, Source: source, Action: string(file.Link)}
26+
27+
if entry.AsRecorded() {
28+
t.Error("an absent target reads as recorded")
29+
}
30+
31+
if err := os.Symlink(filepath.Join("..", "layer", "file"), target); err != nil {
32+
t.Fatal(err)
33+
}
34+
if !entry.AsRecorded() {
35+
t.Error("a dangling link whose endpoint is the recorded source does not read as recorded (#883)")
36+
}
37+
38+
if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil {
39+
t.Fatal(err)
40+
}
41+
if err := os.WriteFile(source, []byte("content"), 0o644); err != nil {
42+
t.Fatal(err)
43+
}
44+
if !entry.AsRecorded() {
45+
t.Error("a resolving link to the recorded source does not read as recorded")
46+
}
47+
48+
if err := os.Remove(target); err != nil {
49+
t.Fatal(err)
50+
}
51+
if err := os.Symlink(filepath.Join("..", "layer", "other"), target); err != nil {
52+
t.Fatal(err)
53+
}
54+
if entry.AsRecorded() {
55+
t.Error("a link elsewhere reads as recorded")
56+
}
57+
58+
if err := os.Remove(target); err != nil {
59+
t.Fatal(err)
60+
}
61+
if err := os.WriteFile(target, []byte("hand-written"), 0o644); err != nil {
62+
t.Fatal(err)
63+
}
64+
if entry.AsRecorded() {
65+
t.Error("a regular file where the link was reads as recorded")
66+
}
67+
}
68+
69+
// TestAsRecorded_Copy pins the copy half: as recorded when the target's digest is the recorded one; not when the
70+
// content moved, and never when the record carries no digest to vouch with.
71+
func TestAsRecorded_Copy(t *testing.T) {
72+
73+
root := t.TempDir()
74+
target := filepath.Join(root, ".file")
75+
content := []byte("rendered")
76+
if err := os.WriteFile(target, content, 0o644); err != nil {
77+
t.Fatal(err)
78+
}
79+
80+
entry := readback.Entry{Target: target, Action: string(file.Copy), RecordedDigest: readback.ContentDigest(content)}
81+
if !entry.AsRecorded() {
82+
t.Error("a copy with the recorded content does not read as recorded")
83+
}
84+
85+
if err := os.WriteFile(target, []byte("edited"), 0o644); err != nil {
86+
t.Fatal(err)
87+
}
88+
if entry.AsRecorded() {
89+
t.Error("an edited copy reads as recorded")
90+
}
91+
92+
if (readback.Entry{Target: target, Action: string(file.Copy)}).AsRecorded() {
93+
t.Error("a record with no digest vouches for a copy")
94+
}
95+
}

‎cmd/writ/writ/readback/readback.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,43 @@ type Entry struct {
7878
At time.Time
7979
}
8080

81+
// AsRecorded reports whether the occupant at the entry's target is what the record wrote (#883, ruled 2026-09-23).
82+
//
83+
// A link is as recorded when the target is a symlink whose literal endpoint, absolutized against the target's own
84+
// directory, is the recorded source -- whether or not that source still exists, so a link that dangles because its
85+
// source moved between layers is still writ's own. A copy is as recorded when the target's content digest is the
86+
// recorded as-deployed digest. Neither side is resolved: the source is never consulted, and a record that carries
87+
// no digest for a copy cannot vouch for it.
88+
//
89+
// Returns:
90+
// - `bool`: true when the occupant matches what the record wrote.
91+
func (e Entry) AsRecorded() bool {
92+
93+
if e.Action == string(file.Link) {
94+
info, err := os.Lstat(e.Target)
95+
if err != nil || info.Mode()&os.ModeSymlink == 0 {
96+
return false
97+
}
98+
endpoint, err := os.Readlink(e.Target)
99+
if err != nil {
100+
return false
101+
}
102+
if !filepath.IsAbs(endpoint) {
103+
endpoint = filepath.Join(filepath.Dir(e.Target), endpoint)
104+
}
105+
return filepath.Clean(endpoint) == filepath.Clean(e.Source)
106+
}
107+
108+
if e.RecordedDigest == "" {
109+
return false
110+
}
111+
current, err := os.ReadFile(e.Target)
112+
if err != nil {
113+
return false
114+
}
115+
return ContentDigest(current) == e.RecordedDigest
116+
}
117+
81118
// Inventory is the fold's output: the deployed entries plus the store-health findings.
82119
type Inventory struct {
83120

‎cmd/writ/writ/reconcile/reconcile.go‎

Lines changed: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ func BuildReport(ctx context.Context, cfg *Config) (*Report, error) {
9595

9696
// classifyEntry classifies one record entry against the system, the record as the reference (#923).
9797
//
98-
// A link is judged through the link itself and the recorded source digest; a copy through the recorded target and
99-
// source digests. No word is decided by consulting the layer checkout beyond the source the record names.
98+
// The occupant is judged by [readback.Entry.AsRecorded] -- what the record wrote -- then the source by the
99+
// recorded source digest. No word is decided by consulting the layer checkout beyond the source the record names.
100100
//
101101
// Parameters:
102102
// - `entry`: the folded record entry.
@@ -114,59 +114,33 @@ func classifyEntry(entry readback.Entry) Entry {
114114
Action: entry.Action,
115115
}
116116

117-
recorded := recordedPair{target: entry.RecordedDigest, source: entry.RecordedSourceDigest}
118-
119117
if entry.Action == string(file.Link) {
120-
classifyLink(&classified, recorded)
118+
classifyLink(&classified, entry)
121119
return classified
122120
}
123121

124-
classifyCopied(&classified, recorded)
122+
classifyCopied(&classified, entry)
125123
return classified
126124
}
127125

128-
// recordedPair is the record's content identity for one entry: the as-deployed target digest and the source digest,
129-
// each "" when the run did not record it.
130-
type recordedPair struct {
131-
target string
132-
source string
133-
}
134-
135126
// classifyLink judges a linked entry: absent, changed, dangling, stale, or linked.
136127
//
137128
// Parameters:
138129
// - `classified`: the report entry to fill; Target and Source are already set.
139-
// - `recorded`: the record's digests.
140-
func classifyLink(classified *Entry, recorded recordedPair) {
130+
// - `entry`: the record entry, with its recorded digests.
131+
func classifyLink(classified *Entry, entry readback.Entry) {
141132

142-
info, err := os.Lstat(classified.Target)
143-
if errors.Is(err, os.ErrNotExist) {
133+
if _, err := os.Lstat(classified.Target); errors.Is(err, os.ErrNotExist) {
144134
classified.State = StateAbsent
145135
classified.Repair = "writ deploy"
146136
classified.Message = "symlink not present"
147137
return
148138
}
149-
if err != nil || info.Mode()&os.ModeSymlink == 0 {
150-
classified.State = StateChanged
151-
classified.Repair = "writ deploy"
152-
classified.Message = "target exists but is not a symlink"
153-
return
154-
}
155139

156-
endpoint, err := os.Readlink(classified.Target)
157-
if err != nil {
140+
if !entry.AsRecorded() {
158141
classified.State = StateChanged
159142
classified.Repair = "writ deploy"
160-
classified.Message = "symlink cannot be read"
161-
return
162-
}
163-
if !filepath.IsAbs(endpoint) {
164-
endpoint = filepath.Join(filepath.Dir(classified.Target), endpoint)
165-
}
166-
if filepath.Clean(endpoint) != filepath.Clean(classified.Source) {
167-
classified.State = StateChanged
168-
classified.Repair = "writ deploy"
169-
classified.Message = "symlink points at " + endpoint
143+
classified.Message = "not the symlink the record wrote"
170144
return
171145
}
172146

@@ -178,7 +152,7 @@ func classifyLink(classified *Entry, recorded recordedPair) {
178152
return
179153
}
180154

181-
if recorded.source != "" && readback.ContentDigest(referent) != recorded.source {
155+
if entry.RecordedSourceDigest != "" && readback.ContentDigest(referent) != entry.RecordedSourceDigest {
182156
classified.State = StateStale
183157
classified.Repair = "writ upgrade"
184158
classified.Message = "source changed since deployment"
@@ -195,8 +169,8 @@ func classifyLink(classified *Entry, recorded recordedPair) {
195169
//
196170
// Parameters:
197171
// - `classified`: the report entry to fill; Target and Source are already set.
198-
// - `recorded`: the record's digests.
199-
func classifyCopied(classified *Entry, recorded recordedPair) {
172+
// - `entry`: the record entry, with its recorded digests.
173+
func classifyCopied(classified *Entry, entry readback.Entry) {
200174

201175
if _, err := os.Lstat(classified.Target); errors.Is(err, os.ErrNotExist) {
202176
classified.State = StateAbsent
@@ -205,17 +179,10 @@ func classifyCopied(classified *Entry, recorded recordedPair) {
205179
return
206180
}
207181

208-
current, err := os.ReadFile(classified.Target)
209-
if err != nil {
210-
classified.State = StateChanged
211-
classified.Repair = "writ upgrade --force"
212-
classified.Message = "target cannot be read"
213-
return
214-
}
215-
if recorded.target != "" && readback.ContentDigest(current) != recorded.target {
182+
if entry.RecordedDigest != "" && !entry.AsRecorded() {
216183
classified.State = StateChanged
217184
classified.Repair = "writ upgrade --force"
218-
classified.Message = "locally modified since deployment"
185+
classified.Message = "not the content the record wrote"
219186
return
220187
}
221188

@@ -226,7 +193,7 @@ func classifyCopied(classified *Entry, recorded recordedPair) {
226193
classified.Message = "the recorded source does not resolve"
227194
return
228195
}
229-
if recorded.source != "" && readback.ContentDigest(source) != recorded.source {
196+
if entry.RecordedSourceDigest != "" && readback.ContentDigest(source) != entry.RecordedSourceDigest {
230197
classified.State = StateStale
231198
classified.Repair = "writ upgrade"
232199
classified.Message = "source changed since deployment"

‎docs/architecture/5.1-reconciliation.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,12 @@ entry:
157157
| `dangling` | the source the record names does not resolve -- a link whose referent is gone, or a copy (a decrypted file, say) whose source is gone: a reference that outlives its referent | `deploy`: only deploy makes a new record; reconcile reports it and leaves it |
158158
| `stale` | as recorded, and the source resolves and its content no longer matches the recorded source digest: a derivative behind an origin that still exists | `upgrade` |
159159

160-
No system-side word reads the layer checkout beyond the source the record names. A link's source is a path the
160+
"What the record wrote" is one predicate, `readback.Entry.AsRecorded` ([#883](https://github.com/NobleFactor/devlore-cli/issues/883)):
161+
a symlink whose literal endpoint, absolutized against the link's own directory, is the recorded source -- resolved
162+
or dangling -- or a file whose content digest is the recorded as-deployed digest. Deploy's pre-flight under `stop`
163+
and `reconcile`'s `changed` decide by it, so a link left dangling by a file that moved between layers is writ's
164+
own and a plain deploy re-points it. No system-side word reads the layer checkout beyond the source the record
165+
names. A link's source is a path the
161166
record holds without a digest, so an edit under a link is in the checkout and is git's business; `stale` is a
162167
copy's word until the record carries a link's source digest. `orphan`, `missing`, `conflict`, `modified` and
163168
`modified-or-stale` are gone: the first two described a record that held every lifetime, and the last had one case,

‎docs/architecture/5.1-reconciliation.status.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ what `readback.Fold` does today against it. **Revised 2026-09-23 (#922):** "The
1818
the fold does" state the landed lifetime document (`lifetimes/<id>.yaml`, `current`), which operations open or
1919
write into it, and the fold bounded to the current lifetime. **Revised 2026-09-23 (#923):** "Reconcile's words" states
2020
the
21-
six-word vocabulary, the record as the reference, and the repair each word names.
21+
six-word vocabulary, the record as the reference, and the repair each word names. **Revised 2026-09-23 (#883):**
22+
"what the record wrote" is the one predicate pre-flight and `reconcile` share.
2223

2324
## Completion
2425

0 commit comments

Comments
 (0)