Skip to content

PRD-004: apiary fleet-root migration (ADR-0003) - #11

Merged
thenotoriousllama merged 1 commit into
mainfrom
feature/prd-004-apiary-fleet-root
Jul 4, 2026
Merged

PRD-004: apiary fleet-root migration (ADR-0003)#11
thenotoriousllama merged 1 commit into
mainfrom
feature/prd-004-apiary-fleet-root

Conversation

@thenotoriousllama

@thenotoriousllama thenotoriousllama commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Implements fleet ADR-0003 for doctor: state moves to the neutral ~/.apiary/ root with per-product subdirs; doctor manages the shared coordination surface.
  • Shared resolveApiaryRoot chain (APIARY_HOME > Linux $XDG_STATE_HOME/apiary > <home>/.apiary), env roots honored only when ABSOLUTE (security amendment), never process.cwd().
  • Registry relocates to ~/.apiary/registry.json: one-time idempotent migration (legacy renamed .migrated), two-location merge window (new wins per daemon name, legacy-only merges additively, no write-back), fail-loud preserved at both locations.
  • device.json relocation with copy-on-read fallback; install-id new-first read.
  • Telemetry trust roots extended to per-product ~/.apiary/<name>/telemetry plus the legacy root; entry names filename-safe-validated; traversal/UNC/drive-relative shapes rejected (test-proven on win32).
  • Legacy install-lock staleness check; workspace migration honors DOCTOR_WORKSPACE_DIR; malformed-registry diagnosis names the actually-broken file.

Close-out (superproject ledger: library/ledger/EXECUTION_LEDGER-apiary-state-root.md)

  • Security audit (security-worker-bee): CLEAN, no Critical/High; 3 Informational/Low documented.
  • QA (quality-worker-bee): 32/32 ACs traced with AC-named tests; 3 Warnings all remediated; report at library/requirements/backlog/prd-004-apiary-fleet-root-migration/qa/2026-07-04-qa-report.md.
  • Gates: typecheck clean; npm test 60 files / 667 tests green (natural run, hermetic home).

Test plan

  • Unit + AC-named suites green (667/667)
  • Home canary: suite never reads/writes the real user home
  • CI green on this PR

Summary by CodeRabbit

  • New Features

    • Introduced a new fleet root layout with automatic migration from the old home-based storage location.
    • Added support for keeping core files, telemetry, and service artifacts under the new root by default.
  • Bug Fixes

    • Improved path handling to ignore unsafe relative overrides and use only valid absolute locations.
    • Preserved access to legacy files during the transition so existing installs continue working smoothly.
  • Tests

    • Added coverage for migration, fallback behavior, and platform-specific path resolution.

Move doctor's state to the neutral ~/.apiary fleet root: shared
resolveApiaryRoot chain (absolute-only env roots), registry relocation
to ~/.apiary/registry.json with the two-location merge window and
one-time migration, device.json relocation, per-product telemetry
trust roots, legacy install-lock staleness check, and hermetic tests.

Security audit clean (no Critical/High); QA passed with all warnings
remediated (667 tests green). See the PRD qa/ report and the
superproject execution ledger EXECUTION_LEDGER-apiary-state-root.md.
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a neutral, absolute-path "apiary" fleet root (~/.apiary) resolved via resolveApiaryRoot, replacing hard-coded ~/.honeycomb paths across registry, device-id, install-lock, config, service, and telemetry modules. It adds boot-time migration utilities (workspace and registry) with legacy fallback support during a compatibility window, updates ADR-0003, and adds extensive tests plus a QA report.

Changes

Fleet root migration

