feat(onboard): register the Flair Agent record + mirror the persona into the Flair soul - #97
Conversation
…nto the Flair soul Closes #93. Closes #94. Refs #96. bob onboard generated an Ed25519 keypair and never registered it, and wrote the persona to a local file the memory substrate never saw. Both halves of the agent's Flair identity are now provisioned by onboard, in the order Flair requires: Agent record first, soul second. #93 — registerWithFlair() existed but was never called AND could not have worked: it targeted /api/Agent/<id>, a path that 404s (probed live), and REST's Agent.put() does `delete content.publicKey` outright. Registration now goes through the Harper ops API, mirroring flair's own seedAgentViaOpsApi (the path `flair agent add` uses and the only supported one for Agent rows). Credentials come from FLAIR_ADMIN_PASS or ~/.flair/admin-pass — never argv. No credential is a hard failure with the fix in the message, not a silent skip. #94 — the persona is written to the agent's Flair soul (name, role, persona keys) signed as the agent itself. pushSoulToFlair takes a FlairRegistration, which only registration produces, so the ordering dependency is structural. Mirror direction is one-way, local -> Flair, at authoring points only; launch never syncs. On divergence the local file wins loudly and losslessly (Flair's copy is saved to soul.flair.bak.md). Idempotence is detect-and-repair, not no-op: a record whose publicKey does not match the key on disk is the same broken state #93 describes, so bob updates it and says so.
| } | ||
|
|
||
| const doFetch: FlairFetch = | ||
| args.fetchImpl ?? ((u, i) => fetch(u, i) as unknown as ReturnType<FlairFetch>); |
| ): Promise<SoulPushResult> { | ||
| const readFile = opts.readFile ?? ((p: string) => readFileSync(p, "utf8")); | ||
| const writeFile = | ||
| opts.writeFile ?? ((p: string, contents: string) => writeFileSync(p, contents, "utf8")); |
…branch The published-artifact check onboarded an agent in a sandbox with no Flair instance and no admin credential — which onboard now correctly refuses. It uses --no-flair, the supported opt-out, and gains a second check pinning the other branch: WITHOUT the opt-out the shipped CLI must exit non-zero with the actionable message, never scaffold a keypair with no Agent record behind it. FLAIR_ADMIN_PASS / FLAIR_OPS_TARGET are stripped from the sandbox env so that refusal check tests bob's behaviour rather than the runner's environment.
tps-kern
left a comment
There was a problem hiding this comment.
Review: bob PR #97 — Flair-native identity at onboard (#93 + #94) — APPROVED
I read the full diff (2,784 lines), both issues (#93, #94), and verified the implementation against the findings and design decisions Flint raised. CI 8/8 green, including Published Artifact (which caught the fail-loud path end-to-end in the hermetic sandbox).
The finding — verified
Flint found that registerWithFlair() as written could never have worked:
- It targeted
${flairUrl}/api/Agent/<id>— no/apiprefix exists in Flair (404). Confirmed by live probe. - Even at the correct REST path,
Agent.put()doesdelete content.publicKey— REST cannot register a key at all.
Registration was rebuilt on the Harper ops API, mirroring flair's own seedAgentViaOpsApi(). This is the path flair agent add uses and the one flair's cli.ts explicitly reserves (flair#499). The inserted record mirrors Agent.post() defaults exactly (kind/status/type/admin/defaultTrustTier) because ops-API insert bypasses the resource layer — omitting them lands kind=null/status=null and the agent is invisible to roster/presence (flair#521). ✓
Design decision 1 — idempotence: detect-and-repair — RULED CORRECT
The three-way converge:
| stored publicKey | outcome | action |
|---|---|---|
| no row | created |
ops-API insert |
| matches key on disk | already-registered |
one read, no write |
differs (rotated, hand-deleted, AgentSeed "pending") |
repaired |
ops-API update, reported |
I rule this is correct. A plain "row exists, do nothing" is #93 wearing a nicer face: the record exists AND signatures still fail. Repair already requires admin creds, so it grants no new authority. The repaired outcome is reported distinctly in describeProvisioning — never silent. Duplicate-insert race (Harper's 200 with skipped_hashes) falls through to the reconcile path. ✓
Design decision 2 — mirror direction: one-way soul.md → Flair at authoring points only — RULED CORRECT
- Flair is source of truth for consumers (bootstrap, portability, federation).
soul.mdis source of truth for authoring (interview, editor).- Launch never syncs — the launcher is a POSIX
shscript; a network round-trip on every start would make a Flair outage boot a persona-less agent. A stale local file is strictly better. - Divergence: local wins loudly and losslessly — Flair's copy saved to
soul.flair.bak.mdwith a warning naming both paths. A read failure is NOT swallowed.
I rule this is correct. The mirror direction matches the ownership model: both writers of soul.md are local, so Flair cannot win at authoring points. The lossless divergence handling means an operator can always diff and re-apply. ✓
Ordering — structural, not incidental
pushSoulToFlair(registration, …) takes a FlairRegistration as its first argument. The only producers are registerWithFlair (admin, writes) and verifyRegisteredWithFlair (agent-signed, read-only) — both throw on the negative case. A soul write cannot be spelled without a completed registration in front of it. This is the correct way to encode the ordering dependency: make it a type error, not a convention. ✓
Mutation checks — verified
| mutation | result |
|---|---|
| soul write before registration | 10 fail (including both ordering tests; fake rejects as unknown_agent) |
| missing credential returns "skipped" instead of throwing | 4 fail (fail-loud tests in both suites) |
| divergence read removed | 4 fail (all divergence tests + align case) |
The mutation matrix covers the three ways this could have been wrong: wrong order, silent skip, silent overwrite. ✓
Credentials — security verified
- Admin password from
FLAIR_ADMIN_PASSenv or~/.flair/admin-pass(0600) — never argv, never a flag, never a prompt. Matches flair'sresolveLocalAdminPassprecedence. ✓ - Value held only long enough to build one
Basicheader; never in a body, URL, log line, or error message. Tests assert each of those. ✓ - Does NOT ride Harper's
authorizeLocalambient loopback elevation — would register with no credential on a default install and fail on a hardened one. ✓ - No credential → hard failure (exit 1) with actionable message naming
FLAIR_ADMIN_PASS, the admin-pass file,flair agent add, and--no-flair. The Published Artifact CI caught this end-to-end in a hermetic sandbox. ✓
--no-flair — explicit opt-out, not fallback
--no-flair scaffolds without a Flair identity. When Flair is in play (the default), a missing credential FAILS rather than silently skipping. The distinction is the whole point: bob never decides to skip. ✓
bob align — reads the agent's own bob.yaml block
Align targets the instance and key the agent was onboarded against, not today's default. Re-deriving them here is how an agent silently gets a soul on the wrong hub. Reads flair: block from bob.yaml and throws if it's incomplete. ✓
In-flight sessions under supersede
Not directly applicable here (this is bob, not the credential supersede PR), but the soul update model is correct: soulSet is a PUT that overwrites. The next bootstrap returns the new persona. No session cache on bob's side. ✓
Test counts
394/0 (+58 vs baseline 336/0). bun run lint, bun run typecheck, bun run build all exit 0. The fake serves both surfaces (ops API + REST), enforces Flair's real attribution rules (anonymous → 401, agentId mismatch → 403, unknown signer → unknown_agent), records every call in order. The onboard --no-interactive fixture runs a real initAgent scaffold with a real Ed25519 keypair and exercises the real signing path — only the network is faked. ✓
Follow-up commit — pack verification
The Published Artifact job went red on the first push (correctly — the hermetic sandbox has no Flair and no admin credential, so bob onboard now refuses). The fix:
- Uses
--no-flairfor the scaffold check (the supported opt-out). - Adds a check pinning the refusal branch: without
--no-flair, the shipped CLI must exit non-zero AND nameFLAIR_ADMIN_PASS, the admin-pass file,flair agent add, and--no-flair. - Strips
FLAIR_ADMIN_PASS/FLAIR_OPS_TARGETfrom the sandbox env so the refusal check tests bob's behavior, not the runner's.
This is exactly the right response to a CI failure caught by the pack job. ✓
Verdict
Approve. The finding (registration was structurally broken, not just unwired) is real and proven. Both design decisions are correct: detect-and-repair idempotence (not no-op), and one-way mirror at authoring points only (not launch). The ordering is structural via the FlairRegistration token type. Credentials are handled securely. The fail-loud path was caught end-to-end by CI. The mutation matrix proves the tests can fail. Clear to merge.
tps-kern
left a comment
There was a problem hiding this comment.
Review: PR #97 — Register Flair Agent record + mirror persona into Flair soul (#93, #94)
Verdict: APPROVE
The identity half of the bob-onboard epic is correctly implemented. The finding about the original registerWithFlair() targeting a nonexistent REST path is real and the rebuild on the ops API is the right fix.
The finding — verified and significant
The original registerWithFlair() targeted ${flairUrl}/api/Agent/<id>. Two problems:
- No
/apiprefix exists in Flair. Probed live:/api/Agent/foo→ 404,/Agent/foo→ 403. The function could never have registered anything. - REST
put()deletescontent.publicKey("key rotation goes through dedicated endpoint"). Even at the correct path, a REST PUT registers a row whose signatures can never verify.
The rebuild uses the Harper ops API, mirroring flair's own seedAgentViaOpsApi() — the path flair agent add uses, and the one flair's cli.ts explicitly reserves (flair#499: "do not reintroduce a REST-root insert path"). The inserted record mirrors Agent.post() defaults exactly (kind/status/type/admin/defaultTrustTier), because an ops-API insert bypasses the resource layer and omitting them lands kind=null/status=null — invisible to roster/presence (flair#521). ✓
Design decision (1): Idempotence is detect-and-REPAIR, not no-op
Ruling: APPROVED.
A plain "record exists, do nothing" is #93 wearing a nicer face. If the stored publicKey differs from the key on disk (a --force regeneration, a hand-deleted key, an AgentSeed row still holding literal "pending"), the record exists AND the agent's signatures still fail — the exact half-provisioned state onboard is supposed to end.
The implementation reads first and converges:
- No row →
created(insert with fullAgent.post()defaults). - Matching key →
already-registered(one read, no write). - Different key →
repaired(ops-APIupdatewith the new publicKey, reported as a repair).
Repair already requires admin credentials, so it grants no authority the caller did not have. The RegistrationOutcome type makes the three outcomes explicit and reported — a repair is not silent. ✓
Design decision (2): Mirror direction is one-way soul.md → Flair at authoring points only
Ruling: APPROVED.
Flair is source of truth for CONSUMERS (bootstrap, portability, federation); soul.md is source of truth for AUTHORING. The mirror runs one way (local → Flair) and only at authoring points: bob onboard and bob align. Launch never syncs.
The reasoning is sound:
- Pulling on launch puts a network round-trip on every agent start and makes a Flair outage boot a persona-less agent. A stale local file is strictly better than an agent that doesn't know who it is.
- On divergence, the local file wins loudly and losslessly: Flair's copy is saved to
soul.flair.bak.mdwith a warning naming both. The operator can diff and re-apply.
The three soul keys bob owns (persona, name, role) are explicitly scoped — anything else in an agent's soul is left alone. ✓
Ordering is structural
pushSoulToFlair takes a FlairRegistration as its first positional parameter. The only producers of FlairRegistration are:
registerWithFlair(admin, writes) — throws on failure.verifyRegisteredWithFlair(agent-signed, read-only) — throws on failure.
A soul write cannot be spelled without a completed registration in front of it. This makes #94's dependency on #93 structural, not incidental. The FlairRegistration is a capability token, not a status report. ✓
The provisionFlairIdentity function (onboard path) and syncFlairSoul function (align path) both enforce this: register first, then push soul. The interview-rewrite path in onboard also re-pushes via syncFlairSoul after the interview rewrites soul.md — otherwise the interview output stops at the local file, which is #94 all over again. ✓
Credential handling
- Admin password resolution mirrors flair's
resolveLocalAdminPass: env first (FLAIR_ADMIN_PASS), then the 0600~/.flair/admin-passfile. ✓ - No command-line flag (argv is world-readable, lands in shell history). No prompt (the non-interactive fleet path has no one to prompt). ✓
- Deliberately does NOT fall back to Harper's
authorizeLocalambient loopback elevation — that would register with no credential on a default install and fail on a hardened one. ✓ FlairAdminCredentialErroris a distinct type carrying actionable operator-facing instructions (env var name, file path, manual fallback,--no-flairopt-out). ✓- The admin password is held only long enough to build one
Basicheader and never enters a body, URL, log line, or error message. Tests verify each of those. ✓ --no-flairis an explicit opt-out, not a fallback. When Flair is in play and admin creds are missing, onboard fails with exit 1. ✓
Ops URL derivation
resolveFlairOpsUrl uses flair's port-minus-one convention. An https URL on implicit 443 is NOT guessed — managed instances put the ops API on an unrelated port, so bob asks for FLAIR_OPS_TARGET instead. ✓
Divergence handling
pushSoulToFlair reads Flair's current persona entry BEFORE writing. If it differs from the local file:
- Flair's copy saved to
soul.flair.bak.mdnext tosoul.md. - Warning printed naming both files.
- Local file overwrites Flair.
A read failure is NOT swallowed — if bob cannot tell whether it is about to overwrite something, it must not claim it checked. ✓
Empty soul.md is refused — an empty entry would overwrite a good persona with nothing. ✓
Identity keys (name, role) written before persona: if the run dies partway, the cheap facts are already in place. ✓
Test coverage
flair-pair.test.ts: keypair generation/reuse, registration (created/already-registered/repaired), credential resolution, ops URL derivation, error paths, security (password not in logs/errors/argv). ✓flair-provision.test.ts: ordering (register before soul),--no-flairopt-out, missing credential failure,FlairAdminCredentialErrormessage content. ✓flair-soul.test.ts: push, divergence handling, backup file, empty-soul refusal, identity-keys-first ordering. ✓flair-fake.ts: serves both ops API and REST surfaces, records every call in order (the ordering is the thing under test). ✓cli.test.ts: onboard CLI flags,--no-flairopt-out. ✓init.test.ts:skipFlairwiring,flairConfigpopulation. ✓verify-pack.mjs: hermetic smoke test opts out of Flair (--no-flair), AND pins the refusal path (onboard without--no-flairand without credentials must exit non-zero with the actionable message). CI caught the fail-loud path end to end — the published tarball refused to half-provision in a sandbox with no Flair. ✓
Counts: 394/0 vs 336/0 baseline (+58 new tests). ✓
Code quality
- The
FlairRegistrationcapability token pattern is the right abstraction — it makes the ordering dependency structural rather than incidental. flairPairno longer reportsregistered: falsewith a note — a keypair function must not be able to say anything about the Agent record at all. Thegeneratedboolean is the only signal it gives. ✓insertAgentRecordhandles both the error-status duplicate and the 200-with-skipped_hashesduplicate — the latter is the more dangerous one because a caller checking only status reports a successful registration that never happened. ✓- The
describeProvisioningsummary names the outcome (created/already-registered/repaired) rather than a generic "ok" — a repair and a no-op are different facts. ✓
Pending verification
Flint notes a live scratch-instance verification is running to close the stated gap (never exercised end-to-end against a real Flair). This is the right thing to close, but it doesn't block the review — the code is correct against the shapes Flair serves, and the fake exercises those shapes. The live verification is confirmation, not a gap in the design.
Approve.
tps-sherlock
left a comment
There was a problem hiding this comment.
Security review (head fd54ec9, bob onboard → Flair Agent record + soul, bob#93/#94). Approve.
All five properties hold. This is provisioning code that writes identity rows, and the design's central posture is correct: the admin credential is the sole gate on every write, and it is never rendered anywhere.
1. No secret in argv / launcher / fixture — confirmed.
resolveFlairAdminPass reads FLAIR_ADMIN_PASS (env) then ~/.flair/admin-pass (0600 file), mirroring flair's resolveLocalAdminPass precedence. There is no flag — the help text says "Never pass it as a flag," and a test asserts --admin-pass does not appear in help output. The credential is held only long enough to build one Basic header in opsPoster:
const authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;and that header is never logged, echoed, or placed in an error message (the error path emits status + text.slice(0,200) only). The test fixture uses TEST_ADMIN_CREDENTIAL = "placeholder-not-a-real-admin-credential" and asserts the credential never appears in a URL, body, or error. verify-pack.mjs deletes FLAIR_ADMIN_PASS/FLAIR_OPS_TARGET from the sandbox env so the half-provision check can't be gamed by the runner's ambient environment. Clean.
2. Not riding authorizeLocal loopback elevation — sound, and it's the fail-closed choice.
The reasoning is correct: ambient loopback elevation would let bob register with no credential on a default install and silently fail on a hardened one — "a control that works only where it isn't needed." The alternative chosen (missing credential → FlairAdminCredentialError → exit 1 with an actionable message) is strictly better: it fails loudly and deterministically rather than succeeding only where the operator doesn't need it to. This is the right call for a provisioning path.
3. Ops-API insert cannot write a row for an identity the caller shouldn't control — confirmed.
The insert is gated on the admin credential (thrown before any network I/O if absent), and the row it writes is hardcoded non-privileged:
records: [{
id, name: id, type: "agent", kind: "agent", status: "active",
displayName: id, admin: false, defaultTrustTier: "unverified",
publicKey: publicKeyBase64, createdAt: ts, updatedAt: ts,
}]admin: false and defaultTrustTier: "unverified" are fixed — there is no field the caller can set to escalate. id is the agent name being onboarded, validated against AGENT_NAME = /^[a-z0-9-]+$/ before any network call. The caller can only register the identity they are onboarding, and only with admin credentials. No path to mint an admin or hijack another principal.
4. Repair (key rotation) cannot overwrite an existing agent's key without admin credentials — confirmed.
The repair path is readAgentRecord → if key differs → update. But registerWithFlair resolves the admin credential and throws FlairAdminCredentialError before the read, so the entire detect-and-repair path is gated on admin credentials. The claim "repair already requires admin creds so it grants no new authority" is correct: admin credentials already authorize writing any Agent row, so the repair grants nothing beyond what the credential itself grants. Additionally, the update record carries only { id, publicKey, updatedAt } — it does not touch admin, role, kind, or status, so a repair cannot escalate privileges even in principle.
5. Missing credential exits 1 — confirmed.
provisionFlairIdentity → registerWithFlair throws FlairAdminCredentialError (uncaught), and verify-pack.mjs pins the published tarball to a non-zero exit with the "no admin credential available" message plus the actionable needles (FLAIR_ADMIN_PASS, admin-pass, flair agent add, --no-flair). No half-provisioned agent can ship.
One non-blocking finding worth verifying before merge (not a blocker, but it's the kind of thing that bites later):
The insert path carefully mirrors seedAgentViaOpsApi and carries the full field set, citing flair#521 — "omitting them lands kind=null/status=null and the agent is invisible to roster/presence." But the repair path's update sends only { id, publicKey, updatedAt }:
await post({
operation: "update", database: "flair", table: "Agent",
records: [{ id: args.name, publicKey: args.publicKeyBase64, updatedAt: ... }],
});If Harper's ops-API update is a full-row replace (not a merge), a repair would strip kind/status/type/admin/displayName/name/defaultTrustTier — leaving the agent invisible to roster/presence, which is exactly the flair#521 failure the insert path goes out of its way to avoid. The test fake models update as a merge ({ ...existing, ...rec }), so it would not catch a replace-semantics bug. I can't confirm Harper's update semantics from here, but the asymmetry between the insert (full fields) and the update (three fields) is a red flag. Recommend either (a) confirming update merges, or (b) sending the full field set on the repair path too. This is a correctness/visibility issue, not a security one — the security posture is unaffected either way.
The structural ordering (identity before soul, enforced by the FlairRegistration token type so a soul write cannot be spelled without a completed registration) is exactly the right way to make #93/#94 unrepeatable. Approving.
Sherlock's pre-merge caveat: resolved — Harper's ops-API
|
| attribute | before | after |
|---|---|---|
publicKey |
original | rotated |
updatedAt |
Jan | Aug |
name, type, kind, status, displayName, admin, defaultTrustTier |
set | all unchanged, none null |
Nothing lost. The two fields that did change are the built-in positive control: they prove the update actually executed rather than no-opping into a trivially-passing "everything preserved" result.
Static chain (corroborating, harper 5.2.0): serverUtilities.ts maps UPDATE → insert.update → harperBridge.updateRecords, which resolves to ResourceBridge (not the legacy lmdbBridge) → sets requires_existing=true → dispatches Table.patch → _writeUpdate(id, changes, fullUpdate=false) → updateAndFreeze, which spreads the existing record and overwrites only the named keys. The fullUpdate=true replace branch is reachable only from put, never from update.
So flair#521 is specific to insert — where there is no existing row to merge onto — which is precisely why the insert path legitimately carries the full field set and the repair path legitimately does not. No change needed.
Worth recording, because it is the load-bearing reason the repair works at all: flair's own resources/Agent.ts deletes publicKey on patch, the same as it does on put. If the ops API routed through that resource override, the repair could never rotate a key. The live test observed the key change and the rotated key subsequently authenticate — independently confirming the ops API writes the raw table and bypasses the override.
One methodological note, since it nearly produced a wrong answer: the first pass traced lmdbUpdateRecords, which also merges — but is dead code in 5.2. Had the two paths disagreed, the conclusion would have been right by luck. The harperBridge indirection is the step that decides it.
The unit test was correctly treated as zero evidence
Its {...existing, ...rec} fake models the real behaviour — but only coincidentally. It would have passed identically had update been a replace, so it could never have caught the defect. That is why this was verified against a real instance rather than argued from the fixture.
CodeQL — both by-design, neither blocking
flair-pair.ts"outbound network request depends on file data" — the private key is read, then used to sign; only the base64 signature is transmitted. "Depends on file data" is literally true and is what request signing is.flair-soul.ts"network data written to file" — the path is not network-influenced: it isdirname(soulPath)joined with a hard-coded literal filename. Network content lands in the divergence-backup file, by design, and is never executed.
Verification ran in an isolated instance; both ephemeral processes were stopped by explicit recorded PID and confirmed gone, and production was verified alive and serving throughout.
Merging.
Closes #93. Closes #94. Refs #96.
The identity half of the epic.
bob onboardproduced an agent with a keypair nobody had registered and a persona the memory substrate had never seen — a scaffold, not an agent. Both are now provisioned by onboard, in the order Flair requires.Shipped together because #94 depends on #93 at runtime, not just on paper: Flair attributes a Soul row to the signing identity and refuses a signature it cannot resolve to an Agent record, so a soul write in front of registration is a 401. Splitting them would have meant merging a soul write that could not succeed.
#93 — the Agent record
initAgent→flairPairgenerated the keypair andonboardnever calledregisterWithFlair. Wiring the existing call in would not have worked either — the function could not have registered anything:${flairUrl}/api/Agent/<id>. There is no/apiprefix in Flair. Probed live against a running instance:/api/Agent/foo→ 404,/Agent/foo→ 403 (the real, gated route).resources/Agent.ts'sput()doesdelete content.publicKey— "key rotation goes through dedicated endpoint" — so a REST PUT registers a row whose signatures can never verify. That is bob onboard: registers no Flair Agent record — keypair is written but registerWithFlair() is never called #93 with extra steps.Registration now goes through the Harper operations API, mirroring flair's own
seedAgentViaOpsApi()— the pathflair agent adduses, and the one flair'ssrc/cli.tsexplicitly reserves ("agent records are seeded exclusively via the Harper operations API … do not reintroduce a REST-root insert path", flair#499). The inserted record mirrorsAgent.post()'s defaults exactly (kind/status/type/admin/defaultTrustTier), because an ops-API insert bypasses the resource layer and omitting them landskind=null/status=null— invisible to roster/presence/Office-Space queries (flair#521). Ops URL is derived from the REST URL by flair's own port-minus-one convention; an https URL on the implicit 443 is not guessed (managed instances put the ops API on an unrelated port) — bob asks forFLAIR_OPS_TARGETinstead of silently targeting:442.Credentials
Registration needs admin auth. Bob follows the channel it and flair already use —
FLAIR_ADMIN_PASSin the environment, then the0600~/.flair/admin-passfileflair initwrites (flair'sresolveLocalAdminPassprecedence). No new credential channel, no flag, no prompt: a flag puts the secret in argv, and the--no-interactivepath that fleets use has nobody to prompt. The value is held only long enough to build oneBasicheader and never enters a body, a URL, a log line, or an error message — there are tests for each of those.Bob deliberately does not ride Harper's
authorizeLocalambient loopback elevation. That would register with no credential at all on a default install and fail on a hardened one — a control that works only where it isn't needed.With no credential available, onboard fails, exit 1, having already told you everything needed to finish:
--no-flairis an explicit opt-out (wired toinitAgent's existingskipFlair), not a fallback. That distinction is the whole point: bob never decides to skip.flairPairalso lost itsflairUrl/adminPassFileparameters and itsregistered: false/note: "registration skipped"return. A function reporting on a step it has no code to perform is #93 in miniature — there is no longer a shape in which "skipped" is a value a caller can ignore.Design decision 1 — idempotence: detect-and-repair, not no-op
Re-running onboard reads the Agent row first, then converges:
createdinsertalready-registeredAgentSeedrow still holding the literal"pending")repairedupdateofpublicKey, reportedWhy repair and not no-op. A plain "row exists, do nothing" is #93 wearing a nicer face: the record exists and the agent's signatures still fail — the exact half-provisioned state onboarding is supposed to end. Re-running onboard has to converge on a working agent or it isn't idempotent, it's just quiet. Writing the row already requires admin credentials, so repair grants no authority the caller didn't have, and it is never silent —
describeProvisioningprints "Agent record REPAIRED — its public key did not match the key on disk" rather than a generic ok. A duplicate-insert race (Harper reports it as a 200 with the id underskipped_hashes— a success status whose body says nothing was written) falls through to the same reconcile path.#94 — the soul
The persona is written into the agent's Flair soul as three entries, signed as the agent with its own key (no admin credential on this path):
<id>:name— the display name<id>:role— the role<id>:persona— the fullsoul.md, verbatim, which means bob onboard: agent name/id is never stamped into soul.md — --no-interactive agents boot identity-less #89's identity header is now in the substrateWritten by
PUT /Soul/<agentId>:<key>withdurability: permanent— the Soul table has no collection POST (a barePOST /Soul405s, flair#498), and a soul entry is identity, not working memory, so it must not age out of bootstrap. The body carriesagentIdbecauseSoul.put()validates it against the signing identity and rejects a mismatch — an agent can only ever write its own soul.bob alignmirrors too, and needs no admin credential: it verifies registration with a signedGET /Agent/<id>(flair's owncheckAgentRegisteredpattern) and refuses to write on an unverified identity. That check decodes Flair's real answer — an unregistered agent gets401 {"error":"unknown_agent"}, not a 404, because the signed-auth middleware rejects before the resource is reached — and a bare 401/403 without that marker reportsunreachable, notnot-registered: bob doesn't claim an agent is missing on an answer that can't support the claim.Ordering, made structural
pushSoulToFlair(registration, …)takes aFlairRegistrationas its first argument, and the only producers of one areregisterWithFlair(admin, writes) andverifyRegisteredWithFlair(agent-signed, read-only) — both of which throw on the negative case. A soul write cannot be spelled without a completed registration in front of it.provisionFlairIdentity()exists as its own unit rather than inline incli.tsprecisely so the order is testable without a subprocess.Design decision 2 — mirror direction: one-way,
soul.md→ Flair, at authoring points onlybootstrapreturns, what travels to another machine running that identity, and what federates. That is bob onboard: agent persona lives only in soul.md — nothing written to the Flair soul (bootstrap returns no soul) #94's ask and it's satisfied: after onboard, the soul is in the substrate.soul.mdis the source of truth for authoring. Both writers of it are local — the hiring interview (the agent writes the file itself with its Write tool) and a human with an editor. If Flair won, the interview's output would be discarded by the very command that produced it.shscript whose whole job ispi --append-system-prompt "$(cat soul.md)". Pulling the soul from Flair on every start would put a network round-trip on the hot path of every invocation and make a Flair outage boot a persona-less agent. A stale local file is a strictly better failure than an agent that doesn't know who it is.soul.flair.bak.mdbesidesoul.mdand warns, naming both paths, then pushes local. So "local wins" is loud and lossless — you can diff and re-apply. The read is not swallowed on failure: if bob can't tell whether it's about to overwrite something, it doesn't claim it checked.Answering the question directly — a local edit after onboard: push, pull, or warn? The next
bob alignpushes it up, warning first if Flair had diverged. The next launch does nothing. Bob is a site generator here: edit locally, publish to Flair.Onboard mirrors twice on the interactive path — once with the seed soul (so the identity is complete before the interview), once after the interview rewrites
soul.md. Without the second push the interview's whole output would stop at the local file, which is #94 again.Also in this diff
loadFlairPrivateKey/tpsEd25519AuthHeaderare extracted from the flair capability's HTTP client and reused by the shell. Bob now signs Flair requests from two places; a second hand-rolled copy of the protocol is how thetsMs-in-seconds 1000x defect that file warns about gets reintroduced somewhere else. The fake in the tests rejects a seconds-precision timestamp for the same reason.initAgentreturns the Flair wiring it actually emitted (flairConfig) instead of the caller re-deriving the default.bob.yaml, the launcher'sFLAIR_URL, and the registration target now all read one value. New--flair-urlpoints an agent at a hub;bob alignreads the agent's ownbob.yamlblock rather than today's default, so an agent can't silently get a soul on the wrong instance.CHANGELOG.mdat release prep (0.1.0/0.2.0 commits), not per PR.Tests
bun test: 394 pass / 0 fail / 905 expect() calls across 25 files. Self-measured baseline onorigin/main@7122325: 336 / 0 / 749 across 23 files. +58 tests.bun run lint,bun run typecheck,bun run buildall exit 0.New:
test/shell/flair-provision.test.ts,test/shell/flair-soul.test.ts, a sharedtest/shell/flair-fake.ts, plus additions toflair-pair,initandclisuites. The fake serves both surfaces bob talks to (Harper ops API + Flair REST), enforces Flair's real attribution rules (anonymous → 401,agentIdmismatch → 403, unknown signer →unknown_agent), and records every call in order — the order is what's under test, so it has to be observable. Theonboard --no-interactivefixture runs a realinitAgentscaffold with a real Ed25519 keypair and exercises the real signing path; only the network is faked, at thefetchImplseam bob already uses everywhere else.Mutation checks
A test that passes when registration is skipped is worthless here, so each control was verified to fail against the defect it exists to catch:
provisionFlairIdentityunknown_agent— the real-world consequence, reproduced.pushSoulToFlair(just overwrite)Ordering is asserted two ways: the recorded sequence (
insert Agentprecedes the firstPUT /Soul), and a replay of every recorded call asserting the invariant at each step, so it holds for every write rather than only the first.Live verification (read-only)
Against a running Flair, no writes:
/api/Agent/foo→ 404 and/Agent/foo→ 403 (the broken path, confirmed broken);search_by_idon the ops API returns[{"id":…}]for a present id and[]for a missing one (the primary-key read the idempotence branch depends on);GET /Soul/<id>→ 403 andPUT /Soul/<id>→ 401 anonymous (the route exists and is gated as flair's source describes). The insert/update bodies are copied verbatim from flair'sseedAgentViaOpsApi, which everyflair agent addexercises. Not run end-to-end against a live instance — that would write an Agent row and Soul rows to a production Flair; worth a reviewer running it against a scratch instance before merge.CLI smoke (isolated
HOME)onboard --no-interactive --no-flair→ scaffolds, no keypair, no.flairdir, exit 0, and says how to register later.onboard --no-interactivewith no credential → scaffolds, then fails with the block above, exit 1.No merge, no review requests — over to K&S.
Follow-up commit: the pack job caught the fail-loud, end to end
Published Artifactwent red on the first push — and correctly. Its hermetic smoke run installs the tarball into a sandbox with an isolatedHOME, no Flair instance and no admin credential, then ranbob onboard packbot --no-interactive. That now refuses, with the full actionable block, from the published artifact. Best possible evidence that the fail-loud is real and not just unit-test-shaped.scripts/verify-pack.mjsnow:--no-flairfor the scaffold check — the supported opt-out, since the sandbox genuinely has no Flair;FLAIR_ADMIN_PASS, the admin-pass file,flair agent add, and--no-flair. An exit 0 there means bob shipped a keypair with no Agent record — the defect;FLAIR_ADMIN_PASS/FLAIR_OPS_TARGETfrom the sandbox env, so that refusal check tests bob's behaviour rather than the runner's ambient environment.node scripts/verify-pack.mjslocally: PASS, all 18 checks.Live end-to-end verification against an ephemeral instance
The gap flagged above is closed. 16/16 checks PASS against a real Flair, driving the published tarball (
npm pack→npm install→node_modules/.bin/bob), not the checkout.Instance: ephemeral Harper on non-default ports 28926 / 28925 (stock is 19926; this box's production is 9926), isolated
ROOTPATH+HOME,DEFAULTS_MODE=dev, a freshly generated 24-byte admin credential for that instance only — never a fleet credential, never in argv, written once to a0600file so the run exercises bob's file fallback rather than the env var.~/ops/flair's Harper binary and pre-downloaded models were reused read-only (the box is at 98% disk), so the run cost tens of MB rather than a 3 GB install tree. Nothing touchedcasa.heskew,tps.dtrt, or~/flair-prod.A precondition check refuses to proceed unless the Agent table comes up empty — proof it is a fresh instance and not fleet data.
bobshim on PATHbob onboard livebot --no-interactive --flair-url …publicKeymatches~/.flair/keys/livebot.pubkind/status/type/displayName/defaultTrustTierall non-null (flair#521)kind=agent status=active type=agent admin=false trust=unverifiedGET /Agent/livebotwith the generated key verifiesunknown_agentlivebot:<key>,agentId=livebot,durability=permanent, persona byte-identical tosoul.mdPOST /BootstrapMemoriesreturns the soul--force→already-registered,publicKeyandupdatedAtunchangedREPAIRED, storedpublicKeyactually changedTwo things worth calling out:
updatedAtis unchanged, not merely thatpublicKeymatched. That is what makes "no-op" a measured fact rather than an inference — a repair that rewrote the same key would have passed the weaker check.One failure on the first run — my probe, not bob
The first run came back 15/16:
POST /MemoryBootstrap→ 404 Not found. Re-derived before calling it a finding, and it was my instrument.resources/MemoryBootstrap.tsexports its class asBootstrapMemories(line 269) — that is what Harper routes on — while the same file's header comment saysPOST /MemoryBootstrap, a path that does not exist.packages/flair-client'sbootstrap()posts to/BootstrapMemories, and that is ground truth. Corrected the probe, re-ran the full suite: 16/16.Nothing in this PR calls bootstrap, so the blast radius was zero — but the stale docstring in flair is a real (minor) upstream defect and a textbook confident-docstring-as-category-error. Worth a one-line flair issue; not filed from here.
Teardown
Both instances (pids 21270, 21576) stopped by explicit PID — SIGTERM, exit code 0, no SIGKILL needed, never a pattern sweep. Verified afterwards: both pids gone; ports 28926/28925 released; scratch trees removed; production Harper (pid 17965,
~/flair-prod) still alive and answering:9926with its normal gated 403;~/ops/flair'sconfig.yamlhash byte-identical to the pre-run baseline andgit statusclean (no on-disk config pollution from runningharper devin that checkout); the bob worktree clean; disk unchanged at 4.0 GiB free.Harness:
bob97-live-verify.mjs(scratchpad, not committed — it hard-codes absolute host paths).