Skip to content

v0.22.0

Choose a tag to compare

@github-actions github-actions released this 13 Jul 23:45
bce6b27

⬆️ 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.

  • authorizeLocal now defaults to false (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 as super_user now gets rejected. The admin credential (~/.flair/admin-pass, --admin-pass, or FLAIR_ADMIN_PASS) is now load-bearing for any local tooling that talks to the ops API directly. flair init/agent add/principal add are unaffected (they already passed real credentials). To restore the old, less-safe behavior for local dev only, set authorizeLocal: true in config.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_ENABLED to true, which changes getModelId()'s output (rows now stamp <model>+searchprefix instead of the bare model id). Every memory written under 0.21.0 or earlier reads as stale under the new stamp. The always-on embedding-stamp migration (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-only exists 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_CONFIG env var, which turned out to persist into harper-config.yaml and 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 resulting models: {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.ts calls harper-fabric-embeddings's own register() factory directly on every boot) — nothing is ever written to harper-config.yaml for 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 rapid and requires manual Harper config to actually run. flair rem rapid now calls Harper's models.generate() server-side and stages MemoryCandidate rows by default, instead of just printing a prompt for you to paste elsewhere. --prompt-only restores 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 a models: block to Harper's root instance config (harper-config.yaml/harperdb-config.yaml at the Harper data directory) — not flair's own config.yaml, which Harper only ever loads as a non-root component config. The specific key is models.generative.<logicalName> (distinct from embeddings' models.embedding.default namespace under the same root block). Verified example from the now-shipped docs/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 via FLAIR_REM_MODEL=hosted; apiKey must 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 own docs/secrets-and-keys.md does 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 no models: backend is configured, flair rem rapid (execute-mode) and the nightly runner's distillation step both fail closed (503 no_backend) — the nightly runner logs it into the audit row's errors[] 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.md via #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's thinking field, which the backend doesn't read, so every REM execute run fails closed with distillation_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-next staged 7 quality candidates in ~7s, dedup held on a second run, a promoted candidate landed with derivedFrom intact.
    • Nightly cycle now spends model tokens/compute nightly, once a backend is configured — step 5 of the nightly runner calls /ReflectMemories with execute: true after 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 a models: backend.
    • Review loop is unchanged and still the only promotion path: flair rem candidates lists 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 enable installs 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.