Layer / File(s) Summary
ADR-0003 documentation
library/knowledge/private/architecture/ADR-0003-...md
Requires absolute-only env overrides for root resolution and normatively specifies resolved absolute registry paths.
Apiary root resolver
src/apiary-root.ts, tests/apiary-root.test.ts
Adds resolveApiaryRoot precedence chain, directory/telemetry helpers, and defaultHoneycombPidPath with legacy fallback.
Config resolution
src/config.ts, tests/config.test.ts
resolveConfig gains a platform param and derives root-based defaults instead of hard-coded honeycomb paths.
Registry fleet resolution/migration
src/registry.ts, tests/apiary-registry.test.ts, tests/apiary-supervision.test.ts, tests/registry.test.ts
Adds resolveRegistryEntries/migrateRegistry, root-aware telemetryDbPath containment validation, and path-aware RegistryError.
Boot-time migration orchestration
src/apiary-migration.ts, tests/apiary-workspace-migration.test.ts
Adds migrateDoctorWorkspace and runApiaryMigrations for idempotent, non-throwing workspace/registry migration.
Device id relocation
src/device-id.ts, tests/device-id.test.ts
Relocates device.json to fleet root with legacy read fallback and best-effort copy-forward.
Install lock legacy check
src/install-lock.ts, tests/install-lock.test.ts
Adds legacyWorkspaceDir and legacyLockHeld() to block/clean up legacy locks.
Telemetry installer-id relocation
src/telemetry/capture.ts, tests/telemetry/capture.test.ts
Resolves distinct_id from new install-id location with legacy and device-id fallback.
Service state directory relocation
src/service/platform.ts, src/service/index.ts, src/service/templates.ts, tests/service/*
Adds apiaryRoot/stateDir and updates staged XML/launchd log paths.
CLI and compose-root wiring
src/cli/index.ts, src/compose/index.ts, tests/cli/*, tests/compose/*
Wires new registry resolution, migration runs, and diagnostic path reporting.
Test infrastructure and QA
tests/setup.ts, vitest.config.ts, library/requirements/backlog/prd-004.../qa-report.md
Enforces hermetic HOME in tests and documents QA traceability.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant runApiaryMigrations
  participant migrateDoctorWorkspace
  participant migrateRegistry
  participant FleetRoot
  CLI->>runApiaryMigrations: boot-time migration call
  runApiaryMigrations->>migrateDoctorWorkspace: migrate legacy doctor files
  migrateDoctorWorkspace->>FleetRoot: move/copy workspace files
  runApiaryMigrations->>migrateRegistry: migrate legacy registry
  migrateRegistry->>FleetRoot: copy registry.json, rename legacy to .migrated
  migrateRegistry-->>runApiaryMigrations: non-throwing result
  runApiaryMigrations-->>CLI: combined migration result
Loading

Possibly related PRs

  • legioncodeinc/doctor#6: Both PRs modify src/registry.ts's telemetryDbPath coercion/containment validation on the DaemonEntry field.

Poem

A burrow moved from home to hive,
~/.apiary now keeps state alive.
Legacy paths still get their due,
while new roots resolve, absolute and true.
Hop, hop — the fleet root's here to stay! 🐇📂

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: the PRD-004 apiary fleet-root migration implementing ADR-0003.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/prd-004-apiary-fleet-root

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/service/index.ts (1)

206-225: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Create p.stateDir before staging the launchd plist

fs.mkdirp(dirname(unitTarget)) only covers ~/Library/LaunchAgents here, not <apiaryRoot>/doctor. Since the plist’s StandardOutPath/StandardErrorPath point into p.stateDir, add fs.mkdirp(p.stateDir) before renderUnit(p) so a fresh install can start cleanly on macOS.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/service/index.ts` around lines 206 - 225, The unit-file staging in the
write path for `renderUnit(p)` only creates the parent directory of
`unitTarget`, so a fresh macOS install can still fail when `StandardOutPath` and
`StandardErrorPath` point into `p.stateDir`. Update the `needsFile` block in
`src/service/index.ts` to create `p.stateDir` itself before calling
`fs.writeFile`, while keeping the existing `dirname(unitTarget)` creation for
the plist target.
🧹 Nitpick comments (4)
tests/apiary-root.test.ts (1)

42-47: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add coverage for the win32 drive-relative absolute-path gotcha.

This suite proves Windows-style absolutes are honored on any platform, but doesn't cover the inverse: a POSIX-style value like /srv/apiary (no drive letter) as APIARY_HOME with platform: "win32". Given win32.isAbsolute treats such paths as absolute despite them being drive-relative on real Windows (see the companion comment on src/apiary-root.ts), a regression test pinning today's (accepted-as-absolute) behavior would make any future hardening of isAbsoluteRoot intentional and visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/apiary-root.test.ts` around lines 42 - 47, Add a regression test in the
resolveApiaryRoot suite to cover the win32 drive-relative absolute-path case:
when APIARY_HOME is a POSIX-style path like /srv/apiary and platform is "win32",
assert the current accepted-as-absolute behavior is pinned. Use the existing
resolveApiaryRoot helper alongside the other security cases so any future change
to isAbsoluteRoot or its win32 handling becomes intentional and visible.
src/cli/index.ts (1)

132-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated legacy-workspace-dir computation across entry points.

join(legacyHoneycombRoot(home), "doctor") (with the identical explanatory comment) is repeated verbatim in src/compose/index.ts (Line 503) for the same createInstallLock call. Extracting a single helper (e.g. legacyDoctorWorkspaceDir(home) in apiary-root.ts) would keep both entry points from drifting if the legacy layout changes.

♻️ Proposed extraction
// src/apiary-root.ts
export function legacyDoctorWorkspaceDir(home: string = homedir()): string {
	return join(legacyHoneycombRoot(home), "doctor");
}
- legacyWorkspaceDir: join(legacyHoneycombRoot(home), "doctor"),
+ legacyWorkspaceDir: legacyDoctorWorkspaceDir(home),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/index.ts` around lines 132 - 142, The legacy doctor workspace path
computation is duplicated in multiple entry points, so extract it into a shared
helper and reuse it wherever createInstallLock is called. Add a dedicated helper
like legacyDoctorWorkspaceDir in apiary-root.ts to encapsulate
join(legacyHoneycombRoot(home), "doctor"), then update src/cli/index.ts and
src/compose/index.ts to call that helper instead of repeating the path/comment
logic.
src/registry.ts (1)

505-562: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Harden migrateRegistry against a silent overwrite race.

exists(newPath) is checked, then copyFile(legacyPath, newPath) runs with no exclusivity guard. Two gaps:

  • If newPath is created between the exists() check and the copyFile() call (e.g. a concurrent boot of another process running the same migration), the default copyFileSync silently overwrites it with legacy content instead of failing.
  • If the exists() probe itself throws (caught and treated as "fall through"), the code proceeds to copy even though newPath might genuinely already exist, again risking silent clobber.

Since copyFileSync overwrites the destination by default, using COPYFILE_EXCL turns this race into a safe, already-handled "copy-failed"/"new-present" outcome instead of silent data loss.

🛡️ Proposed fix using COPYFILE_EXCL
-	const copyFile = seams.copyFile ?? copyFileSync;
+	const copyFile =
+		seams.copyFile ??
+		((src: string, dst: string): void => {
+			copyFileSync(src, dst, constants.COPYFILE_EXCL);
+		});
 	const newPath = defaultRegistryPath(home, env, platform);
 	const legacyPath = legacyRegistryPath(home);
 	...
 	try {
 		makeDir(dirname(newPath));
 		copyFile(legacyPath, newPath);
 	} catch {
+		// EEXIST here means a concurrent writer won the race: treat exactly like the
+		// pre-check "new-present" idempotent no-op rather than losing the race silently.
 		return { migrated: false, reason: "copy-failed" };
 	}

(add constants to the node:fs import)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/registry.ts` around lines 505 - 562, migrateRegistry has a race where
copyFileSync can silently overwrite an already-created new registry file after
the exists(newPath) check. Update the migrateRegistry flow to use an exclusive
copy via COPYFILE_EXCL (and add constants to the node:fs import) so concurrent
boots or a failed exists probe resolve to the existing copy-failed/new-present
handling instead of clobbering data; the key symbols to adjust are
migrateRegistry, copyFile, and newPath.
src/apiary-migration.ts (1)

187-203: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Migration outcomes (reason, legacyRenamed, failed) are never logged.

Both WorkspaceMigrationResult.reason and RegistryMigrationResult.reason are documented as an "audit trail" (Lines 70-71 in this file; similarly commented in registry.ts), but only the workspace-overridden branch inside migrateDoctorWorkspace actually calls logger.info(...). Every other outcome — migrated, no-legacy, legacy-unreadable, error, and the entire registry migration result — is silently discarded here, and the referenced caller (runWatchdog in src/cli/index.ts) doesn't consume the return value either. If a production migration silently fails (e.g. copy-failed, legacy-malformed), there will be no trace in the logs to explain why the fleet is still reading from the legacy location.

♻️ Proposed fix: log a summary before returning
 export function runApiaryMigrations(options: ApiaryMigrationOptions = {}): ApiaryMigrationsResult {
+	const logger = options.logger ?? silentLogger;
 	const workspace = migrateDoctorWorkspace(options);
 	let registry: RegistryMigrationResult;
 	try {
 		registry = migrateRegistry(options);
 	} catch {
 		// migrateRegistry is itself total; this is defense in depth so boot never throws.
 		registry = { migrated: false, reason: "copy-failed" };
 	}
+	logger.info("apiary_migration.summary", {
+		workspaceReason: workspace.reason,
+		workspaceFailed: workspace.failed,
+		registryReason: registry.reason,
+	});
 	return { workspace, registry };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/apiary-migration.ts` around lines 187 - 203, The migration results from
runApiaryMigrations are returned silently, so the audit-trail fields like
reason, legacyRenamed, and failed never reach logs. Update runApiaryMigrations
to log a concise summary of both migrateDoctorWorkspace and migrateRegistry
outcomes before returning, using the existing logger path and the result types
WorkspaceMigrationResult and RegistryMigrationResult. Ensure every outcome
branch (including no-legacy, legacy-unreadable, error, copy-failed, and
migrated) is recorded, not just the workspace-overridden case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@library/knowledge/private/architecture/ADR-0003-fleet-directory-ownership-and-neutral-state-root.md`:
- Around line 21-28: The pseudocode signature in the ADR is out of sync with the
mirrored implementation, so update the canonical description to match the actual
parameter order used by resolveApiaryRoot(env, home, platform). Keep the
branching logic the same, but rewrite the resolveFleetRoot header and
surrounding wording so the spec reflects the byte-for-byte mirror and does not
imply a different argument order. Use resolveFleetRoot and resolveApiaryRoot as
the identifying symbols when locating the text.

In `@src/apiary-root.ts`:
- Around line 57-67: The root validation in isAbsoluteRoot currently uses
win32.isAbsolute, which allows POSIX-shaped paths like /srv/apiary on Windows;
tighten this check so only Windows-style absolute roots are accepted. Update
isAbsoluteRoot to require a drive-letter or UNC root explicitly, and keep the
helper as the single place used by the env-root resolution logic in
src/apiary-root.ts.

In `@src/compose/index.ts`:
- Around line 293-330: The explicit registryPath branch in resolveDaemons is
bypassing the injected environment and platform and instead letting
readRegistryFile use process defaults, which breaks hermetic behavior. Update
the options.registryPath path to pass the same env and platform values used by
resolveRegistryEntries so both code paths resolve registry defaults
consistently; use resolveDaemons and readRegistryFile as the key symbols to
locate the change.

---

Outside diff comments:
In `@src/service/index.ts`:
- Around line 206-225: The unit-file staging in the write path for
`renderUnit(p)` only creates the parent directory of `unitTarget`, so a fresh
macOS install can still fail when `StandardOutPath` and `StandardErrorPath`
point into `p.stateDir`. Update the `needsFile` block in `src/service/index.ts`
to create `p.stateDir` itself before calling `fs.writeFile`, while keeping the
existing `dirname(unitTarget)` creation for the plist target.

---

Nitpick comments:
In `@src/apiary-migration.ts`:
- Around line 187-203: The migration results from runApiaryMigrations are
returned silently, so the audit-trail fields like reason, legacyRenamed, and
failed never reach logs. Update runApiaryMigrations to log a concise summary of
both migrateDoctorWorkspace and migrateRegistry outcomes before returning, using
the existing logger path and the result types WorkspaceMigrationResult and
RegistryMigrationResult. Ensure every outcome branch (including no-legacy,
legacy-unreadable, error, copy-failed, and migrated) is recorded, not just the
workspace-overridden case.

In `@src/cli/index.ts`:
- Around line 132-142: The legacy doctor workspace path computation is
duplicated in multiple entry points, so extract it into a shared helper and
reuse it wherever createInstallLock is called. Add a dedicated helper like
legacyDoctorWorkspaceDir in apiary-root.ts to encapsulate
join(legacyHoneycombRoot(home), "doctor"), then update src/cli/index.ts and
src/compose/index.ts to call that helper instead of repeating the path/comment
logic.

In `@src/registry.ts`:
- Around line 505-562: migrateRegistry has a race where copyFileSync can
silently overwrite an already-created new registry file after the
exists(newPath) check. Update the migrateRegistry flow to use an exclusive copy
via COPYFILE_EXCL (and add constants to the node:fs import) so concurrent boots
or a failed exists probe resolve to the existing copy-failed/new-present
handling instead of clobbering data; the key symbols to adjust are
migrateRegistry, copyFile, and newPath.

In `@tests/apiary-root.test.ts`:
- Around line 42-47: Add a regression test in the resolveApiaryRoot suite to
cover the win32 drive-relative absolute-path case: when APIARY_HOME is a
POSIX-style path like /srv/apiary and platform is "win32", assert the current
accepted-as-absolute behavior is pinned. Use the existing resolveApiaryRoot
helper alongside the other security cases so any future change to isAbsoluteRoot
or its win32 handling becomes intentional and visible.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e50aa91-1b74-452e-9bd8-00ec1db3ecf6

📥 Commits

Reviewing files that changed from the base of the PR and between ce5ce5c and 0ae3209.

📒 Files selected for processing (33)
  • library/knowledge/private/architecture/ADR-0003-fleet-directory-ownership-and-neutral-state-root.md
  • library/requirements/backlog/prd-004-apiary-fleet-root-migration/qa/2026-07-04-qa-report.md
  • src/apiary-migration.ts
  • src/apiary-root.ts
  • src/cli/index.ts
  • src/compose/index.ts
  • src/config.ts
  • src/device-id.ts
  • src/install-lock.ts
  • src/registry.ts
  • src/service/index.ts
  • src/service/platform.ts
  • src/service/templates.ts
  • src/telemetry/capture.ts
  • tests/apiary-registry.test.ts
  • tests/apiary-root.test.ts
  • tests/apiary-supervision.test.ts
  • tests/apiary-workspace-migration.test.ts
  • tests/cli/run-watchdog.test.ts
  • tests/cli/status-version-wiring.test.ts
  • tests/compose/create-doctor.test.ts
  • tests/compose/multi-daemon.test.ts
  • tests/compose/telemetry-wiring.test.ts
  • tests/config.test.ts
  • tests/device-id.test.ts
  • tests/install-lock.test.ts
  • tests/registry.test.ts
  • tests/service/helpers.ts
  • tests/service/service-module.test.ts
  • tests/service/templates.test.ts
  • tests/setup.ts
  • tests/telemetry/capture.test.ts
  • vitest.config.ts

Comment thread src/apiary-root.ts
Comment thread src/compose/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant