ClientUtils.Harness.Onboarding (client_utils v0.1.31)

View Source

What onboarding a working copy decides, with none of the doing.

Pure policy, so the two callers can share it without sharing anything else. Mix.Tasks.Harness.Onboard writes these results to disk for a generated application; CodeMySpec's Mix.Tasks.Cms.Harness.Onboard writes the same results through its own environment abstraction, by passing io: {CodeMySpec.Environments, env} and no adapter at all — see ClientUtils.Harness.Onboarding.Port, whose four callbacks are that module's own functions for exactly this reason. Neither this module nor that task learns what the other is.

That claim was false for a week. c570d32 introduced the port and reported the integration in the past tense — "CodeMySpec's own task calls the same code with its own filesystem adapter" — while nothing there called it and the port as written could not have carried it. Both halves are true as of 0.1.30.

That split is the reason onboarding lives here rather than in CodeMySpec: a generated application depends on client_utils and not on CodeMySpec, so this is the only placement where both can onboard themselves with one command.

Nothing here touches a disk or a database

Every function returns a value. The commands for creating and migrating databases are returned as strings — they are handed to whoever ran the command, never executed. That rule is not stylistic: the version of this that ran them shelled out to mix with an environment that had MIX_ENV scrubbed out of it, resolved to the shared development database, and a sibling truncate emptied it three times on 2026-08-13.

Summary

Functions

The address an agent's model turns relay through.

Whether the copy at root has been onboarded, and what to run if not.

Every database this working copy uses, each with the commands that create and migrate it.

Git settings a working copy needs.

The file hooks resolve identity from.

Configure the working copy at root, and report what is left.

The database partition name for a working copy at root.

The partition recorded for the copy at root.

The recorded partition, or a raise naming what to run.

The settings a working copy needs, as a map ready to be encoded.

The file settings belong in. Untracked, deliberately — see settings/2.

Functions

anthropic_url(base_url, harness_id)

@spec anthropic_url(String.t(), String.t()) :: String.t()

The address an agent's model turns relay through.

One function, both callers, on purpose. This string was rendered twice — once here and once in CodeMySpec's own task — and the two disagreed: /api/harnesses/<id> against /harnesses/<id>/anthropic, of which only the first is a real route in CmsHarness.Web.Router. A copy onboarded by the wrong one relays every turn to a 404 and its agent's work never reaches the server.

That is the defect this whole story exists to remove — two derivations of one value, disagreeing by accident — reproduced by the refactor meant to fix it, and hidden because each half looked right on its own. Neither half should be able to render this alone.

The caller supplies the base because only it knows one: CodeMySpec reads the port the generated plugin was addressed to.

check(root, opts \\ [])

@spec check(
  String.t(),
  keyword()
) :: %{onboarded: boolean(), missing: [String.t()], remedy: String.t()}

Whether the copy at root has been onboarded, and what to run if not.

Absence has to report itself. A missing thing that produces no error where it is missing surfaces somewhere else wearing another failure's costume, and stopping that is what onboarding is for.

So this asserts the keys settings/3 writes rather than that its file exists. Existence was the whole test, and it answered onboarded: for a copy that was not — a run that refused partway leaves the file behind, written by an earlier attempt, holding neither CMS_HARNESS_ID nor an id on ANTHROPIC_BASE_URL. The observed output printed onboarded: and hooks: skipped two lines apart, and the copy then addressed the parent checkout's harness for three days while its own went unscanned — requirements, hooks and MCP all answering confidently about a working copy nobody was editing (c79ee092).

missing names which keys are absent, because "not onboarded" without a reason sends the reader back to the file to work out which half failed — and a half-onboarded copy is the case where that matters most.

The remedy stays mix harness.onboard: the write is a merge, so re-running it repairs a partial copy without disturbing anything the operator put there.

databases(partition, app)

@spec databases(String.t(), atom() | String.t()) :: [
  %{
    name: String.t(),
    partition: String.t(),
    create: String.t(),
    migrate: String.t()
  }
]

Every database this working copy uses, each with the commands that create and migrate it.

Three, not one. An analyzer and an interactive session deliberately use different databases — running a suite and an analyzer concurrently against one produced 13 orphaned rows and 17 phantom failures — so reporting a single database hides the others, and hiding one is how an agent migrates the rest and stays blocked on it for three days.

<partition> the interactive session's mix test <partition>s the analyzer's spex run <partition>a the analyzer's exunit run

The third is newer than the other two and closes the last pair that still shared. mix harness.onboard records MIX_TEST_PARTITION in .claude/settings.local.json's env block so resolve_partition!/2 can read it back — and Claude Code exports that block into the agent's session, so the agent's own mix test and the analyzer's exunit run resolved to the same name. Two full suites in one database, live every time someone tested during a sweep, failing in the direction of "your branch broke these tests".

This list is what tells anyone which databases a working copy uses, and nothing here creates them: onboarding names databases and does not manage them, after a version that shelled out to mix with MIX_ENV scrubbed and emptied dev three times on 2026-08-13. A partition missing from here is a database nobody knows about.

