ClientUtils.Harness.Onboarding (client_utils v0.1.28)
View SourceWhat 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.OnboardHarness writes the same results
through its own environment abstraction. Neither this module nor that task
learns what the other is.
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
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.
@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.
@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 to create, and nothing creates
them on its own: the harness 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. So a partition missing from here is a database nobody is
told to create, and the analyzer meets it as "database does not exist".
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 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.
@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.
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.
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.
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.
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.
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.
@spec settings_path() :: String.t()
The file settings belong in. Untracked, deliberately — see settings/2.