@tpsdev-ai/flair 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1434 -288
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/AdminPrincipals.js +10 -1
  15. package/dist/resources/Agent.js +105 -11
  16. package/dist/resources/AgentSeed.js +10 -2
  17. package/dist/resources/MemoryBootstrap.js +7 -8
  18. package/dist/resources/MemoryUsage.js +18 -0
  19. package/dist/resources/Presence.js +8 -1
  20. package/dist/resources/SemanticSearch.js +17 -45
  21. package/dist/resources/abstention.js +1 -1
  22. package/dist/resources/agent-admin.js +149 -0
  23. package/dist/resources/agent-auth.js +98 -5
  24. package/dist/resources/auth-middleware.js +92 -19
  25. package/dist/resources/embeddings-boot.js +10 -12
  26. package/dist/resources/embeddings-provider.js +10 -7
  27. package/dist/resources/health.js +24 -19
  28. package/dist/resources/in-process.js +234 -0
  29. package/dist/resources/mcp-handler.js +14 -4
  30. package/dist/resources/mcp-tools.js +23 -17
  31. package/dist/resources/migration-boot.js +80 -10
  32. package/dist/resources/migrations/data-dir.js +205 -0
  33. package/dist/resources/migrations/progress.js +33 -0
  34. package/dist/resources/migrations/runner.js +29 -2
  35. package/dist/resources/migrations/state.js +13 -2
  36. package/dist/resources/models-dir.js +18 -9
  37. package/dist/resources/presence-internal.js +6 -1
  38. package/dist/resources/record-owner-guard.js +149 -0
  39. package/dist/resources/semantic-retrieval-core.js +5 -4
  40. package/dist/src/lib/scheduler-platform.js +128 -0
  41. package/dist/src/lib/xml-escape.js +54 -0
  42. package/dist/src/rem/scheduler.js +35 -87
  43. package/docs/deploying-on-fabric.md +267 -0
  44. package/docs/deployment.md +5 -0
  45. package/docs/embedding-in-a-harper-app.md +303 -0
  46. package/docs/federation.md +61 -4
  47. package/docs/integrations.md +3 -0
  48. package/docs/mcp-clients.md +16 -7
  49. package/docs/quickstart.md +80 -54
  50. package/docs/releasing.md +72 -38
  51. package/docs/supply-chain-policy.md +36 -0
  52. package/docs/troubleshooting.md +24 -0
  53. package/docs/upgrade.md +99 -4
  54. package/package.json +1 -11
  55. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  56. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  57. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  58. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  59. package/dist/resources/rerank-provider.js +0 -569
  60. package/docs/rerank-provisioning.md +0 -101
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Platform-scheduler primitives shared by every Flair-managed background job.
3
+ *
4
+ * Two modules install user-session scheduler entries today:
5
+ * - src/rem/scheduler.ts (`flair rem nightly enable`)
6
+ * - src/federation/scheduler.ts (`flair federation sync enable`)
7
+ *
8
+ * They differ in what they schedule and how often; they do NOT differ in how
9
+ * you ask launchd/systemd whether a job is really loaded, how you read the
10
+ * answer, or how you render a template into a unit file. This module exists so
11
+ * there is exactly ONE implementation of those — same reason src/lib/xml-escape.ts
12
+ * exists (#918): the second copy of a subtle rule is where it goes wrong.
13
+ *
14
+ * `interpretActiveResult()` in particular encodes a production lesson
15
+ * (flair#850) that took a real outage to learn. It must not be re-derived.
16
+ */
17
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
18
+ import { resolve, dirname } from "node:path";
19
+ import { platform } from "node:os";
20
+ import { spawnSync } from "node:child_process";
21
+ /**
22
+ * 30s ceiling on launchctl/systemctl invocations so a hung service manager
23
+ * can't block the CLI indefinitely. Sherlock #415 follow-up.
24
+ */
25
+ export const SPAWN_TIMEOUT_MS = 30_000;
26
+ /**
27
+ * Status-check spawns (launchctl print / systemctl is-active) return
28
+ * near-instantly when the service manager is reachable, and fail fast (no
29
+ * hang) when it isn't (e.g. "Failed to connect to bus"). A short ceiling
30
+ * keeps status commands and the Health endpoint responsive even when the
31
+ * query is inconclusive.
32
+ */
33
+ export const STATUS_CHECK_TIMEOUT_MS = 5_000;
34
+ export function detectPlatform(label, override) {
35
+ if (override)
36
+ return override;
37
+ const p = platform();
38
+ if (p === "darwin")
39
+ return "darwin";
40
+ if (p === "linux")
41
+ return "linux";
42
+ throw new Error(`unsupported platform for ${label}: ${p} (only darwin and linux)`);
43
+ }
44
+ export function spawnReport(cmd, timeoutMs = SPAWN_TIMEOUT_MS) {
45
+ const r = spawnSync(cmd[0], cmd.slice(1), {
46
+ encoding: "buffer",
47
+ timeout: timeoutMs,
48
+ });
49
+ return {
50
+ code: r.status,
51
+ stdout: r.stdout?.toString("utf-8") ?? "",
52
+ stderr: r.stderr?.toString("utf-8") ?? "",
53
+ };
54
+ }
55
+ /**
56
+ * Interprets the result of a `launchctl print` / `systemctl --user is-active`
57
+ * probe. Shared by the sync (CLI) and async (server) callers of both
58
+ * schedulers.
59
+ *
60
+ * - true — the service manager confirms the job is loaded/active.
61
+ * - false — confirmed NOT active. This includes the "no session bus" case
62
+ * (flair#850): when `systemctl --user` can't reach a bus, it fails
63
+ * before printing a status word ("Failed to connect to bus: No medium
64
+ * found") — but nothing CAN be running without a bus, so `false` is the
65
+ * honest answer, not "unknown".
66
+ * - null — genuinely inconclusive (the command itself couldn't run at
67
+ * all — e.g. the binary is missing — with no output to interpret).
68
+ */
69
+ export function interpretActiveResult(plat, code, stdout, stderr) {
70
+ const noOutput = !stdout.trim() && !stderr.trim();
71
+ if (plat === "darwin") {
72
+ if (code === 0)
73
+ return true;
74
+ if (code === null && noOutput)
75
+ return null; // spawn itself failed — inconclusive
76
+ return false; // launchctl ran and reported not-loaded
77
+ }
78
+ const out = stdout.trim();
79
+ if (out === "active" || out === "activating")
80
+ return true;
81
+ if (out === "inactive" || out === "failed" || out === "unknown")
82
+ return false;
83
+ if (code === null && noOutput)
84
+ return null; // spawn itself failed — inconclusive
85
+ return false; // covers the no-bus case: empty stdout, nonzero/failed exit
86
+ }
87
+ /**
88
+ * Human remedy text for a failed scheduler-load attempt (flair#850). Covers
89
+ * the one root cause traced so far: a missing systemd user session bus,
90
+ * which blocks `systemctl --user` entirely in ssh-without-lingering,
91
+ * container, and CI contexts. Returns null when the failure doesn't match a
92
+ * known pattern — the caller already prints the raw stderr, so the operator
93
+ * still has something to go on.
94
+ *
95
+ * `enableCommand` is the caller's own enable invocation, named in the remedy
96
+ * so the operator is told to re-run the command they actually ran.
97
+ */
98
+ export function describeLoadFailure(plat, loadResult, enableCommand) {
99
+ const stderr = loadResult.stderr || "";
100
+ if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
101
+ return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
102
+ "in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
103
+ `— then re-run \`${enableCommand}\`.`);
104
+ }
105
+ return null;
106
+ }
107
+ /** Single-pass `{{KEY}}` substitution, with a per-value escape hook. */
108
+ export function renderTemplateWith(text, subs, escape) {
109
+ return text.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
110
+ const value = subs[key];
111
+ if (value === undefined)
112
+ throw new Error(`unknown template placeholder: ${key}`);
113
+ return escape(String(value));
114
+ });
115
+ }
116
+ export function readTemplate(rootDir, relativePath) {
117
+ const full = resolve(rootDir, relativePath);
118
+ if (!existsSync(full)) {
119
+ throw new Error(`template not found: ${full}`);
120
+ }
121
+ return readFileSync(full, "utf-8");
122
+ }
123
+ export function writeFileWithDir(path, contents, mode = 0o600) {
124
+ const dir = dirname(path);
125
+ if (!existsSync(dir))
126
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+ writeFileSync(path, contents, { mode });
128
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * XML entity escaping for the launchd plists this CLI generates.
3
+ *
4
+ * A launchd plist is XML, so ANY value interpolated into one has to be
5
+ * escaped or the document is malformed — and `launchctl load` rejects a
6
+ * malformed plist outright, so the service silently never registers.
7
+ *
8
+ * Two independent writers generate plists, and both go through here:
9
+ * - buildLaunchdPlist() in src/cli.ts (the Harper service, `flair init`)
10
+ * - renderPlistTemplate() in src/rem/scheduler.ts (`flair rem nightly enable`)
11
+ *
12
+ * This module exists so there is exactly ONE implementation to get right.
13
+ * Anything that interpolates into plist XML imports it rather than
14
+ * hand-rolling a `.replace()` chain at the call site — a local chain is how
15
+ * this class of bug got in (and how it stayed partial: the original one
16
+ * covered three of the five entities).
17
+ */
18
+ /** The five XML predefined entities, in the order they must be replaced. */
19
+ const XML_ESCAPES = [
20
+ // `&` MUST be first: replacing it after `<` would turn the `&` of an
21
+ // already-emitted `&lt;` into `&amp;lt;` and corrupt the value.
22
+ [/&/g, "&amp;"],
23
+ [/</g, "&lt;"],
24
+ [/>/g, "&gt;"],
25
+ [/"/g, "&quot;"],
26
+ [/'/g, "&apos;"],
27
+ ];
28
+ /**
29
+ * Escape the five XML predefined entities for use inside an XML text node.
30
+ *
31
+ * Safe to apply to any string, including one with no special characters.
32
+ * The result round-trips exactly through unescapeXml().
33
+ */
34
+ export function escapeXml(s) {
35
+ let out = s;
36
+ for (const [pattern, replacement] of XML_ESCAPES)
37
+ out = out.replace(pattern, replacement);
38
+ return out;
39
+ }
40
+ /**
41
+ * Inverse of escapeXml(), for reading a value back out of a document we wrote.
42
+ *
43
+ * `&amp;` MUST be decoded LAST, mirroring escapeXml()'s ordering: decoding it
44
+ * first would turn `&amp;lt;` (the escaping of a literal "&lt;") into `<`
45
+ * instead of back into `&lt;`.
46
+ */
47
+ export function unescapeXml(s) {
48
+ return s
49
+ .replace(/&lt;/g, "<")
50
+ .replace(/&gt;/g, ">")
51
+ .replace(/&quot;/g, '"')
52
+ .replace(/&apos;/g, "'")
53
+ .replace(/&amp;/g, "&");
54
+ }
@@ -13,25 +13,25 @@
13
13
  * No daemon code lives here — the scheduler invokes the shim, the shim
14
14
  * invokes `flair rem nightly run-once`, the runner module does the work.
15
15
  */
16
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync } from "node:fs";
16
+ import { existsSync, chmodSync, rmSync } from "node:fs";
17
17
  import { resolve, dirname } from "node:path";
18
- import { homedir, platform } from "node:os";
19
- import { spawn, spawnSync } from "node:child_process";
18
+ import { homedir } from "node:os";
19
+ import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
+ import { escapeXml } from "../lib/xml-escape.js";
22
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
23
+ // Re-exported so this module's public surface is unchanged by the extraction
24
+ // into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
25
+ // sync enable` — needs the identical launchctl/systemctl interpretation, and
26
+ // flair#850's lesson must have exactly one implementation).
27
+ export { interpretActiveResult };
21
28
  export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
22
29
  export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
23
30
  export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
24
31
  export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.timer");
25
32
  export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.service");
26
33
  function detectPlatform(override) {
27
- if (override)
28
- return override;
29
- const p = platform();
30
- if (p === "darwin")
31
- return "darwin";
32
- if (p === "linux")
33
- return "linux";
34
- throw new Error(`unsupported platform for REM nightly scheduler: ${p} (only darwin and linux)`);
34
+ return detectPlatformFor("REM nightly scheduler", override);
35
35
  }
36
36
  function defaultTemplateRoot() {
37
37
  // Templates live alongside dist/ in the published package and alongside
@@ -50,19 +50,30 @@ function defaultTemplateRoot() {
50
50
  throw new Error(`unable to locate templates directory (looked in: ${candidates.join(", ")})`);
51
51
  }
52
52
  export function renderTemplate(text, subs) {
53
- return text.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
54
- const value = subs[key];
55
- if (value === undefined)
56
- throw new Error(`unknown template placeholder: ${key}`);
57
- return String(value);
58
- });
53
+ return renderTemplateWith(text, { ...subs }, (v) => v);
54
+ }
55
+ /**
56
+ * renderTemplate() for the launchd plist template specifically: substituted
57
+ * values are XML-escaped.
58
+ *
59
+ * A launchd plist is XML, so a substitution carrying `&`, `<`, `>`, `"` or
60
+ * `'` makes it malformed and `launchctl bootstrap` rejects it — the timer
61
+ * silently never registers. FLAIR_URL is the realistic carrier (a URL with
62
+ * more than one query parameter contains `&`), but HOME and SHIM_PATH are
63
+ * arbitrary paths and equally capable of it. HOUR/MINUTE and AGENT_ID are
64
+ * validated upstream in buildSubstitutions(), but they are escaped too
65
+ * rather than exempted — the point of a chokepoint is that no value gets to
66
+ * argue it is special.
67
+ *
68
+ * Deliberately NOT folded into renderTemplate(): the same substitutions are
69
+ * rendered into the systemd unit files and the shell shim, where XML
70
+ * escaping would be corruption rather than a fix.
71
+ */
72
+ export function renderPlistTemplate(text, subs) {
73
+ return renderTemplateWith(text, { ...subs }, escapeXml);
59
74
  }
60
75
  export function readTemplate(rootDir, relativePath) {
61
- const full = resolve(rootDir, relativePath);
62
- if (!existsSync(full)) {
63
- throw new Error(`template not found: ${full}`);
64
- }
65
- return readFileSync(full, "utf-8");
76
+ return readTemplateFrom(rootDir, relativePath);
66
77
  }
67
78
  /**
68
79
  * Validates the hour:minute schedule. Throws on invalid input rather than
@@ -93,32 +104,6 @@ function buildSubstitutions(opts, shimPath, flairBin) {
93
104
  MINUTE_PAD: String(opts.minute).padStart(2, "0"),
94
105
  };
95
106
  }
96
- function writeFileWithDir(path, contents, mode = 0o600) {
97
- const dir = dirname(path);
98
- if (!existsSync(dir))
99
- mkdirSync(dir, { recursive: true, mode: 0o700 });
100
- writeFileSync(path, contents, { mode });
101
- }
102
- // 30s ceiling on launchctl/systemctl invocations so a hung service manager
103
- // can't block the CLI indefinitely. Sherlock #415 follow-up.
104
- const SPAWN_TIMEOUT_MS = 30_000;
105
- // Status-check spawns (launchctl print / systemctl is-active) return
106
- // near-instantly when the service manager is reachable, and fail fast (no
107
- // hang) when it isn't (e.g. "Failed to connect to bus"). A short ceiling
108
- // keeps `flair rem nightly status` and the Health endpoint responsive even
109
- // when the query is inconclusive.
110
- const STATUS_CHECK_TIMEOUT_MS = 5_000;
111
- function spawnReport(cmd, timeoutMs = SPAWN_TIMEOUT_MS) {
112
- const r = spawnSync(cmd[0], cmd.slice(1), {
113
- encoding: "buffer",
114
- timeout: timeoutMs,
115
- });
116
- return {
117
- code: r.status,
118
- stdout: r.stdout?.toString("utf-8") ?? "",
119
- stderr: r.stderr?.toString("utf-8") ?? "",
120
- };
121
- }
122
107
  /**
123
108
  * Command to genuinely query the platform scheduler's active/loaded state
124
109
  * for a given install (as opposed to the shape of `loadCommand`, which
@@ -131,37 +116,6 @@ function activeCheckCommand(plat) {
131
116
  }
132
117
  return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
133
118
  }
134
- /**
135
- * Interprets the result of `activeCheckCommand()`. Shared by the sync
136
- * (CLI) and async (server) callers.
137
- *
138
- * - true — the service manager confirms the job is loaded/active.
139
- * - false — confirmed NOT active. This includes the "no session bus" case
140
- * (flair#850): when `systemctl --user` can't reach a bus, it fails
141
- * before printing a status word ("Failed to connect to bus: No medium
142
- * found") — but nothing CAN be running without a bus, so `false` is the
143
- * honest answer, not "unknown".
144
- * - null — genuinely inconclusive (the command itself couldn't run at
145
- * all — e.g. the binary is missing — with no output to interpret).
146
- */
147
- export function interpretActiveResult(plat, code, stdout, stderr) {
148
- const noOutput = !stdout.trim() && !stderr.trim();
149
- if (plat === "darwin") {
150
- if (code === 0)
151
- return true;
152
- if (code === null && noOutput)
153
- return null; // spawn itself failed — inconclusive
154
- return false; // launchctl ran and reported not-loaded
155
- }
156
- const out = stdout.trim();
157
- if (out === "active" || out === "activating")
158
- return true;
159
- if (out === "inactive" || out === "failed" || out === "unknown")
160
- return false;
161
- if (code === null && noOutput)
162
- return null; // spawn itself failed — inconclusive
163
- return false; // covers the no-bus case: empty stdout, nonzero/failed exit
164
- }
165
119
  /**
166
120
  * Synchronous active-state check for CLI use (`flair rem nightly status`).
167
121
  * Blocking is fine here — this is a one-shot process and the caller wants
@@ -220,13 +174,7 @@ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEO
220
174
  * still has something to go on.
221
175
  */
222
176
  export function describeLoadFailure(plat, loadResult) {
223
- const stderr = loadResult.stderr || "";
224
- if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
225
- return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
226
- "in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
227
- "— then re-run `flair rem nightly enable`.");
228
- }
229
- return null;
177
+ return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
230
178
  }
231
179
  /**
232
180
  * Formats the `flair rem nightly enable` report from an `EnableResult`.
@@ -330,7 +278,7 @@ export function enableScheduler(opts) {
330
278
  // 2. Write the scheduler entry.
331
279
  if (plat === "darwin") {
332
280
  const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
333
- const plistContents = renderTemplate(readTemplate(templateRoot, "launchd/dev.flair.rem.nightly.plist.tmpl"), subs);
281
+ const plistContents = renderPlistTemplate(readTemplate(templateRoot, "launchd/dev.flair.rem.nightly.plist.tmpl"), subs);
334
282
  writeFileWithDir(plistPath, plistContents, 0o600);
335
283
  const loadCommand = ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath];
336
284
  let loadResult;
@@ -0,0 +1,267 @@
1
+ # Deploying on Harper Fabric
2
+
3
+ Run Flair as a component on [Harper Fabric](https://www.harperdb.io/) — managed, hosted,
4
+ multi-region — instead of a local process you own. The hosted counterpart to
5
+ [deployment.md](deployment.md); two things define it: you *deploy* rather than install,
6
+ and **you get no shell on the node.**
7
+
8
+ ## When to use it
9
+
10
+ | | Standalone local | Harper Fabric |
11
+ |---|---|---|
12
+ | You run | `flair init` | `flair deploy` |
13
+ | Shell on the node | yes | **no** |
14
+ | Multi-region / failover | no | yes |
15
+ | Upgrade | `flair upgrade` | `flair upgrade --target <url>` |
16
+
17
+ Choose Fabric for an always-on hub in more than one region. For a single instance on a VPS
18
+ you already have, [Remote Server](../README.md#remote-server) is simpler and keeps your shell.
19
+
20
+ ---
21
+
22
+ ## Quickstart
23
+
24
+ ### 1. Deploy the component
25
+
26
+ ```bash
27
+ export FABRIC_USER=<admin> FABRIC_PASSWORD=<pass>
28
+
29
+ # Validate args and package layout without deploying
30
+ flair deploy --fabric-org <org> --fabric-cluster <cluster> --dry-run
31
+
32
+ flair deploy --fabric-org <org> --fabric-cluster <cluster>
33
+ ```
34
+
35
+ Target defaults to `https://<cluster>.<org>.harperfabric.com`; override with `--target`.
36
+
37
+ Credentials go via the environment, not argv, so they stay out of `ps`. Use
38
+ `--fabric-password-file <path>` (mode `0600`) when scripting; inline flags leak to history.
39
+
40
+ Deploy verifies the served API (Harper reports success for a component serving
41
+ nothing; `--no-verify` opts out), waits 600s for replication (Harper's 120s default
42
+ aborts mid-replicate on Fabric), and polls for convergence before reporting one.
43
+
44
+ > `--fabric-token` is accepted but **fails** — `deploy_component` is Basic-auth only.
45
+
46
+ ### 2. Provision
47
+
48
+ ```bash
49
+ flair init --target https://<cluster>.<org>.harperfabric.com \
50
+ --ops-target <ops-url> \
51
+ --cluster-admin-user <user> --cluster-admin-pass <pass> \
52
+ --remote --force
53
+ ```
54
+
55
+ - `--force` is required — this writes to a live instance.
56
+ - `--remote` marks it a federation **hub** and creates the `flair_pair_initiator` role;
57
+ without it, pairing later fails role-not-found.
58
+ - Generated admin password lands in `~/.tps/secrets/flair-fabric-hdb` (mode `0600`);
59
+ `--flair-admin-pass` to choose your own.
60
+
61
+ Run **once**, before serving multi-region traffic — see
62
+ [Multi-region](#multi-region-replication-vs-federation).
63
+
64
+ ### 3. Verify
65
+
66
+ ```bash
67
+ curl -sf https://<cluster>.<org>.harperfabric.com/Health
68
+ flair fleet verify --target https://<cluster>.<org>.harperfabric.com
69
+ flair status --target https://<cluster>.<org>.harperfabric.com
70
+ ```
71
+
72
+ ### 4. Connect a client
73
+
74
+ ```bash
75
+ export FLAIR_URL=https://<cluster>.<org>.harperfabric.com
76
+
77
+ # Register an agent — --ops-target is required, see Ports below
78
+ flair agent add mybot --target "$FLAIR_URL" --ops-target <ops-url>
79
+ ```
80
+
81
+ Also set `FLAIR_PUBLIC_URL` to that URL in the component's environment — OAuth metadata
82
+ and A2A discovery advertise it, or clients see a loopback address.
83
+
84
+ ---
85
+
86
+ ## Ports: the derivation trap
87
+
88
+ Locally, Flair serves data on `19926` and the ops API on `19925`. The CLI derives
89
+ **ops = data − 1** everywhere.
90
+
91
+ A managed endpoint is HTTPS on 443 with no port, so derivation produces **`:442`** —
92
+ where nothing answers:
93
+
94
+ ```bash
95
+ # --target https://<cluster>.<org>.harperfabric.com → ops derived as :442 ✗
96
+ # --target https://<fabric-node>:19926/<instance> → ops derived as :19925 ✓
97
+ ```
98
+
99
+ **Pass `--ops-target <url>` explicitly** (or set `FLAIR_OPS_TARGET`) on any command that
100
+ touches the ops API: `init --target`, `agent add --target`, `federation token --target`.
101
+
102
+ > **Gap — needs a Fabric account to verify.** This guide does not state the correct
103
+ > ops-API URL for a managed `*.harperfabric.com` instance, or whether the ops API is
104
+ > reachable remotely there at all. The derivation above is certain (read from source);
105
+ > the right value to pass is not.
106
+
107
+ Precedence: `--target` > `--url` > `FLAIR_TARGET` > `FLAIR_URL` > localhost. For ops:
108
+ `--ops-target` > `FLAIR_OPS_TARGET` > derived > localhost.
109
+
110
+ ## Auth
111
+
112
+ Flair ships `authorizeLocal: false` — it only governs loopback, so it changes nothing
113
+ here; remote callers always need real credentials.
114
+
115
+ - Admin ops use Basic auth; agents use Ed25519 ([auth.md](auth.md)).
116
+ - `/Health` is public — your liveness check.
117
+ - Don't debug with raw `curl` — on Fabric the auth gate fires before the resource
118
+ handler, so a hand-rolled request 401s in a way that looks like a Flair bug and isn't.
119
+
120
+ ---
121
+
122
+ ## Pairing a spoke to a hosted hub
123
+
124
+ No shell on the hub, so mint the token remotely rather than over `ssh`:
125
+
126
+ ```bash
127
+ # On any machine — note --ops-target, this hits the ops API
128
+ FLAIR_ADMIN_PASS=<hub-admin-password> flair federation token \
129
+ --target https://<cluster>.<org>.harperfabric.com \
130
+ --ops-target <ops-url> > ./pair-triple.json
131
+
132
+ # On the spoke
133
+ flair federation pair https://<cluster>.<org>.harperfabric.com \
134
+ --token-from ./pair-triple.json
135
+ ```
136
+
137
+ `federation token` takes `--admin-pass`, **not** `--admin-pass-file`. The triple holds a
138
+ one-time credential — delete it after pairing (60-min TTL, `--ttl`).
139
+
140
+ ### Federation is push-only
141
+
142
+ **A spoke pushes up. It cannot pull down.** `POST /FederationSync` is one-directional per
143
+ call and there is no pull endpoint anywhere.
144
+
145
+ For both directions, each side pairs **as a spoke of the other**: two pairings, two
146
+ tokens, two syncs. No setting makes sync bidirectional.
147
+
148
+ Syncs `Memory`, `Soul`, `Agent`, `Relationship`. **`Presence` is not federated** — no
149
+ cross-spoke roster.
150
+
151
+ ### Keeping a spoke synced
152
+
153
+ `flair federation sync` is one-shot; `watch` dies with its terminal. Use the scheduled
154
+ driver ([federation.md](federation.md#keeping-it-synced)):
155
+
156
+ ```bash
157
+ flair federation sync enable --interval 300
158
+ flair federation sync status
159
+ ```
160
+
161
+ > **The driver is local-only.** It writes a launchd job or systemd timer **on the machine
162
+ > running the CLI**; `--target` just points that local job at a remote instance. You
163
+ > cannot install a driver on a Fabric node.
164
+
165
+ A periodic one-shot, not a supervised watcher; `--interval` is the latency knob.
166
+ `flair federation status` omits its driver verdict for a remote `--target`.
167
+
168
+ ---
169
+
170
+ ## Multi-region: replication vs federation
171
+
172
+ Fabric gives you N regional nodes running one component — **not** N Flair instances.
173
+ Instance identity lives in the `Instance` table (`schemas/federation.graphql`); it is a
174
+ Harper table, so **it replicates** — every node shares one Flair identity.
175
+
176
+ | Layer | Handles | Scope |
177
+ |---|---|---|
178
+ | Harper replication | your app across its own regions | intra-app, automatic |
179
+ | Flair federation | this app ↔ another Flair instance | instance-to-instance, you pair it |
180
+
181
+ **You do not federate your own regions to each other** — Harper already did. Use
182
+ `flair federation pair` only to reach a *separate* Flair instance.
183
+
184
+ One asymmetry: identity replicates, but `flair-instance.yaml` (port config, per data
185
+ directory) does not. A node reporting a different **port** is expected; a different
186
+ **instance id** is not.
187
+
188
+ Provision once, from one place, before serving multi-region traffic: step 2's
189
+ `flair init … --remote` seeds the identity row so it exists before any node needs it.
190
+
191
+ ---
192
+
193
+ ## Operating it
194
+
195
+ ### What works remotely
196
+
197
+ | Command | Gives you |
198
+ |---|---|
199
+ | `GET /Health` | liveness, no auth |
200
+ | `flair status --target <url>` | subsystem rollups; the only byte counts available remotely |
201
+ | `flair quality --target <url>` | recall/coverage; halted-migration reasons |
202
+ | `flair fleet verify --target <url>` | health, auth, version across origin + Flair peers |
203
+ | `flair federation status\|verify\|reachability --target <url>` | peer table, sync recency, probes |
204
+
205
+ `fleet verify` exit codes: 1 origin failed, 2 peer version skew, 3 peer unverifiable.
206
+
207
+ > **A credential mismatch renders as an empty section, not an error.** `flair status`
208
+ > reads `/HealthDetail` with `FLAIR_ADMIN_PASS` / `HDB_ADMIN_PASSWORD` / a pinned agent
209
+ > key — **not** the `FABRIC_*` credentials `deploy` and `fleet verify` use. On failure it
210
+ > renders blank — a blank Disk section means "couldn't authenticate" as often as
211
+ > "nothing to report".
212
+
213
+ ### What doesn't
214
+
215
+ **`flair doctor` takes no `--target`** — it hardcodes localhost, reads a local PID file
216
+ and shells out to `lsof`. The command you'd reach for when something breaks is unavailable
217
+ here. Unavailable too: `start`, `stop`, `restart`, `snapshot`, `reembed`, `rem`, `bridge`.
218
+
219
+ **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation
220
+ peer table, not Harper's cluster nodes — `cluster_status` is harper-pro-only and absent
221
+ from the OSS `harper` build. **`0 peers known` means "0 on file", never "0 exist."**
222
+
223
+ **There is no disk or quota telemetry.** `flair status` reports usage for two directories:
224
+ no free space, no total, no quota, no warning threshold, walk capped at six levels, no
225
+ per-component size. `system_information` is never called — an instance can hit its quota
226
+ with nothing saying so. The one indirect signal is a migration halting for space.
227
+
228
+ > **On `get_components`.** A source comment claims Harper excludes `node_modules`
229
+ > server-side. That is uncited and unverified, so this guide doesn't rely on it — and it
230
+ > wouldn't matter: Flair calls it only as a post-failure convergence oracle and discards
231
+ > the `size` field. Component disk usage is invisible structurally.
232
+
233
+ ### Known hazard: unbounded npm cache
234
+
235
+ **Open — [flair#886](https://github.com/tpsdev-ai/flair/issues/886).** Every deploy runs a
236
+ server-side `npm install` using the node's default cache. npm never evicts it, so it
237
+ grows until it fills the quota.
238
+
239
+ No in-product mitigation: no cache flag, no alternate location, no cleanup. One install
240
+ per deploy bounds the *rate*, not the total. Harper consults `install_command` only when
241
+ `node_modules` is absent and `deploy_component` has no force-reinstall option, so the
242
+ obvious fix isn't available — and clearing the cache needs node access this shape
243
+ doesn't give you.
244
+
245
+ ### Backup and rollback
246
+
247
+ `flair snapshot` is local-only. Back up before every upgrade — `flair backup` takes
248
+ **`--url`**, not `--target`:
249
+
250
+ ```bash
251
+ flair backup --url https://<cluster>.<org>.harperfabric.com \
252
+ --admin-pass-file <path> --output ./flair-backup.json
253
+
254
+ # --check shows the version diff and plan without deploying
255
+ flair upgrade --target https://<cluster>.<org>.harperfabric.com --check
256
+ ```
257
+
258
+ Full path: [upgrade.md](upgrade.md#upgrading-a-fabric-deployed-instance).
259
+
260
+ ---
261
+
262
+ ## See also
263
+
264
+ - [deployment.md](deployment.md) — the standalone local shape
265
+ - [upgrade.md](upgrade.md#upgrading-a-fabric-deployed-instance) — Fabric upgrades, fleet verify
266
+ - [federation.md](federation.md) — pairing, sync driver, conflict resolution
267
+ - [spoke-bringup.md](spoke-bringup.md) · [auth.md](auth.md) · [secrets-and-keys.md](secrets-and-keys.md)
@@ -116,6 +116,11 @@ Note: embeddings run on CPU in Docker (no Metal acceleration). Performance is ac
116
116
 
117
117
  Deploying to a Harper Fabric cluster is a different mechanism from the installs above — `flair deploy` pushes Flair as a cluster component instead of `npm install -g`. To upgrade an already-deployed Fabric instance in place, use `FABRIC_USER=<admin> FABRIC_PASSWORD=<pass> flair upgrade --target <fabric-url>` (or `--fabric-password-file <path>` in place of the env var), not the local upgrade path. Inline `--fabric-user`/`--fabric-password` flags also work but are discouraged — both leak to shell history and `ps`. See [`docs/upgrade.md` — Upgrading a Fabric-deployed instance](upgrade.md#upgrading-a-fabric-deployed-instance) for the full walkthrough, including the automatic post-deploy fleet-convergence sweep.
118
118
 
119
+ For the hosted shape end to end — when to choose it, ports and auth against a managed
120
+ Fabric endpoint, pairing local spokes to a hosted hub, and what you can and cannot
121
+ observe without a shell on the node — see
122
+ [`docs/deploying-on-fabric.md`](deploying-on-fabric.md).
123
+
119
124
  ---
120
125
 
121
126
  ## Remote Access