🌙 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: true on POST /ReflectMemories (resources/MemoryReflect.ts, resources/memory-reflect-lib.ts) runs distillation server-side via models.generate() — schema-constrained output on the first attempt, a json-mode fallback with one retry on malformed output, fail-closed (no retry) on a thrown network/timeout error. Validated output stages MemoryCandidate rows: shape validation, sourceMemoryIds checked 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 via FLAIR_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/harper 5.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. generatedBy on a staged candidate is the configured logical model name — the pinned Harper version's GenerateResult carries 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 /ReflectMemories with execute: true. The audit row gets slice: "2" whenever distillation was attempted (success or failure) with staged candidate ids on success or a distillation: entry in errors[] on failure — maintenance results stand either way, a distillation failure never fails the whole nightly cycle. --dry-run skips the distillation call entirely (staging rows and spending model tokens are real side effects, deliberately not exercised in a dry run).
  • CLI: flair rem rapid executes by default now and prints a staged-candidate summary with a review hint; --prompt-only preserves 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-once now 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 own config.yaml — flair always loads as a non-root component, so Harper never reads a models: block from component config. New docs/rem.md (linked from the README) documents this prominently, plus the FLAIR_REM_MODEL env var, the clustered-deploy single-timer rule, and the snapshot-locality note.
  • Non-thinking model requirement — thinking/reasoning models' output lands in Ollama's thinking response field, which Harper's Ollama backend never reads, so an execute-mode call against a thinking model always fails closed with 502 distillation_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 preserved derivedFrom) is required. Documented in docs/rem.md (#713).
  • Deferred to a later slice, called out explicitly in #710's PR body: tags is schema-validated on a candidate but not persisted (MemoryCandidate has no tags column 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's getEmbedding() now calls models.embed(text, {model: "default"}) instead of dynamic-importing harper-fabric-embeddings and hand-rolling init; the @node-llama-cpp/<platform> addon-discovery + VM-sandbox init block is deleted outright. harper-fabric-embeddings bumped 0.2.3 → 0.3.0. No inputType was 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-embeddings 0.3.0's l2NormalizeInPlace casts to Float32Array before 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_CONFIG references to the mechanism actually shipped, and clarifying that Harper's global models export and a component's scope.models are the same boot-time singleton (not two things).
  • inputType plumbing + the prefix gate, initially parked (#689). Added EmbedInputType ('document' | 'query') plumbing through every getEmbedding() call site, and a single chokepoint constant EMBEDDING_PREFIXES_ENABLED in embeddings-provider.ts that atomically controls both whether inputType is forwarded to models.embed() and whether getModelId() appends a +searchprefix suffix (the two can never diverge by construction). Landed with the gate off — K&S reviewed an N=126-query A/B (prefixes=on vs off) and ratified parking the flip: Δp@3 −0.016, ΔMRR −0.003, noise-scale at this instrument's N, not a directional signal. Also shipped test/bench/recall-harness/BASELINE.json as 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_CONFIG env var) persisted models.embedding.default into harper-config.yaml. Downgrading to a pre-#685 build (which never sets HARPER_CONFIG because the feature didn't exist yet) made Harper's env-config layer delete the persisted keys individually with no stored original — leaving models: {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.ts calls harper-fabric-embeddings's register() factory directly on every boot, loaded via the existing jsResource glob) — nothing is ever persisted to harper-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_ENABLEDtrue, 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 expecting search_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>+searchprefix id; the always-on embedding-stamp migration 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 what flair-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.ts detect 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 ledger OrgEvent + 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-stamp migration (always-active, derived-only, part of #690): re-embeds any row whose stamp doesn't match the current getModelId() output. Regenerates via a genuine admin-authenticated loopback PUT /Memory/:id — the same mechanism flair reembed already uses in production — rather than a bare in-process databases.flair.Memory.put() call, which was found (via the integration test against real Harper) to bypass resources/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): public GET /Health now also reports version; every CLI command gets a cached (~60s TTL) version check via a global preAction hook (gated on isTTY); flair doctor shows the version triple plus migration state, with --fix offering 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 via flair 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/restore is.)
    • upgrade-smoke extended: 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 real verifyClientAssertion in live-package interop tests, not a mirror implementation.
  • CIMD document build + hosting (MCPClientMetadata) — resolves through the plugin's real, SSRF-guarded resolveCimdClient. Negative-tested: allowedHosts rejection, 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) and 429 slow_down handling that respects Retry-After with full-jitter backoff (exponential fallback when the header is absent). flair mcp token now actually mints (--dry-run still 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.0 mounted 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: Bearer against /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 --share to 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).
  • --label is 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 test run; 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.fileBasename is 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.json the 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.sh and .github/workflows/release-publish.yml both hardcode their package lists, and neither had been updated when flair-bench was added) — confirmed via npm view @tpsdev-ai/flair-bench returning 404 — and fixed both lists, plus discovered and fixed a missing LICENSE file that would have silently dropped out of every tarball. flair-bench is still not live on the npm registry as of this releasenpm stage publish requires the package to already exist, so a maintainer with npm org-owner access must do a one-time manual npm 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 with continue-on-error: true specifically 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 to lastHeartbeatAt).
  • 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 activity to "idle" and currentTask to null; the last-known label moves to a new public lastActivity field plus activityAgeMs/activityFresh, so a client can render "offline (was: debugging)" without re-deriving staleness itself.
  • Heartbeats self-decay: activityUpdatedAt is 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: null instead of the frozen last value — any downstream consumer (the PR names the Office Space dashboard specifically) reading activity directly instead of lastActivity will 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 rawScore rises, mirroring retrievalBoost'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 × rFactor to a small band around 1.0 via COMPOSITE_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 by COMPOSITE_RELEVANCE_FLOOR (default 0.5, same value as retrievalBoost'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 replaces compositeScore'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): mirrors memory_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 a validTo or delete, then write the new one — a different predicate does NOT auto-close the old triple).
  • CLI flair relationship add (--agent required, Ed25519-signed via the existing api() helper — mirrors flair 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. agentId is 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 older request.tpsAgent-direct pattern to resolveAgentAuth() (matching Memory.post()/Memory.put()) — anonymous denied (401), a non-admin agent's agentId always comes from the verified signature (never the request body; a mismatched body agentId is rejected with 403 rather than silently overwritten), admin/internal calls remain unfiltered (no regression to existing internal callers).
  • Provenance parity: Relationship gains a nullable provenance field, stamped via the SAME buildProvenance() helper Memory uses (now extracted to resources/provenance.ts so both tables share one implementation) — identical {v, verified:{agentId,timestamp}, claimed?:{model}} shape, no relationship-specific format. Additive/nullable; a pre-existing row with no provenance field still reads back fine (migration-equivalence, same discipline as the earlier usageCount addition).
  • 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.agentId present; a pre-provenance row reads null without error), and a render-safety check on the attention read's subject → predicate → object line — 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()) from SemanticSearch.post() — the HNSW/BM25 retrieval + all post-retrieval filtering (temporal/expiry/supersede exclusion, the scope.isAllowed() defense-in-depth re-check), taking primitives only, never a Resource instance. Auth, rate-limiting, the reranker, and the retrievalCount/lastRetrieved hit-tracking side effects stay in SemanticSearch.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.search calls conditioned on agentId==self (+ durability/createdAt, all @indexed), explicit select (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), sized K = max(3 × expected fill, 5 × teammate count, 50), capped at 100.
  • Per-set supersede exclusion (own slices independently, candidate pool independently) — the unconditional past-validTo guard (the primary supersede defense) is preserved verbatim.
  • memoriesAvailable is 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 tool record_usage (resources/RecordUsage.ts): report that memory id(s) were actually cited/used, with an optional opaque attribution string. A dedicated endpoint, not Memory.put() — usage feedback is a cross-agent write (agent B reports using agent A's memory) that Memory.put()'s ownership check would 403; this does a targeted usageCount-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 RPM rate-limit bucket, a dedup ledger (MemoryUsage table, 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 as retrievalBoost (min(1.0 + 0.1·log2(n), 1.1), floor 0.5). compositeScore now uses usageBoost(usageCount) in place of retrievalBoost(retrievalCount) — dropped outright, not just outweighed, since the old signal was contaminated by construction (a search hit ≠ verified use). retrievalCount/retrievalBoost remain exported (a future weak-prior idea, not built here) but are no longer read by compositeScore.
  • 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, the retrievalCount shape: 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 with recall-eval.mjs on 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.node is >=22 with 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-unit and typecheck (.github/workflows/test.yml) now run a strategy.matrix.node-version: ["22", "24", "26"] (fail-fast: false, so one version's failure doesn't hide the others); a test-unit-gate/typecheck-gate job re-emits the fixed Unit Tests/Type Check check 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 under node, the most representative "does a real user's Node actually work" path. test-integration stays pinned to Node 22 (its existing HarperFast/harper#386 native-spawn-vs-Docker mitigation is version-load-bearing) and upgrade-smoke moves 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-smoke now 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 bump package.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.mjs gate, 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 built dist/cli.js command tree). Found and fixed real remaining rot on introduction: soul set/get/list had shipped with blank descriptions.

  • Strict typecheck extended to src/** (flair#643, #669). tsconfig.check.json previously only covered resources/**; the CLI, probe, fleet-verify, deploy, and bridges modules under src/ had zero strict-mode coverage in CI beyond the non-strict build:cli compile. New tsconfig.check.src.json (strict, excludes only src/cli.ts pending a later split, and the naturally-out-of-scope src/cli-shim.cts) wired into the existing typecheck-strict job. 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 SemanticSearch call with neither q nor queryEmbedding — the "list everything in my scope" shape (agentId/tag/subject-only calls; see test/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-filtered allowedById candidate set directly at rawScore 0 when neither retrieval signal is present, matching the legacy contract exactly. Regression-guarded by test/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

  • authorizeLocal now defaults to false — 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 as super_user (Harper's authorizeLocal: 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 for flair init / agent add / principal add. The admin credential is now load-bearing for local ops~/.flair/admin-pass (written by flair init), --admin-pass, or FLAIR_ADMIN_PASS — a missing credential now fails closed instead of riding the ambient authorizeLocal super_user forgery. A new CI hard gate (pack-smoke in .github/workflows/test.yml) proves the bootstrap-ordering invariant this required: on a fresh flair 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 — authorizeLocal only ever governed loopback, and remote has always required real credentials. Set authorizeLocal: true in config.yaml to restore the old (insecure) behavior for local development only.

  • Gate FederationInstance/FederationPeers/HealthDetail/SkillScanauthorizeLocal class (#632, closes #631) — the #614/#630 CI backstop surfaced four resources with no explicit allow-decision, falling through to Harper's default super_user check, satisfiable by authorizeLocal's forged loopback super_user. FederationInstance/FederationPeers now require admin; HealthDetail requires a verified caller (and fixes a backwards isAdmin default that treated an unresolved caller as admin); SkillScan requires a verified caller.

  • Fabric deploy/upgrade credential flags no longer recommend leaking secrets to shell history (#650). flair upgrade --target, flair deploy, and flair fleet verify docs 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 local ps observer for the process lifetime. New --fabric-password-file <path> (mode-0600 file, reuses the existing --admin-pass-file secure reader, refuses group/other-readable files) is now the recommended path; inline --fabric-user now warns (parity with the pre-existing inline---fabric-password warning). Precedence: inline --fabric-password (warned) > --fabric-password-file > FABRIC_PASSWORD env. Docs (deployment.md, upgrade.md) flipped to lead with FABRIC_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/*.ts and 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-flair had real test suites CI only typechecked, never ran.

  • Fix port drift + stale security-model docs + upgrade.md (#629) — standardized docs on the real 19926 default, corrected security-model docs still describing the retired grant-gated read model, unfroze upgrade.md from 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/harper 5.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 authorizeLocal default 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 as super_user without 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/models comment corrections (#686, #668) — see the native-embeddings section above for #686; #668 is the equivalent same-week correction for authorizeLocal-related CLI comments that still described the pre-flip behavior.