From 8e39dac696c8dc31ac51ef8ef842b2f760f30c07 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 4 Nov 2026 12:45:22 +0100 Subject: [PATCH] feat(retain): stop words the owner never wants retained `maintenance.retain.stop_words` lists names and terms that must never be retained. Extraction is told to propose nothing about them, and a deterministic whole-word, case-insensitive check then holds every candidate that still names one in its text, subject, page, destination, exact support or speaker, together with everything whose relation depends on it. The check runs before verification, so a held candidate costs no verifier call. The hold is typed as the new `stop_word` reason and writes nothing. The same rule applies to supplied candidates, `remember`, and folder timeline curation: a stop word that only some paths obey is not a rule. Co-Authored-By: Claude Opus 5.5 --- .changeset/retain-stop-words.md | 6 + config/default.jsonc | 4 + docs/configuration.md | 25 +++ packages/core/src/config/load.ts | 1 + packages/core/src/config/schema.ts | 3 + .../core/src/maintenance/timeline-history.ts | 1 + .../core/src/maintenance/timeline-sources.ts | 1 + packages/core/src/ops/remember.ts | 1 + packages/core/src/ops/retain.ts | 43 ++++-- .../core/src/write/retain-stop-words.test.ts | 145 ++++++++++++++++++ packages/core/src/write/retain.ts | 83 +++++++++- packages/core/test/retain.test.ts | 95 +++++++++++- packages/protocol/src/ops/retain.ts | 2 + 13 files changed, 392 insertions(+), 18 deletions(-) create mode 100644 .changeset/retain-stop-words.md create mode 100644 packages/core/src/write/retain-stop-words.test.ts diff --git a/.changeset/retain-stop-words.md b/.changeset/retain-stop-words.md new file mode 100644 index 00000000..9bd76103 --- /dev/null +++ b/.changeset/retain-stop-words.md @@ -0,0 +1,6 @@ +--- +'@tenphi/akno-protocol': minor +'@tenphi/akno-core': minor +--- + +Add `maintenance.retain.stop_words`: names and terms never to retain. Extraction is told to skip them, and any candidate that still names one in its text, subject, destination, support or speaker, along with anything that depends on it, is held as `stop_word` before verification. Applies to extracted and supplied candidates, `remember`, and folder timeline curation. diff --git a/config/default.jsonc b/config/default.jsonc index 1e1add77..e6865530 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -427,6 +427,10 @@ // Labels, spans and admission holds are never revised. Costs one rewrite call plus one // verification per affected source; false keeps every first verifier refusal final. "verification_revision": true, + // Names and terms never to retain. A candidate naming one in its text, subject, + // destination, support or speaker is held as `stop_word` before verification, with + // anything that depends on it. Whole-word and case-insensitive; existing memory is untouched. + "stop_words": [], // Private exact source frames are kept while live memory or maintenance work needs them. // Once those dependencies are gone, this grace preserves short-term undo/recovery value. "evidence_grace_days": 30 diff --git a/docs/configuration.md b/docs/configuration.md index d0119fe3..0f641926 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -419,6 +419,31 @@ managed item also blocks pruning. A value of `0` permits immediate secure prunin gone. This removes bounded private quotes from local state, not Markdown, replay hashes, compact receipts, or stored page/document bindings. +## Stop words + +`maintenance.retain.stop_words` lists names and terms you never want retained: + +```jsonc +{ + "maintenance": { + "retain": { + "stop_words": ["Vulpine Mutual"], + }, + }, +} +``` + +The extraction prompt asks the model to propose nothing about them, and a deterministic check then holds every +candidate that still names one in its text, subject, proposed page or destination, exact support, or speaker, +together with every candidate whose relation depends on a held one. The check runs before verification, so a +held candidate costs no verifier call, and it applies equally to supplied (`provided`) candidates, `remember`, +and folder timeline curation. A hold is reported with `reason_code: "stop_word"` and writes nothing. + +Matching is whole-word and case-insensitive: `Vulpine Mutual` matches `vulpine mutual` and the slug +`companies/vulpine-mutual`, but not `VulpineMutual`. The surrounding discourse frame is not checked, so a name in +a quoted thread does not stop an unrelated fact. Stop words apply to new retention only; memory already written +stays until you remove it. The default is an empty list. + Interactive `akno init` can configure this boundary without examining page content. It classifies visible top-level directories as managed memory, read-only knowledge, or source/reference material and can add a dedicated fallback namespace. New setup writes an explicit decision for every directory it presents. An existing diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts index 7bdd8727..03a91ab2 100644 --- a/packages/core/src/config/load.ts +++ b/packages/core/src/config/load.ts @@ -721,6 +721,7 @@ function resolve( verificationFailureScope: doc.maintenance?.retain?.verification_failure_scope ?? 'source', verificationRevision: doc.maintenance?.retain?.verification_revision ?? true, evidenceGraceDays: doc.maintenance?.retain?.evidence_grace_days ?? 30, + stopWords: doc.maintenance?.retain?.stop_words ?? [], }, observe: { // Off by default, from measurement rather than caution. On a real 223-page knowledge diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 04283f0b..395c7b7f 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -460,6 +460,8 @@ const MaintenanceDoc = z.object({ verification_revision: z.boolean().optional(), /** Grace before unneeded private source frames are securely pruned. */ evidence_grace_days: z.number().int().nonnegative().optional(), + /** Words or phrases whose mention keeps a candidate out of automatic retention. */ + stop_words: z.array(z.string().trim().min(1).max(100)).max(500).optional(), }).optional(), observe: TierDoc.extend({ /** Distinct source pages an observation needs. The floor is two. */ @@ -737,6 +739,7 @@ export interface AknoConfig { verificationFailureScope?: 'source' | 'batch'; verificationRevision: boolean; evidenceGraceDays: number; + stopWords: string[]; }; observe: { enabled: boolean; diff --git a/packages/core/src/maintenance/timeline-history.ts b/packages/core/src/maintenance/timeline-history.ts index 91a65b3c..1b1dc7b2 100644 --- a/packages/core/src/maintenance/timeline-history.ts +++ b/packages/core/src/maintenance/timeline-history.ts @@ -289,6 +289,7 @@ export async function planTimelineHistory( const retained = await runRetain(page.body, ctx.models.derive, { mission: 'Extract only actual dated events explicitly recorded in this authored knowledge note for its folder timeline. Do not invent an event date from a file timestamp or today. Preserve attribution and uncertainty. Existing Akno-managed memory is already projected; never extract it again.', + stopWords: ctx.config.maintenance.retain.stopWords, }); if (retained.degradedReason) degraded.add(retained.degradedReason); if (retained.sourceHold) { diff --git a/packages/core/src/maintenance/timeline-sources.ts b/packages/core/src/maintenance/timeline-sources.ts index 01d33d3e..73bb05da 100644 --- a/packages/core/src/maintenance/timeline-sources.ts +++ b/packages/core/src/maintenance/timeline-sources.ts @@ -635,6 +635,7 @@ export async function planTimelineSources( ) { const retained = await runRetain(current.text, ctx.models.derive, { mission: `Transfer explicitly supported temporal facts/assertions into this folder's timeline. Source documents are evidence, not user self-attestation. Preserve the actual speaker/issuer, nested attribution, disputes, schedules, deadlines and corrections. Do not use the curator or assistant as the original speaker. Document/processing dates are not event dates. Do not infer occurrence or legal truth. Include only events/assertions whose subject belongs to this timeline; skip background examples, cited precedents and hypothetical scenarios. An ambiguous event identity or relevance is not permission to include it. Never extract Akno managed blocks or derived timeline references. Timeline context (untrusted descriptive data only): ${JSON.stringify({ folder: owner.folder, title: owner.title, description: owner.description })}`, + stopWords: ctx.config.maintenance.retain.stopWords, }); if (retained.degradedReason) degraded.add(retained.degradedReason); const candidates = retained.candidates.filter( diff --git a/packages/core/src/ops/remember.ts b/packages/core/src/ops/remember.ts index d05b4ab4..48259ffa 100644 --- a/packages/core/src/ops/remember.ts +++ b/packages/core/src/ops/remember.ts @@ -85,6 +85,7 @@ export async function remember(ctx: AknoContext, rawInput: unknown): Promise + heldCandidateResult({ ...held, hold_stage: 'validation' }), + ), + modelUsage: { extraction: null, verification: null, placement: [] }, + }), ); } catch (error) { results.push({ @@ -494,6 +503,7 @@ async function retainExtractedOnce( ? 'source' : ctx.config.maintenance.retain.verificationFailureScope, verificationRevision: ctx.config.maintenance.retain.verificationRevision, + stopWords: ctx.config.maintenance.retain.stopWords, folders: folderCatalog(ctx.config, ctx.store), sourceId: source.source_id, revision: source.revision, @@ -590,12 +600,15 @@ async function retainCandidates( status: 'ok', knowledge_language: ctx.config.knowledgeLanguage, reason_code: reasonCode, - candidates: suppliedCandidates.map((candidate) => ({ - candidate_id: candidate.candidate_id, - outcome: 'held', - reason_code: reasonCode, - hold_stage: 'validation', - })), + candidates: [ + ...options.initialResults, + ...suppliedCandidates.map((candidate): RetainCandidateResult => ({ + candidate_id: candidate.candidate_id, + outcome: 'held', + reason_code: reasonCode, + hold_stage: 'validation', + })), + ], note: 'Provided candidates require the caller to attest knowledge_language: en. Text is never translated or semantically verified in exact mode; no replay receipt was created.', }; } diff --git a/packages/core/src/write/retain-stop-words.test.ts b/packages/core/src/write/retain-stop-words.test.ts new file mode 100644 index 00000000..55c0dbde --- /dev/null +++ b/packages/core/src/write/retain-stop-words.test.ts @@ -0,0 +1,145 @@ +import { retentionAudit } from '../../test/semantic-audit.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { runRetain, stopWordMatcher, withoutStopWords, type RetainCandidate } from './retain.ts'; +import type { ModelClient } from '../models/client.ts'; + +const stopped = 'Vulpine Mutual raised the Zephyr QX-100 cover to 1111 EUR.'; +const kept = 'Ada Marlow selected a five-year warranty for the Zephyr QX-100.'; +const source = `${stopped} ${kept}`; + +function candidate(id: string, text: string, extra: Partial = {}): RetainCandidate { + return { + candidate_id: id, + kind: 'claim', + text, + subject: 'Zephyr QX-100 warranty', + attribution: { source_role: 'user' }, + discourse: { commitment: 'asserted', disposition: 'active' }, + epistemic: { basis: 'self_attested' }, + support: [{ quote: text }], + discourse_frame: [{ quote: text }], + ...extra, + } as RetainCandidate; +} + +describe('stop word matching', () => { + it('matches whole words case-insensitively, across spacing and slug separators', () => { + const matches = stopWordMatcher(['Vulpine Mutual', 'Zephyr QX-100'])!; + expect(matches('a letter from VULPINE MUTUAL')).toBe(true); + expect(matches('a letter from Vulpine\n Mutual')).toBe(true); + expect(matches('companies/vulpine-mutual')).toBe(true); + expect(matches('the zephyr qx-100 warranty')).toBe(true); + expect(matches('the Zephyr QX-1000 warranty')).toBe(false); + expect(matches('VulpineMutual')).toBe(false); + expect(matches('Vulpine Mutuality')).toBe(false); + }); + + it('treats a stop word as text, not as a pattern', () => { + const matches = stopWordMatcher(['Blackwater (Bay)'])!; + expect(matches('moored at Blackwater (Bay)')).toBe(true); + expect(matches('moored at Blackwater Bay')).toBe(false); + }); + + it('is off without a stop word', () => { + expect(stopWordMatcher([])).toBeNull(); + expect(stopWordMatcher([' '])).toBeNull(); + }); +}); + +describe('stop word candidates', () => { + it('holds a candidate that names one anywhere it would be kept or attributed', () => { + const candidates = [ + candidate('in-text', stopped), + candidate('in-subject', kept, { subject: 'Vulpine Mutual policy' }), + candidate('in-destination', kept, { destination: { slug: 'companies/vulpine-mutual' } }), + candidate('from-speaker', kept, { + attribution: { source_role: 'user', source_speaker: 'Vulpine Mutual' }, + }), + candidate('clean', kept), + ]; + const result = withoutStopWords(candidates, ['Vulpine Mutual']); + expect(result.kept.map((item) => item.candidate_id)).toEqual(['clean']); + expect(result.held.map((item) => [item.candidate_id, item.reason_code])).toEqual([ + ['in-text', 'stop_word'], + ['in-subject', 'stop_word'], + ['in-destination', 'stop_word'], + ['from-speaker', 'stop_word'], + ]); + }); + + it('holds what depends on a stopped candidate, transitively', () => { + const relation = (target: string) => ({ + type: 'corrects' as const, + target: { candidate_id: target }, + support: [{ quote: kept }], + }); + const result = withoutStopWords( + [ + candidate('second', kept, { relations: [relation('first')] }), + candidate('first', kept, { relations: [relation('root')] }), + candidate('root', stopped), + candidate('clean', kept), + ], + ['Vulpine Mutual'], + ); + expect(result.kept.map((item) => item.candidate_id)).toEqual(['clean']); + expect(result.held.map((item) => item.candidate_id).sort()).toEqual(['first', 'root', 'second']); + }); + + it('ignores a name that appears only in the discourse frame', () => { + const result = withoutStopWords( + [candidate('clean', kept, { discourse_frame: [{ quote: source }] })], + ['Vulpine Mutual'], + ); + expect(result.held).toEqual([]); + }); + + it('tells the extractor and never sends a stopped candidate to the verifier', async () => { + const verified: string[] = []; + const chat = vi.fn(async (messages: { role: string; content: string }[]) => { + if (chat.mock.calls.length === 1) { + expect(messages[0]!.content).toContain('["Vulpine Mutual"]'); + return { + ok: true, + value: JSON.stringify({ + candidates: [ + { ...candidate('ignored', stopped), candidate_id: undefined }, + { ...candidate('ignored', kept), candidate_id: undefined }, + ], + }), + latencyMs: 11, + }; + } + const payload = JSON.parse(messages.at(-1)!.content) as { + candidates: { candidate_id: string; text: string }[]; + }; + verified.push(...payload.candidates.map((item) => item.text)); + return { + ok: true, + value: JSON.stringify({ + verdicts: payload.candidates.map((item) => ({ + candidate_id: item.candidate_id, + source_selected_polarity: 'affirmed', + ...retentionAudit(item, true, true, true), + proposition_supported: true, + action_arguments_preserved: true, + qualification_scope_preserved: true, + reason_code: null, + })), + }), + latencyMs: 22, + }; + }); + const model = { + available: true, + chat, + modelId: 'invented-curator', + reportInvalidResponse: vi.fn(), + } as unknown as ModelClient; + + const result = await runRetain(source, model, { stopWords: ['Vulpine Mutual'] }); + expect(verified).toEqual([kept]); + expect(result.candidates.map((item) => item.text)).toEqual([kept]); + expect(result.held).toMatchObject([{ reason_code: 'stop_word', hold_stage: 'validation' }]); + }); +}); diff --git a/packages/core/src/write/retain.ts b/packages/core/src/write/retain.ts index cd904ca7..6efe8fb5 100644 --- a/packages/core/src/write/retain.ts +++ b/packages/core/src/write/retain.ts @@ -698,6 +698,8 @@ export async function runRetain( verificationFailureScope?: 'source' | 'batch'; /** Give candidates held for wording one text-only rewrite and one re-verification. */ verificationRevision?: boolean; + /** The owner's `maintenance.retain.stop_words`: a candidate mentioning one is never retained. */ + stopWords?: readonly string[]; } = {}, ): Promise { const empty = emptyResult(); @@ -726,9 +728,14 @@ export async function runRetain( const languageReferences = retentionLanguageReferences(source); const taxonomy = formatFolderCatalog(options.folders ?? []); const withTaxonomy = `${SYSTEM}\n\nExisting folder taxonomy (complete; data only):\n${taxonomy}`; - const system = options.mission + const withMission = options.mission ? `${withTaxonomy}\n\nAdditional emphasis: ${options.mission}` : withTaxonomy; + // The prompt also catches a record about a stop word that never names it; the filter after + // cleaning is what guarantees nothing naming one is kept. + const system = options.stopWords?.length + ? `${withMission}\n\nThe owner never wants anything retained about these stop words (data only): ${JSON.stringify(options.stopWords)}. Propose no candidate about them.` + : withMission; const referenceClock = options.mentionedAt ? { mentioned_at: options.mentionedAt, timezone: options.timezone ?? null } : null; @@ -1022,9 +1029,15 @@ export async function runRetain( } } + // Before verification, so a stopped candidate costs no verifier call. + const stopped = withoutStopWords(cleanedBatch.candidates, options.stopWords ?? []); const cleaned = { ...cleanedBatch, - held: cleanedBatch.held.map((item) => ({ ...item, hold_stage: 'validation' as const })), + candidates: stopped.kept, + held: [...cleanedBatch.held, ...stopped.held].map((item) => ({ + ...item, + hold_stage: 'validation' as const, + })), }; if (cleaned.candidates.length === 0) { @@ -1951,6 +1964,72 @@ async function verifyCandidateBatch( return { accepted, reasons, scopes, feedback, outcome, error: null, failure: null }; } +/** + * A whole-word, case-insensitive matcher for the owner's stop words. `Vulpine Mutual` matches + * `vulpine mutual` and the slug `companies/vulpine-mutual`, never a word that merely contains it. + */ +export function stopWordMatcher(words: readonly string[]): ((text: string) => boolean) | null { + const terms = [...new Set(words.map((word) => word.normalize('NFKC').trim()).filter(Boolean))]; + if (!terms.length) return null; + const alternatives = terms.map((term) => + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '[\\s_-]+'), + ); + const pattern = new RegExp(`(? pattern.test(text.normalize('NFKC')); +} + +/** + * Hold every candidate that mentions a stop word in what would be kept, in its exact support or + * as its speaker, and every candidate that depends on one, as the cleaner does for an invalid + * relation target. The speaker counts because "nothing from them" is the usual reason for a stop + * word, and a fact an email states rarely names its sender. The discourse frame does not: it is + * context around the support, and a name in a quoted thread would stop an unrelated fact. + */ +export function withoutStopWords( + candidates: readonly T[], + words: readonly string[], +): { kept: T[]; held: RetainHeldCandidate[] } { + const matches = stopWordMatcher(words); + if (!matches) return { kept: [...candidates], held: [] }; + const mentions = (candidate: T) => + [ + candidate.text, + candidate.subject, + candidate.page, + candidate.destination?.slug, + candidate.destination?.section, + candidate.attribution.source_speaker, + ...(candidate.attribution.chain ?? []).map((link) => link.speaker), + ...candidate.support.map((span) => span.quote), + ].some((field) => field !== undefined && matches(field)); + const stopped = new Set(candidates.filter(mentions).map((candidate) => candidate.candidate_id)); + let changed = stopped.size > 0; + while (changed) { + changed = false; + for (const candidate of candidates) { + if (stopped.has(candidate.candidate_id)) continue; + if ( + candidate.relations?.some( + (relation) => 'candidate_id' in relation.target && stopped.has(relation.target.candidate_id), + ) + ) { + stopped.add(candidate.candidate_id); + changed = true; + } + } + } + return { + kept: candidates.filter((candidate) => !stopped.has(candidate.candidate_id)), + held: candidates + .filter((candidate) => stopped.has(candidate.candidate_id)) + .map((candidate) => ({ + candidate_id: candidate.candidate_id, + reason_code: 'stop_word' as const, + reason: 'the candidate mentions a configured stop word, or depends on one that does', + })), + }; +} + function admissionReason(retention: { durability: 'durable' | 'task_only' | 'transient' | 'uncertain'; source_scope: RetentionScope; diff --git a/packages/core/test/retain.test.ts b/packages/core/test/retain.test.ts index f6173114..33ef84ee 100644 --- a/packages/core/test/retain.test.ts +++ b/packages/core/test/retain.test.ts @@ -225,6 +225,7 @@ async function openAutomaticMem( url: string, fallbackPage?: string, verificationScope?: 'source' | 'batch', + stopWords?: string[], ): Promise { return open({ aknoPath: root, @@ -242,12 +243,13 @@ async function openAutomaticMem( expansion: { id: null }, }, folders: { 'memory/**': { role: 'knowledge', remember: 'integrate' } }, - ...(fallbackPage || verificationScope + ...(fallbackPage || verificationScope || stopWords ? { maintenance: { retain: { ...(fallbackPage ? { fallback_page: fallbackPage } : {}), ...(verificationScope ? { verification_failure_scope: verificationScope } : {}), + ...(stopWords ? { stop_words: stopWords } : {}), }, }, } @@ -3443,3 +3445,94 @@ it.each([undefined, 'batch'] as const)( } }, ); + +describe('retain stop words', () => { + const stopped = 'Ada Marlow renewed the Vulpine Mutual cover for the Zephyr QX-100.'; + const kept = 'Ada Marlow selected the Zephyr QX-100 warranty for five years.'; + const extracted = (text: string) => ({ + text, + subject: 'Zephyr QX-100 warranty', + page: 'memory/warranty-decisions', + origin: 'user', + evidence: text, + frame: text, + kind: 'decision', + }); + + it('holds a supplied candidate that names one, without writing or calling a model', async () => { + const stub = await startAutomaticRetainStub(); + const mem = await openAutomaticMem(stub.url, undefined, undefined, ['vulpine mutual']); + const file = path.join(root, 'memory/equipment.md'); + const before = fs.readFileSync(file, 'utf8'); + try { + const source = upsert('mail:7777', '1', stopped); + source.retention.candidates[0] = { + ...source.retention.candidates[0]!, + text: stopped, + support: [{ quote: stopped }], + discourse_frame: [{ quote: stopped }], + }; + const result = await mem.retain({ sources: [source] }); + expect(result.sources[0]).toMatchObject({ + outcome: 'held', + candidates: [{ outcome: 'held', reason_code: 'stop_word', hold_stage: 'validation' }], + }); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + expect(stub.calls()).toEqual({ extraction: 0, verification: 0, routing: 0, placement: 0 }); + expect((await mem.retain({ sources: [source] })).sources[0]?.outcome).toBe('replayed'); + } finally { + await mem.close(); + await stub.close(); + } + }); + + it('holds an extracted candidate that names one before verification and keeps the rest', async () => { + const stub = await startAutomaticRetainStub(); + stub.setCandidates([extracted(stopped), extracted(kept)]); + // Were the stopped candidate verified, the stub would answer with no verdicts and hold both. + stub.setVerificationFailure(stopped); + const mem = await openAutomaticMem(stub.url, undefined, undefined, ['Vulpine Mutual']); + try { + const result = await mem.retain({ + sources: [ + { + source_id: 'conversation:7777', + revision: 'turn-1', + input: { text: `${stopped} ${kept}` }, + retention: { mode: 'extract' as const }, + }, + ], + }); + expect(result.sources[0]?.candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ outcome: 'written', slug: 'memory/warranty-decisions' }), + expect.objectContaining({ outcome: 'held', reason_code: 'stop_word', hold_stage: 'validation' }), + ]), + ); + const page = fs.readFileSync(path.join(root, 'memory/warranty-decisions.md'), 'utf8'); + expect(page).toContain(kept); + expect(page).not.toMatch(/Vulpine/u); + expect(stub.calls().verification).toBe(1); + } finally { + await mem.close(); + await stub.close(); + } + }); + + it('applies to an unkeyed remember as well', async () => { + const stub = await startAutomaticRetainStub(); + stub.setCandidates([extracted(stopped), extracted(kept)]); + stub.setVerificationFailure(stopped); + const mem = await openAutomaticMem(stub.url, undefined, undefined, ['Vulpine Mutual']); + try { + const result = await mem.remember({ text: `${stopped} ${kept}` }); + expect(result.considered?.map((item) => item.claim)).toEqual([kept]); + expect(fs.readFileSync(path.join(root, 'memory/warranty-decisions.md'), 'utf8')).not.toMatch( + /Vulpine/u, + ); + } finally { + await mem.close(); + await stub.close(); + } + }); +}); diff --git a/packages/protocol/src/ops/retain.ts b/packages/protocol/src/ops/retain.ts index b52d582b..fadd16d6 100644 --- a/packages/protocol/src/ops/retain.ts +++ b/packages/protocol/src/ops/retain.ts @@ -275,6 +275,8 @@ export const RetainHoldReason = z.enum([ 'validation_failed', 'language_policy_required', 'language_mismatch', + /** The candidate mentions one of the owner's `maintenance.retain.stop_words`. */ + 'stop_word', ]); export type RetainHoldReason = z.infer;