@tpsdev-ai/flair 0.45.0 → 0.47.0

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 (39) hide show
  1. package/config.yaml +35 -2
  2. package/dist/build-info.json +6 -0
  3. package/dist/cli.js +847 -168
  4. package/dist/doctor-client.js +358 -11
  5. package/dist/federation/scheduler.js +114 -9
  6. package/dist/hook-install.js +150 -1
  7. package/dist/install/global-bin-path.js +234 -0
  8. package/dist/lib/entity-vocab-cli.js +113 -0
  9. package/dist/lib/mcp-enable.js +71 -21
  10. package/dist/lib/scheduler-platform.js +363 -1
  11. package/dist/postinstall.cjs +88 -0
  12. package/dist/rem/runner.js +177 -10
  13. package/dist/rem/scheduler.js +126 -20
  14. package/dist/resources/AttentionQuery.js +5 -3
  15. package/dist/resources/AutoPromoteCandidates.js +18 -12
  16. package/dist/resources/Federation.js +49 -5
  17. package/dist/resources/Memory.js +36 -2
  18. package/dist/resources/MemoryBootstrap.js +118 -7
  19. package/dist/resources/MemoryMaintenance.js +8 -2
  20. package/dist/resources/MemoryReflect.js +70 -5
  21. package/dist/resources/auto-promote-lib.js +46 -0
  22. package/dist/resources/build-info.js +50 -0
  23. package/dist/resources/entity-vocab.js +25 -1
  24. package/dist/resources/health.js +25 -5
  25. package/dist/resources/mcp-oauth-flag.js +20 -0
  26. package/dist/resources/mcp-oauth.js +6 -1
  27. package/dist/resources/mcp-tools.js +53 -3
  28. package/dist/resources/memory-reflect-lib.js +201 -4
  29. package/dist/src/lib/scheduler-platform.js +363 -1
  30. package/dist/src/rem/scheduler.js +126 -20
  31. package/docs/deepseek-harness.md +110 -0
  32. package/docs/entity-vocabulary.md +15 -0
  33. package/docs/integrations.md +1 -0
  34. package/docs/mcp-clients.md +4 -0
  35. package/docs/notes/mcp-oauth-model2.md +52 -3
  36. package/package.json +5 -4
  37. package/schemas/memory.graphql +12 -0
  38. package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
  39. package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
@@ -123,7 +123,12 @@ export async function registerMcpOAuthRoute(deps = {}) {
123
123
  return decide({
124
124
  mounted: false,
125
125
  status: "Not enabled",
126
- reason: "Set FLAIR_MCP_OAUTH=1 (and an issuer) to serve MCP over HTTP.",
126
+ // "true" not "1": flair's flag accepts either, but the component's
127
+ // config-side read of the same var (config.yaml `mcp.enabled:
128
+ // ${FLAIR_MCP_OAUTH}`, flair#1152) accepts ONLY "true"/"false" — with
129
+ // "1" the /mcp route registers and every request 401s against a
130
+ // component that never mounted its AS.
131
+ reason: "Set FLAIR_MCP_OAUTH=true (and an issuer) to serve MCP over HTTP.",
127
132
  });
128
133
  }
129
134
  // Boot guard (flair#1021): fail loudly if the operator enabled the flag but
@@ -788,6 +788,11 @@ export const TOOLS = {
788
788
  "sections", "tokenEstimate", "maxTokens", "memoriesIncluded", "memoriesAvailable",
789
789
  "memoriesTruncated", "teammateFindingsIncluded", "teammateFindingsTruncated",
790
790
  "teammateFindingsMatched", "context", "flairVersion",
791
+ // flair#1270 — the payload token LEDGER: every token-charged content
792
+ // class has a counter, so tokenEstimate ≈ scaffoldTokens + soulTokens +
793
+ // memoryTokens + trustTokens + eventsTokens decomposes from the payload
794
+ // alone (see the identity block in MemoryBootstrap's response tail).
795
+ "soulTokens", "memoryTokens", "trustTokens", "eventsTokens", "scaffoldTokens",
791
796
  ],
