carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Isolated Exec, Plan 4 of 5: The Layout Pass on the Box
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Give a box the pass that moves an activator-managed host between the flat layout and the nested one, exactly and recoverably: a journal in `/data/layout`, a two-column compare-and-swap on the row, renames through the raw `.instances` directory, a fence that holds in-process and across an agent restart, convergence to a recorded fleet intent on every serial move, the `layout` field in the box snapshot, and the guards that stop an older binary from ever meeting a nested row.
6
7**Architecture:** A new package `internal/layout` owns the plan (from the row and host name only), the journal (phases `files`, `row`, `done`), recovery (checked against both endpoints derived from the journal's own direction), and the pass (`Converge` and `Recover`). The registry gains `SetInstancePaths`. The activator gains `Unfence`, the release of the in-process fence `FenceAndStop` installs. The agent runs `Recover` at start and `Converge` on every serial move against `control/layout.json` (the intent object plan 5's console door writes; this plan defines it and reads it). The box snapshot carries `layout_mode` and each managed row's classification. `carlos system apply`, `carlos route --hibernate=false` and the provisioning reconciler refuse the three things a downgrade must never meet.
8
9**Tech Stack:** Go 1.26, `internal/activator` (Classify, FenceAndStop), `internal/instancefs`, `internal/provision`, `internal/registry`, `internal/release` (Store, DirStore for tests), `internal/status`.
10
11**Spec:** `docs/superpowers/specs/2026-09-11-isolated-exec-design.md` (revision 14): §3.2 (raw directory, EXDEV), §3.3 (a journal is not an instruction), §8.1, §8.2 (all nine steps, recovery, revert, the downgrade rules), §8.3, §8.4, §9, §10 ("Layout pass" bullet). Rulings in §13 are not to be reopened.
12
13## Global Constraints
14
15- Gate before every commit: `go build ./... && go vet ./... && gofmt -l .` empty `&& go test ./... -count=1`; env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g`; socket-binding packages need the sandbox off. Cross-compile gate: darwin, windows, linux-arm64; Linux-only syscalls in `_linux.go` with `!linux` stubs.
16- **Nothing in this plan mints a nested row on its own.** A row becomes nested only inside `Converge` under a recorded intent of `nested`. Without an intent object every box behaves as before, and every existing test keeps passing without assertion edits.
17- The plan for a host is built from the row and the host name only (§8.2 step 3): sources are `<DataDir>/<host>.db` (plus `-wal`, `-shm`), `<DataDir>/<host>.env`, `<DataDir>/<host>-sidecar.env`, `<DataDir>/<host>.config.env`, `<SocketDir>/<host>.sock`; destinations are the §3.1 helpers. A row whose `DBPath` or `Socket` is neither the flat nor the nested default is `foreign` and is never moved. Nothing is read from an instance directory to build the plan.
18- Renames of the database and its sidecar files cross into the tree through the RAW directory `/data/.instances/<iid>/` (never through the `/data/instances` bind mount, which is a different mount and answers `EXDEV`). The one caller of the literal `.instances` is `internal/layout`; a grep test pins it.
19- The journal is written `O_EXCL`; recovery refuses a journal whose `from`/`to` do not equal the host's derived endpoints for the journal's own `mode`, or whose row equals neither endpoint. A refused journal keeps the host fenced.
20- Quiesce is `activator.FenceAndStop` with a fresh generation; an unproven stop (`ErrStopUnproven`) refuses the host for this pass and leaves the in-process fence installed (never released over an unproven stop).
21- Copy: no em dashes anywhere; comments are full sentences saying why, never process history; log lines use `->` between values.
22- Commit after every task with the trailer `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. Branch `worktree-isolated-exec-spec`.
23
24---
25
26## File Structure
27
28| File | Responsibility |
29|---|---|
30| `internal/registry/registry.go` (modify) | `SetInstancePaths(host, fromDB, fromSock, toDB, toSock)`: one statement, CAS on both columns |
31| `internal/activator/maintenance.go` (modify) | `Unfence(host, generation)`; `Fenced(host) bool` |
32| `internal/release/layoutintent.go` (create) | `LayoutIntent{Schema, Mode, Scope, RequestedAt, ID}`, `LayoutIntentKey`, `LoadLayoutIntent`, `LayoutModeNone/Flat/Nested` |
33| `internal/layout/plan.go` (create) | `Mode`, `Endpoint`, `Artifact`, `Plan`, `PlanFor(dataDir, socketDir, host, mode)`, `rawInstanceDir` |
34| `internal/layout/journal.go` (create) | `Journal`, phases, `WriteJournal` (O_EXCL), `ReadJournal`, `UpdatePhase`, `Validate` |
35| `internal/layout/preflight.go` (create) | ownership preflight (`x` vs `x.config`), destination check, `EnsureDirs` |
36| `internal/layout/pass.go` (create) | `Pass{Registry, Fence, DataDir, SocketDir, Reload, Now, Logf}`, `Converge(ctx, mode, scope)`, `Recover(ctx)`, `migrateHost` |
37| `internal/layout/*_test.go` (create) | plan, journal, preflight, pass with a real registry and a temp tree; the raw-dir rename test with a bind mount (skipped when it cannot mount) |
38| `internal/host/agent.go` (modify) | wire: `Recover` at start, `Converge` in `fastTick` on a serial move; the `layoutReload` seam |
39| `internal/appconfig/syncexec.go`, `internal/ops/litestream.go` (modify) | skip a fenced host (journal exists) |
40| `internal/status/snapshot.go` (modify) | `Box.Layout *LayoutReport{Mode, Rows map[string]RowLayout}`; `CollectBox` takes a classifier |
41| `internal/console/apistatusinstances.go`, root `status_instances.go` (modify) | `GET /api/cli/status/layout`; `carlos status --layout` |
42| `internal/console/view.go`, `templates/app_instance.html` (modify) | `RouteView.Layout`; a `Layout` fact on the instance page |
43| `internal/host/systemapply.go` (modify) | refuse a candidate that predates the layout while reversal is incomplete |
44| `cli.go`, `internal/provision/reconcile.go` (modify) | refuse `--hibernate=false` on a nested row; the reconciler's flag application refuses likewise; drift log names the layout |
45| `docs/site/cli.md` (modify) | `carlos status --layout`; the layout section under `carlos system` (the verb itself is plan 5) |
46
47---
48
49### Task 1: `SetInstancePaths`, `Unfence`, the intent object
50
51**Files:**
52- Modify: `internal/registry/registry.go` (after `SetDBPath`), `internal/registry/registry_test.go`
53- Modify: `internal/activator/maintenance.go`, `internal/activator/maintenance_test.go`
54- Create: `internal/release/layoutintent.go`, `internal/release/layoutintent_test.go`
55
56**Interfaces:**
57- Produces:
58 ```go
59 // registry
60 func (r *Registry) SetInstancePaths(host, fromDB, fromSock, toDB, toSock string) error // ErrPathsChanged wrapped when the CAS misses
61 var ErrPathsChanged = errors.New("registry: instance paths changed underneath this pass")
62 // activator
63 func (a *Service) Unfence(host string, generation uint64) bool // true when the fence with this generation was removed
64 func (a *Service) Fenced(host string) bool
65 // release
66 const ( LayoutModeNone = ""; LayoutModeFlat = "flat"; LayoutModeNested = "nested" )
67 type LayoutIntent struct { Schema int; ID string; RequestedAt string; Mode string; Scope LayoutScope }
68 type LayoutScope struct { Box string } // "" = the fleet
69 func LayoutIntentKey() string // "control/layout.json"
70 func LoadLayoutIntent(ctx, s Store) (*LayoutIntent, bool, error) // found=false when absent; schema>1 is an error
71 func (in *LayoutIntent) Applies(boxLabel string) bool
72 func ValidLayoutMode(m string) bool
73 ```
74
75- [ ] **Step 1: Write the failing tests**
76
77`internal/registry/registry_test.go` (append; use the file's own `newTestRegistry`/`Open` helper and `Upsert` of a minimal `Route{Host, Kind: "instance", Socket, DBPath}`):
78
79```go
80func TestSetInstancePathsIsACompareAndSwapOnBothColumns(t *testing.T) {
81 r := openTestRegistry(t)
82 if err := r.Upsert(Route{Host: "h.example.com", Kind: "instance", Socket: "/run/carlos/h.example.com.sock", DBPath: "/data/h.example.com.db"}); err != nil {
83 t.Fatal(err)
84 }
85 if err := r.SetInstancePaths("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock", "/data/instances/h.example.com/h.example.com.db", "/run/carlos/instances/h.example.com/app.sock"); err != nil {
86 t.Fatalf("CAS from the current values: %v", err)
87 }
88 rt, _ := r.Get("h.example.com")
89 if rt.DBPath != "/data/instances/h.example.com/h.example.com.db" || rt.Socket != "/run/carlos/instances/h.example.com/app.sock" {
90 t.Fatalf("row after CAS = %q %q", rt.DBPath, rt.Socket)
91 }
92 // A second CAS from the OLD values misses on both columns and names the current ones.
93 err := r.SetInstancePaths("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock", "/x", "/y")
94 if !errors.Is(err, ErrPathsChanged) || !strings.Contains(err.Error(), "/data/instances/h.example.com/h.example.com.db") {
95 t.Fatalf("stale CAS = %v", err)
96 }
97 // A CAS where only ONE column matches also misses: both must agree.
98 err = r.SetInstancePaths("h.example.com", "/data/instances/h.example.com/h.example.com.db", "/run/carlos/h.example.com.sock", "/x", "/y")
99 if !errors.Is(err, ErrPathsChanged) {
100 t.Fatalf("half-matching CAS = %v", err)
101 }
102 if err := r.SetInstancePaths("absent.example.com", "a", "b", "c", "d"); err == nil || strings.Contains(err.Error(), "changed") {
103 t.Fatalf("absent host = %v, want a no-route error", err)
104 }
105}
106```
107
108`internal/activator/maintenance_test.go` (append; `testActivator` is in activator_test.go):
109
110```go
111func TestUnfenceReleasesOnlyTheMatchingGeneration(t *testing.T) {
112 a, _, _, _ := testActivator(t, time.Hour)
113 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
114 defer cancel()
115 if _, err := a.FenceAndStop(ctx, "h.example.com", 7); err != nil && !errors.Is(err, ErrStopUnproven) {
116 t.Fatal(err)
117 }
118 if !a.Fenced("h.example.com") {
119 t.Fatal("fence not installed")
120 }
121 if a.Unfence("h.example.com", 6) {
122 t.Fatal("an older generation must not release a newer fence")
123 }
124 if !a.Fenced("h.example.com") {
125 t.Fatal("fence lost to an older generation")
126 }
127 if !a.Unfence("h.example.com", 7) || a.Fenced("h.example.com") {
128 t.Fatal("the matching generation must release the fence")
129 }
130 if a.Unfence("h.example.com", 7) {
131 t.Fatal("a second release must report nothing to release")
132 }
133}
134```
135
136`internal/release/layoutintent_test.go`:
137
138```go
139package release
140
141import (
142 "context"
143 "testing"
144)
145
146func TestLayoutIntentRoundTripAndScope(t *testing.T) {
147 s := NewDirStore(t.TempDir())
148 ctx := context.Background()
149 if _, found, err := LoadLayoutIntent(ctx, s); err != nil || found {
150 t.Fatalf("absent object: found=%v err=%v", found, err)
151 }
152 in := &LayoutIntent{Schema: 1, ID: "abc", RequestedAt: "2026-09-12T00:00:00Z", Mode: LayoutModeNested, Scope: LayoutScope{Box: "kass-1"}}
153 if err := PutLayoutIntent(ctx, s, in, ""); err != nil {
154 t.Fatal(err)
155 }
156 got, found, err := LoadLayoutIntent(ctx, s)
157 if err != nil || !found || got.Mode != LayoutModeNested || got.ID != "abc" {
158 t.Fatalf("round trip: %+v %v %v", got, found, err)
159 }
160 if !got.Applies("kass-1") || got.Applies("kass-2") {
161 t.Error("box scope must match only the named box")
162 }
163 got.Scope.Box = ""
164 if !got.Applies("anything") {
165 t.Error("an empty scope is the fleet")
166 }
167 if err := s.PutRaw(ctx, LayoutIntentKey(), []byte(`{"schema":2,"mode":"nested"}`)); err != nil {
168 t.Fatal(err)
169 }
170 if _, _, err := LoadLayoutIntent(ctx, s); err == nil {
171 t.Fatal("a newer schema must be an error, not a guess")
172 }
173}
174```
175
176(`PutLayoutIntent(ctx, s, in, etag)` and the DirStore's raw put: use whatever `Store` method the file's siblings use for a CAS put (`s.Put(ctx, key, body, etag)`); if there is no `PutRaw`, write the newer-schema object through `s.Put` with etag `""` after deleting the key, or through the DirStore's directory directly.)
177
178- [ ] **Step 2: Run the tests to verify they fail**
179
180Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/registry ./internal/activator ./internal/release -run 'TestSetInstancePaths|TestUnfence|TestLayoutIntent' -count=1`
181Expected: build failures for the undefined symbols.
182
183- [ ] **Step 3: Write the code**
184
185`internal/registry/registry.go`, after `SetDBPath`:
186
187```go
188// ErrPathsChanged is SetInstancePaths's answer when the row's two path
189// columns no longer hold the values the caller compared against.
190var ErrPathsChanged = errors.New("registry: instance paths changed underneath this pass")
191
192// SetInstancePaths moves a row between the flat and the nested layout
193// (isolated-exec design §8.2 step 8): one UPDATE guarded by BOTH columns'
194// current values, so a row that was migrated, reverted or re-added between
195// the pass's read and its write is left exactly as it is and the caller
196// learns what it holds now. It is SetDBPath's sibling; the two columns are
197// swapped together because a row with one flat path and one nested path is
198// neither layout and every consumer would refuse it.
199func (r *Registry) SetInstancePaths(host, fromDB, fromSock, toDB, toSock string) error {
200 res, err := r.sql.Exec(`UPDATE routes SET db_path = ?, socket = ? WHERE host = ? AND db_path = ? AND socket = ?`, toDB, toSock, host, fromDB, fromSock)
201 if err != nil {
202 return err
203 }
204 n, err := res.RowsAffected()
205 if err != nil {
206 return err
207 }
208 if n == 0 {
209 var curDB, curSock string
210 serr := r.sql.QueryRow(`SELECT db_path, socket FROM routes WHERE host = ?`, host).Scan(&curDB, &curSock)
211 if serr == sql.ErrNoRows {
212 return fmt.Errorf("no route for host %q", host)
213 }
214 if serr != nil {
215 return serr
216 }
217 return fmt.Errorf("%w: %s now holds db %q socket %q", ErrPathsChanged, host, curDB, curSock)
218 }
219 return nil
220}
221```
222
223`internal/activator/maintenance.go` (append):
224
225```go
226// Unfence releases the fence FenceAndStop installed for host, and only
227// when the generation matches: a pass that fenced a host is the one that
228// may release it, and a later pass's fence is never removed by an earlier
229// pass finishing late. It reports whether a fence was removed.
230func (a *Service) Unfence(host string, generation uint64) bool {
231 a.mu.Lock()
232 defer a.mu.Unlock()
233 if a.fences == nil || generation == 0 || a.fences[host] != generation {
234 return false
235 }
236 delete(a.fences, host)
237 return true
238}
239
240// Fenced reports whether a maintenance fence is installed for host.
241func (a *Service) Fenced(host string) bool { return !a.wakeAllowed(host) }
242```
243
244`internal/release/layoutintent.go`:
245
246```go
247// The fleet's layout intent (isolated-exec design §8.2): one small object
248// the console writes and every box converges to on every serial move. It
249// records a MODE, not a request, so a row that becomes eligible after the
250// intent was recorded is migrated on the next pass, and a box that was away
251// converges when it returns. An old box never reads the key; an old console
252// never writes it.
253package release
254
255import (
256 "context"
257 "encoding/json"
258 "errors"
259 "fmt"
260 "os"
261)
262
263const (
264 LayoutModeNone = ""
265 LayoutModeFlat = "flat"
266 LayoutModeNested = "nested"
267)
268
269// LayoutScope narrows an intent to one box by label; empty is the fleet.
270type LayoutScope struct {
271 Box string `json:"box,omitempty"`
272}
273
274// LayoutIntent is control/layout.json.
275type LayoutIntent struct {
276 Schema int `json:"schema"` // 1
277 ID string `json:"id"`
278 RequestedAt string `json:"requested_at"`
279 Mode string `json:"mode"`
280 Scope LayoutScope `json:"scope"`
281}
282
283func LayoutIntentKey() string { return "control/layout.json" }
284
285func ValidLayoutMode(m string) bool { return m == LayoutModeFlat || m == LayoutModeNested }
286
287// Applies reports whether the intent addresses the box with this label.
288func (in *LayoutIntent) Applies(boxLabel string) bool {
289 return in != nil && (in.Scope.Box == "" || in.Scope.Box == boxLabel)
290}
291
292// LoadLayoutIntent reads the object. found=false means it is absent, which
293// is every deployment's state until an operator records an intent. A newer
294// schema is an error: a box that cannot be sure what the intent means must
295// do nothing.
296func LoadLayoutIntent(ctx context.Context, s Store) (*LayoutIntent, bool, error) {
297 raw, _, err := s.Get(ctx, LayoutIntentKey())
298 if errors.Is(err, os.ErrNotExist) {
299 return nil, false, nil
300 }
301 if err != nil {
302 return nil, false, err
303 }
304 var in LayoutIntent
305 if err := json.Unmarshal(raw, &in); err != nil {
306 return nil, false, fmt.Errorf("layout intent: %w", err)
307 }
308 if in.Schema > 1 {
309 return nil, false, fmt.Errorf("layout intent: schema %d is newer than this binary understands; upgrade carlos", in.Schema)
310 }
311 if !ValidLayoutMode(in.Mode) {
312 return nil, false, fmt.Errorf("layout intent: mode %q is not flat or nested", in.Mode)
313 }
314 return &in, true, nil
315}
316
317// PutLayoutIntent writes the object with the caller's etag (create-only when
318// empty). Plan 5's console door is its caller; it lives here so the reader
319// and the writer share one shape.
320func PutLayoutIntent(ctx context.Context, s Store, in *LayoutIntent, etag string) error {
321 if !ValidLayoutMode(in.Mode) {
322 return fmt.Errorf("layout intent: mode %q is not flat or nested", in.Mode)
323 }
324 in.Schema = 1
325 body, err := json.Marshal(in)
326 if err != nil {
327 return err
328 }
329 _, err = s.Put(ctx, LayoutIntentKey(), body, etag)
330 return err
331}
332```
333
334(Match `Store.Put`'s real signature from the sibling `AppendRestartScope`; it returns `(etag, error)`.)
335
336- [ ] **Step 4: Run the tests to verify they pass**
337
338Run the same command as step 2. Expected: PASS.
339
340- [ ] **Step 5: gofmt and commit**
341
342```bash
343gofmt -l internal/registry internal/activator internal/release
344git add internal/registry internal/activator internal/release
345git commit -m "layout: SetInstancePaths, Unfence, the fleet layout intent object (plan 4, task 1)"
346```
347
348---
349
350### Task 2: The plan and the journal
351
352**Files:**
353- Create: `internal/layout/plan.go`, `internal/layout/journal.go`, `internal/layout/plan_test.go`, `internal/layout/journal_test.go`
354
355**Interfaces:**
356- Produces:
357 ```go
358 package layout
359 type Mode string // "nested" | "flat" (release.LayoutModeNested/Flat)
360 type Endpoint struct { DB, Socket string }
361 type Artifact struct { Name string; Src, Dst string; Present bool; Required bool } // Name: db, db-wal, db-shm, env, sidecar-env, config-env, socket
362 type Plan struct { Host, IID string; Mode Mode; From, To Endpoint; Artifacts []Artifact }
363 func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) // error for an unknown mode or an invalid host
364 func rawInstanceDir(dataDir, host string) string // <dataDir>/.instances/<iid>
365 func rawRunDir(socketDir, host string) string // <socketDir>/.instances/<iid>
366 type Phase string; const ( PhaseFiles Phase = "files"; PhaseRow = "row"; PhaseDone = "done" )
367 type Journal struct { Host string; Mode Mode; From, To Endpoint; Artifacts []JournalArtifact; Phase Phase; WrittenAt string }
368 type JournalArtifact struct { Name string; Present bool }
369 func WriteJournal(dataDir string, j *Journal) error // O_EXCL; ErrJournalExists
370 func ReadJournal(dataDir, host string) (*Journal, bool, error) // found=false when absent; 64KB cap; a FIFO or symlink is an error
371 func UpdatePhase(dataDir string, j *Journal, p Phase) error // rewrite via temp + rename in /data/layout
372 func RemoveJournal(dataDir, host string) error
373 func Validate(j *Journal, dataDir, socketDir string, row registry.Route) (atFrom bool, err error) // §8.2 recovery check
374 ```
375
376The socket artifact records presence for reporting only; nothing is renamed for it (a socket file is a leftover and both `prepare` and `SecureUnixListen` unlink before binding). It is `Required: false` and `Src`/`Dst` are the two socket paths.
377
378- [ ] **Step 1: Write the failing tests**
379
380`internal/layout/plan_test.go`:
381
382```go
383package layout
384
385import (
386 "path/filepath"
387 "strings"
388 "testing"
389
390 "github.com/carlosframework/platform/internal/provision"
391)
392
393func TestPlanForNestedDerivesEveryPathFromTheHost(t *testing.T) {
394 p, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("nested"))
395 if err != nil {
396 t.Fatal(err)
397 }
398 iid := provision.InstanceID("h.example.com")
399 if p.From != (Endpoint{DB: "/data/h.example.com.db", Socket: "/run/carlos/h.example.com.sock"}) {
400 t.Errorf("from = %+v", p.From)
401 }
402 if p.To != (Endpoint{DB: provision.NestedDBPath("/data", "h.example.com"), Socket: provision.NestedSocket("h.example.com")}) {
403 t.Errorf("to = %+v", p.To)
404 }
405 byName := map[string]Artifact{}
406 for _, a := range p.Artifacts {
407 byName[a.Name] = a
408 }
409 if byName["db"].Src != "/data/h.example.com.db" || byName["db"].Dst != filepath.Join("/data/.instances", iid, "h.example.com.db") || !byName["db"].Required {
410 t.Errorf("db artifact = %+v (the destination must be the RAW directory)", byName["db"])
411 }
412 if byName["env"].Dst != filepath.Join("/data/.instances", iid, "h.example.com.env") || byName["env"].Required {
413 t.Errorf("env artifact = %+v", byName["env"])
414 }
415 if byName["db-wal"].Src != "/data/h.example.com.db-wal" || byName["db-shm"].Src != "/data/h.example.com.db-shm" {
416 t.Errorf("sidecar files = %+v %+v", byName["db-wal"], byName["db-shm"])
417 }
418 if byName["config-env"].Src != "/data/h.example.com.config.env" || byName["sidecar-env"].Src != "/data/h.example.com-sidecar.env" {
419 t.Errorf("env files = %+v %+v", byName["config-env"], byName["sidecar-env"])
420 }
421 if strings.Contains(byName["db"].Dst, "/data/instances/") {
422 t.Error("a destination through the bind mount would EXDEV")
423 }
424}
425
426func TestPlanForFlatIsTheExactReverse(t *testing.T) {
427 n, _ := PlanFor("/data", "/run/carlos", "h.example.com", Mode("nested"))
428 f, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("flat"))
429 if err != nil {
430 t.Fatal(err)
431 }
432 if f.From != n.To || f.To != n.From {
433 t.Errorf("flat endpoints %+v %+v are not nested's swapped %+v %+v", f.From, f.To, n.From, n.To)
434 }
435 for i := range n.Artifacts {
436 if f.Artifacts[i].Src != n.Artifacts[i].Dst || f.Artifacts[i].Dst != n.Artifacts[i].Src {
437 t.Errorf("artifact %s not reversed: %+v vs %+v", n.Artifacts[i].Name, f.Artifacts[i], n.Artifacts[i])
438 }
439 }
440}
441
442func TestPlanForRefusesBadInput(t *testing.T) {
443 if _, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("sideways")); err == nil {
444 t.Error("unknown mode accepted")
445 }
446 if _, err := PlanFor("/data", "/run/carlos", "../h", Mode("nested")); err == nil {
447 t.Error("invalid host accepted")
448 }
449}
450```
451
452`internal/layout/journal_test.go`:
453
454```go
455package layout
456
457import (
458 "errors"
459 "os"
460 "path/filepath"
461 "testing"
462
463 "github.com/carlosframework/platform/internal/registry"
464)
465
466func journalFor(t *testing.T, dd string, mode Mode) *Journal {
467 t.Helper()
468 p, err := PlanFor(dd, "/run/carlos", "h.example.com", mode)
469 if err != nil {
470 t.Fatal(err)
471 }
472 return p.Journal([]bool{true, false, false, true, false, true, false})
473}
474
475func TestJournalWriteIsExclusiveAndPhasesAdvance(t *testing.T) {
476 dd := t.TempDir()
477 j := journalFor(t, dd, Mode("nested"))
478 if err := WriteJournal(dd, j); err != nil {
479 t.Fatal(err)
480 }
481 if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) {
482 t.Fatalf("second write = %v, want ErrJournalExists", err)
483 }
484 got, found, err := ReadJournal(dd, "h.example.com")
485 if err != nil || !found || got.Phase != PhaseFiles || got.Mode != Mode("nested") || len(got.Artifacts) != 7 {
486 t.Fatalf("read back = %+v %v %v", got, found, err)
487 }
488 if err := UpdatePhase(dd, got, PhaseRow); err != nil {
489 t.Fatal(err)
490 }
491 got, _, _ = ReadJournal(dd, "h.example.com")
492 if got.Phase != PhaseRow {
493 t.Errorf("phase = %s", got.Phase)
494 }
495 if err := RemoveJournal(dd, "h.example.com"); err != nil {
496 t.Fatal(err)
497 }
498 if _, found, _ := ReadJournal(dd, "h.example.com"); found {
499 t.Error("journal survives removal")
500 }
501}
502
503func TestReadJournalRefusesASymlinkAndAFIFO(t *testing.T) {
504 dd := t.TempDir()
505 dir := filepath.Join(dd, "layout")
506 os.MkdirAll(dir, 0o700)
507 target := filepath.Join(dd, "elsewhere.json")
508 os.WriteFile(target, []byte(`{"host":"h.example.com"}`), 0o600)
509 if err := os.Symlink(target, filepath.Join(dir, "h.example.com.json")); err != nil {
510 t.Skip(err)
511 }
512 if _, _, err := ReadJournal(dd, "h.example.com"); err == nil {
513 t.Error("a symlinked journal was read")
514 }
515}
516
517func TestValidateChecksBothEndpointsFromTheJournalsOwnDirection(t *testing.T) {
518 dd := t.TempDir()
519 j := journalFor(t, dd, Mode("nested"))
520 flat := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
521 nested := registry.Route{Host: "h.example.com", DBPath: j.To.DB, Socket: j.To.Socket}
522 if atFrom, err := Validate(j, dd, "/run/carlos", flat); err != nil || !atFrom {
523 t.Errorf("row at from: %v %v", atFrom, err)
524 }
525 if atFrom, err := Validate(j, dd, "/run/carlos", nested); err != nil || atFrom {
526 t.Errorf("row at to: %v %v", atFrom, err)
527 }
528 other := registry.Route{Host: "h.example.com", DBPath: "/data/other.db", Socket: j.From.Socket}
529 if _, err := Validate(j, dd, "/run/carlos", other); err == nil {
530 t.Error("a row at neither endpoint was accepted")
531 }
532 // A forged journal whose `to` points somewhere else is refused whatever the row says.
533 forged := *j
534 forged.To.DB = "/data/console/console.db"
535 if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil {
536 t.Error("a journal with a foreign destination was accepted")
537 }
538 forged = *j
539 forged.From.DB = "/data/console/console.db"
540 if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil {
541 t.Error("a journal with a foreign source was accepted")
542 }
543 // The flat direction swaps the two, and Validate derives from the mode.
544 r := journalFor(t, dd, Mode("flat"))
545 if atFrom, err := Validate(r, dd, "/run/carlos", nested); err != nil || !atFrom {
546 t.Errorf("revert journal, row at from (nested): %v %v", atFrom, err)
547 }
548}
549```
550
551- [ ] **Step 2: Run the tests to verify they fail**
552
553Run: `go test ./internal/layout -count=1` (env as above). Expected: build failure.
554
555- [ ] **Step 3: Write `plan.go`**
556
557```go
558// Package layout is the pass that moves an activator-managed host between
559// the flat layout (<data>/<host>.db, /run/carlos/<host>.sock) and the nested
560// one (<data>/instances/<iid>/, /run/carlos/instances/<iid>/), and back
561// (isolated-exec design §8.2). It builds its plan from the row and the host
562// name only, journals every step in /data/layout, and recovers from the
563// journal exactly. Nothing in an instance directory is read to decide
564// anything (§3.3).
565package layout
566
567import (
568 "errors"
569 "fmt"
570 "path/filepath"
571
572 "github.com/carlosframework/platform/internal/controlname"
573 "github.com/carlosframework/platform/internal/provision"
574 "github.com/carlosframework/platform/internal/release"
575)
576
577// Mode is the direction of a move: the layout the host ends in.
578type Mode string
579
580const (
581 ModeNested Mode = release.LayoutModeNested
582 ModeFlat Mode = release.LayoutModeFlat
583)
584
585// Endpoint is the pair of row columns a layout is defined by.
586type Endpoint struct {
587 DB string `json:"db"`
588 Socket string `json:"socket"`
589}
590
591// Artifact is one file the pass moves. Src and Dst are RAW paths for files
592// inside the instance tree (design §3.2): the bind mount at /data/instances
593// is a different mount from /data/.instances and rename(2) between them
594// answers EXDEV, so the pass renames through the raw directory and the
595// mount shows the result.
596type Artifact struct {
597 Name string
598 Src, Dst string
599 // Required marks the database: a missing required source is an error,
600 // a missing optional one is recorded absent and skipped (R2-7).
601 Required bool
602 Present bool
603}
604
605// Plan is everything the pass will do for one host, computed before it
606// touches anything.
607type Plan struct {
608 Host string
609 IID string
610 Mode Mode
611 From, To Endpoint
612 Artifacts []Artifact
613}
614
615// artifactNames is the fixed order of the seven artifacts. The journal
616// records presence by this order, so it is part of the on-disk contract.
617var artifactNames = []string{"db", "db-wal", "db-shm", "env", "sidecar-env", "config-env", "socket"}
618
619// rawInstanceDir and rawRunDir are the ONLY places the platform names the
620// raw directories behind the two bind mounts; a grep test pins that.
621func rawInstanceDir(dataDir, host string) string {
622 return filepath.Join(dataDir, ".instances", provision.InstanceID(host))
623}
624
625func rawRunDir(socketDir, host string) string {
626 return filepath.Join(socketDir, ".instances", provision.InstanceID(host))
627}
628
629// flatFiles and nestedFiles are the seven artifact paths under each layout,
630// in artifactNames order. The nested set is expressed through the raw
631// directories.
632func flatFiles(dataDir, socketDir, host string) []string {
633 return []string{
634 provision.DefaultDBPath(dataDir, host),
635 provision.DefaultDBPath(dataDir, host) + "-wal",
636 provision.DefaultDBPath(dataDir, host) + "-shm",
637 filepath.Join(dataDir, host+".env"),
638 filepath.Join(dataDir, host+"-sidecar.env"),
639 filepath.Join(dataDir, host+".config.env"),
640 filepath.Join(socketDir, host+".sock"),
641 }
642}
643
644func nestedFiles(dataDir, socketDir, host string) []string {
645 d := rawInstanceDir(dataDir, host)
646 return []string{
647 filepath.Join(d, host+".db"),
648 filepath.Join(d, host+".db-wal"),
649 filepath.Join(d, host+".db-shm"),
650 filepath.Join(d, host+".env"),
651 filepath.Join(d, host+"-sidecar.env"),
652 filepath.Join(d, host+".config.env"),
653 filepath.Join(rawRunDir(socketDir, host), "app.sock"),
654 }
655}
656
657// PlanFor derives the plan for host in the given direction from the host
658// name alone (design §8.2 step 3). The endpoints are the row values the
659// pass will compare against and swap; the artifacts are the files it will
660// rename.
661func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) {
662 if mode != ModeNested && mode != ModeFlat {
663 return nil, fmt.Errorf("layout: mode %q is not nested or flat", mode)
664 }
665 if !controlname.ValidHostShape(host) {
666 return nil, fmt.Errorf("layout: %q is not a hostname", host)
667 }
668 flat := Endpoint{DB: provision.DefaultDBPath(dataDir, host), Socket: provision.DefaultSocket(host)}
669 nested := Endpoint{DB: provision.NestedDBPath(dataDir, host), Socket: provision.NestedSocket(host)}
670 src, dst := flatFiles(dataDir, socketDir, host), nestedFiles(dataDir, socketDir, host)
671 p := &Plan{Host: host, IID: provision.InstanceID(host), Mode: mode, From: flat, To: nested}
672 if mode == ModeFlat {
673 p.From, p.To = nested, flat
674 src, dst = dst, src
675 }
676 for i, name := range artifactNames {
677 p.Artifacts = append(p.Artifacts, Artifact{Name: name, Src: src[i], Dst: dst[i], Required: name == "db"})
678 }
679 return p, nil
680}
681
682// Journal builds the journal for this plan with the given presence
683// vector (one bool per artifact, artifactNames order).
684func (p *Plan) Journal(present []bool) *Journal {
685 j := &Journal{Host: p.Host, Mode: p.Mode, From: p.From, To: p.To, Phase: PhaseFiles}
686 for i, a := range p.Artifacts {
687 j.Artifacts = append(j.Artifacts, JournalArtifact{Name: a.Name, Present: i < len(present) && present[i]})
688 }
689 return j
690}
691
692var errNoSuchArtifact = errors.New("layout: no such artifact")
693```
694
695`provision.DefaultSocket(host)` is `<SocketDir>/<host>.sock` with the compiled-in `provision.SocketDir`; `socketDir` is only used for the artifact paths so tests can point it at a temp dir. `controlname.ValidHostShape` is what `FenceAndStop` uses.
696
697- [ ] **Step 4: Write `journal.go`**
698
699```go
700package layout
701
702import (
703 "encoding/json"
704 "errors"
705 "fmt"
706 "io"
707 "os"
708 "path/filepath"
709 "time"
710
711 "github.com/carlosframework/platform/internal/provision"
712 "github.com/carlosframework/platform/internal/registry"
713)
714
715// Phase is how far a move got. files: sources are being renamed; row: the
716// files are at their destinations and the row is being swapped; done: the
717// row is swapped and only the journal remains.
718type Phase string
719
720const (
721 PhaseFiles Phase = "files"
722 PhaseRow Phase = "row"
723 PhaseDone Phase = "done"
724)
725
726// Journal is /data/layout/<host>.json (design §8.2 step 6). It records
727// presence so recovery can tell an optional artifact that was absent from
728// one that was lost (R2-7). It is NOT an instruction (§3.3): Validate
729// re-derives both endpoints from the host and the mode and refuses a
730// journal that names anything else.
731type Journal struct {
732 Host string `json:"host"`
733 Mode Mode `json:"mode"`
734 From Endpoint `json:"from"`
735 To Endpoint `json:"to"`
736 Artifacts []JournalArtifact `json:"artifacts"`
737 Phase Phase `json:"phase"`
738 WrittenAt string `json:"written_at"`
739}
740
741type JournalArtifact struct {
742 Name string `json:"name"`
743 Present bool `json:"present"`
744}
745
746var ErrJournalExists = errors.New("layout: a journal already exists for this host")
747
748const journalMax = 64 << 10
749
750func journalDir(dataDir string) string { return filepath.Join(dataDir, "layout") }
751
752// WriteJournal creates the journal O_EXCL. An existing file, whoever wrote
753// it, is a refusal: the pass never overwrites a record of a move it did not
754// make.
755func WriteJournal(dataDir string, j *Journal) error {
756 if err := os.MkdirAll(journalDir(dataDir), 0o700); err != nil {
757 return err
758 }
759 if j.WrittenAt == "" {
760 j.WrittenAt = time.Now().UTC().Format(time.RFC3339)
761 }
762 raw, err := json.MarshalIndent(j, "", " ")
763 if err != nil {
764 return err
765 }
766 f, err := os.OpenFile(provision.LayoutJournalPath(dataDir, j.Host), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
767 if errors.Is(err, os.ErrExist) {
768 return ErrJournalExists
769 }
770 if err != nil {
771 return err
772 }
773 if _, err := f.Write(append(raw, '\n')); err != nil {
774 f.Close()
775 return err
776 }
777 return f.Close()
778}
779
780// ReadJournal reads the host's journal without following a symlink and
781// without blocking on a special file: /data/layout is agent-owned, but a
782// flat tenant shares the agent's uid during the transition and can plant
783// things there.
784func ReadJournal(dataDir, host string) (*Journal, bool, error) {
785 path := provision.LayoutJournalPath(dataDir, host)
786 f, err := os.OpenFile(path, os.O_RDONLY|syscallNofollow|syscallNonblock, 0)
787 if errors.Is(err, os.ErrNotExist) {
788 return nil, false, nil
789 }
790 if err != nil {
791 return nil, false, err
792 }
793 defer f.Close()
794 fi, err := f.Stat()
795 if err != nil {
796 return nil, false, err
797 }
798 if !fi.Mode().IsRegular() {
799 return nil, false, fmt.Errorf("layout: %s is not a regular file", path)
800 }
801 raw, err := io.ReadAll(io.LimitReader(f, journalMax+1))
802 if err != nil {
803 return nil, false, err
804 }
805 if len(raw) > journalMax {
806 return nil, false, fmt.Errorf("layout: %s is larger than %d bytes", path, journalMax)
807 }
808 var j Journal
809 if err := json.Unmarshal(raw, &j); err != nil {
810 return nil, false, fmt.Errorf("layout: %s: %w", path, err)
811 }
812 return &j, true, nil
813}
814
815// UpdatePhase rewrites the journal with a new phase through a temp file and
816// rename in the same directory, so a crash leaves either the old phase or
817// the new one, never a torn file.
818func UpdatePhase(dataDir string, j *Journal, p Phase) error {
819 j.Phase = p
820 raw, err := json.MarshalIndent(j, "", " ")
821 if err != nil {
822 return err
823 }
824 dir := journalDir(dataDir)
825 tmp, err := os.CreateTemp(dir, "."+j.Host+".tmp-*")
826 if err != nil {
827 return err
828 }
829 name := tmp.Name()
830 if _, err := tmp.Write(append(raw, '\n')); err != nil {
831 tmp.Close()
832 os.Remove(name)
833 return err
834 }
835 if err := tmp.Close(); err != nil {
836 os.Remove(name)
837 return err
838 }
839 if err := os.Rename(name, provision.LayoutJournalPath(dataDir, j.Host)); err != nil {
840 os.Remove(name)
841 return err
842 }
843 return nil
844}
845
846func RemoveJournal(dataDir, host string) error {
847 err := os.Remove(provision.LayoutJournalPath(dataDir, host))
848 if errors.Is(err, os.ErrNotExist) {
849 return nil
850 }
851 return err
852}
853
854// Validate is the recovery check of design §8.2: the journal's endpoints
855// must be exactly what PlanFor derives for its host and mode, and the row
856// must sit at one of them. It returns whether the row is at `from` (the
857// CAS has not run) or at `to` (it has).
858func Validate(j *Journal, dataDir, socketDir string, row registry.Route) (atFrom bool, err error) {
859 if j.Host != row.Host {
860 return false, fmt.Errorf("layout: journal is for %s, row is %s", j.Host, row.Host)
861 }
862 p, err := PlanFor(dataDir, socketDir, j.Host, j.Mode)
863 if err != nil {
864 return false, err
865 }
866 if j.From != p.From || j.To != p.To {
867 return false, fmt.Errorf("layout: %s: journal endpoints %+v -> %+v are not the derived %+v -> %+v; not acting", j.Host, j.From, j.To, p.From, p.To)
868 }
869 if len(j.Artifacts) != len(artifactNames) {
870 return false, fmt.Errorf("layout: %s: journal lists %d artifacts, expected %d", j.Host, len(j.Artifacts), len(artifactNames))
871 }
872 for i, a := range j.Artifacts {
873 if a.Name != artifactNames[i] {
874 return false, fmt.Errorf("layout: %s: journal artifact %d is %q, expected %q", j.Host, i, a.Name, artifactNames[i])
875 }
876 }
877 switch (Endpoint{DB: row.DBPath, Socket: row.Socket}) {
878 case j.From:
879 return true, nil
880 case j.To:
881 return false, nil
882 }
883 return false, fmt.Errorf("layout: %s: row holds db %q socket %q, at neither journal endpoint; not acting", j.Host, row.DBPath, row.Socket)
884}
885```
886
887`syscallNofollow`/`syscallNonblock` are `unix.O_NOFOLLOW|unix.O_NONBLOCK` in `journal_linux.go` and `0` in `journal_other.go` (`//go:build !linux`), so the package builds everywhere and the checks bite on Linux.
888
889- [ ] **Step 5: Run the tests to verify they pass**
890
891Run: `go test ./internal/layout -count=1`. Expected: PASS.
892
893- [ ] **Step 6: gofmt and commit**
894
895```bash
896gofmt -l internal/layout
897git add internal/layout
898git commit -m "layout: the plan from the host name and the exclusive, validated journal (plan 4, task 2)"
899```
900
901---
902
903### Task 3: Preflight, directories, and the pass
904
905**Files:**
906- Create: `internal/layout/preflight.go`, `internal/layout/pass.go`, `internal/layout/pass_linux_test.go`, `internal/layout/preflight_test.go`, `internal/layout/rawdir_test.go`
907
908**Interfaces:**
909- Produces:
910 ```go
911 // preflight.go
912 func Preflight(p *Plan, routes []registry.Route) error // ownership: a name that another route owns under the flat rules refuses the plan naming both owners
913 func DestinationCheck(p *Plan, now func() time.Time) (aside []string, err error) // db present at dst = refusal; env/sidecar-env present at dst = moved aside <name>.aside-<unix>; config-env replaced
914 func EnsureDirs(dataDir, socketDir, host string, socketsGID int) error // raw <data>/.instances/<iid> 0700; raw <run>/.instances/<iid> 2750 gid socketsGID (skip chown when gid<0)
915 // pass.go
916 type Fence interface { FenceAndStop(ctx, host string, generation uint64) (*activator.StopEvidence, error); Unfence(host string, generation uint64) bool }
917 type Pass struct {
918 Registry *registry.Registry
919 Fence Fence // nil: no activator (tests); the pass still journals and moves
920 DataDir, SocketDir string
921 SocketsGID int // carlos-sockets gid; -1 in tests
922 Reload func() // routing reload after a row flip; nil = the 2s poll
923 Now func() time.Time
924 Logf func(string, ...any)
925 Generation func() uint64 // monotonic; default time.Now().UnixNano()
926 }
927 type Result struct { Host string; Outcome string; Reason string } // Outcome: migrated | reverted | recovered | skipped | refused | unchanged
928 func (p *Pass) Recover(ctx context.Context) []Result // every journal in /data/layout
929 func (p *Pass) Converge(ctx context.Context, mode Mode) []Result // every managed row not already in mode
930 func (p *Pass) Classify(rt registry.Route, peers []registry.Route) (activator.Layout, string)
931 ```
932
933- [ ] **Step 1: Write the failing tests**
934
935`internal/layout/preflight_test.go`:
936
937```go
938package layout
939
940import (
941 "os"
942 "path/filepath"
943 "strings"
944 "testing"
945 "time"
946
947 "github.com/carlosframework/platform/internal/registry"
948)
949
950func TestPreflightRefusesAConfigEnvOwnedByAnotherRoute(t *testing.T) {
951 p, _ := PlanFor("/data", "/run/carlos", "x.example.com", ModeNested)
952 routes := []registry.Route{{Host: "x.example.com"}, {Host: "x.example.com.config"}}
953 err := Preflight(p, routes)
954 if err == nil || !strings.Contains(err.Error(), "x.example.com.config") {
955 t.Fatalf("ambiguous config-env accepted: %v", err)
956 }
957 if err := Preflight(p, []registry.Route{{Host: "x.example.com"}}); err != nil {
958 t.Fatalf("unambiguous plan refused: %v", err)
959 }
960 // Reverting applies the same table: a flat destination another route owns is a refusal.
961 r, _ := PlanFor("/data", "/run/carlos", "x.example.com", ModeFlat)
962 if err := Preflight(r, routes); err == nil {
963 t.Fatal("revert onto another route's file accepted")
964 }
965}
966
967func TestDestinationCheckRefusesTwoDatabasesAndMovesEnvAside(t *testing.T) {
968 dd := t.TempDir()
969 p, _ := PlanFor(dd, filepath.Join(dd, "run"), "h.example.com", ModeNested)
970 os.MkdirAll(rawInstanceDir(dd, "h.example.com"), 0o700)
971 os.WriteFile(p.Artifacts[0].Dst, []byte("x"), 0o600)
972 if _, err := DestinationCheck(p, time.Now); err == nil || !strings.Contains(err.Error(), "two databases") {
973 t.Fatalf("two databases not refused: %v", err)
974 }
975 os.Remove(p.Artifacts[0].Dst)
976 os.WriteFile(p.Artifacts[3].Dst, []byte("fresh env minted by an older binary"), 0o600)
977 aside, err := DestinationCheck(p, func() time.Time { return time.Unix(1700000000, 0) })
978 if err != nil {
979 t.Fatal(err)
980 }
981 if len(aside) != 1 || !strings.HasSuffix(aside[0], ".env.aside-1700000000") {
982 t.Fatalf("aside = %v", aside)
983 }
984 if _, err := os.Stat(p.Artifacts[3].Dst); !os.IsNotExist(err) {
985 t.Error("env destination still occupied")
986 }
987}
988
989func TestEnsureDirsCreatesTheRawDirectoriesWithTheRightModes(t *testing.T) {
990 dd, rd := t.TempDir(), t.TempDir()
991 if err := EnsureDirs(dd, rd, "h.example.com", -1); err != nil {
992 t.Fatal(err)
993 }
994 fi, err := os.Stat(rawInstanceDir(dd, "h.example.com"))
995 if err != nil || fi.Mode().Perm() != 0o700 {
996 t.Errorf("data dir: %v %v", fi, err)
997 }
998 fi, err = os.Stat(rawRunDir(rd, "h.example.com"))
999 if err != nil || fi.Mode().Perm() != 0o750 || fi.Mode()&os.ModeSetgid == 0 {
1000 t.Errorf("run dir: %v %v", fi, err)
1001 }
1002}
1003```
1004
1005`internal/layout/pass_linux_test.go` (`//go:build linux`; the registry opens a real SQLite file, so use `registry.Open(filepath.Join(dir, "registry.db"))` or the constructor the registry tests use):
1006
1007```go
1008//go:build linux
1009
1010package layout
1011
1012import (
1013 "context"
1014 "os"
1015 "path/filepath"
1016 "strings"
1017 "testing"
1018 "time"
1019
1020 "github.com/carlosframework/platform/internal/activator"
1021 "github.com/carlosframework/platform/internal/provision"
1022 "github.com/carlosframework/platform/internal/registry"
1023)
1024
1025type fakeFence struct{ fences map[string]uint64; unproven map[string]bool }
1026
1027func (f *fakeFence) FenceAndStop(_ context.Context, host string, gen uint64) (*activator.StopEvidence, error) {
1028 if f.fences == nil {
1029 f.fences = map[string]uint64{}
1030 }
1031 f.fences[host] = gen
1032 if f.unproven[host] {
1033 return nil, activator.ErrStopUnproven
1034 }
1035 return &activator.StopEvidence{}, nil
1036}
1037func (f *fakeFence) Unfence(host string, gen uint64) bool {
1038 if f.fences[host] != gen {
1039 return false
1040 }
1041 delete(f.fences, host)
1042 return true
1043}
1044
1045// box is a temp data dir, a temp socket dir and a registry with one flat
1046// managed row whose files exist.
1047func box(t *testing.T) (*Pass, *fakeFence, string) {
1048 t.Helper()
1049 dd := t.TempDir()
1050 rd := filepath.Join(dd, "run")
1051 os.MkdirAll(rd, 0o755)
1052 reg := openTestRegistry(t, dd)
1053 host := "h.example.com"
1054 rt := registry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dd, host)}
1055 if err := reg.Upsert(rt); err != nil {
1056 t.Fatal(err)
1057 }
1058 os.WriteFile(rt.DBPath, []byte("db"), 0o600)
1059 os.WriteFile(rt.DBPath+"-wal", []byte("wal"), 0o600)
1060 os.WriteFile(filepath.Join(dd, host+".env"), []byte("CARLOS_ADMIN_TOKEN=t\n"), 0o600)
1061 f := &fakeFence{}
1062 var logs []string
1063 p := &Pass{Registry: reg, Fence: f, DataDir: dd, SocketDir: rd, SocketsGID: -1, Now: time.Now,
1064 Logf: func(format string, a ...any) { logs = append(logs, format) }}
1065 return p, f, host
1066}
1067
1068func TestConvergeNestedMovesFilesFlipsTheRowAndRemovesTheJournal(t *testing.T) {
1069 p, f, host := box(t)
1070 res := p.Converge(context.Background(), ModeNested)
1071 if len(res) != 1 || res[0].Outcome != "migrated" {
1072 t.Fatalf("results = %+v", res)
1073 }
1074 rt, _ := p.Registry.Get(host)
1075 if rt.DBPath != provision.NestedDBPath(p.DataDir, host) || rt.Socket != provision.NestedSocket(host) {
1076 t.Fatalf("row = %q %q", rt.DBPath, rt.Socket)
1077 }
1078 raw := rawInstanceDir(p.DataDir, host)
1079 for _, name := range []string{host + ".db", host + ".db-wal", host + ".env"} {
1080 if _, err := os.Stat(filepath.Join(raw, name)); err != nil {
1081 t.Errorf("%s not at the raw destination: %v", name, err)
1082 }
1083 }
1084 if _, err := os.Stat(provision.DefaultDBPath(p.DataDir, host)); !os.IsNotExist(err) {
1085 t.Error("flat db still present")
1086 }
1087 if _, found, _ := ReadJournal(p.DataDir, host); found {
1088 t.Error("journal left behind after a clean move")
1089 }
1090 if len(f.fences) != 0 {
1091 t.Error("fence left installed after a clean move")
1092 }
1093 // Idempotent: a second converge does nothing.
1094 if res := p.Converge(context.Background(), ModeNested); len(res) != 1 || res[0].Outcome != "unchanged" {
1095 t.Fatalf("second converge = %+v", res)
1096 }
1097 // Revert restores the flat layout byte for byte.
1098 if res := p.Converge(context.Background(), ModeFlat); len(res) != 1 || res[0].Outcome != "reverted" {
1099 t.Fatalf("revert = %+v", res)
1100 }
1101 if b, err := os.ReadFile(provision.DefaultDBPath(p.DataDir, host)); err != nil || string(b) != "db" {
1102 t.Errorf("flat db after revert: %q %v", b, err)
1103 }
1104}
1105
1106func TestConvergeRefusesAnUnprovenStopAndKeepsTheFence(t *testing.T) {
1107 p, f, host := box(t)
1108 f.unproven = map[string]bool{host: true}
1109 res := p.Converge(context.Background(), ModeNested)
1110 if res[0].Outcome != "refused" || !strings.Contains(res[0].Reason, "unproven") {
1111 t.Fatalf("results = %+v", res)
1112 }
1113 if f.fences[host] == 0 {
1114 t.Error("fence released over an unproven stop")
1115 }
1116 if _, found, _ := ReadJournal(p.DataDir, host); found {
1117 t.Error("a journal was written before quiescence")
1118 }
1119}
1120
1121func TestConvergeSkipsForeignAndUnmanagedRows(t *testing.T) {
1122 p, _, _ := box(t)
1123 p.Registry.Upsert(registry.Route{Host: "custom.example.com", Kind: "instance", Hibernate: true, Socket: provision.DefaultSocket("custom.example.com"), DBPath: "/data/sub/store.db"})
1124 p.Registry.Upsert(registry.Route{Host: "ext.example.com", Kind: "instance", Hibernate: false, Socket: provision.DefaultSocket("ext.example.com"), DBPath: provision.DefaultDBPath(p.DataDir, "ext.example.com")})
1125 out := map[string]Result{}
1126 for _, r := range p.Converge(context.Background(), ModeNested) {
1127 out[r.Host] = r
1128 }
1129 if out["custom.example.com"].Outcome != "skipped" || !strings.Contains(out["custom.example.com"].Reason, "foreign") {
1130 t.Errorf("foreign row: %+v", out["custom.example.com"])
1131 }
1132 if _, listed := out["ext.example.com"]; listed {
1133 t.Errorf("an unmanaged row was considered: %+v", out["ext.example.com"])
1134 }
1135}
1136
1137func TestRecoverCompletesEveryCrashBoundary(t *testing.T) {
1138 // Drive the pass through migrateHost with a hook that aborts after each
1139 // step, then Recover, and assert the end state is the nested layout with
1140 // no journal, every time.
1141 steps := []string{"after-journal", "mid-files", "after-files", "after-cas", "before-remove"}
1142 for _, step := range steps {
1143 t.Run(step, func(t *testing.T) {
1144 p, _, host := box(t)
1145 p.abortAt = step
1146 p.Converge(context.Background(), ModeNested)
1147 p.abortAt = ""
1148 if _, found, _ := ReadJournal(p.DataDir, host); !found {
1149 t.Fatalf("no journal after aborting at %s", step)
1150 }
1151 res := p.Recover(context.Background())
1152 if len(res) != 1 || res[0].Outcome != "recovered" {
1153 t.Fatalf("recover = %+v", res)
1154 }
1155 rt, _ := p.Registry.Get(host)
1156 if rt.DBPath != provision.NestedDBPath(p.DataDir, host) {
1157 t.Errorf("row not nested after recovery from %s: %q", step, rt.DBPath)
1158 }
1159 if _, err := os.Stat(filepath.Join(rawInstanceDir(p.DataDir, host), host+".db")); err != nil {
1160 t.Errorf("db not at destination after recovery from %s", step)
1161 }
1162 if _, found, _ := ReadJournal(p.DataDir, host); found {
1163 t.Error("journal remains after recovery")
1164 }
1165 })
1166 }
1167}
1168
1169func TestRecoverRefusesAForgedJournalAndKeepsTheHostFenced(t *testing.T) {
1170 p, _, host := box(t)
1171 j := &Journal{Host: host, Mode: ModeNested, From: Endpoint{DB: "/data/console/console.db", Socket: provision.DefaultSocket(host)},
1172 To: Endpoint{DB: provision.NestedDBPath(p.DataDir, host), Socket: provision.NestedSocket(host)}, Phase: PhaseFiles}
1173 for _, n := range artifactNames {
1174 j.Artifacts = append(j.Artifacts, JournalArtifact{Name: n, Present: true})
1175 }
1176 if err := WriteJournal(p.DataDir, j); err != nil {
1177 t.Fatal(err)
1178 }
1179 res := p.Recover(context.Background())
1180 if res[0].Outcome != "refused" || !strings.Contains(res[0].Reason, "not acting") {
1181 t.Fatalf("forged journal: %+v", res)
1182 }
1183 if _, found, _ := ReadJournal(p.DataDir, host); !found {
1184 t.Error("forged journal removed; the host must stay fenced for an operator")
1185 }
1186 if _, err := os.Stat(filepath.Join(p.DataDir, "console")); err == nil {
1187 t.Error("something touched the forged source")
1188 }
1189}
1190
1191func TestConvergeRecoversAJournalBeforeConvergingToANewIntent(t *testing.T) {
1192 p, _, host := box(t)
1193 p.abortAt = "after-files"
1194 p.Converge(context.Background(), ModeNested)
1195 p.abortAt = ""
1196 // The intent changed to flat while the move was in flight: the journal
1197 // completes to ITS endpoint (nested), then the host converges to flat.
1198 res := p.Converge(context.Background(), ModeFlat)
1199 outcomes := []string{}
1200 for _, r := range res {
1201 outcomes = append(outcomes, r.Outcome)
1202 }
1203 rt, _ := p.Registry.Get(host)
1204 if rt.DBPath != provision.DefaultDBPath(p.DataDir, host) {
1205 t.Fatalf("row after recovery and convergence: %q (outcomes %v)", rt.DBPath, outcomes)
1206 }
1207 if _, err := os.Stat(provision.DefaultDBPath(p.DataDir, host)); err != nil {
1208 t.Errorf("db not back at the flat path: %v", err)
1209 }
1210}
1211```
1212
1213`internal/layout/rawdir_test.go` (the grep test and, where it can, a real bind mount):
1214
1215```go
1216package layout
1217
1218import (
1219 "os"
1220 "os/exec"
1221 "path/filepath"
1222 "strings"
1223 "testing"
1224)
1225
1226// The raw directories are named in exactly one package: a second caller
1227// would be a second way to bypass the bind mount's nosymfollow.
1228func TestRawInstancesDirectoryIsNamedOnlyHere(t *testing.T) {
1229 root := filepath.Join("..", "..")
1230 out, err := exec.Command("grep", "-rl", "--include=*.go", `\.instances`, filepath.Join(root, "internal"), filepath.Join(root, "main.go")).Output()
1231 if err != nil && len(out) == 0 {
1232 t.Skip("grep unavailable")
1233 }
1234 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
1235 if line == "" || strings.HasSuffix(line, "_test.go") {
1236 continue
1237 }
1238 if !strings.Contains(line, "internal/layout/") && !strings.Contains(line, "internal/host/bootstrap.go") {
1239 t.Errorf("%s names the raw .instances directory; only internal/layout (and bootstrap's tmpfiles lines) may", line)
1240 }
1241 }
1242}
1243
1244// With a real bind mount, a rename THROUGH the mount answers EXDEV and the
1245// pass's rename through the raw directory succeeds. Needs CAP_SYS_ADMIN;
1246// skipped where it cannot mount.
1247func TestRenameThroughTheRawDirectoryAvoidsEXDEV(t *testing.T) {
1248 if os.Geteuid() != 0 {
1249 t.Skip("needs root to bind mount")
1250 }
1251 base := t.TempDir()
1252 raw, view := filepath.Join(base, ".instances"), filepath.Join(base, "instances")
1253 os.MkdirAll(raw, 0o700)
1254 os.MkdirAll(view, 0o700)
1255 if out, err := exec.Command("mount", "--bind", "-o", "nosymfollow", raw, view).CombinedOutput(); err != nil {
1256 t.Skipf("cannot bind mount here: %v %s", err, out)
1257 }
1258 defer exec.Command("umount", view).Run()
1259 src := filepath.Join(base, "h.db")
1260 os.WriteFile(src, []byte("x"), 0o600)
1261 if err := os.Rename(src, filepath.Join(view, "h.db")); err == nil {
1262 t.Fatal("rename through the bind mount succeeded; the EXDEV premise of §3.2 does not hold here")
1263 }
1264 if err := os.Rename(src, filepath.Join(raw, "h.db")); err != nil {
1265 t.Fatalf("rename through the raw directory: %v", err)
1266 }
1267 if _, err := os.Stat(filepath.Join(view, "h.db")); err != nil {
1268 t.Fatalf("the view does not show the renamed file: %v", err)
1269 }
1270}
1271```
1272
1273- [ ] **Step 2: Run the tests to verify they fail**
1274
1275Run: `go test ./internal/layout -count=1`. Expected: build failure.
1276
1277- [ ] **Step 3: Write `preflight.go`**
1278
1279```go
1280package layout
1281
1282import (
1283 "errors"
1284 "fmt"
1285 "os"
1286 "path/filepath"
1287 "strings"
1288 "time"
1289
1290 "github.com/carlosframework/platform/internal/registry"
1291)
1292
1293// Preflight is the ownership check of design §8.2 step 4. Under the flat
1294// rules <host>.config.env is host's rendered bundle UNLESS a route named
1295// <host>.config exists, in which case it is that route's instance env
1296// (SyncExec's own collision rule). Moving it with host would take another
1297// route's file; reverting onto it would overwrite one. Either is a refusal
1298// naming both owners.
1299func Preflight(p *Plan, routes []registry.Route) error {
1300 hosts := map[string]bool{}
1301 for _, rt := range routes {
1302 hosts[rt.Host] = true
1303 }
1304 if other := p.Host + ".config"; hosts[other] {
1305 return fmt.Errorf("layout: %s: %s.config.env is owned by both %s (its bundle) and %s (its instance env); refusing to move it", p.Host, p.Host, p.Host, other)
1306 }
1307 if strings.HasSuffix(p.Host, ".config") {
1308 if owner := strings.TrimSuffix(p.Host, ".config"); hosts[owner] {
1309 return fmt.Errorf("layout: %s: its env file %s.env is also %s's rendered bundle; refusing to move it", p.Host, p.Host, owner)
1310 }
1311 }
1312 return nil
1313}
1314
1315// DestinationCheck is step 5: a database already at the destination is a
1316// refusal (two databases; refusing to choose). An env file already at the
1317// destination, which an older binary running between a migration and a
1318// revert can have minted fresh, is moved aside as <name>.aside-<unix> so
1319// the source, the file the instance ran with for longer, takes its place.
1320// The rendered bundle is simply replaced; sync re-renders it anyway.
1321func DestinationCheck(p *Plan, now func() time.Time) (aside []string, err error) {
1322 for _, a := range p.Artifacts {
1323 if a.Name == "socket" {
1324 continue
1325 }
1326 _, statErr := os.Lstat(a.Dst)
1327 if errors.Is(statErr, os.ErrNotExist) {
1328 continue
1329 }
1330 if statErr != nil {
1331 return aside, statErr
1332 }
1333 switch a.Name {
1334 case "db", "db-wal", "db-shm":
1335 return aside, fmt.Errorf("layout: %s: two databases (%s exists at the destination); refusing to choose", p.Host, a.Dst)
1336 case "env", "sidecar-env":
1337 to := fmt.Sprintf("%s.aside-%d", a.Dst, now().Unix())
1338 if err := os.Rename(a.Dst, to); err != nil {
1339 return aside, err
1340 }
1341 aside = append(aside, to)
1342 case "config-env":
1343 if err := os.Remove(a.Dst); err != nil {
1344 return aside, err
1345 }
1346 }
1347 }
1348 return aside, nil
1349}
1350
1351// EnsureDirs creates the raw directories the bind mounts show: the data
1352// directory 0700 (the tenant's own ground) and the run directory 2750 with
1353// the carlos-sockets group, so an enrolled consumer can reach app.sock. The
1354// directories must exist before the row flips, because Restore and prepare
1355// both assume them.
1356func EnsureDirs(dataDir, socketDir, host string, socketsGID int) error {
1357 if err := os.MkdirAll(rawInstanceDir(dataDir, host), 0o700); err != nil {
1358 return err
1359 }
1360 run := rawRunDir(socketDir, host)
1361 if err := os.MkdirAll(run, 0o750); err != nil {
1362 return err
1363 }
1364 if err := os.Chmod(run, 0o750|os.ModeSetgid); err != nil {
1365 return err
1366 }
1367 if socketsGID >= 0 {
1368 if err := os.Chown(run, -1, socketsGID); err != nil {
1369 return err
1370 }
1371 }
1372 return nil
1373}
1374
1375func lstatPresent(path string) (bool, error) {
1376 _, err := os.Lstat(path)
1377 if errors.Is(err, os.ErrNotExist) {
1378 return false, nil
1379 }
1380 return err == nil, err
1381}
1382
1383var _ = filepath.Join
1384```
1385
1386- [ ] **Step 4: Write `pass.go`**
1387
1388```go
1389package layout
1390
1391import (
1392 "context"
1393 "errors"
1394 "fmt"
1395 "os"
1396 "sort"
1397 "time"
1398
1399 "github.com/carlosframework/platform/internal/activator"
1400 "github.com/carlosframework/platform/internal/provision"
1401 "github.com/carlosframework/platform/internal/registry"
1402)
1403
1404// Fence is what the pass needs from the activator: stop the host and keep
1405// it stopped (FenceAndStop), and let it wake again (Unfence).
1406type Fence interface {
1407 FenceAndStop(ctx context.Context, host string, generation uint64) (*activator.StopEvidence, error)
1408 Unfence(host string, generation uint64) bool
1409}
1410
1411// Pass is the layout pass for one box.
1412type Pass struct {
1413 Registry *registry.Registry
1414 Fence Fence
1415 DataDir string
1416 SocketDir string
1417 SocketsGID int
1418 Reload func()
1419 Now func() time.Time
1420 Logf func(string, ...any)
1421 Generation func() uint64
1422 // abortAt is a test seam: the step name after which migrateHost returns
1423 // as if the agent had died there.
1424 abortAt string
1425}
1426
1427// Result is what the pass says about one host.
1428type Result struct {
1429 Host string
1430 Outcome string // migrated | reverted | recovered | skipped | refused | unchanged
1431 Reason string
1432}
1433
1434const quiesceTimeout = 60 * time.Second
1435
1436func (p *Pass) now() time.Time {
1437 if p.Now != nil {
1438 return p.Now()
1439 }
1440 return time.Now()
1441}
1442
1443func (p *Pass) logf(format string, a ...any) {
1444 if p.Logf != nil {
1445 p.Logf(format, a...)
1446 }
1447}
1448
1449func (p *Pass) generation() uint64 {
1450 if p.Generation != nil {
1451 return p.Generation()
1452 }
1453 return uint64(time.Now().UnixNano())
1454}
1455
1456func (p *Pass) socketDir() string {
1457 if p.SocketDir != "" {
1458 return p.SocketDir
1459 }
1460 return provision.SocketDir
1461}
1462
1463func (p *Pass) layoutInputs(routes []registry.Route) activator.LayoutInputs {
1464 return activator.LayoutInputs{
1465 DataDir: p.DataDir,
1466 SocketDir: p.SocketDir,
1467 Journal: func(host string) bool {
1468 _, found, err := ReadJournal(p.DataDir, host)
1469 return found || (err != nil && !errors.Is(err, os.ErrNotExist))
1470 },
1471 Peers: func() ([]registry.Route, error) { return routes, nil },
1472 }
1473}
1474
1475// Classify is the box's one classification, shared with the status
1476// snapshot so both say the same thing about a row.
1477func (p *Pass) Classify(rt registry.Route, peers []registry.Route) (activator.Layout, string) {
1478 return activator.Classify(rt, p.layoutInputs(peers))
1479}
1480
1481// Recover completes or refuses every journal in /data/layout (design §8.2,
1482// "Recovery is the journal"). It runs at agent start and at the top of
1483// every Converge.
1484func (p *Pass) Recover(ctx context.Context) []Result {
1485 entries, err := os.ReadDir(journalDir(p.DataDir))
1486 if err != nil {
1487 return nil
1488 }
1489 routes, err := p.Registry.List()
1490 if err != nil {
1491 p.logf("layout: recover: list routes: %v", err)
1492 return nil
1493 }
1494 byHost := map[string]registry.Route{}
1495 for _, rt := range routes {
1496 byHost[rt.Host] = rt
1497 }
1498 var out []Result
1499 for _, e := range entries {
1500 name := e.Name()
1501 if len(name) < 6 || name[len(name)-5:] != ".json" || name[0] == '.' {
1502 continue
1503 }
1504 host := name[:len(name)-5]
1505 out = append(out, p.recoverHost(ctx, host, byHost))
1506 }
1507 return out
1508}
1509
1510func (p *Pass) recoverHost(ctx context.Context, host string, byHost map[string]registry.Route) Result {
1511 j, found, err := ReadJournal(p.DataDir, host)
1512 if err != nil {
1513 return Result{host, "refused", "journal unreadable: " + err.Error() + "; not acting"}
1514 }
1515 if !found {
1516 return Result{host, "unchanged", ""}
1517 }
1518 rt, ok := byHost[host]
1519 if !ok {
1520 return Result{host, "refused", "journal names a host with no row; not acting"}
1521 }
1522 atFrom, err := Validate(j, p.DataDir, p.socketDir(), rt)
1523 if err != nil {
1524 return Result{host, "refused", err.Error()}
1525 }
1526 plan, _ := PlanFor(p.DataDir, p.socketDir(), host, j.Mode)
1527 gen := p.generation()
1528 if p.Fence != nil {
1529 if _, err := p.Fence.FenceAndStop(deadline(ctx), host, gen); err != nil {
1530 return Result{host, "refused", "recovery: stop unproven: " + err.Error()}
1531 }
1532 defer p.Fence.Unfence(host, gen)
1533 }
1534 switch j.Phase {
1535 case PhaseFiles:
1536 if err := p.moveFiles(plan, j); err != nil {
1537 return Result{host, "refused", err.Error()}
1538 }
1539 if err := UpdatePhase(p.DataDir, j, PhaseRow); err != nil {
1540 return Result{host, "refused", err.Error()}
1541 }
1542 fallthrough
1543 case PhaseRow:
1544 if atFrom {
1545 if err := p.Registry.SetInstancePaths(host, j.From.DB, j.From.Socket, j.To.DB, j.To.Socket); err != nil {
1546 return Result{host, "refused", "row: " + err.Error()}
1547 }
1548 }
1549 if err := UpdatePhase(p.DataDir, j, PhaseDone); err != nil {
1550 return Result{host, "refused", err.Error()}
1551 }
1552 fallthrough
1553 case PhaseDone:
1554 if err := RemoveJournal(p.DataDir, host); err != nil {
1555 return Result{host, "refused", err.Error()}
1556 }
1557 default:
1558 return Result{host, "refused", fmt.Sprintf("journal phase %q unknown; not acting", j.Phase)}
1559 }
1560 p.reload()
1561 p.logf("layout: %s: recovered to %s (journal phase was %s)", host, j.Mode, j.Phase)
1562 return Result{host, "recovered", string(j.Mode)}
1563}
1564
1565// moveFiles renames every artifact recorded present that is still at its
1566// source; one already at its destination is done; one at neither is an
1567// error that keeps the fence.
1568func (p *Pass) moveFiles(plan *Plan, j *Journal) error {
1569 for i, a := range plan.Artifacts {
1570 if a.Name == "socket" || !j.Artifacts[i].Present {
1571 continue
1572 }
1573 atDst, err := lstatPresent(a.Dst)
1574 if err != nil {
1575 return err
1576 }
1577 if atDst {
1578 continue
1579 }
1580 atSrc, err := lstatPresent(a.Src)
1581 if err != nil {
1582 return err
1583 }
1584 if !atSrc {
1585 return fmt.Errorf("layout: %s: %s is at neither %s nor %s; not acting", plan.Host, a.Name, a.Src, a.Dst)
1586 }
1587 if err := os.Rename(a.Src, a.Dst); err != nil {
1588 return fmt.Errorf("layout: %s: rename %s: %w", plan.Host, a.Name, err)
1589 }
1590 if p.abortAt == "mid-files" {
1591 return errAborted
1592 }
1593 }
1594 return nil
1595}
1596
1597var errAborted = errors.New("layout: aborted by test seam")
1598
1599// Converge runs Recover, then moves every managed row that is not already
1600// in mode (design §8.2 steps 1 to 9). Rows are visited in host order so a
1601// log reads the same way twice.
1602func (p *Pass) Converge(ctx context.Context, mode Mode) []Result {
1603 out := p.Recover(ctx)
1604 routes, err := p.Registry.List()
1605 if err != nil {
1606 p.logf("layout: converge: list routes: %v", err)
1607 return out
1608 }
1609 sort.Slice(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
1610 for _, rt := range routes {
1611 if !activator.ActivatorManaged(rt) {
1612 continue
1613 }
1614 if ctx.Err() != nil {
1615 break
1616 }
1617 out = append(out, p.convergeHost(ctx, rt, routes, mode))
1618 }
1619 return out
1620}
1621
1622func (p *Pass) convergeHost(ctx context.Context, rt registry.Route, routes []registry.Route, mode Mode) Result {
1623 layout, reason := p.Classify(rt, routes)
1624 switch layout {
1625 case activator.LayoutFenced:
1626 return Result{rt.Host, "refused", reason}
1627 case activator.LayoutConflict:
1628 return Result{rt.Host, "refused", reason}
1629 case activator.LayoutForeign:
1630 return Result{rt.Host, "skipped", "foreign: " + reason}
1631 case activator.LayoutNested:
1632 if mode == ModeNested {
1633 return Result{rt.Host, "unchanged", ""}
1634 }
1635 case activator.LayoutFlat:
1636 if mode == ModeFlat {
1637 return Result{rt.Host, "unchanged", ""}
1638 }
1639 }
1640 return p.migrateHost(ctx, rt, routes, mode)
1641}
1642
1643// migrateHost is steps 2 to 9 for one host. Every early return before the
1644// journal is written leaves nothing changed; every return after it leaves
1645// a journal Recover completes.
1646func (p *Pass) migrateHost(ctx context.Context, rt registry.Route, routes []registry.Route, mode Mode) Result {
1647 host := rt.Host
1648 plan, err := PlanFor(p.DataDir, p.socketDir(), host, mode)
1649 if err != nil {
1650 return Result{host, "refused", err.Error()}
1651 }
1652 if err := Preflight(plan, routes); err != nil {
1653 return Result{host, "refused", err.Error()}
1654 }
1655 gen := p.generation()
1656 if p.Fence != nil {
1657 if _, err := p.Fence.FenceAndStop(deadline(ctx), host, gen); err != nil {
1658 // The fence stays: an unproven stop is never released over.
1659 return Result{host, "refused", "stop unproven: " + err.Error()}
1660 }
1661 defer p.Fence.Unfence(host, gen)
1662 }
1663 // Re-read the row under the fence: the copy we classified could be stale.
1664 fresh, err := p.Registry.Get(host)
1665 if err != nil || fresh == nil {
1666 return Result{host, "refused", "row vanished under the fence"}
1667 }
1668 if (Endpoint{DB: fresh.DBPath, Socket: fresh.Socket}) != plan.From {
1669 return Result{host, "refused", fmt.Sprintf("row moved under the fence: db %q socket %q", fresh.DBPath, fresh.Socket)}
1670 }
1671 if mode == ModeNested {
1672 if err := EnsureDirs(p.DataDir, p.socketDir(), host, p.SocketsGID); err != nil {
1673 return Result{host, "refused", err.Error()}
1674 }
1675 }
1676 aside, err := DestinationCheck(plan, p.now)
1677 if err != nil {
1678 return Result{host, "refused", err.Error()}
1679 }
1680 present := make([]bool, len(plan.Artifacts))
1681 for i, a := range plan.Artifacts {
1682 ok, err := lstatPresent(a.Src)
1683 if err != nil {
1684 return Result{host, "refused", err.Error()}
1685 }
1686 if a.Required && !ok {
1687 return Result{host, "refused", fmt.Sprintf("%s is missing at %s", a.Name, a.Src)}
1688 }
1689 present[i] = ok
1690 }
1691 j := plan.Journal(present)
1692 if err := WriteJournal(p.DataDir, j); err != nil {
1693 return Result{host, "refused", err.Error()}
1694 }
1695 if p.abortAt == "after-journal" {
1696 return Result{host, "refused", "aborted"}
1697 }
1698 if err := p.moveFiles(plan, j); err != nil {
1699 return Result{host, "refused", err.Error()}
1700 }
1701 if p.abortAt == "after-files" {
1702 return Result{host, "refused", "aborted"}
1703 }
1704 if err := UpdatePhase(p.DataDir, j, PhaseRow); err != nil {
1705 return Result{host, "refused", err.Error()}
1706 }
1707 if err := p.Registry.SetInstancePaths(host, plan.From.DB, plan.From.Socket, plan.To.DB, plan.To.Socket); err != nil {
1708 return Result{host, "refused", "row: " + err.Error()}
1709 }
1710 if p.abortAt == "after-cas" {
1711 return Result{host, "refused", "aborted"}
1712 }
1713 if err := UpdatePhase(p.DataDir, j, PhaseDone); err != nil {
1714 return Result{host, "refused", err.Error()}
1715 }
1716 if p.abortAt == "before-remove" {
1717 return Result{host, "refused", "aborted"}
1718 }
1719 if err := RemoveJournal(p.DataDir, host); err != nil {
1720 return Result{host, "refused", err.Error()}
1721 }
1722 p.reload()
1723 outcome := "migrated"
1724 if mode == ModeFlat {
1725 outcome = "reverted"
1726 }
1727 p.logf("layout: %s: %s -> %s (%d file(s) moved aside: %v)", host, plan.From.DB, plan.To.DB, len(aside), aside)
1728 return Result{host, outcome, ""}
1729}
1730
1731// reload asks the edge and the activator to pick the new row up now rather
1732// than on the next poll, so the host's next request runs on the committed
1733// row (§6.8 covers a request that copied the old one).
1734func (p *Pass) reload() {
1735 if p.Reload != nil {
1736 p.Reload()
1737 }
1738}
1739
1740func deadline(ctx context.Context) context.Context {
1741 c, _ := context.WithTimeout(ctx, quiesceTimeout) //nolint:govet
1742 return c
1743}
1744```
1745
1746(Give `deadline` a proper cancel: return `(ctx, cancel)` and `defer cancel()` at both call sites; the snippet compresses it.) A `Result{host, "refused", "aborted"}` from a test-seam abort leaves the journal, which is what the recovery tests need.
1747
1748- [ ] **Step 5: Run the tests to verify they pass**
1749
1750Run: `go test ./internal/layout -count=1 -race`. Expected: PASS; the bind-mount test skips without root.
1751
1752- [ ] **Step 6: gofmt and commit**
1753
1754```bash
1755gofmt -l internal/layout
1756git add internal/layout
1757git commit -m "layout: preflight, raw directories, and the recoverable pass (plan 4, task 3)"
1758```
1759
1760---
1761
1762### Task 4: Agent wiring and the fenced consumers
1763
1764**Files:**
1765- Modify: `internal/host/agent.go` (the pass at start and on a serial move), `internal/host/agent_layout_test.go` (create)
1766- Modify: `internal/appconfig/syncexec.go`, `internal/ops/litestream.go` and their tests
1767
1768**Interfaces:**
1769- Produces: `func newLayoutPass(reg *registry.Registry, svc *activator.Service, dd string, reload func()) *layout.Pass`; `func layoutLeg(ctx, store release.Store, boxLabel string, pass *layout.Pass)` (reads the intent, converges when it applies, logs each non-unchanged result); `fastDeps` gains `layoutLeg func(ctx)` called after `scheduleLeg` in `fastTick`.
1770- `appconfig.SyncExec` and `ops.FleetDBsWithSkipped` gain a `fenced func(host string) bool` seam (nil = never); `SyncExec` skips a fenced host with a printed line; `FleetDBsWithSkipped` puts a fenced host's db in `skipped`.
1771
1772- [ ] **Step 1: Write the failing tests**
1773
1774`internal/host/agent_layout_test.go`:
1775
1776```go
1777package host
1778
1779import (
1780 "context"
1781 "strings"
1782 "testing"
1783
1784 "github.com/carlosframework/platform/internal/layout"
1785 "github.com/carlosframework/platform/internal/release"
1786)
1787
1788func TestLayoutLegConvergesOnlyWhenTheIntentAppliesToThisBox(t *testing.T) {
1789 s := release.NewDirStore(t.TempDir())
1790 ctx := context.Background()
1791 calls := []layout.Mode{}
1792 fake := func(mode layout.Mode) { calls = append(calls, mode) }
1793 // No intent: nothing runs.
1794 layoutLegWith(ctx, s, "kass-1", fake)
1795 if len(calls) != 0 {
1796 t.Fatal("converged with no intent")
1797 }
1798 release.PutLayoutIntent(ctx, s, &release.LayoutIntent{Mode: release.LayoutModeNested, Scope: release.LayoutScope{Box: "kass-2"}}, "")
1799 layoutLegWith(ctx, s, "kass-1", fake)
1800 if len(calls) != 0 {
1801 t.Fatal("converged for another box's intent")
1802 }
1803 release.PutLayoutIntent(ctx, s, &release.LayoutIntent{Mode: release.LayoutModeFlat}, "")
1804 // The second put replaces: read the etag first in the real code; here the DirStore accepts "" as overwrite or the test deletes the key. Adapt to the store's CAS rule.
1805 layoutLegWith(ctx, s, "kass-1", fake)
1806 if len(calls) != 1 || calls[0] != layout.ModeFlat {
1807 t.Fatalf("calls = %v", calls)
1808 }
1809}
1810```
1811
1812(`layoutLegWith(ctx, store, label, converge func(layout.Mode))` is the testable core; `layoutLeg` binds it to the pass.)
1813
1814`internal/appconfig/syncexec_test.go` (append): copy `TestSyncExecWritesANestedRouteIntoItsInstanceDir` and pass `fenced` returning true for the host; assert no file is written and the output names the fence. `internal/ops/ops_test.go` (append): a flat route whose `fenced` returns true lands in `skipped`.
1815
1816- [ ] **Step 2: Run the tests to verify they fail**
1817
1818Expected: build failures.
1819
1820- [ ] **Step 3: Write the code**
1821
1822`agent.go`, where the exec backend and activator are built (after `svc.Routes = reg`):
1823
1824```go
1825 // The layout pass (isolated-exec design §8.2): recovers any journal
1826 // left by a previous agent before the activator starts, so a host mid-
1827 // move is never woken on a half-moved layout, and converges to the
1828 // fleet's recorded intent on every serial move after that.
1829 pass := newLayoutPass(reg, svc, dd, func() { ed.Reload() })
1830 for _, r := range pass.Recover(ctx) {
1831 if r.Outcome != "unchanged" {
1832 log.Printf("agent: layout recovery: %s: %s %s", r.Host, r.Outcome, r.Reason)
1833 }
1834 }
1835```
1836
1837(`ed` is the edge handle the agent holds; use the real variable name. If the agent does not own an `Edge` value in this function, pass `nil` and say so in a comment: the 2s registry poll reloads routing, and §6.8's refresh covers a request that raced it.)
1838
1839```go
1840func newLayoutPass(reg *registry.Registry, svc *activator.Service, dd string, reload func()) *layout.Pass {
1841 gid := -1
1842 if g, err := user.LookupGroup(socketsGroup); err == nil {
1843 if n, err := strconv.Atoi(g.Gid); err == nil {
1844 gid = n
1845 }
1846 }
1847 return &layout.Pass{Registry: reg, Fence: svc, DataDir: dd, SocketsGID: gid, Reload: reload, Logf: log.Printf}
1848}
1849
1850// layoutLeg rides the serial move like the restart and schedule legs: it
1851// reads the intent, and when the intent addresses this box it converges.
1852// Reading the object on every move is one Get; converging is idempotent
1853// and the results say what changed.
1854func layoutLeg(ctx context.Context, store release.Store, boxLabel string, pass *layout.Pass) {
1855 layoutLegWith(ctx, store, boxLabel, func(mode layout.Mode) {
1856 for _, r := range pass.Converge(ctx, mode) {
1857 if r.Outcome != "unchanged" {
1858 log.Printf("agent: layout: %s: %s %s", r.Host, r.Outcome, r.Reason)
1859 }
1860 }
1861 })
1862}
1863
1864func layoutLegWith(ctx context.Context, store release.Store, boxLabel string, converge func(layout.Mode)) {
1865 in, found, err := release.LoadLayoutIntent(ctx, store)
1866 if err != nil {
1867 log.Printf("agent: layout intent: %v (not converging this move)", err)
1868 return
1869 }
1870 if !found || !in.Applies(boxLabel) {
1871 return
1872 }
1873 converge(layout.Mode(in.Mode))
1874}
1875```
1876
1877`socketsGroup` is bootstrap's constant for `carlos-sockets` (read bootstrap.go for its name). The box label is whatever the agent already uses for its snapshot (`status.CollectBox`'s label source). `fastDeps` gains `layoutLeg func(context.Context)`; `fastTick` calls it right after `scheduleLeg` under the same `doc.Serial != d.lastSerial` branch; the agent sets it to `func(ctx) { layoutLeg(ctx, store, label, pass) }`. When `*activator.Service` is nil (no activator armed) the pass gets `Fence: nil` (an untyped nil, not a nil `*Service`, so the interface is nil).
1878
1879`syncexec.go`: `SyncExec(..., ack func(ConfigAck), fenced func(host string) bool)` (append the parameter; update the two callers in agent.go with `func(h string) bool { _, found, err := layout.ReadJournal(dd, h); return found || err != nil }`); in the per-route loop, before rendering: `if fenced != nil && fenced(rt.Host) { fmt.Printf(" %s: layout migration in flight; bundle not written this pass\n", rt.Host); continue }`. `litestream.go`: `FleetDBsWithSkipped(reg, dataDir, fenced func(host string) bool)`; a route whose `fenced(r.Host)` is true is not added to `claimed`, so its db lands in `skipped` with the rest. Update callers and the `LitestreamConfig` generator's call site.
1880
1881- [ ] **Step 4: Gate**
1882
1883Run the full gate (all packages, sandbox off). Expected: green; every existing syncexec and ops test passes with `nil` for the new parameter.
1884
1885- [ ] **Step 5: gofmt and commit**
1886
1887```bash
1888gofmt -l internal/host internal/appconfig internal/ops
1889git add internal/host internal/appconfig internal/ops
1890git commit -m "agent: the layout pass at start and on every serial move; sync and litestream honour the fence (plan 4, task 4)"
1891```
1892
1893---
1894
1895### Task 5: Status, the console page, `carlos status --layout`
1896
1897**Files:**
1898- Modify: `internal/status/snapshot.go`, `internal/status/snapshot_test.go`
1899- Modify: `internal/host/agent.go` (the snapshot writer passes the classifier)
1900- Modify: `internal/console/apistatusinstances.go` (new door), `internal/console/authcli.go` (route), `internal/console/view.go`, `internal/console/templates/app_instance.html`
1901- Modify: root `status.go`/`status_instances.go` (`--layout`), `docs/site/cli.md`
1902
1903**Interfaces:**
1904- Produces:
1905 ```go
1906 // status
1907 type RowLayout struct { Layout string `json:"layout"`; Reason string `json:"reason,omitempty"` }
1908 type LayoutReport struct { Mode string `json:"mode"`; Rows map[string]RowLayout `json:"rows"` } // Mode: none|flat|nested (the intent this box last converged to, "" before any)
1909 // Box gains: Layout *LayoutReport `json:"layout,omitempty"`
1910 // CollectBox(routes, p Probes, now) unchanged; Probes gains Layout *LayoutReport (the agent fills it from pass.Classify over the managed rows and the last intent it saw)
1911 // console
1912 GET /api/cli/status/layout -> {"boxes":[{"box":label,"mode":...,"stale":bool,"rows":[{"host","layout","reason"}]}]} // fleet-operator gate, same as /status/instances
1913 // RouteView gains Layout string (from the box snapshot that reports the host; "unknown (box predates layout reporting)" when none does)
1914 // CLI: carlos status --layout
1915 ```
1916
1917- [ ] **Step 1: Write the failing tests**
1918
1919`internal/status/snapshot_test.go`: `TestCollectBoxCarriesTheLayoutReport` (a `Probes{Layout: &LayoutReport{Mode: "nested", Rows: {"h.example.com": {Layout: "nested"}}}}` round-trips through `CollectBox` and JSON; a `Probes` without one yields no `layout` key in the JSON). Console: a handler test in the style of `apistatusinstances_test.go` asserting the door is closed to non-operators (404) and lists two boxes' rows, marking a stale box. CLI: a table-rendering test if `status_instances.go` has one for `--instances`; otherwise the console test suffices.
1920
1921- [ ] **Step 2: Run the tests to verify they fail**
1922
1923Expected: build failure.
1924
1925- [ ] **Step 3: Write the code**
1926
1927In `snapshot.go` add the two types and the `Box.Layout` field with a doc comment: hostnames here are private, like `Metrics`; the public `fleet.json` merge never copies it (check `merge`/the public writer and assert in the test that the fleet document has no `layout` key). In `Probes` add `Layout *LayoutReport`; `CollectBox` copies it. In the agent's snapshot writer, build it: `mode` from the last intent `layoutLeg` saw (a small `atomic.Value` on the agent, `"none"` before any), rows from `pass.Classify(rt, routes)` for every `activator.ActivatorManaged` row, plus `external` for an unflagged socket instance route.
1928
1929Console: `handleAPIStatusLayout` beside `handleAPIStatusInstances`, same gate (`apiFleetOperator` or whatever the instances door uses), reading `status.LoadBoxes` and emitting one entry per box with `Stale` from `status.BoxStale`. Route it in `authcli.go` next to `/api/cli/status/instances`. `RouteView.Layout` is filled in `Assemble` from a map the caller builds once from `LoadBoxes` (host -> layout word, first box that reports the host wins; missing -> the "unknown (box predates layout reporting)" string); the template gains `<dt>Layout</dt><dd>{{.Row.Layout}}</dd>` after `Channel`.
1930
1931CLI: `carlos status --layout` fetches `/api/cli/status/layout` and prints `BOX HOST LAYOUT REASON` sorted by box then host, stale boxes suffixed `(stale)`. `docs/site/cli.md`: under `### carlos status` (find it) add the `--layout` paragraph: what the six words mean and that a fenced or conflict host does not wake; under `### carlos system` add a `#### Instance layout` subsection describing `layout migrate|revert` as the verb plan 5 adds (write it now so the docsite gate sees the heading once the verb lands; mark the verb "arrives with the console door").
1932
1933- [ ] **Step 4: Gate, including the docsite gate**
1934
1935Run: the full gate plus `go test ./internal/docsite/...`. Expected: green.
1936
1937- [ ] **Step 5: gofmt and commit**
1938
1939```bash
1940git add internal/status internal/host internal/console status.go status_instances.go docs/site/cli.md
1941git commit -m "status: the layout report per box, the console's layout fact, carlos status --layout (plan 4, task 5)"
1942```
1943
1944---
1945
1946### Task 6: The downgrade guards
1947
1948**Files:**
1949- Modify: `internal/host/systemapply.go`, `internal/host/systemapply_test.go`
1950- Modify: `cli.go` (route `--hibernate=false`), `internal/provision/reconcile.go`, `internal/provision/reconcile_test.go`
1951- Modify: `docs/site/cli.md` (one sentence under `carlos system`: a rollback to a release before the layout is refused while any row is nested)
1952
1953**Interfaces:**
1954- Produces:
1955 ```go
1956 // host
1957 func layoutReversalIncomplete(reg *registry.Registry, dataDir string) (reasons []string) // rows with DBPath or Socket under /data/instances or /run/carlos/instances (by path shape, any flags), fenced or conflict rows, any journal
1958 func candidatePredatesLayout(stagedRoot string) bool // the staged etc/systemd/system tree lacks data-instances.mount
1959 // provision
1960 func NestedPaths(dataDir string, rt registry.Route) bool // DBPath under <dataDir>/instances or Socket under <SocketDir>/instances (shape only)
1961 ```
1962
1963- [ ] **Step 1: Write the failing tests**
1964
1965`internal/host/systemapply_test.go`: using the file's applier fixture, a staged root without `data-instances.mount` and a registry with one nested row makes `Run` record a refusal naming the row (`errSystemRefused`, message contains `layout revert`); the same staged root with every row flat installs; a staged root WITH the mount unit installs regardless of nested rows. `internal/provision/reconcile_test.go`: a record with `Hibernate: false` against an existing provisioned nested row logs `layout revert first` and leaves `Hibernate` true; the drift log for a nested row says `nested layout` instead of the shape-mismatch text. `cli.go`: a test in the CLI's route test file (or a unit test of the extracted check `refuseUnflagNested(rt, dataDir) error`) that a nested row refuses `--hibernate=false` with `layout revert first`.
1966
1967- [ ] **Step 2: Run the tests to verify they fail**
1968
1969Expected: FAIL.
1970
1971- [ ] **Step 3: Write the code**
1972
1973`provision.NestedPaths` (provision.go, beside the nested helpers): `strings.HasPrefix(rt.DBPath, filepath.Join(dataDir, "instances")+"/") || strings.HasPrefix(rt.Socket, filepath.Join(SocketDir, "instances")+"/")`.
1974
1975`systemapply.go`, in `Run` after `stageCandidate` and before `install`:
1976
1977```go
1978 // A release before the layout removes the mount units with the binary
1979 // and then serves a nested row from an empty mountpoint directory
1980 // (design §8.2, "That order is enforced"): so a candidate that lacks the
1981 // mount unit is refused while anything on the box is nested, fenced,
1982 // in conflict or mid-move. The check is by path shape and not by flags,
1983 // because a row can leave management while keeping nested paths.
1984 if candidatePredatesLayout(staged) {
1985 if reasons := layoutReversalIncomplete(a.reg, a.dataDir); len(reasons) > 0 {
1986 return a.record(req, fmt.Errorf("%w: %s predates the instance layout and this box still has: %s; run `carlos system layout revert` and wait for every row to report flat first", errSystemRefused, req.Version, strings.Join(reasons, "; ")))
1987 }
1988 }
1989```
1990
1991(`a.reg`/`a.dataDir`: give the applier a registry-opening seam `openRegistry func() (*registry.Registry, error)` and the data dir it already knows; a registry that cannot be opened is itself a refusal reason.) `candidatePredatesLayout` stats `<stagedRoot>/etc/systemd/system/data-instances.mount`. `layoutReversalIncomplete` lists rows with `provision.NestedPaths`, rows a `layout.Pass{Registry: reg, DataDir: dd}.Classify` calls fenced or conflict, and every `*.json` in `<dd>/layout`.
1992
1993`cli.go` `runRoute`, before `SetHibernate(rt.Host, false)`: `if !on && provision.NestedPaths(dataDir, rt) { log.Fatalf("route: %s: its paths are nested; layout revert first (a nested row must stay activator-managed so the revert pass can quiesce it)", rt.Host) }` (the CLI knows the data dir the same way `carlos add` does). `reconcile.go`, in the owned branch where `rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate`: when `!*rec.Hibernate && provision.NestedPaths(cfg.DataDir, existing)` log `provision: %s: record asks to clear hibernate but the row's paths are nested; layout revert first` and skip the write. In the drift block, when `provision.NestedPaths(cfg.DataDir, existing)` and `want.Kind == existing.Kind`, log `provision: %s: row is on the nested layout (db %q socket %q); the record's flat derivation is not drift` instead of the shape-mismatch message.
1994
1995- [ ] **Step 4: Gate**
1996
1997Full gate plus the docsite gate. Expected: green.
1998
1999- [ ] **Step 5: gofmt and commit**
2000
2001```bash
2002git add internal/host internal/provision cli.go docs/site/cli.md
2003git commit -m "isolated exec: a pre-layout candidate, an unflag, and a reconcile flag-clear all refuse a nested row (plan 4, task 6)"
2004```
2005
2006---
2007
2008## Self-review
2009
2010**Spec coverage.** §8.2 steps 1 to 9: Task 3 (`convergeHost`/`migrateHost`: eligibility via `ActivatorManaged` and `Classify`; quiesce via `FenceAndStop`; plan from host name; preflight; destination check; O_EXCL journal; renames through the raw directory; two-column CAS; journal removal, reload, unfence). Recovery against both endpoints from the journal's direction, every phase, refusal keeps the fence: Tasks 2 and 3. Convergence on every serial move and a later-eligible row: Task 4. Revert: Task 3 (`ModeFlat`). Downgrade rules (`system apply` refusal by path shape, unflag refusal in the CLI and the reconciler): Task 6. §8.3 foreign rows skipped and reported: Task 3. §8.4 status field, box mode, console page, CLI: Task 5 (`carlos status --layout` instead of `carlos instances --layout`: the status family already carries the operator's per-box view and its gate; spec wording to follow in the next revision). §9 fenced consumers (SyncExec writer, litestream generator): Task 4; movespass and the activator already refuse. §3.2 raw-directory rename pinned by a grep test and a bind-mount test: Task 3. **Left to plan 5:** the console door and `carlos system layout migrate|revert` (writers of the intent object this plan reads), `layout-marks`, the console rollback door's refusal, and the control service's unmounted-mountpoint inspection.
2011
2012**Placeholder scan.** Task 5's console and CLI steps describe the code by reference to their siblings (`handleAPIStatusInstances`, `--instances`) rather than quoting it: the implementer copies the sibling. Everything else carries code. No "similar to Task N".
2013
2014**Type consistency.** `Mode`, `Endpoint`, `Artifact`, `Plan`, `Journal`, `JournalArtifact`, `Phase*`, `PlanFor`, `WriteJournal`, `ReadJournal`, `UpdatePhase`, `RemoveJournal`, `Validate`, `Preflight`, `DestinationCheck`, `EnsureDirs`, `Pass`, `Fence`, `Result`, `Recover`, `Converge`, `Classify`, `rawInstanceDir`, `rawRunDir`, `SetInstancePaths`, `ErrPathsChanged`, `Unfence`, `Fenced`, `LayoutIntent`, `LayoutScope`, `LoadLayoutIntent`, `PutLayoutIntent`, `LayoutIntentKey`, `RowLayout`, `LayoutReport`, `NestedPaths`, `layoutReversalIncomplete`, `candidatePredatesLayout` are spelled identically across tasks.
2015