Skip to content
2 changes: 1 addition & 1 deletion docs/de/platform/admin/governance/trash.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Um einen Chat-Verlauf-Thread wiederherzustellen, öffne **Einstellungen > Richtl

## Die zwei Status

**Verworfen** ist der normale Soft-Delete-Zustand. Das Aufbewahrungsfenster der Zeile ist abgelaufen, sie ist in den Papierkorb gewandert, und das Kulanzfenster tickt noch. Wiederherstellen führt die Zeile in ihre Quellliste zurück, ohne die Richtlinie zu überschreiben.
**Verworfen** ist der normale Soft-Delete-Zustand. Das Aufbewahrungsfenster der Zeile ist abgelaufen, sie ist in den Papierkorb gewandert, und das Kulanzfenster tickt noch. Wiederherstellen führt die Zeile in ihre Quellliste zurück, ohne die Richtlinie zu überschreiben. Das Aufbewahrungsfenster beginnt dabei von vorn — ein wiederhergestellter Chat-Thread, ein Dokument oder eine externe Konversation zählt ab dem Moment der Wiederherstellung, und der nächste Cleanup lässt die Zeile in Ruhe, statt sie erneut ablaufen zu lassen.

**Abgelaufen** ist der zweite Zustand — das Kulanzfenster ist abgelaufen und die Zeile ist für die endgültige Löschung im nächsten Cleanup vorgemerkt. Wiederherstellen ist weiterhin möglich, aber es ist eine Überschreibung: der Dialog verlangt, dass du `restore` tippst, und das Audit-Log dokumentiert die Überschreibung mit deinem Namen.

Expand Down
2 changes: 1 addition & 1 deletion docs/en/platform/admin/governance/trash.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ To restore a chat history thread, open **Settings > Governance > Trash** and swi

## The two statuses

**Trashed** is the normal soft-delete state. The row's retention window elapsed, it moved to trash, and the grace window is still ticking. Restore returns the row to its source list with no policy override.
**Trashed** is the normal soft-delete state. The row's retention window elapsed, it moved to trash, and the grace window is still ticking. Restore returns the row to its source list with no policy override. The retention clock restarts at the restore — a restored chat thread, document, or external conversation counts from that moment, so the next cleanup pass leaves it alone instead of expiring it again.

**Expired** is the second state — the grace window ran out and the row is queued for permanent deletion at the next cleanup. Restore is still possible but is an override: the dialog asks you to type `restore` and the audit log records the override with your name.

Expand Down
2 changes: 1 addition & 1 deletion docs/fr/platform/admin/governance/trash.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Pour restaurer un thread d'historique de chat, ouvre **Paramètres > Gouvernance

## Les deux statuts

**Mis à la corbeille** est l'état soft-delete normal. La fenêtre de rétention de la ligne a expiré, elle s'est déplacée à la corbeille, et la fenêtre de grâce tourne encore. Restaurer ramène la ligne dans sa liste source sans dépasser la politique.
**Mis à la corbeille** est l'état soft-delete normal. La fenêtre de rétention de la ligne a expiré, elle s'est déplacée à la corbeille, et la fenêtre de grâce tourne encore. Restaurer ramène la ligne dans sa liste source sans dépasser la politique. La fenêtre de rétention repart de zéro au moment de la restauration — un thread de chat, un document ou une conversation externe restaurés comptent à partir de ce moment, et le prochain nettoyage le laisse tranquille au lieu de le faire expirer à nouveau.

**Expiré** est le second état — la fenêtre de grâce s'est écoulée et la ligne est en file pour suppression définitive au prochain nettoyage. Restaurer reste possible mais est un dépassement : la boîte de dialogue te demande de taper `restore` et le journal d'audit enregistre le dépassement avec ton nom.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest';

import { diffBounds } from './retention_bounds_proposal';
import {
RETENTION_CATEGORIES,
type AppliedBoundsByCategory,
} from '../../../lib/shared/schemas/retention';
import { buildImpactPreview, diffBounds } from './retention_bounds_proposal';
import {
RETENTION_POLICY_FIELD_BY_CATEGORY,
clampConfigToBounds,
type EffectiveBoundDef,
} from './retention_floors';