792
797
  fieldTypes: {
793
798
  agentId: "string", soul: "object", memories: "array", predicted: "array",
@@ -796,6 +801,8 @@ export const TOOLS = {
796
801
  memoriesAvailable: "number", memoriesTruncated: "number",
797
802
  teammateFindingsIncluded: "number", teammateFindingsTruncated: "number",
798
803
  teammateFindingsMatched: "number", context: "string", flairVersion: "string",
804
+ soulTokens: "number", memoryTokens: "number", trustTokens: "number",
805
+ eventsTokens: "number", scaffoldTokens: "number",
799
806
  },
800
807
  invariants: {
801
808
  // count == delivered — the historical count/charge/deliver drift.
@@ -822,12 +829,55 @@ export const TOOLS = {
822
829
  // tolerance covers the fixed JSON scaffolding + the #1207 prose-vs-
823
830
  // structured charge gap; uncounted content does not fit under it.
824
831
  budgetCap: { estimate: "tokenEstimate", budget: "maxTokens", tolerance: 0.25 },
825
- // #1207 count arithmetic: included + truncated <= available, for own
826
- // memories AND teammate findings (each a disjoint split of its pool).
832
+ // flair#1290 step 4 the #1270 token-ledger identity, enforced:
833
+ // tokenEstimate scaffold + soul + memory + trust + events, two-sided
834
+ // (constants sized in bootstrap-token-ledger-1270.test.ts, which reads
835
+ // them from HERE — one identity, one tolerance definition). The upper
836
+ // bound is waived when the request opted into the prose mirror
837
+ // (includeContext), which legitimately widens the gap by design.
838
+ tokenDecomposition: {
839
+ total: "tokenEstimate",
840
+ terms: ["scaffoldTokens", "soulTokens", "memoryTokens", "trustTokens", "eventsTokens"],
841
+ perItemContainers: ["memories", "predicted", "teammateFindings"],
842
+ perItemGap: 60,
843
+ fixedSlack: 150,
844
+ roundingSlack: 48,
845
+ proseMirrorArg: "includeContext",
846
+ },
847
+ // #1207 — count arithmetic: included + truncated <= available. The
848
+ // own-memory triple CAN fail: memoriesAvailable comes from an
849
+ // independent count query, so a double-counted memory breaks it.
827
850
  countCoherence: [
828
851
  { included: "memoriesIncluded", truncated: "memoriesTruncated", available: "memoriesAvailable" },
829
- { included: "teammateFindingsIncluded", truncated: "teammateFindingsTruncated", available: "teammateFindingsMatched" },
852
+ // flair#1290 the teammate triple ({ teammateFindingsIncluded,
853
+ // teammateFindingsTruncated, teammateFindingsMatched }) is
854
+ // deliberately NOT here. teammateFindingsMatched is DEFINED as
855
+ // included + truncated (informational-derived — see the note at its
856
+ // definition in resources/MemoryBootstrap.ts), so the entry asserted
857
+ // X <= X: permanently green by construction. An invariant that
858
+ // cannot fail is decorative, and a decorative entry in this array
859
+ // misrepresents the coverage the suite provides. K&S ruling on
860
+ // #1290: drop it rather than adding an independently-measured pool
861
+ // tally (a new counter that would itself need an invariant); the
862
+ // trust path's real protections are budgetCap plus the own-memory
863
+ // triple above, now exercised under includeTrust:true.
864
+ ],
865
+ // flair#1290 — populated-or-hint (#1182's 0.44.11 rule, previously
866
+ // asserted nowhere): every structured container that ships empty
867
+ // carries its "why" hint, and no hint ships beside a populated
868
+ // container. Conditions per the hintWhenEmpty type doc — predictedHint
869
+ // additionally requires subjects to have been requested;
870
+ // currentTaskHint keys off the request, not a container.
871
+ hintWhenEmpty: [
872
+ { hint: "eventsHint", container: "events" },
873
+ { hint: "teammateFindingsHint", container: "teammateFindings" },
874
+ { hint: "predictedHint", container: "predicted", requiresNonEmptyArrayArg: "subjects" },
875
+ { hint: "currentTaskHint", presentWhenStringArgBlank: "currentTask" },
830
876
  ],
877
+ // flair#1290 — nothing the wrapper's own classifier calls a zero-row
878
+ // no-op ships as an event (#1200 render filter as a semantic class,
879
+ // not hardcoded fixture strings).
880
+ noOpEventsSuppressed: { container: "events" },
831
881
  // #1199 — prose is a pointer at the default, not a second copy.
832
882
  proseContextIsPointerAtDefault: { field: "context" },
833
883
  // shape of the structured containers a connector reads; #1188 leak bites on memories.
@@ -39,8 +39,114 @@ export const DEFAULT_MAX_TOKENS = 2000;
39
39
  * over creative extrapolation.
40
40
  */
41
41
  export const GENERATE_TEMPERATURE = 0.2;
42
+ // ─── Continuity-journal distillation (flair#1257 slice 3) ────────────────────
43
+ // The session-continuity journal (slice 2, #1283) writes ephemeral+private
44
+ // rows tagged `adk:continuity:<sessionId>`. REM distills those journals with
45
+ // the SAME scope:"tagged" machinery ADK per-user tags use (#1205b) — slice 3
46
+ // is wiring and guards, not a new engine. The constants/predicates here are
47
+ // the continuity-specific guards:
48
+ //
49
+ // - stale-intent (two layers, Kern-ruled): the distiller prompt carries the
50
+ // rule (primary), AND a text-shape post-filter drops in-flight-intent-
51
+ // shaped candidates when the source session is stale (testable
52
+ // defense-in-depth — see filterStaleSessionIntentCandidates).
53
+ // - visibility (Sherlock-ruled, default-private-unless): promotion out of
54
+ // ephemeral+private is a visibility ESCALATION from the most sensitive
55
+ // tier. The distiller may rule "shared" only AFFIRMATIVELY, with a
56
+ // team-relevance justification recorded on the candidate — never by
57
+ // default, never silently (see resolveCandidateVisibilityRuling).
58
+ /** Tag prefix for continuity-journal rows (`adk:continuity:<sessionId>`).
59
+ * Canonical string duplicated in packages/flair-mcp/src/continuity.ts
60
+ * (CONTINUITY_TAG_PREFIX — the writer) and src/rem/runner.ts — the three
61
+ * live on opposite sides of npm-packaging boundaries (resources/ ships as
62
+ * the Harper component; src/ and packages/ ship separately; imports across
63
+ * them don't survive packaging — see src/cli.ts's header). Kept in sync by
64
+ * the shared canonical string. */
65
+ export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
66
+ /** True iff `tag` is a continuity-journal scope tag (has a non-empty
67
+ * sessionId component — the bare prefix is not a session). */
68
+ export function isContinuityScopeTag(tag) {
69
+ return typeof tag === "string" && tag.length > CONTINUITY_SCOPE_TAG_PREFIX.length && tag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
70
+ }
71
+ /**
72
+ * Staleness horizon for the stale-intent guard (spec item 3, default 72h,
73
+ * FLAIR_REM_STALE_INTENT_HOURS). A journal entry like "about to merge X" is
74
+ * useful context shortly after the session died (the intent may still be
75
+ * live); past this horizon the intent has resolved or died, and promoting it
76
+ * manufactures a false present. Distinct from the SETTLE window (2h,
77
+ * src/rem/runner.ts) — settle decides when a session may be distilled at
78
+ * all; this horizon decides whether in-flight-intent content from it may
79
+ * still promote.
80
+ */
81
+ export const DEFAULT_STALE_INTENT_HORIZON_MS = 72 * 3600_000;
82
+ /**
83
+ * Text shapes that mark a candidate as IN-FLIGHT INTENT — an action described
84
+ * as pending/current rather than decided/done. Deliberately the obvious
85
+ * shapes only (Kern's ruling: the prompt rule is the primary layer; this
86
+ * post-filter is testable defense-in-depth and need not be exhaustive).
87
+ * Case-insensitive; word-bounded so e.g. "roundabout to" doesn't match.
88
+ */
89
+ export const IN_FLIGHT_INTENT_PATTERNS = [
90
+ /\babout to\b/i,
91
+ /\bwaiting (?:on|for)\b/i,
92
+ /\bgoing to\b/i,
93
+ /\bplanning to\b/i,
94
+ ];
95
+ /** True iff `text` matches an in-flight-intent shape. */
96
+ export function isInFlightIntentShaped(text) {
97
+ return IN_FLIGHT_INTENT_PATTERNS.some((p) => p.test(text));
98
+ }
99
+ /**
100
+ * The stale-intent POST-FILTER (spec item 3, the testable layer). When the
101
+ * source session is STALE — its newest entry older than `horizonMs` — drop
102
+ * every candidate whose claim is in-flight-intent-shaped. Runs AFTER
103
+ * parseAndValidateCandidates (a drop here is a policy skip, never a batch
104
+ * failure) and BEFORE dedup/staging.
105
+ *
106
+ * Fresh sessions pass everything through (an "about to merge X" from two
107
+ * hours ago is genuinely useful resume context). Stale sessions still
108
+ * promote DECISION-class content — the filter drops only the in-flight
109
+ * shapes, which is the positive control the acceptance set demands.
110
+ *
111
+ * An UNDATEABLE session (no newest-entry timestamp) is treated as STALE:
112
+ * this guard exists to stop manufactured false-presents, and "can't tell how
113
+ * old" must fail toward filtering, not toward promoting (fail-closed).
114
+ */
115
+ export function filterStaleSessionIntentCandidates(candidates, params) {
116
+ const horizonMs = params.horizonMs ?? DEFAULT_STALE_INTENT_HORIZON_MS;
117
+ const newestMs = params.sessionNewestCreatedAt ? new Date(params.sessionNewestCreatedAt).getTime() : NaN;
118
+ const sessionStale = !Number.isFinite(newestMs) || params.now.getTime() - newestMs > horizonMs;
119
+ if (!sessionStale)
120
+ return { kept: candidates, droppedStaleIntent: [] };
121
+ const kept = [];
122
+ const droppedStaleIntent = [];
123
+ for (const c of candidates) {
124
+ (isInFlightIntentShaped(c.claim) ? droppedStaleIntent : kept).push(c);
125
+ }
126
+ return { kept, droppedStaleIntent };
127
+ }
128
+ /**
129
+ * Resolve a distilled candidate's visibility ruling (Sherlock's
130
+ * default-private-unless, flair#1257 slice 3). Returns a ruling ONLY when the
131
+ * distiller AFFIRMATIVELY ruled "shared" AND recorded a non-empty
132
+ * team-relevance justification — anything less (absent, "private", "shared"
133
+ * with no justification, whitespace justification) returns null, which
134
+ * downstream reads as the private default. The uncertainty fallback is
135
+ * private, fail-closed; a shared promoted row must always trace to a
136
+ * recorded justification on its candidate, never to a default.
137
+ */
138
+ export function resolveCandidateVisibilityRuling(candidate) {
139
+ if (candidate.visibility !== "shared")
140
+ return null;
141
+ const rationale = typeof candidate.teamRelevance === "string" ? candidate.teamRelevance.trim() : "";
142
+ if (rationale.length === 0)
143
+ return null;
144
+ return { ruling: "shared", rationale };
145
+ }
42
146
  // ─── Candidate shape (spec §3A) ───────────────────────────────────────────────
43
147
  // { candidates: [ { claim: string, sourceMemoryIds: string[], tags?: string[] } ] }
148
+ // Continuity runs (flair#1257 slice 3) may additionally carry per-candidate
149
+ // `visibility` + `teamRelevance` — see resolveCandidateVisibilityRuling.
44
150
  //
45
151
  // Passed as `responseFormat: { schema: CANDIDATES_SCHEMA }` to models.generate()
46
152
  // so backends that honor structured output (Ollama, OpenAI — verified against
@@ -64,6 +170,11 @@ export const CANDIDATES_SCHEMA = {
64
170
  claim: { type: "string" },
65
171
  sourceMemoryIds: { type: "array", items: { type: "string" } },
66
172
  tags: { type: "array", items: { type: "string" } },
173
+ // flair#1257 slice 3 (continuity runs only — see the module note
174
+ // above CONTINUITY_SCOPE_TAG_PREFIX): an AFFIRMATIVE visibility
175
+ // ruling. Optional for every run; validated when present.
176
+ visibility: { type: "string", enum: ["private", "shared"] },
177
+ teamRelevance: { type: "string" },
67
178
  },
68
179
  required: ["claim", "sourceMemoryIds"],
69
180
  },
@@ -77,7 +188,39 @@ export const FOCUS_PROMPTS = {
77
188
  patterns: "Identify recurring patterns across these memories. What themes, approaches, or outcomes appear multiple times? Extract each pattern as a persistent memory.",
78
189
  decisions: "Catalog the key decisions made and their outcomes. For each: what was decided, why, and what resulted. Promote important decisions to persistent.",
79
190
  errors: "Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
191
+ // flair#1257 slice 3 — continuity-journal distillation. The source rows are
192
+ // an agent's auto-captured working-state journal (ephemeral, private,
193
+ // intent-class), not curated knowledge — distill what deserves to OUTLIVE
194
+ // the session. The stale-intent prompt rule here is the PRIMARY layer of
195
+ // the two-layer guard (Kern's ruling); filterStaleSessionIntentCandidates
196
+ // is the testable second layer.
197
+ continuity: "These memories are an agent's short-term session journal: auto-captured working-state deltas (what it was doing, deciding, and why). Distill the DURABLE takeaways — decisions made and their reasons, outcomes, lessons — into atomic persistent memories. Do NOT promote in-flight intent (e.g. \"about to merge X\", \"waiting on Y\", \"going to\", \"planning to\") from a session that is no longer live: the action has since resolved or died, and restating it as current manufactures a false present. Do not promote world-recoverable facts (PR status, CI state) — they are re-observable and go stale.",
80
198
  };
199
+ /**
200
+ * Extra execute-mode instruction block for continuity runs (flair#1257 slice
201
+ * 3). Two parts:
202
+ * - the VISIBILITY ruling contract (Sherlock, default-private-unless): the
203
+ * source journal is the most sensitive tier (ephemeral+private), so the
204
+ * promoted claim defaults private; the distiller may rule "shared" only
205
+ * affirmatively, and then MUST justify team-relevance (the justification
206
+ * is recorded on the candidate — resolveCandidateVisibilityRuling drops
207
+ * any shared ruling that arrives without one).
208
+ * - when the session is STALE, an explicit restatement of the stale-intent
209
+ * rule with the session's age class named (the prompt-layer half of the
210
+ * two-layer guard; the post-filter backstops it either way).
211
+ */
212
+ export function buildContinuityExecuteAddendum(params) {
213
+ const lines = [
214
+ `Continuity visibility rules:`,
215
+ `- Every candidate's visibility defaults to "private". Omit the visibility field unless you are AFFIRMATIVELY ruling a candidate team-relevant.`,
216
+ `- To rule a candidate shared, set visibility: "shared" AND teamRelevance: one sentence stating why teammates need this. A shared ruling without a teamRelevance justification is discarded and the candidate stays private.`,
217
+ `- If uncertain, stay private.`,
218
+ ];
219
+ if (params.sessionStale) {
220
+ lines.push(`This session is STALE (its newest journal entry is beyond the staleness horizon): do NOT emit candidates describing in-flight actions ("about to", "waiting on", "going to", "planning to") — those intents have resolved or died. Distill only decisions, outcomes, and lessons.`);
221
+ }
222
+ return lines.join("\n");
223
+ }
81
224
  /**
82
225
  * Shared "Source Memories" block for both prompt mode and execute mode
83
226
  * (K&S prompt-injection hardening, spec §3A item 7). Each memory is wrapped
@@ -127,9 +270,12 @@ For each insight:
127
270
  * handing a prompt to a human/agent.
128
271
  */
129
272
  export function buildExecutePrompt(params) {
130
- const { agentId, focus, scope, sinceISO, memories } = params;
273
+ const { agentId, focus, scope, sinceISO, memories, continuity } = params;
131
274
  const focusText = FOCUS_PROMPTS[focus] ?? FOCUS_PROMPTS.lessons_learned;
132
275
  const validIds = memories.map((m) => `"${m.id}"`).join(", ");
276
+ const candidateShape = continuity
277
+ ? `{"candidates": [{"claim": string, "sourceMemoryIds": string[], "tags"?: string[], "visibility"?: "shared", "teamRelevance"?: string}]}`
278
+ : `{"candidates": [{"claim": string, "sourceMemoryIds": string[], "tags"?: string[]}]}`;
133
279
  return `# Memory Reflection — ${agentId}
134
280
  Focus: ${focus}
135
281
  Scope: ${scope} (since ${sinceISO})
@@ -137,13 +283,13 @@ Memories: ${memories.length}
137
283
 
138
284
  ## Task
139
285
  ${focusText}
140
-
286
+ ${continuity ? `\n${buildContinuityExecuteAddendum(continuity)}\n` : ""}
141
287
  ## Source Memories
142
288
  ${buildSourceMemoriesBlock(memories)}
143
289
 
144
290
  ## Output
145
291
  Respond with ONLY a JSON object of this shape (no prose, no markdown fences):
146
- {"candidates": [{"claim": string, "sourceMemoryIds": string[], "tags"?: string[]}]}
292
+ ${candidateShape}
147
293
  Rules:
148
294
  - Every sourceMemoryIds entry must be one of: ${validIds || "(none available)"}
149
295
  - claim must be a single atomic insight, at most ${MAX_CLAIM_LENGTH} characters
@@ -219,7 +365,27 @@ export function parseAndValidateCandidates(raw, gatheredMemoryIds) {
219
365
  }
220
366
  tags = candidate.tags;
221
367
  }
222
- candidates.push({ claim: candidate.claim, sourceMemoryIds, tags });
368
+ // flair#1257 slice 3: optional visibility ruling fields (continuity runs).
369
+ // Validated for every run — an unknown visibility value must fail closed
370
+ // exactly like any other malformed field, never pass through to a place
371
+ // where "not private" could later read as readable (the free-form-string
372
+ // exact-match lesson). Whether a valid ruling has any EFFECT is decided
373
+ // downstream (resolveCandidateVisibilityRuling, continuity staging only).
374
+ let visibility;
375
+ if (candidate.visibility !== undefined) {
376
+ if (candidate.visibility !== "private" && candidate.visibility !== "shared") {
377
+ return { ok: false, reason: "shape_mismatch" };
378
+ }
379
+ visibility = candidate.visibility;
380
+ }
381
+ let teamRelevance;
382
+ if (candidate.teamRelevance !== undefined) {
383
+ if (typeof candidate.teamRelevance !== "string") {
384
+ return { ok: false, reason: "shape_mismatch" };
385
+ }
386
+ teamRelevance = candidate.teamRelevance;
387
+ }
388
+ candidates.push({ claim: candidate.claim, sourceMemoryIds, tags, visibility, teamRelevance });
223
389
  }
224
390
  return { ok: true, candidates };
225
391
  }
@@ -310,6 +476,33 @@ export function dedupeCandidates(candidates, existingPendingClaims) {
310
476
  // rules, not scope selection.
311
477
  export function memoryMatchesReflectScope(record, params) {
312
478
  const { scope, tag, sinceDate } = params;
479
+ // ── flair#1257 slice 3: continuity-journal containment (both directions) ───
480
+ // The JOURNAL is the ephemeral rows carrying a continuity session tag.
481
+ // Two rules keep it contained:
482
+ //
483
+ // 1. A continuity-tag tagged run gathers THE JOURNAL ONLY — ephemeral
484
+ // rows carrying that session's tag. Promoted rows PRESERVE the
485
+ // session scopeTag (spec item 2), so without the durability bound a
486
+ // re-distill of the same tag would gather its own previous OUTPUTS as
487
+ // input — a distill-of-distilled feedback loop.
488
+ // 2. A journal row is distillable ONLY through its own session's
489
+ // continuity run — the path that carries every slice-3 guard (settle
490
+ // window, continuity focus prompt, stale-intent post-filter, the
491
+ // visibility ruling contract). Without this, the agentId-wide
492
+ // scope:"recent"/"all" gather would sweep a LIVE session's journal
493
+ // into a generic distill, bypassing all of those guards at once (the
494
+ // settle window would be a check that cannot fire).
495
+ //
496
+ // Non-journal rows that carry a continuity tag (the promoted persistent
497
+ // rows) follow the NORMAL scope rules below — they stay re-reflectable
498
+ // like any other durable memory.
499
+ if (scope === "tagged" && isContinuityScopeTag(tag)) {
500
+ return record.durability === "ephemeral" && (record.tags ?? []).includes(tag);
501
+ }
502
+ const rowIsJournal = record.durability === "ephemeral" && (record.tags ?? []).some(isContinuityScopeTag);
503
+ if (rowIsJournal) {
504
+ return false;
505
+ }
313
506
  if (scope === "tagged") {
314
507
  // No tag ⇒ admit nothing. A tagged reflection with no tag must gather an
315
508
  // EMPTY set (fail-closed), never fall through to admitting everything —
@@ -355,5 +548,9 @@ export function buildStagedCandidateRow(params) {
355
548
  if (params.scope === "tagged" && typeof params.tag === "string" && params.tag.length > 0) {
356
549
  row.scopeTag = params.tag;
357
550
  }
551
+ if (params.visibilityRuling) {
552
+ row.visibilityRuling = params.visibilityRuling.ruling;
553
+ row.visibilityRationale = params.visibilityRuling.rationale;
554
+ }
358
555
  return row;
359
556
  }