What a host does with the list is its own business, and CodeMySpec now creates them in-VM on the first mix test or mix spex that needs one — which is a different thing from a background process with DDL rights over a server an absent environment variable could re-aim. This library still returns strings.

Each command names its own database for the same reason. The migration guard printed MIX_ENV=test mix ecto.migrate while checking a partition that command never touches; an agent followed it correctly and nothing changed.

The name has no separator before the partition, because that is what config/test.exs composes: "<app>_test<partition>". An earlier version wrote _test_, so the printed name and the database its own command created differed by one character — the same defect as the hardcoded prefix before it, surviving the fix for that one because the output was only ever compared to itself (c4a2acae).

git_config()

@spec git_config() :: [{String.t(), String.t()}]

Git settings a working copy needs.

submodule.recurse because a submodule sits in detached HEAD by design and so accepts writes with no branch to carry them. Four QA briefs — including the evidence behind a story shipped with QA recorded complete — existed in exactly one working copy for a day because of it, and nothing reported a problem.

harness_config_path()

@spec harness_config_path() :: String.t()

The file hooks resolve identity from.

CMS_HARNESS_ID in .claude/settings.local.json's env block only reaches MCP — Claude Code interpolates ${CMS_HARNESS_ID} into the request header itself. Hooks run through a separate Go relay that never sees that interpolation and has no environment variable to fall back to; it resolves a working copy's identity by walking up from the hook payload's own cwd looking for this file's harness_id key. A copy addressed only in settings.local.json has working MCP and silently unaddressed hooks — the relay omits the header with no error, and the server-side refusal is the only place that ever said so.

onboard(root, opts \\ [])

@spec onboard(
  String.t(),
  keyword()
) :: map()

Configure the working copy at root, and report what is left.

Writes the settings a copy needs, sets the git config it needs, then names the databases it still wants — and does not create them. Returns a report; the caller decides how to show it.

Pass io: to route the writes somewhere other than the real filesystem. The default is FileIO, which is what a generated application gets without configuring anything.

No database is created, migrated or dropped. The commands come back as strings and running them is the operator's job. The rule is not stylistic: the version that ran them shelled out to mix with an environment that had MIX_ENV scrubbed out of it, resolved to the shared development database, and a sibling truncate emptied it three times on 2026-08-13.

No worktree is created. This configures the copy it is pointed at.

partition_name(root)

@spec partition_name(String.t()) :: String.t()

The database partition name for a working copy at root.

Assigned here once, at onboarding, and recorded — which is the whole point. Two mechanisms currently derive this independently and disagree by accident: config/test.exs builds a name from the worktree path, TestDatabase digests the cwd. The digest below is not better than either; it is only computed in one place and written down, and that is what makes it answerable.

resolve_partition(root, opts \\ [])

@spec resolve_partition(
  String.t(),
  keyword()
) :: {:ok, String.t()} | {:error, :not_onboarded}

The partition recorded for the copy at root.

Read, never derived. There is one path: onboarding assigns the partition and writes it down, and everything afterwards reads that. A copy with nothing recorded is not onboarded, and this says so rather than inventing a value.

The fallback that used to live here was the second mechanism. Onboarding recorded a partition and nothing read it: CmsHarness.TestDatabase digested the cwd, partition_name/1 digested it identically, and the analyzer set MIX_TEST_PARTITION from its own copy. They agreed because one algorithm had been written twice — a coincidence maintained by hand rather than a shared value, and recording a value nobody reads turned two derivations into three (acaf8035). Leaving a derive-on-miss path in the resolver would have kept both alive, silently, for exactly the copies nobody had onboarded.

partition_name/1 still exists, and is only for assigning a name during onboarding. Nothing resolves through it.

Pass read: to resolve through something other than the filesystem — a 1-arity function taking a path relative to root.

resolve_partition!(root, opts \\ [])

@spec resolve_partition!(
  String.t(),
  keyword()
) :: String.t()

The recorded partition, or a raise naming what to run.

For callers that cannot proceed without one. The message is the remedy, because a refusal whose fix is not stated is the failure this story is about.

settings(harness_id, partition, base_url, relay_model_turns? \\ false)

@spec settings(String.t() | nil, String.t(), String.t(), boolean()) :: map()

The settings a working copy needs, as a map ready to be encoded.

Goes to .claude/settings.local.json, never .claude/settings.json. The base URL carries the harness id, and settings.json is tracked — so writing it there stages one machine's identity for commit and the next clone inherits it, looking onboarded while talking to a harness that is not its own.

ANTHROPIC_BASE_URL is opt-in (relay_model_turns?, default false) and CMS_HARNESS_ID is not. Every model turn an agent makes is Claude Code's own Anthropic client, posted directly, and the harness proxy in front of it only understands /v1/messages — no other route it forwards, /remote-control included, so setting this address on an interactive session silently makes those unavailable with no error anywhere. CMS_HARNESS_ID carries none of that risk: it only ever reaches MCP, interpolated into a header, so it stays on whenever there is an id to give.

settings_path()

@spec settings_path() :: String.t()

The file settings belong in. Untracked, deliberately — see settings/2.