v0.22.0
⬆️ Upgrade notes
Read this before upgrading from 0.21.0. Three of the four items below change default behavior; none require a manual command, but the first boot after upgrade does more work than a typical patch bump.
-
authorizeLocalnow defaults tofalse(flair#654, #671). Already shipped in this Unreleased batch (see existing 🔒 Security entry) but worth restating at the top: a credential-less loopback call to Harper's raw ops API (:9925) that used to be auto-authorized assuper_usernow gets rejected. The admin credential (~/.flair/admin-pass,--admin-pass, orFLAIR_ADMIN_PASS) is now load-bearing for any local tooling that talks to the ops API directly.flair init/agent add/principal addare unaffected (they already passed real credentials). To restore the old, less-safe behavior for local dev only, setauthorizeLocal: trueinconfig.yaml. Not remotely exploitable either way — this only ever governed loopback. -
Embeddings re-embed automatically on first boot — no manual step, but it takes time. #700 flips
EMBEDDING_PREFIXES_ENABLEDtotrue, which changesgetModelId()'s output (rows now stamp<model>+searchprefixinstead of the bare model id). Every memory written under 0.21.0 or earlier reads as stale under the new stamp. The always-onembedding-stampmigration (part of the zero-touch auto-migration runner shipped in #690) picks these rows up and re-embeds them automatically on the first boot that reaches them — this is stated as intentional in #700's own PR description ("this is intended, not incidental — it's this migration's first real payload since it shipped"). Nothing to run by hand;flair reembed --stale-onlyexists if you want to trigger it deliberately instead of waiting for boot. The migration runner enforces a 90%-disk-headroom pre-flight check, takes a risk-scoped snapshot before running, and halts (rather than partially completing) if it can't proceed safely — see the migration-runner section below. Neither PR states an expected re-embed duration for a production-sized corpus — budget first-boot time proportional to corpus size; don't assume it's instant. -
Embeddings config registration changed mechanism (again) mid-release — the version you're actually getting is the safe one. #685/#689 initially registered the embeddings backend via Harper's
HARPER_CONFIGenv var, which turned out to persist intoharper-config.yamland brick a downgrade back to 0.21.0 (flair#694, root-caused in #698's PR body: Harper's env-config layer deletes the persisted keys individually when a build that predates the feature boots without setting the env var, and the resultingmodels: {embedding: {default: {}}}fails Harper's own config validator on the next boot). #698 (merged before the prefix flip landed) replaced this with fully in-process registration (resources/embeddings-boot.tscallsharper-fabric-embeddings's ownregister()factory directly on every boot) — nothing is ever written toharper-config.yamlfor embeddings config as of this release. No operator action needed; flagging so you know the mechanism is reassert-only, not persisted, if you go looking for it on disk and don't find it. -
REM execute-mode is now the default for
flair rem rapidand requires manual Harper config to actually run.flair rem rapidnow calls Harper'smodels.generate()server-side and stagesMemoryCandidaterows by default, instead of just printing a prompt for you to paste elsewhere.--prompt-onlyrestores the exact pre-0.22.0 prompt-return behavior (no model call, no staging). For execute-mode to work at all, an operator must add amodels:block to Harper's root instance config (harper-config.yaml/harperdb-config.yamlat the Harper data directory) — not flair's ownconfig.yaml, which Harper only ever loads as a non-root component config. The specific key ismodels.generative.<logicalName>(distinct from embeddings'models.embedding.defaultnamespace under the same root block). Verified example from the now-shippeddocs/rem.md(origin/main, via #711), local zero-key Ollama default:# harper-config.yaml models: generative: default: # unset FLAIR_REM_MODEL resolves to this logical name backend: ollama host: localhost:11434 # optional — already the default model: llama3.1 # required — Ollama has no built-in default model
Hosted providers (OpenAI/Anthropic/Bedrock) are supported the same way under a different logical name (e.g.
models.generative.hosted), selected viaFLAIR_REM_MODEL=hosted;apiKeymust be${ENV_VAR}indirection, never a literal in the YAML (flagged at Harper boot) — on Fabric this env var is provisioned through Harper's own Fabric secrets mechanism (enc:v1:at rest), a Harper-side concern flair's owndocs/secrets-and-keys.mddoes not cover. Pointing this at a hosted provider sends the memory content being reflected on to that provider — the docs call this out explicitly as a "data egress is a configuration decision" warning; local Ollama is the only backend that keeps everything on-box.
If nomodels:backend is configured,flair rem rapid(execute-mode) and the nightly runner's distillation step both fail closed (503no_backend) — the nightly runner logs it into the audit row'serrors[]and otherwise proceeds normally, so an un-configured instance is not broken, it just never gets execute-mode REM until a backend is configured.- Non-thinking model requirement. Per real dogfooding (documented in
docs/rem.mdvia #713): thinking/reasoning models (qwen3-next,deepseek-r1, and similar) "currently return empty generations through Harper's Ollama backend: Ollama routes their output into the response'sthinkingfield, which the backend doesn't read, so every REM execute run fails closed withdistillation_failed" — an availability failure, not a correctness one (zero partial/bad candidates). Use a non-thinking model (llama3.1,qwen3-coder-next,gemma3, …) instead. Dogfooded successfully:qwen3-coder-nextstaged 7 quality candidates in ~7s, dedup held on a second run, a promoted candidate landed withderivedFromintact. - Nightly cycle now spends model tokens/compute nightly, once a backend is configured — step 5 of the nightly runner calls
/ReflectMemorieswithexecute: trueafter maintenance succeeds (skipped entirely under--dry-run). This is a new recurring cost that didn't exist pre-0.22.0; there is no separate opt-out from the nightly step short of not configuring amodels:backend. - Review loop is unchanged and still the only promotion path:
flair rem candidateslists pending rows,flair rem promote <id> --rationale "<why>"/flair rem reject <id> --reason "<why>"decide them — nothing self-promotes, execute-mode or not. - Clustered/Fabric deploys:
flair rem nightly enableinstalls a platform timer (launchd/systemd) on whichever single node runs the command — v1 requires picking exactly one node deliberately (enabling on every node would run the cycle N times); this is a pre-existing v1 constraint, not new in 0.22.0, but worth knowing before enabling nightly REM on a multi-node deploy. Snapshot locality follows the timer's node.
- Non-thinking model requirement. Per real dogfooding (documented in
🌙 REM: in-process distillation — /ReflectMemories execute mode (flair#707, #708, #710, #711)
REM (Reflect · Extract · Merge) is flair's memory-curation cycle — it reads an agent's recent memories, distills them into candidate insights, and stages those as reviewable rows for explicit promotion; nothing self-promotes. Before this slice, /ReflectMemories could only return a prompt for a human or another agent to paste into an LLM elsewhere — the actual distillation step was always a manual handoff (flair rem rapid produced homework, not results). This slice closes that gap: REM now executes reflection itself, in-process, against Harper's own model-serving surface, and stages the result as reviewable MemoryCandidate rows directly. Three PRs: a K&S-reviewed spec (#708), the resource-level execute mode (#710), and the nightly runner + CLI + docs wiring (#711).
execute: trueonPOST /ReflectMemories(resources/MemoryReflect.ts,resources/memory-reflect-lib.ts) runs distillation server-side viamodels.generate()— schema-constrained output on the first attempt, ajson-mode fallback with one retry on malformed output, fail-closed (no retry) on a thrown network/timeout error. Validated output stagesMemoryCandidaterows: shape validation,sourceMemoryIdschecked as a subset of the gathered memory set, named-constant batch caps.execute: false(the pre-0.22.0 behavior) still returns a prompt only — nothing changed there.- Data-not-directives hardening applies to both modes: memory content is now delimiter-wrapped (
<memory id="…">…</memory>, replacing the old bracket-list prompt format) with an explicit instruction that memory content is data, not instructions — closes a prompt-injection-shaped surface where a memory's own content could otherwise be read as directives by the distillation call. - Backend is pluggable, zero provider code in flair: whatever Harper's
models.generative.<logicalName>config points at — local Ollama by default (zero-key, nothing leaves the box), or a hosted OpenAI/Anthropic/Bedrock backend selected viaFLAIR_REM_MODEL, with the API key required to be${ENV_VAR}indirection (never a literal in the YAML) and, on Fabric, provisioned through Harper's own Fabric secrets mechanism (enc:v1:at rest) — a Harper-side concern, not something flair's own code implements. Verified against@harperfast/harper5.1.17:responseFormat: { schema }is honored by the Ollama/OpenAI backends; Anthropic accepts but ignores it, so output is independently re-validated regardless of which backend is configured.generatedByon a staged candidate is the configured logical model name — the pinned Harper version'sGenerateResultcarries no model id of its own. Docs lead with an explicit "data egress is a configuration decision" warning: pointing at a hosted provider sends the reflected-on memory content to that provider. - Nightly runner step 5 (
src/rem/runner.ts): after the existing maintenance step succeeds, the nightly cycle calls/ReflectMemorieswithexecute: true. The audit row getsslice: "2"whenever distillation was attempted (success or failure) with staged candidate ids on success or adistillation:entry inerrors[]on failure — maintenance results stand either way, a distillation failure never fails the whole nightly cycle.--dry-runskips the distillation call entirely (staging rows and spending model tokens are real side effects, deliberately not exercised in a dry run). - CLI:
flair rem rapidexecutes by default now and prints a staged-candidate summary with a review hint;--prompt-onlypreserves the exact pre-#710 prompt-return behavior byte-for-byte. Distinct error messaging for a 503 no-backend response (points at the docs) versus a 502 distillation-failed response (suggests retry or--prompt-only).rem nightly run-oncenow also surfaces the staged-candidate count. - Config note (load-bearing, verified against Harper 5.1.17 source): the
models:block must live in Harper's root instance config, not flair's ownconfig.yaml— flair always loads as a non-root component, so Harper never reads amodels:block from component config. Newdocs/rem.md(linked from the README) documents this prominently, plus theFLAIR_REM_MODELenv var, the clustered-deploy single-timer rule, and the snapshot-locality note. - Non-thinking model requirement — thinking/reasoning models' output lands in Ollama's
thinkingresponse field, which Harper's Ollama backend never reads, so an execute-mode call against a thinking model always fails closed with 502distillation_failed. REM's fail-closed posture held throughout (zero partial candidates, no leakage) — this is an availability gap, not a correctness one. A non-thinking model (dogfooded:qwen3-coder-next, 7 quality candidates staged in ~7s, dedup held on a second run, promotion preservedderivedFrom) is required. Documented indocs/rem.md(#713). - Deferred to a later slice, called out explicitly in #710's PR body:
tagsis schema-validated on a candidate but not persisted (MemoryCandidatehas notagscolumn yet). - Tests: hermetic suite grew from 2225 to 2365 passing across the two implementation PRs (0 failures); strict typecheck clean on all three configs.
🧬 Native embeddings — Phase 1 (models.embed) + search-prefix flip (flair#504, #685, #686, #689, #698, #700, #701)
Multi-PR migration off flair's own hand-rolled harper-fabric-embeddings init/addon-discovery code, onto Harper's native models.embed() facade — tracked end-to-end under flair#504.
- Phase 1 — infra swap, dead-flat wash (#685).
resources/embeddings-provider.ts'sgetEmbedding()now callsmodels.embed(text, {model: "default"})instead of dynamic-importingharper-fabric-embeddingsand hand-rolling init; the@node-llama-cpp/<platform>addon-discovery + VM-sandbox init block is deleted outright.harper-fabric-embeddingsbumped 0.2.3 → 0.3.0. NoinputTypewas passed in this phase (byte-identical output to pre-migration). Measured on the recall-eval harness (3 runs, hybrid on): exact zero delta — p@3=0.967, MRR=0.892 both before and after, SE=0.000. Full unit (1979/1979) and integration (259/259) suites green before and after.- A real, separately-filed upstream finding surfaced along the way (not fixed here, not currently observable in recall numbers):
harper-fabric-embeddings0.3.0'sl2NormalizeInPlacecasts toFloat32Arraybefore dividing by the norm (0.2.3 divided in double precision first), a reproducible ~5.85e-8/dim relative difference. - #686 is a same-day docs-only follow-up correcting stale
HARPER_SET_CONFIGreferences to the mechanism actually shipped, and clarifying that Harper's globalmodelsexport and a component'sscope.modelsare the same boot-time singleton (not two things).
- A real, separately-filed upstream finding surfaced along the way (not fixed here, not currently observable in recall numbers):
inputTypeplumbing + the prefix gate, initially parked (#689). AddedEmbedInputType('document' | 'query') plumbing through everygetEmbedding()call site, and a single chokepoint constantEMBEDDING_PREFIXES_ENABLEDinembeddings-provider.tsthat atomically controls both whetherinputTypeis forwarded tomodels.embed()and whethergetModelId()appends a+searchprefixsuffix (the two can never diverge by construction). Landed with the gate off — K&S reviewed an N=126-query A/B (prefixes=onvsoff) and ratified parking the flip: Δp@3 −0.016, ΔMRR −0.003, noise-scale at this instrument's N, not a directional signal. Also shippedtest/bench/recall-harness/BASELINE.jsonas the frozen reference point a later flip would need to re-baseline through.- Downgrade-safety bug + fix (flair#694, #698). The interim registration mechanism (
HARPER_CONFIGenv var) persistedmodels.embedding.defaultintoharper-config.yaml. Downgrading to a pre-#685 build (which never setsHARPER_CONFIGbecause the feature didn't exist yet) made Harper's env-config layer delete the persisted keys individually with no stored original — leavingmodels: {embedding: {default: {}}}on disk, which fails Harper's config validator on the next boot with'models.embedding.default.backend' is required. Reproduced via a real three-boot repro (published 0.21.0 → this build → 0.21.0 again). Fixed by moving registration fully in-process (resources/embeddings-boot.tscallsharper-fabric-embeddings'sregister()factory directly on every boot, loaded via the existingjsResourceglob) — nothing is ever persisted toharper-config.yaml, so there's no downgrade bug class left to hit. Verified: the same three-boot repro now boots clean at every step, plus a live embed→search round-trip (semantic match, zero shared keywords,_score: 1) survives the downgrade boot. - Search prefixes flipped ON by default (#700).
EMBEDDING_PREFIXES_ENABLED→true, re-baselined through the ratchet #689 established. Recall numbers are the same A/B as #689 (this flip changes which arm ships by default, not the embedding math either arm computes): p@3 0.976/MRR 0.946 with prefixes on vs. 0.992/0.949 off — a small, previously-measured, noise-scale-at-this-N delta. Flipped on strategic grounds rather than a recall win: nomic-embed-text-v1.5 is trained expectingsearch_document:/search_query:prefixes (running unprefixed was the actual departure from convention), and this is the first real payload for the boot-keyed auto-migration machinery (see below) to prove itself against, deliberately exercised now rather than left dormant until a higher-stakes future migration needs it first. Every existing row's embedding stamp becomes stale under the new<model>+searchprefixid; the always-onembedding-stampmigration re-embeds them automatically (see Upgrade notes and the migration-runner section). - Recall-harness instrumentation (#701), used for a Q8-vs-Q4 GGUF quantization bakeoff (not a shipped default change):
--model-file <path>override, per-kind MRR reporting, seed-pass latency reporting. Measured Q8_0 vs Q4_K_M: +0.008 p@3, +0.004 MRR, zero per-kind regressions, ~38% faster embed on M4, +62MB disk — informational, no default model changed in this release; this instrument is also whatflair-bench(below) validates itself against.
🔄 Zero-touch auto-migration runner + CI enforcement lanes (flair#695, #690, #692)
Runtime infrastructure for unattended schema/data migrations across an upgrade, plus the CI lanes that prove the safety invariants hold.
- Boot-keyed migration runner (
resources/migrations/*, #690): on boot,MigrationRegistry+runner.tsdetect pending migrations, compute one shared async pre-hash after boot-ready but before any first write, then run a per-migration pre-flight ladder — disk-space check with a 90%-headroom floor → prune old snapshots → take a risk-scoped snapshot → content-only export fallback → halt with an exact reason if none of that clears. Migrations run in throttled batches with per-row progress markers; a risk-class-specific completion gate (derived-only: count+marker; schema-additive: count+full envelope; content-transform: count+old-row-envelope+new-row-presence) gates a post-hash + a structural-only ledgerOrgEvent+ a state-file update + snapshot prune. Single-flight via an in-process mutex plus a stale-tolerant file lock. Designed to never throw out of the boot path — every failure resolves to a halted/failed progress entry. embedding-stampmigration (always-active, derived-only, part of #690): re-embeds any row whose stamp doesn't match the currentgetModelId()output. Regenerates via a genuine admin-authenticated loopbackPUT /Memory/:id— the same mechanismflair reembedalready uses in production — rather than a bare in-processdatabases.flair.Memory.put()call, which was found (via the integration test against real Harper) to bypassresources/Memory.ts's subclass entirely. This is the mechanism that automatically re-embeds the corpus after the search-prefix flip above.- Version handshake (
src/version-handshake.ts, #690): publicGET /Healthnow also reportsversion; every CLI command gets a cached (~60s TTL) version check via a global preAction hook (gated onisTTY);flair doctorshows the version triple plus migration state, with--fixoffering a restart on mismatch. - CI enforcement lanes (
.github/workflows/migration-ci-lanes.yml, #692), enforcing the migration-safety invariants tracked at flair#695:downgrade-and-revert— installs the last published release, seeds a 140-row corpus, swaps in the PR build with test migrations enabled, catches the synthetic migration genuinely mid-flight via a bounded poll, kills Harper, reinstalls the previously-published release against the same partially-migrated store, and asserts it boots and serves the corpus byte-identically.snapshot-restore-drill— seeds, lets auto-migration run to completion, deliberately corrupts migration-touched rows via a raw ops-API partial update, restores viaflair snapshot create/restore, verifies byte-identical integrity. (Design note surfaced for K&S: the migration runner's own internal pre-flight snapshot is deliberately risk-class-scoped — metadata-only or schema+metadata, never row content — so it isn't itself the content-recovery mechanism this drill exercises;flair snapshot/restoreis.)upgrade-smokeextended: seeds stub-stamped rows before the version swap and asserts the auto-migration completed, its content-hash envelope matched, and recall parity held post-migration.- Shared bounded-retry primitives (
scripts/ci/migration-lane-lib.sh) back every post-boot/post-restart check in all three lanes — no single-shot probes.
🔐 Cloud-agent auth consumer — client_credentials + private_key_jwt (#663)
Flair's consumer side of headless cloud-agent auth to a Harper MCP endpoint, per RFC 7523: client_credentials grant + private_key_jwt client assertions over Ed25519 (EdDSA), now proven against the published @harperfast/oauth@2.2.0 (previously a stub while the upstream contract was unfinalized).
- Assertion signing (
signClientAssertion) — accepted by the plugin's realverifyClientAssertionin live-package interop tests, not a mirror implementation. - CIMD document build + hosting (
MCPClientMetadata) — resolves through the plugin's real, SSRF-guardedresolveCimdClient. Negative-tested:allowedHostsrejection, and a document leaking private-key material is rejected by the plugin's own validator even bypassing flair's build-time guard. - Live token round-trip (
requestMcpAccessToken/getMcpAccessToken) — real token POST honoring the 2.2.0 rate limiter's two consumer disciplines: token caching (mint once per client/endpoint/resource, reuse until near-expiry) and429 slow_downhandling that respectsRetry-Afterwith full-jitter backoff (exponential fallback when the header is absent).flair mcp tokennow actually mints (--dry-runstill available for inspect-only). - Proof: 41 unit tests (including a post-auth-debit isolation proof — five forged assertions against a capacity-1 rate-limit bucket never drain it, the legitimate client still mints) plus 5 integration tests against a real ephemeral Harper with
@harperfast/oauth@2.2.0mounted as a genuine component. - Known boundary, by design: 2.2.0's CIMD fetcher unconditionally refuses loopback/private hosts, so the full over-the-network CIMD-fetch-to-200-mint path cannot be exercised against a local Harper at all — deferred to a follow-up run against a real public-HTTPS host. Consuming the minted token in an actual MCP client session (
Authorization: Beareragainst/mcp) is also out of scope for this PR.
📊 flair-bench — standalone npx embedding benchmark (#702, #703, #705)
New workspace package @tpsdev-ai/flair-bench — an npx-runnable recall benchmark for any GGUF embedding model, with no flair install required.
- Commands:
flair-bench run --model-file <a.gguf> [--model-file <b.gguf> …] [--label <str>],flair-bench recommend, and--shareto write a redacted, locally-saved result file (the hosted submission endpoint is a documented placeholder — no network call is made anywhere in this package as shipped). --labelis a freeform user-chosen infra tag (never auto-filled from the real hostname) — intended to let a set of shared results build a model × infra comparison matrix, not just a model comparison.- Corpus and scorer are kept honest against drift: the corpus is a build-time copy of the internal recall-harness's corpus, synced via a script and deep-equal-checked against the live source on every root
bun testrun; the scorer is a faithful hand-replication of the harness's scoring function, guarded by a source-text tripwire test that fails if the harness's formula changes shape. - Share schema is redacted by design — no hostname, filesystem path, or username in the document;
model.fileBasenameis a basename only. Gated by a dedicated schema test. - Recommend heuristic is a documented, simple fixed-threshold rule (best MRR among models whose peak-RSS delta fits within 50% of available RAM and whose ms/embed is ≤500ms) — explicitly not learned or host-class-aware, and the README documents a real limitation this validation run surfaced:
os.freemem()under-reports available RAM on macOS relative to what's actually usable. - Validated against the same v2 corpus/
BASELINE.jsonthe internal recall-harness uses: p@3 matched exactly (0.976); MRR differences were small (+0.002 to +0.004) except for one flagged outlier (nomic-embed-text-v2-moe, ΔMRR −0.029 despite matching p@3) attributed to exact-cosine-vs-HNSW/BM25-fusion scoring differences, called out honestly as a hypothesis rather than resolved. - #703 (same day) adds a README Features entry pointing at the package. #705 discovered flair-bench had been left out of both release mechanisms entirely (
scripts/release.shand.github/workflows/release-publish.ymlboth hardcode their package lists, and neither had been updated when flair-bench was added) — confirmed vianpm view @tpsdev-ai/flair-benchreturning 404 — and fixed both lists, plus discovered and fixed a missingLICENSEfile that would have silently dropped out of every tarball. flair-bench is still not live on the npm registry as of this release —npm stage publishrequires the package to already exist, so a maintainer with npm org-owner access must do a one-time manualnpm publish+ Trusted Publisher registration before it starts flowing through the normal tag-triggered release pipeline; #705's fix isolates flair-bench's stage-publish step withcontinue-on-error: truespecifically so this doesn't block the other 7 packages' releases in the meantime.
💓 Presence: liveness beacon instead of a sticky status board (#657)
Fixes a real bug verified live on an adopter install: an agent offline for 13 days still showed activity: "debugging" on the public roster — presenceStatus correctly went offline, but activity/currentTask were frozen at their last-set value forever.
- New additive field
Presence.activityUpdatedAt: BigInt— when activity/task were last actually asserted (absent on pre-existing records; readers fall back tolastHeartbeatAt). - Read model changes: a fresh presence reports current activity/task as before. A stale one (heartbeat past the existing offline threshold, or the activity stamp itself lapsed) now decays
activityto"idle"andcurrentTasktonull; the last-known label moves to a new publiclastActivityfield plusactivityAgeMs/activityFresh, so a client can render "offline (was: debugging)" without re-deriving staleness itself. - Heartbeats self-decay:
activityUpdatedAtis only re-stamped on a beat that actually asserts activity/task; a pure liveness beat (no activity change) preserves the prior stamp, so activity decays naturally once an agent stops updating it.POST /Presence's wire contract is unchanged. currentTask's existing verified-reader gate (flair#592-class) is unchanged — this does not widen who can see it, only how stale content is presented.- ⚠ Flagged consumer behavior change in the PR itself: an offline/stale agent now reports
activity: "idle"/currentTask: nullinstead of the frozen last value — any downstream consumer (the PR names the Office Space dashboard specifically) readingactivitydirectly instead oflastActivitywill see a behavior change on upgrade.
🎯 compositeScore relevance-gate hardening (flair#623 follow-up, #661, #662)
Follow-up to the flair#623 default-to-raw flip already in this Unreleased batch. A harder 87-record synthetic corpus added to the recall-harness (#661) reproduced the original compositeScore bug in isolation at Δp@3 −0.900 (far worse than the −0.38 to −0.50 measured live): compositeScore's durability-weight × recency-decay multiplier applied completely unconditionally, with no relevance floor at all (unlike retrievalBoost's existing floor gate), so an unrelated-but-permanent/fresh record could rack up zero discount at all and outrank the objectively correct match.
- First attempt, documented as a dead end in the code (not reintroduced): ramping the discount open as
rawScorerises, mirroringretrievalBoost's gate shape. Measured worse (p@3 0.033 vs the original bug's 0.067) — on this corpus a genuine match already has a high raw score, so ramping-by-relevance applies the discount hardest to exactly the records most needing protection. - The actual fix: bound
dWeight × rFactorto a small band around 1.0 viaCOMPOSITE_DISCOUNT_FLOOR(default 0.98, max −2%, tuned empirically to fully close the recall-harness gap to raw on both p@3 and MRR across 3 runs), gated byCOMPOSITE_RELEVANCE_FLOOR(default 0.5, same value asretrievalBoost's floor) so records below the relevance bar get no adjustment at all. Both are env-overridable (FLAIR_COMPOSITE_DISCOUNT_FLOOR,FLAIR_COMPOSITE_RELEVANCE_FLOOR). scoring: "composite"remains off by default — this hardens the mechanism for the (still-manual) opt-in path; it does not itself flip the default. (The later usage-feedback signal work in this same Unreleased batch, flair#683/#684, separately replacescompositeScore's reinforcement term but keeps composite off by default for the same reason.)
🔗 Ergonomic relationship-write surface — relationship_store MCP tool, flair relationship add, RelationshipApi
A full auth-gated Relationship resource (subject/predicate/object triples with temporal validity) already existed, but there was no ergonomic, agent-directed way to write one — no MCP tool, no CLI command, no typed client helper. An agent couldn't say "record that X manages Y"; the graph the attention read (MemoryBootstrap.ts) queries stayed near-empty. This adds the write surface, mirroring the established memory_store shape at every layer, plus folds in auth/dedup/provenance hardening to the existing resource:
RelationshipApi(packages/flair-client):client.relationship.write({subject, predicate, object, confidence?, validFrom?, validTo?, source?})→PUT /Relationship/<canonical-id>. Built first — the MCP tool and CLI command are thin wrappers over it.- MCP tool
relationship_store(packages/flair-mcp): mirrorsmemory_store's shape (zod schema,content[]+structuredContent). Description spells out the triple model, the assert/upsert semantics, a recommended soft predicate vocabulary (manages, works_on, reviews, depends_on, replaces, owns, reports_to, advises — free text, no server enum), and the contradict-a-prior-relationship workflow (re-assert with avalidToor delete, then write the new one — a different predicate does NOT auto-close the old triple). - CLI
flair relationship add(--agentrequired, Ed25519-signed via the existingapi()helper — mirrorsflair memory add). - Canonical, per-owner, deterministic id (dedup):
base64url(SHA-256(lowercased agentId+subject+predicate+object)[:16 bytes]). Re-asserting the identical triple upserts the same row (mutable fields — confidence/validFrom/validTo/source — update; id and identity stay stable) instead of creating a duplicate row, via Harper's ordinary PUT-by-primary-key (no pre-insert query, no race). A real SHA-256 (crypto.createHash), not a weak/platform-specific hash; fields are NUL-joined before hashing so free-text subject/predicate/object can't collide across a field boundary shift.agentIdis folded into the hash, so the same triple asserted by two different agents lands at two different ids (per-owner, no cross-agent collision). - Auth reconcile:
Relationship.put()AND.delete()upgraded from the olderrequest.tpsAgent-direct pattern toresolveAgentAuth()(matchingMemory.post()/Memory.put()) — anonymous denied (401), a non-admin agent'sagentIdalways comes from the verified signature (never the request body; a mismatched bodyagentIdis rejected with 403 rather than silently overwritten), admin/internal calls remain unfiltered (no regression to existing internal callers). - Provenance parity:
Relationshipgains a nullableprovenancefield, stamped via the SAMEbuildProvenance()helperMemoryuses (now extracted toresources/provenance.tsso both tables share one implementation) — identical{v, verified:{agentId,timestamp}, claimed?:{model}}shape, no relationship-specific format. Additive/nullable; a pre-existing row with noprovenancefield still reads back fine (migration-equivalence, same discipline as the earlierusageCountaddition). - Scope unchanged: this stays owner-scoped exactly as today — relationship reads are NOT made open-within-org here. That's a deliberate, separate follow-on decision (gated on the same federation-edge hardening as any future Memory read-scope change), not a default.
- Covered by round-trip (write → surfaces in bootstrap for a predicted subject, proving lowercasing + the read contract), dedup (same triple twice → one row; a different confidence upserts; a different predicate is a separate row), auth (anonymous 401; a caller cannot claim another agent's
agentId), provenance (verified.agentIdpresent; a pre-provenance row reads null without error), and a render-safety check on the attention read'ssubject → predicate → objectline — all against a real spawned Harper instance, plus unit coverage for the canonical-id algorithm and a CLI/flair-client cross-check guarding against the two implementations drifting apart.
⚡ Bootstrap scale fix — bounded queries replace the org-wide memory scan
MemoryBootstrap.post() loaded the entire org's non-private Memory corpus into RAM on every bootstrap call (an unbounded Memory.search() — no limit/select, every row's full 768-float embedding vector included) then ran a hand-rolled O(N·d) JS dot-product scan over all of it. Both scaled with the size of the whole org's memory corpus, not the caller's own — a liability on a hot, every-session path that got worse as collision surfacing (flair#681) and open-within-org reads (flair#578) widened the scanned set.
- Extracted a pure retrieval core (
resources/semantic-retrieval-core.ts,retrieveCandidates()) fromSemanticSearch.post()— the HNSW/BM25 retrieval + all post-retrieval filtering (temporal/expiry/supersede exclusion, thescope.isAllowed()defense-in-depth re-check), taking primitives only, never a Resource instance. Auth, rate-limiting, the reranker, and theretrievalCount/lastRetrievedhit-tracking side effects stay inSemanticSearch.post()'s wrapper, so bootstrap's internal call never trips them.SemanticSearch's own behavior is unchanged (full unit-test suite green; the isolated recall-harness returns byte-identical p@3/MRR before and after). - Own-scoped pushdowns for the permanent/recent/predicted lifecycle slices:
Memory.searchcalls conditioned onagentId==self(+ durability/createdAt, all@indexed), explicitselect(no raw embedding) — replacing the post-load JS filter over the full org corpus. - Bounded HNSW candidate pool for task-relevant/teammate/collision surfaces via the same
retrieveCandidates()core — HNSW-leg pushdown only (no BM25 fusion, no reranker; a different, likely-worse cost profile for a one-shot session load — opt-in follow-on), sizedK = max(3 × expected fill, 5 × teammate count, 50), capped at 100. - Per-set supersede exclusion (own slices independently, candidate pool independently) — the unconditional past-
validToguard (the primary supersede defense) is preserved verbatim. memoriesAvailableis now an own-scoped count (agentId==self, a cheap indexed seek) instead of the org-wide exact figure — computing that exactly was itself the scan being removed.
No scope widening: own-scoped queries are strictly narrower than the previous load-then-filter; the candidate pool carries the identical scope.condition (own OR non-private). Measured on a synthetic 6,100-record corpus (6,000 org-wide + 100 own) against an ephemeral Harper instance: bootstrap latency dropped from 350ms (cold) / 309ms (warm mean) to 91ms / 48ms.
🎯 Usage-feedback signal — usageCount + usageBoost replaces retrievalBoost (flair#683)
flair#623 found compositeScore measurably losing to raw relevance and flipped the default to raw. The root cause was the signal, not the model: the reinforcement term was retrievalCount — incremented on every search hit (resources/SemanticSearch.ts) — so a doc surfacing once got boosted, surfaced more, boosted more, independent of whether it was ever actually useful. This ships a stronger, distinct signal: verified use, captured explicitly.
- Schema:
Memory.usageCount: Int(additive/nullable, absent = 0). Never auto-incremented on search — the only writer is the new endpoint below. POST /RecordUsage+ MCP toolrecord_usage(resources/RecordUsage.ts): report that memory id(s) were actually cited/used, with an optional opaqueattributionstring. A dedicated endpoint, notMemory.put()— usage feedback is a cross-agent write (agent B reports using agent A's memory) thatMemory.put()'s ownership check would 403; this does a targetedusageCount-only bump instead, so no other field on the target memory can change. Verified-agent auth, no ownership requirement. Anti-gaming (three layers): a~30 RPMrate-limit bucket, a dedup ledger (MemoryUsagetable,resources/MemoryUsage.ts) capping each (agent, memory) pair at ≤1 contribution, and the capped/floor-gated boost itself. Responses are identical for a not-found id, an already-counted id, and a fresh valid id — no ID enumeration.- Scoring:
usageBoost()(resources/scoring.ts) — the exact same gentle, capped, floor-gated shape asretrievalBoost(min(1.0 + 0.1·log2(n), 1.1), floor 0.5).compositeScorenow usesusageBoost(usageCount)in place ofretrievalBoost(retrievalCount)— dropped outright, not just outweighed, since the old signal was contaminated by construction (a search hit ≠ verified use).retrievalCount/retrievalBoostremain exported (a future weak-prior idea, not built here) but are no longer read bycompositeScore. - Harness rematch (
test/bench/recall-harness/run.ts --usage-rematch): a usage-injection path measuring composite-vs-raw under three regimes on the existing 87-record/30-query corpus — POSITIVE (usage on ground-truth-relevant docs: composite beats raw, p@3 +0.033/MRR +0.042), NEGATIVE CONTROL (usage on whatever merely surfaces, theretrievalCountshape: composite reproduces the #623 loss, p@3 -0.200/MRR -0.155 — proving the fix is about signal quality, not the boost mechanism), and a NOISE SWEEP (ground truth + random non-ground-truth usage at 0–4× ratios: composite-with-usage held ≥ raw across the full tested range on this corpus). - Composite stays OFF BY DEFAULT (raw remains the default, unchanged since #623). This ships the mechanism + the simulated-usage rematch; the real-world default-flip decision needs usage accrued from live dogfooding of
/RecordUsage, re-measured withrecall-eval.mjson the live corpus.
🏢 Collision surfacing in bootstrap — "Others in the room" (flair#681)
The attention plane's flagship (design: FLAIR-ATTENTION-PLANE.md "Phase 2"). MemoryBootstrap.ts now surfaces a short, ranked "## Others in the room" block — teammates whose active work collides with the caller's, e.g. Anvil is touching issue:tpsdev-ai/flair#504 (implementing embeddings) — last active 4m ago. Two independently-scoped surfaces are joined, never conflated: Memory is the semantic surface, reusing flair#550's existing scored-Memory path as-is (the caller's currentTask embedding, dot-product against in-org Memory, the SAME score > 0.3 relevance floor — no new embedding code anywhere in this feature); WorkspaceState/OrgEvent are the entity surface (exact vocabulary-string overlap against the caller's own declared entities — a new optional entities field on the MemoryBootstrap request, falling back to the caller's own most-recent WorkspaceState.entities when omitted). Both surfaces are freshness-gated on Presence (a teammate absent from the roster, or presenceStatus: "offline", never surfaces regardless of match strength); when both surfaces match the same teammate, the entity match wins (higher precision). WorkspaceState/OrgEvent reads run the SAME internal server-side path flair#678's AttentionQuery established (Sherlock Option 1 — the raw table object, never the exported WorkspaceState resource class, which would just re-apply per-agent 403 scoping to the caller's own identity) — this does not broaden WorkspaceState's general read model; a direct cross-agent GET/search() still 403/404s, verified end-to-end against a real spawned Harper. The Presence roster fetch (the synthetic delegation-context trick that preserves Presence.get()'s verified-agent currentTask content gate, #592) is now a shared helper (resources/presence-internal.ts), extracted from AttentionQuery.ts so the pattern has exactly one implementation — AttentionQuery.ts's own behavior is unchanged (same tests, same assertions). New pure join/rank/format module resources/collision-lib.ts (Harper-free, unit-tested directly) also fixed a real bug caught only by e2e testing against a real spawned Harper: a single-entity OR-condition ({operator: "or", conditions: [...]} with exactly one clause) throws in Harper's real query engine ("An 'or' operator requires at least two conditions") — silently swallowed by the collision block's best-effort try/catch, so a caller declaring exactly one entity (the common case) would have produced zero results with no visible error. buildEntityMatchCondition() special-cases the single-entity form. Covered by test/unit/collision-lib.test.ts (join/rank/freshness-gate logic) and test/integration/bootstrap-collision-e2e.test.ts (real Harper, real embeddings — entity overlap, non-overlap exclusion, the freshness gate against a genuinely stale Presence row, semantic-only surfacing, the metadata-leak/cross-agent-boundary probes).
🐛 flair workspace set sent a bare POST that 405s against real Harper (flair#679)
Surfaced by the attention-query e2e testing (#677/#678), measured against a real spawned Harper: flair workspace set sent a bare POST /WorkspaceState (no id in the URL). WorkspaceState.post() (resources/WorkspaceState.ts) delegates to super.post() — the Harper-generated table class's own post handler — which 405s a collection POST ("does not have a post method implemented to handle HTTP method POST"), the same restriction resources/Memory.ts documents and soul set was already fixed for (#498). Table writes over real HTTP require PUT /<Table>/<id>. flair workspace set now signs and sends PUT /WorkspaceState/{agentId}:{ref}, including agentId/createdAt in the body (WorkspaceState.put(), unlike post(), doesn't auto-attribute or default these — it 403s a mismatched agentId rather than overwriting it, so this is a self-declaration the server verifies against the Ed25519 signature, not a forgeable claim).
flair orgevent's bare POST /OrgEvent does not actually 405 today (measured directly, test/integration/workspace-orgevent-cli-e2e.test.ts) — OrgEvent.post() bypasses super.post() and calls databases.flair.OrgEvent.put() directly, so it's reachable over real HTTP. It's switched to PUT /OrgEvent/{id} anyway, for consistency with every other table resource and so a future refactor that made OrgEvent.post() delegate to super.post() (mirroring WorkspaceState.post()) can't silently reintroduce this exact 405. The id is now client-generated (${agentId}-${randomUUID()}, mirroring flair-client's Memory.write() convention) rather than relying on post()'s own ${authorId}-${isoTimestamp} default, which risked same-millisecond collisions.
Both commands are covered end-to-end against a real spawned Harper (test/integration/workspace-orgevent-cli-e2e.test.ts): the CLI subprocess writes, and the row is read back over real HTTP to confirm it landed — not just that the CLI exited 0.
🔭 Attention-plane query — "what's touching entity E in the last N days?" (flair#677)
The Phase 1 query from FLAIR-ATTENTION-PLANE.md, built on the entity vocabulary + entities[] fields from flair#675/#676. New POST /AttentionQuery (resources/AttentionQuery.ts), CLI (flair attention <entity> [--days N], default 7d), and MCP tool (attention, resources/mcp-tools.ts) return a unified, grouped-by-source, recency-ranked view across Memory, Relationship, WorkspaceState, Presence, and OrgEvent for one validated vocabulary string. Read-only, exact-match index pushdown — no scans, no collision surfacing (that's a separate follow-up). Per-source read-scoping is strictly respected: Memory goes through the centralized resolveReadScope() (open-within-org, minus private); Relationship mirrors its own existing per-agent scoping; OrgEvent rides its already-org-open read model; Presence goes through the Presence resource's get() so its verified-agent currentTask content gate (#592) is preserved, never the raw table. WorkspaceState is the one deliberate exception (Sherlock's K&S-approved Option 1): normally strict per-agent (403 cross-agent), it's queried via the raw table object as a narrow, server-computed join scoped to one validated entity + a bounded day window — never a general broadening of WorkspaceState's read model (direct GET /WorkspaceState cross-agent access is unchanged). Malformed entity strings 400 via the existing entity-vocab.ts validator.
🔭 Attention-plane foundation — entity vocabulary + entities[] fields (flair#675)
Foundation slice of the attention plane (design: FLAIR-ATTENTION-PLANE.md, K&S-approved). New resources/entity-vocab.ts documents and enforces the entity vocabulary convention — namespaced type:value strings, lowercased type, from a closed set (repo:<owner>/<name>, issue:<repo>#<n>, customer:<slug>, subsystem:<slug>, agent:<id>, person:<id>); matching is exact on the full string, no prefix/regex. entities: [String] @indexed is now an additive/nullable field on WorkspaceState, OrgEvent, and Memory (added in v1, not deferred to v2, per Kern's review — gives the future attention query uniform index pushdown across all three instead of a partial one); existing rows carry no entities, readers tolerate absence, same pattern as Presence.activityUpdatedAt. WorkspaceState.ts/OrgEvent.ts/Memory.ts validate entities on write via the new invalidEntitiesResponse() helper (400 on malformed values). Relationship gets no schema change — its subject/object are already the vocabulary carrier (validating them against this convention is a follow-up). Full writeup in docs/entity-vocabulary.md. This slice is vocabulary + fields + validator ONLY — the attention query (flair attention <entity>) and bootstrap collision surfacing are separate, later slices.
🧰 Tooling / CI
-
CI now matrices Node 22/24/26 instead of testing on Node 22 alone (#672) —
engines.nodeis>=22with no upper bound, but every CI job pinned exactly one Node version, so a currently-maintained major could reach production without CI ever having run against it.test-unitandtypecheck(.github/workflows/test.yml) now run astrategy.matrix.node-version: ["22", "24", "26"](fail-fast: false, so one version's failure doesn't hide the others); atest-unit-gate/typecheck-gatejob re-emits the fixedUnit Tests/Type Checkcheck names branch protection expects, since GitHub Actions suffixes matrixed job names/contexts with the matrix value.pack-smoke(install-from-tarball smoke) also matrices 22/24/26 — it's the job that spawns the packed CLI directly undernode, the most representative "does a real user's Node actually work" path.test-integrationstays pinned to Node 22 (its existing HarperFast/harper#386 native-spawn-vs-Docker mitigation is version-load-bearing) andupgrade-smokemoves from 22 to 26 (Current) as a single-version pin — its invariant is cross-version data survival, not Node-runtime behavior, so matrixing it would 3x an already-heavy job for no extra signal. Currently-maintained set as of 2026-07: 22 = Maintenance LTS, 24 = Active LTS, 26 = Current (25 reached EOL 2026-06-01 when 26 shipped, excluded). -
upgrade-smokenow runs the real upgrade path on every PR, not just version-bump PRs (flair#620, #664). The job always executed, but an internal version-string comparison (BASELINE == HEAD_VERSION, true on the vast majority of ordinary feature PRs that don't bumppackage.json) short-circuited the actual install→seed→upgrade→verify sequence to a trivial pass. Removed the short-circuit — HEAD is now always packed from the PR's real tree regardless of its version string, so the highest-blast-radius failure mode (upgrade data loss/corruption) gets tested on every PR instead of at release cadence. -
CI docs-freshness gate (flair#618, #658). New
node scripts/docs-freshness-check.mjsgate, runnable locally, zero new deps: fails independently on a stale version-pin in install commands, a hardcoded (non-placeholder) version in quickstart, a retired-port reference presented as current, a non-@tpsdev-ai-scoped package name, an emptied[Unreleased]section while feat/fix commits exist since the last tag, or any CLI command/subcommand with a blank.description()(walked from the real builtdist/cli.jscommand tree). Found and fixed real remaining rot on introduction:soul set/get/listhad shipped with blank descriptions. -
Strict typecheck extended to
src/**(flair#643, #669).tsconfig.check.jsonpreviously only coveredresources/**; the CLI, probe, fleet-verify, deploy, and bridges modules undersrc/had zero strict-mode coverage in CI beyond the non-strictbuild:clicompile. Newtsconfig.check.src.json(strict, excludes onlysrc/cli.tspending a later split, and the naturally-out-of-scopesrc/cli-shim.cts) wired into the existingtypecheck-strictjob. All 37 covered files were already strict-clean — no source changes needed to land the gate. -
Harper Docker image tag now derived from
package.json(flair#625, #656) instead of a separately-maintained literal, closing a version-drift class between the declared and materialized Harper Docker version.
🧹 Removed vestigial legacy observatory ingestion surface (flair#628)
Deleted the March-2026 prototype observatory surface (resources/IngestEvents.ts, ObsOffice.ts, ObsAgentSnapshot.ts, ObsEventFeed.ts, ObservationCenter.ts, src/observatory-sync.ts, ui/observation-center.html, the three Obs* tables in schemas/schema.graphql, and their dedicated tests) alongside its allow-list/role wiring (auth-middleware.ts's /ObservationCenter public early-return, cli.ts's ObsOffice/ObsAgentSnapshot/ObsEventFeed role grants). Also drops the now-obsolete ui/ package entry (package.json's files, src/deploy.ts's REQUIRED_PACKAGE_FILES) — ui/observation-center.html was the sole ui/ file, so its removal emptied the directory. The surface was unused in this repo — no code path produces a request to Flair's own /IngestEvents, and production observability now runs on the standalone tpsdev-ai/observatory app — so this removes dead attack surface (IngestEvents' signature check only covered replay via a timestamp window, no nonce store) rather than changing any live behavior.
🕵️ Presence gains a debugging activity (flair#613)
The activity enum (coding/reviewing/planning/idle) had no value for the flagship collision-detection use case: a live incident/production investigation. Agents fell back to --activity reviewing, misrepresenting what they were doing on the public roster. debugging is now a valid flair presence set --activity value end-to-end — CLI validation (src/cli.ts), the /Presence resource's server-side validation (resources/Presence.ts), the PresenceActivity type (packages/flair-mcp/src/presence.ts), and the schema doc comment (schemas/schema.graphql). Auto-presence's deriveActivity() (flair#608) also gains a matching surface-name mapping — debug/investigat/incident in the surface string now derives debugging instead of falling through to coding.
🎯 SemanticSearch scoring default flipped from composite to raw (flair#623)
Measured 2026-07-08 with recall-eval.mjs against the live corpus (BM25 hybrid active): scoring: "composite" (the previous default) is net-HARMFUL — Δp@3 (composite − raw) = -0.38 to -0.50 across repeated runs (raw held steady at p@3=0.50/MRR=0.438; composite ran 0.13→0.00 p@3 / 0.073→0.056 MRR as reruns fed retrievalCount's rich-get-richer loop). Root cause: compositeScore's durability-weight × recency-decay multiplier (resources/scoring.ts) applies unconditionally — no relevance gate, unlike retrievalBoost's existing RBOOST_RELEVANCE_FLOOR — so a permanent-durability or freshly-created but weakly-matching record routinely outranks the objectively best semantic/BM25 match. This was a smaller effect before BM25+RRF fusion normalized raw scores into a tight band; now the ±10-30% durability/recency multiplier is often larger than the real relevance gap between candidates, so it dominates ranking instead of nudging it. scoring: "composite" is unchanged and still available as an explicit opt-in (flair search --scoring composite, or scoring: "composite" in the /SemanticSearch payload) for callers who want durability/recency-aware re-ranking; it is simply no longer the default. No change to compositeScore itself — revert by passing scoring: "composite" explicitly, or reverting this commit.
🔎 BM25 + union-RRF hybrid retrieval — ACTIVATED (follow-up to #519)
FLAIR_HYBRID_RETRIEVAL now defaults ON (was default-OFF since #519 shipped the feature). Recall-eval at build time validated the intended gain: the NEW-8 within-cluster gate held p@3=0.88 (no regression); the OLD-6 severe near-verbatim misses recovered 0/6 → 4/6 into top-10 (1/6 into top-3). A fresh isolated-Harper measurement at activation time (ephemeral spawned instance, zero production contact) confirmed zero regression on a synthetic severe-miss/within-cluster-gate corpus and a small latency delta (~+4ms/query, ~27ms absolute at n≈90 records). Revert lever unchanged: set FLAIR_HYBRID_RETRIEVAL=false (also "0"/"off") to fall back to the byte-identical legacy HNSW + keyword-bump path — no code rollback needed.
- Fixed a blocking regression found during activation testing: the hybrid path's candidate-union RRF fusion silently returned zero results for a
SemanticSearchcall with neitherqnorqueryEmbedding— the "list everything in my scope" shape (agentId/tag/subject-only calls; seetest/integration/memory-visibility-scoping-e2e.test.ts), which the legacy path answers with a full scoped listing.resources/SemanticSearch.ts's hybrid branch now falls back to emitting the already-security-filteredallowedByIdcandidate set directly atrawScore 0when neither retrieval signal is present, matching the legacy contract exactly. Regression-guarded bytest/integration/bm25-hybrid-noquery-listing.test.ts.
The upgrade path becomes one tested transaction — install, restart, verify, and roll back automatically on failure — backed by a pre-upgrade data snapshot, a nightly-checked downgrade path, and a post-deploy fleet-convergence sweep. Also closes out the remaining authorizeLocal-class security gaps from the 0.21.0 state review.
🔁 flair upgrade restarts by default, verifies, and rolls back (#635, #641)
Upgrade is now one transaction: install → restart → verify → rollback-on-failure, instead of leaving the OLD process serving while the version on disk lied about what was actually running. Restart-after-install is the new default (--no-restart opts out; the old --restart flag is a deprecated no-op). After restart, probeInstance confirms /Health, an authenticated round-trip, and that the reported running version matches what was just installed (--no-verify to skip). On verification failure, flair upgrade reinstalls the previously-running version, restarts, and re-verifies — and if that rollback also fails to verify, it points at the pre-upgrade snapshot instead of looping.
📸 Pre-upgrade data snapshot (opt-in) + flair snapshot command + tested downgrade path (#637, #647)
flair upgrade --snapshot snapshots ~/.flair/data to ~/.flair/upgrade-snapshots/ (timestamped tar.gz, exact file modes preserved, keep-last-3 retention) before touching any package — quiescing Flair first, since a live RocksDB directory mid-compaction isn't safe to copy. A snapshot failure aborts the upgrade before any package changes. Opt-in, off by default: the default run instead prints a non-blocking recommendation nudge (never prompts/blocks). The same mechanism is now also a standalone flair snapshot create|list|restore command — physical, byte-exact, local-only, distinct from the logical JSON flair backup/flair restore. docs/upgrade.md gains a full Downgrade procedure, and a nightly compat test (test/compat/downgrade-boot.test.ts) actually boots the last npm-published release against newer data and confirms it reads back cleanly — replacing the old "not a tested path" language with an honest, continuously-checked claim.
🚦 flair fleet verify — post-deploy convergence sweep (#636, #642)
Fabric deploys tolerate replication errors by design (origin-first), but nothing previously confirmed peers actually converged — the 0.21.0 deploy shipped with a peer still throwing 1006s while the CLI reported success. New standalone flair fleet verify --target <url> sweeps the origin + every known Flair federation peer, prints a per-node table, and exits 0 (all verified) / 1 (origin failed) / 2 (peer version skew) / 3 (peer unreachable/unverifiable). Wired automatically into flair deploy and flair upgrade --target post-success (--no-fleet-verify to skip). Explicitly scoped to Flair's own federation peers, not Harper's own cluster-replication nodes (cluster_status is harper-pro-only and unavailable to this build).
🔑 CLI sends real local credentials instead of riding authorizeLocal (#634, #640)
api() previously sent no Authorization header for local targets, relying on Harper's authorizeLocal to forge a super_user for credential-less loopback requests — a gap the #632 security fix below closed, which meant credential-less local calls like flair federation status started getting a real 403. Fixed: local targets now resolve real credentials in precedence order FLAIR_TOKEN > FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD > agent Ed25519 key > the ~/.flair/admin-pass file flair init writes. A 403 with no credentials now throws a clear, actionable message instead of a raw "forbidden" body.
🛰️ Version-stamped presence + fleet staleness in doctor (#639, #645)
POST /Presence now stamps the serving instance's running flairVersion + harperVersion on every heartbeat, gated behind the same verified-agent read as currentTask. flair doctor gets a new "Fleet presence" section listing known instances oldest-version-first and flagging any behind the newest version seen across the roster (org-relative, not npm-latest). Note: Presence doesn't currently participate in federation sync, so on a hub+spokes deployment this only reports the querying instance's own directly-heartbeating agents.
🧪 Mixed-version federation compat CI (#638, #644)
A nightly + PR-triggered suite spawns the last published @tpsdev-ai/flair alongside the current build as two independent Harper instances, pairs them reciprocally, and drives a real federation round-trip through each side's own CLI. Surfaced two orthogonal version-skew findings along the way (documented inline, not fixed there): the published baseline predates #634's local-credential fix and predates the authorizeLocal-forged-super_user hardening on /FederationInstance.
🔒 Security
-
authorizeLocalnow defaults tofalse— closes unauthenticated loopback admin on the Harper ops API (#654) — a credential-less loopback POST to :9925 (system_information,insert,add_user, ...) was auto-authorized assuper_user(Harper'sauthorizeLocal: true,config.yaml). Flair's own application-layer resources were already immune to this forgery (#655's credential-evidence gate), but the raw ops API sat below that layer — any local process, co-tenant, or loopback-SSRF on the host could run unauthenticated admin operations directly against Harper. Not remotely exploitable (remote always required real auth), but a real defense-in-depth hole. All four ops-API seed call sites (seedAgentViaOpsApi,seedFederationInstanceViaOpsApi,agent add,principal add) already pass a real admin credential over Basic auth, so this changes no functional behavior forflair init/agent add/principal add. The admin credential is now load-bearing for local ops —~/.flair/admin-pass(written byflair init),--admin-pass, orFLAIR_ADMIN_PASS— a missing credential now fails closed instead of riding the ambientauthorizeLocalsuper_user forgery. A new CI hard gate (pack-smokein.github/workflows/test.yml) proves the bootstrap-ordering invariant this required: on a freshflair init, the admin credential exists before any seed call fires, a credential-less loopback ops-API call is rejected, and both the agent seed and the federation-instance seed still succeed via genuine Basic admin auth. Does not affect remote/Fabric admin —authorizeLocalonly ever governed loopback, and remote has always required real credentials. SetauthorizeLocal: trueinconfig.yamlto restore the old (insecure) behavior for local development only. -
Gate
FederationInstance/FederationPeers/HealthDetail/SkillScan—authorizeLocalclass (#632, closes #631) — the #614/#630 CI backstop surfaced four resources with no explicit allow-decision, falling through to Harper's defaultsuper_usercheck, satisfiable byauthorizeLocal's forged loopback super_user.FederationInstance/FederationPeersnow require admin;HealthDetailrequires a verified caller (and fixes a backwardsisAdmindefault that treated an unresolved caller as admin);SkillScanrequires a verified caller. -
Fabric deploy/upgrade credential flags no longer recommend leaking secrets to shell history (#650).
flair upgrade --target,flair deploy, andflair fleet verifydocs and examples led with--fabric-user <admin> --fabric-password <pass>— both the admin username and password land in shell history and are visible to any localpsobserver for the process lifetime. New--fabric-password-file <path>(mode-0600 file, reuses the existing--admin-pass-filesecure reader, refuses group/other-readable files) is now the recommended path; inline--fabric-usernow warns (parity with the pre-existing inline---fabric-passwordwarning). Precedence: inline--fabric-password(warned) >--fabric-password-file>FABRIC_PASSWORDenv. Docs (deployment.md,upgrade.md) flipped to lead withFABRIC_USER=… FABRIC_PASSWORD=… flair …, inline flags demoted to a labeled discouraged fallback. No credential value is logged anywhere in the warning/error path. Prompted by a real observation that the docs were recommending the leaky form by default, not a live incident.
🧹 Tooling / CI / hygiene
-
Assert every Resource declares an explicit allow-decision (#630, closes #614) — a repo-wide backstop that enumerates every
resources/*.tsand fails when a new one ships with no allow-decision; found the four gaps closed by #632 above. -
Wire the remaining 5 packages' tests into CI (#633, closes #619) —
flair-client,langgraph-flair,n8n-nodes-flair,openclaw-flair,pi-flairhad real test suites CI only typechecked, never ran. -
Fix port drift + stale security-model docs +
upgrade.md(#629) — standardized docs on the real19926default, corrected security-model docs still describing the retired grant-gated read model, unfrozeupgrade.mdfrom a pinned old version. -
Name the real storage engine — Harper 5.x is RocksDB, not LMDB (#648) — corrects the #647 snapshot-consistency rationale, which cited the wrong engine (LMDB is what Harper ≤4 used, and remains in the dependency tree, which is where the mislabel came from). The quiesce-before-snapshot design itself is unchanged.
-
Bump
@harperfast/harper5.1.15 → 5.1.17 (#607) — patch bump: replication 503-vs-404 reliability, Docker entrypoint fix, npm-shrinkwrap packaging, MQTT shared-port. No Flair code change needed. -
Public-repo hygiene sweep (#696, #697). Comment/doc-only pass (26 files) replacing every reference to private ops spec paths and internal tracker ids with the public tracking issue flair#695 (which now carries the distilled migration-safety invariants + CI-lane rationale). A same-day follow-up (#697) fixed 7 lines the mechanical sweep had garbled — two cases of a path-shaped sentence fragment getting an issue number substituted mid-prose, five citations mechanically re-pointed at the wrong (migration-safety) anchor instead of the correct attention-plane issues (#677, #681) they originally cited. Process fix applied going forward: a mechanical "comments only" sweep now gets a full line-by-line read of the final diff before merge, not just a structural comments-only check — three reviewers had pattern-matched "safe" and missed it.
-
Docs: document the ops-API auth surface split (flair#654, #674). Docs-only follow-up to the
authorizeLocaldefault flip: documents that the Harper ops API now requires admin Basic auth for network requests, while the ops-API domain socket (operations-server, owner-write-only) remains an inherent local-admin channel that authorizes assuper_userwithout credentials, by design — required by the admin-password rotation flow. Explicitly scopes what is and isn't mitigated: any process running as the box owner can still reach the socket; owner-write permissions keep it unreachable by other OS users, co-tenants, or the network. -
Docs: stale
HARPER_SET_CONFIG/modelscomment corrections (#686, #668) — see the native-embeddings section above for #686; #668 is the equivalent same-week correction forauthorizeLocal-related CLI comments that still described the pre-flip behavior.