describe('diffBounds', () => {
it('returns an empty diff for identical snapshots', () => {
Expand Down Expand Up @@ -121,3 +130,40 @@ describe('diffBounds', () => {
expect(diff.every((d) => d.direction === 'tighten')).toBe(true);
});
});

describe('buildImpactPreview ↔ clampConfigToBounds parity', () => {
it('promises exactly the clamp the sweep performs, for every category', () => {
// Every category bounded to [10, 20]; the stored policy sits below the
// floor on half of them and above the ceiling on the other half.
const proposed: AppliedBoundsByCategory = {};
const bounds: Partial<Record<string, EffectiveBoundDef>> = {};
const stored: Record<string, number> = {};
RETENTION_CATEGORIES.forEach((category, index) => {
proposed[category] = { min: 10, max: 20 };
bounds[category] = {
category,
min: 10,
max: 20,
default: 15,
unit: category.endsWith('Hours') ? 'hours' : 'days',
source: 'file',
minEnv: { envName: '', source: 'none', applied: false },
maxEnv: { envName: '', source: 'none', applied: false },
defaultEnv: { envName: '', source: 'none', applied: false },
};
stored[RETENTION_POLICY_FIELD_BY_CATEGORY[category]] =
index % 2 === 0 ? 1 : 99;
});
const preview = buildImpactPreview(proposed, stored);
const clamped = clampConfigToBounds(bounds, stored);
expect(preview).toHaveLength(RETENTION_CATEGORIES.length);
for (const entry of preview) {
expect(clamped[entry.field]).toBe(entry.willClampTo);
expect(clamped[entry.field]).not.toBe(entry.current);
}
// The category whose preview used to lie: clamp and preview agree.
expect(clamped.agentRunsRetentionDays).toBe(
preview.find((entry) => entry.category === 'agentRuns')?.willClampTo,
);
});
});
Original file line number Diff line number Diff line change
@@ -1,25 +1,10 @@
import {
RETENTION_CATEGORIES,
type AppliedBoundsByCategory,
type RetentionCategory,
} from '../../../lib/shared/schemas/retention';
import { isRecord } from '../../../lib/utils/type-utils';
const POLICY_FIELD_BY_CATEGORY: Record<RetentionCategory, string> = {
documents: 'documentsRetentionDays',
userTempHours: 'userTempRetentionHours',
agentTempHours: 'agentTempRetentionHours',
chatHistory: 'chatHistoryRetentionDays',
auditLog: 'auditLogRetentionDays',
workflowLog: 'workflowLogRetentionDays',
usageLedger: 'usageLedgerRetentionDays',
loginAttempt: 'loginAttemptRetentionDays',
chatFilterEvents: 'chatFilterEventsRetentionDays',
messageFeedback: 'messageFeedbackRetentionDays',
contacts: 'contactsRetentionDays',
externalConversations: 'externalConversationsRetentionDays',
notifications: 'notificationsRetentionDays',
agentRuns: 'agentRunsRetentionDays',
};
import { RETENTION_POLICY_FIELD_BY_CATEGORY } from './retention_floors';

export interface BoundDiffEntry {
category: string;
field: 'min' | 'max';
Expand Down Expand Up @@ -125,7 +110,9 @@ export function diffBounds(
* For each diffed category, project what would happen to the org's
* stored retention value if the proposal is applied. Reads
* `governancePolicies.retention_policy.config` and clamps each
* `<category>RetentionDays/Hours` field to the proposed `[min, max]`.
* `<category>RetentionDays/Hours` field to the proposed `[min, max]` —
* the same field↔category pairing `clampConfigToBounds` enforces, so the
* preview can only promise what the sweep does.
*/
export function buildImpactPreview(
proposed: AppliedBoundsByCategory,
Expand All @@ -136,7 +123,7 @@ export function buildImpactPreview(
for (const cat of RETENTION_CATEGORIES) {
const bound = proposed[cat];
if (!bound) continue;
const field = POLICY_FIELD_BY_CATEGORY[cat];
const field = RETENTION_POLICY_FIELD_BY_CATEGORY[cat];
const current = storedConfig[field];
if (typeof current !== 'number' || !Number.isFinite(current)) continue;
const clamped = Math.min(Math.max(current, bound.min), bound.max);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import type { RetentionDefaultsConfig } from '../../../lib/shared/schemas/retention';
import {
RETENTION_CATEGORIES,
type RetentionDefaultsConfig,
} from '../../../lib/shared/schemas/retention';
import {
RETENTION_POLICY_FIELD_BY_CATEGORY,
RetentionBoundsViolation,
RetentionConfigMissingError,
applyEnvTightening,
Expand Down Expand Up @@ -376,3 +380,54 @@ describe('isRetentionDisabled', () => {
expect(isRetentionDisabled()).toBe(false);
});
});

describe('RETENTION_POLICY_FIELD_BY_CATEGORY — the one field↔category map', () => {
it('names a distinct policy field for every retention category', () => {
const fields = RETENTION_CATEGORIES.map(
(category) => RETENTION_POLICY_FIELD_BY_CATEGORY[category],
);
expect(new Set(fields).size).toBe(RETENTION_CATEGORIES.length);
for (const field of fields) {
expect(field).toMatch(/Retention(Days|Hours)$/);
}
// The category the hand-rolled lists kept forgetting.
expect(RETENTION_POLICY_FIELD_BY_CATEGORY.agentRuns).toBe(
'agentRunsRetentionDays',
);
});

it('clampConfigToBounds clamps agentRuns like every other category', () => {
const bound: EffectiveBoundDefLike = {
category: 'agentRuns',
min: 30,
max: 365,
default: 90,
unit: 'days',
source: 'file',
minEnv: { envName: '', source: 'none', applied: false },
maxEnv: { envName: '', source: 'none', applied: false },
defaultEnv: { envName: '', source: 'none', applied: false },
};
const out = clampConfigToBounds(
{ agentRuns: bound },
{ agentRunsRetentionDays: 7, agentRunsEnabled: true },
);
expect(out.agentRunsRetentionDays).toBe(30);
expect(out.agentRunsEnabled).toBe(true);
});

it('leaves a category the bounds snapshot does not cover untouched', () => {
// An applied snapshot that predates a category must not crash the sweep
// (nor clamp by a bound nobody applied); the banner proposes it instead.
const out = clampConfigToBounds(
{},
{ agentRunsRetentionDays: 7, documentsRetentionDays: 1 },
);
expect(out).toStrictEqual({
agentRunsRetentionDays: 7,
documentsRetentionDays: 1,
});
});
});

type EffectiveBoundDefLike = Parameters<typeof clampToBounds>[0];
59 changes: 37 additions & 22 deletions services/platform/backend/core/governance/retention_floors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
* to take effect; see docs/self-hosted/configuration/retention.md.
*/

import type { RetentionPolicyConfig } from '../../../lib/shared/schemas/governance';
import {
RETENTION_CATEGORIES,
type RetentionCategory,
Expand Down Expand Up @@ -340,45 +341,59 @@ export function clampToBounds(
}

/**
* Map of every retention-config field to its `RetentionCategory`. Drives
* `clampConfigToBounds` so an org's stored values never bypass freshly
* tightened bounds, even when the row was persisted under the old
* config.
* THE pairing of each retention category with the policy field that carries
* its value — the one place it lives. The sweep's clamp
* (`clampConfigToBounds`), the policy save's bounds check, the bounds
* banner's impact preview, and the shortening detector all derive from it,
* so a category can no longer be enforced in one and forgotten in another:
* `agentRuns` was missing from the clamp and the save check while the
* preview promised a clamp that never happened. Exhaustive over
* `RETENTION_CATEGORIES` by type; the completeness test pins it.
*/
const CONFIG_FIELD_TO_CATEGORY: Record<string, RetentionCategory> = {
documentsRetentionDays: 'documents',
userTempRetentionHours: 'userTempHours',
agentTempRetentionHours: 'agentTempHours',
chatHistoryRetentionDays: 'chatHistory',
auditLogRetentionDays: 'auditLog',
workflowLogRetentionDays: 'workflowLog',
usageLedgerRetentionDays: 'usageLedger',
loginAttemptRetentionDays: 'loginAttempt',
chatFilterEventsRetentionDays: 'chatFilterEvents',
messageFeedbackRetentionDays: 'messageFeedback',
contactsRetentionDays: 'contacts',
externalConversationsRetentionDays: 'externalConversations',
notificationsRetentionDays: 'notifications',
export const RETENTION_POLICY_FIELD_BY_CATEGORY: Record<
RetentionCategory,
keyof RetentionPolicyConfig
> = {
documents: 'documentsRetentionDays',
userTempHours: 'userTempRetentionHours',
agentTempHours: 'agentTempRetentionHours',
chatHistory: 'chatHistoryRetentionDays',
auditLog: 'auditLogRetentionDays',
workflowLog: 'workflowLogRetentionDays',
usageLedger: 'usageLedgerRetentionDays',
loginAttempt: 'loginAttemptRetentionDays',
chatFilterEvents: 'chatFilterEventsRetentionDays',
messageFeedback: 'messageFeedbackRetentionDays',
contacts: 'contactsRetentionDays',
externalConversations: 'externalConversationsRetentionDays',
notifications: 'notificationsRetentionDays',
agentRuns: 'agentRunsRetentionDays',
};

/**
* Clamp every retention-config field to current effective bounds.
* Returns a shallow-cloned config; original is unchanged. Fields absent
* from the input or whose value is non-numeric are left untouched.
* from the input or whose value is non-numeric are left untouched, and so
* is a category the bounds map does not cover (an applied snapshot that
* predates the category — the bounds banner proposes it; the sweep must
* not crash on it).
*
* Pure: takes a pre-resolved `boundsByCategory` map. Build it via
* `buildBoundsByCategory(orgConfig)` after loading the file at the IO
* boundary.
*/
export function clampConfigToBounds<C extends Record<string, unknown>>(
boundsByCategory: Record<RetentionCategory, EffectiveBoundDef>,
boundsByCategory: Partial<Record<RetentionCategory, EffectiveBoundDef>>,
config: C,
): C {
const out = { ...config };
for (const [field, category] of Object.entries(CONFIG_FIELD_TO_CATEGORY)) {
for (const category of RETENTION_CATEGORIES) {
const field = RETENTION_POLICY_FIELD_BY_CATEGORY[category];
const bound = boundsByCategory[category];
const value = out[field];
if (bound === undefined) continue;
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const clamped = clampToBounds(boundsByCategory[category], value);
const clamped = clampToBounds(bound, value);
if (clamped !== value) {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- field exists on out (just read above)
(out as Record<string, unknown>)[field] = clamped;
Expand Down
Loading
Loading