carlos / platform Public

host, spec: /data/instances and /data/.instances are one inode, one mode

/data/instances is a nosymfollow bind mount of /data/.instances, so once
the mount is active the two tmpfiles lines (and the two bootstrap dirs
entries) describe the same inode. Giving them different modes (0700 vs
0755) meant the mode flipped between the two on every tmpfiles run. Both
are 0700 now, in bootstrap.go and in the matching spec fixture, and the
spec's layout table wording for the other two bind-of-itself mounts is
corrected to name what they actually bind: /run/carlos/instances binds
/run/carlos/.instances, and /run/carlos/logs binds /run/carlos/.logs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev ab141b6bc2e24ada9f0b2c266b62fee646c33b40 parent 6a43b84
3 files changed, +14 −6
  • docs/superpowers/specs/2026-09-11-isolated-exec-design.md +3 −3
  • internal/host/bootstrap.go +10 −2
  • internal/host/units_isolation_test.go +1 −1
diff --git a/docs/superpowers/specs/2026-09-11-isolated-exec-design.md b/docs/superpowers/specs/2026-09-11-isolated-exec-design.md
index 25e7ed0de..463dea34d 100644
--- a/docs/superpowers/specs/2026-09-11-isolated-exec-design.md
+++ b/docs/superpowers/specs/2026-09-11-isolated-exec-design.md
@@ -278,12 +278,12 @@ scoped to the three instance templates and does not extend to
<host>.db.corrupt a quarantined restore, as today
/data/layout/ 0700 carlos:carlos
<host>.json the migration journal for a host in flight (§8.2)
-/run/carlos/instances/ bind mount of itself, nosymfollow; 2750 carlos:carlos-sockets
+/run/carlos/instances/ nosymfollow bind mount OF /run/carlos/.instances; 2750 carlos:carlos-sockets
/run/carlos/instances/<iid>/ 2750 carlos:carlos-sockets, created by the agent
app.sock the instance's listener (rt.Socket); 0660 after health, as today
app.sock.control the agent's control listener (rt.Socket + ".control"); 0600
exit.<role>.<invocation>.json the unit's advisory exit record (§6.5); tenant-writable, and treated so
-/run/carlos/logs/ bind mount of itself, nosymfollow; 0700 carlos:carlos; NOT bound into any namespace
+/run/carlos/logs/ nosymfollow bind mount OF /run/carlos/.logs; 0700 carlos:carlos; NOT bound into any namespace
<iid>.run.sock the agent's log listener for the instance (§6.4)
<iid>.seed.sock ... for the seed
<iid>.sidecar.sock ... for the sidecar
@@ -435,7 +435,7 @@ caller at all.
```
d /data/.instances 0700 carlos carlos -
-d /data/instances 0755 carlos carlos -
+d /data/instances 0700 carlos carlos -
d /data/layout 0700 carlos carlos -
d /run/carlos/.instances 2750 carlos carlos-sockets -
d /run/carlos/.logs 0700 carlos carlos -
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index b4ba32877..2b7435ab3 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -67,7 +67,12 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup +
// consumers group and setgid, exactly like /run/carlos above, so an
// enrolled router can still dial a migrated instance (issue #103).
"d /data/.instances 0700 " + serviceUser + " " + serviceUser + " -\n" +
- "d /data/instances 0755 " + serviceUser + " " + serviceUser + " -\n" +
+ // /data/instances is a nosymfollow bind mount OF /data/.instances, so
+ // once the mount is active the two paths name ONE inode: a mode that
+ // differed between the two lines would flip back and forth on every
+ // tmpfiles run, since each write lands on the same inode the other
+ // line just set. Both are 0700 so re-runs converge instead of racing.
+ "d /data/instances 0700 " + serviceUser + " " + serviceUser + " -\n" +
"d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
@@ -341,7 +346,10 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error {
// raw directories the mount units bind onto the undotted views;
// see tmpfilesConf for the modes and why both exist.
{filepath.Join(dataDir, ".instances"), 0o700},
- {filepath.Join(dataDir, "instances"), 0o755},
+ // One inode once the bind is active (see tmpfilesConf): both this
+ // entry and the tmpfiles line for /data/instances must agree, or
+ // the mode flips on every tmpfiles run.
+ {filepath.Join(dataDir, "instances"), 0o700},
{filepath.Join(dataDir, "layout"), 0o700},
{filepath.Join(runDir, ".instances"), 0o750 | os.ModeSetgid},
{filepath.Join(runDir, "instances"), 0o750 | os.ModeSetgid},
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
index db678d790..12a4ea4c8 100644
--- a/internal/host/units_isolation_test.go
+++ b/internal/host/units_isolation_test.go
@@ -127,7 +127,7 @@ func TestEmbeddedUnitsIncludeSlicesAndMounts(t *testing.T) {
func TestTmpfilesCreatesTheInstanceTrees(t *testing.T) {
for _, want := range []string{
"d /data/.instances 0700 carlos carlos -",
- "d /data/instances 0755 carlos carlos -",
+ "d /data/instances 0700 carlos carlos -",
"d /data/layout 0700 carlos carlos -",
"d /run/carlos/.instances 2750 carlos carlos-sockets -",
"d /run/carlos/instances 2750 carlos carlos-sockets -",