Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/lifecycle-retention-onlywhen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
'@objectstack/service-automation': minor
---

ADR-0057 (#2834): `retention.onlyWhen` status predicate — mixed tables can scope the age reap.

- **spec**: `lifecycle.retention.onlyWhen` — a row filter (per-field equality or `{ $in: [...] }`) the retention window applies to; rows outside it are retained regardless of age. Rejected when combined with rotation `storage` (shard DROPs ignore filters) or `archive` (the Archiver moves rows by age alone).
- **objectql**: the LifecycleService Reaper merges `onlyWhen` into every retention delete, including tenant-override passes.
- **service-automation**: the run-history age sweep is now declarative — `sys_automation_run` declares `retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }` and the platform Reaper owns it; suspended (`paused`) runs never match. The plugin's own sweep loop is retired: `ObjectStoreSuspendedRunStore.pruneHistory`, the `DEFAULT_RUN_HISTORY_RETENTION_DAYS` export, and the `runHistoryRetentionDays` / `runHistorySweepMs` plugin options are removed (launch-window breaking-as-minor). The write-time per-flow overflow cap (`runHistoryMaxPerFlow`) stays.
7 changes: 7 additions & 0 deletions content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,19 @@ lifecycle: {
retention: { maxAge: '90d' },
archive: { after: '90d', to: 'archive', keep: '7y' },
}

// Mixed table (live workflow state + terminal history): scope the reap
lifecycle: {
class: 'telemetry',
retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
}
```

| Key | Description |
| :--- | :--- |
| `class` | Persistence contract (see table below); `record` is the implicit default |
| `retention.maxAge` | Age-based reap on `created_at` |
| `retention.onlyWhen` | Row filter the reap is scoped to (per-field equality or `{ $in: [...] }`); rows outside it are retained regardless of age. Incompatible with rotation `storage` and `archive` |
| `ttl` | Per-row expiry: `field` + `expireAfter` |
| `storage` | `{ strategy: 'rotation', shards, unit }` — time-shard + O(1) shard `DROP` (SQLite) |
| `archive` | Cold-store hand-off: `after` (must equal `retention.maxAge`) + `to` (datasource name) + optional `keep` |
Expand Down
56 changes: 56 additions & 0 deletions packages/objectql/src/lifecycle/lifecycle-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ describe('LifecycleService.sweep — Reaper', () => {
expect(report.swept[0].policy).toBe('ttl');
});

it('merges retention.onlyWhen into the reap filter (mixed tables, #2834)', async () => {
const { engine, deletes } = captureEngine([
{
name: 'sys_automation_run',
lifecycle: {
class: 'telemetry',
retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
} as any,
},
]);

const report = await service(engine).sweep();

// Age cutoff AND the status predicate — a paused run older than the
// window must never match the delete.
expect(deletes).toHaveLength(1);
expect(deletes[0].where).toEqual({
created_at: { $lt: isoCutoff('30d') },
status: { $in: ['completed', 'failed'] },
});
expect(report.swept[0].policy).toBe('retention');
});

it('never touches record-class or undeclared objects', async () => {
const { engine, deletes } = captureEngine([
{ name: 'crm_account' },
Expand Down Expand Up @@ -498,6 +521,39 @@ describe('LifecycleService.sweep — governance (P4)', () => {
expect(deletes).toHaveLength(2);
});

it('retention.onlyWhen survives tenant-scoped overrides on every pass', async () => {
const { engine, deletes } = captureEngine([
{
name: 'sys_automation_run',
lifecycle: {
class: 'telemetry',
retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
} as any,
},
]);
(engine as any).find = async (object: string) =>
object === 'sys_organization' ? [{ id: 'org_reg' }] : [];
const settings = fakeSettings(
{},
{ org_reg: { retention_overrides: { sys_automation_run: { maxAge: '2y' } } } },
);

await service(engine, { getSettings: () => settings }).sweep();

const predicate = { status: { $in: ['completed', 'failed'] } };
expect(deletes[0].where).toEqual({
created_at: { $lt: isoCutoff('2y') },
organization_id: 'org_reg',
...predicate,
});
expect(deletes[1].where).toEqual({
created_at: { $lt: isoCutoff('30d') },
$or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }],
...predicate,
});
expect(deletes).toHaveLength(2);
});

it('raises quota and growth alerts (observe-only — no extra deletes)', async () => {
const onAlert = vi.fn();
const count = vi.fn(async () => 1_500);
Expand Down
13 changes: 10 additions & 3 deletions packages/objectql/src/lifecycle/lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,9 @@ export class LifecycleService {
// live shards — and immediately bounds a legacy table the Rotator just
// adopted whole into its first shard.
const windowMs = this.effectiveWindowMs(ov.maxAge, parseLifecycleDuration(lc.retention.maxAge), object);
outcomes.push(await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report));
outcomes.push(
await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report, lc.retention.onlyWhen),
);
} else if (lc.storage?.strategy === 'rotation' && !rotated && !lc.ttl) {
// Rotation declared but the driver can't shard physically: the shard
// window IS the bound — enforce the same window with an age-based reap
Expand Down Expand Up @@ -591,12 +593,16 @@ export class LifecycleService {
field: string,
windowMs: number,
report: LifecycleSweepReport,
onlyWhen?: Record<string, unknown>,
): Promise<number | undefined> {
const cutoff = new Date(this.now() - windowMs).toISOString();
const overrideKey = policy === 'ttl' ? 'expireAfter' : 'maxAge';
const tenantWindows = (this.governance.tenantOverrides.get(object) ?? []).filter(
(t) => typeof t[overrideKey] === 'string',
);
// `retention.onlyWhen` narrows every delete to the declared row filter —
// rows outside it (live workflow state) are retained regardless of age.
const scope = onlyWhen ?? {};

let total: number | undefined = 0;
const accumulate = (res: unknown) => {
Expand All @@ -608,7 +614,7 @@ export class LifecycleService {
if (tenantWindows.length === 0) {
accumulate(
await engine.delete(object, {
where: { [field]: { $lt: cutoff } },
where: { [field]: { $lt: cutoff }, ...scope },
multi: true,
context: { ...SYSTEM_CTX },
}),
Expand All @@ -621,7 +627,7 @@ export class LifecycleService {
const tCutoff = new Date(this.now() - tMs).toISOString();
accumulate(
await engine.delete(object, {
where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId },
where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope },
multi: true,
context: { ...SYSTEM_CTX },
}),
Expand All @@ -637,6 +643,7 @@ export class LifecycleService {
{ organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } },
{ organization_id: null },
],
...scope,
},
multi: true,
context: { ...SYSTEM_CTX },
Expand Down
8 changes: 6 additions & 2 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ import {
// ── Audit ─────────────────────────────────────────────────────────────────
// sys_audit_log / sys_activity / sys_comment moved to @objectstack/plugin-audit
// and sys_presence to @objectstack/service-realtime (ADR-0029 K2 / D8). Their
// i18n extraction now lives in those packages; the already-generated bundles
// here keep working until the next regeneration. sys_attachment stays here
// i18n extraction lives in those packages (each has its own
// scripts/i18n-extract.config.ts + src/translations bundles) and the leftover
// copies here were pruned in the #2834 ⑤ regeneration — verified line-by-line
// against the owning plugins' bundles before committing. The
// bundle-ownership test guards this invariant from here on: this package's
// bundles carry ONLY this package's objects. sys_attachment stays here
// pending the storage-domain decomposition (it belongs with service-storage).
import {
SysNotification,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated
// object-translation bundles must carry ONLY objects this package's extract
// config actually imports. When an object moves to another package (audit,
// realtime, …), its translations move to that package's own bundles — a
// leftover copy here silently DIES on the next `os i18n extract` run, taking
// curated translations with it (the sys_audit_log incident). This test turns
// that silent loss into a red build: an object present in the bundle but not
// in the ownership list below means either (a) the extract config gained an
// object — add it here — or (b) a moved object's keys were left behind —
// migrate them to the owning package's bundles, then regenerate.

import { describe, it, expect } from 'vitest';
import { enObjects } from './en.objects.generated.js';

// Objects the extract config (scripts/i18n-extract.config.ts) imports —
// keep the two lists in sync when adding/moving platform objects.
const OWNED_OBJECTS = new Set([
// identity
'sys_user', 'sys_session', 'sys_account', 'sys_verification', 'sys_organization',
'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit',
'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code',
'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token',
'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_jwks',
// audit / messaging-adjacent (still owned here)
'sys_notification', 'sys_attachment', 'sys_email', 'sys_email_template',
'sys_saved_report', 'sys_report_schedule', 'sys_job', 'sys_job_run', 'sys_job_queue',
// metadata
'sys_metadata', 'sys_metadata_history', 'sys_view_definition', 'sys_metadata_audit',
// system
'sys_setting', 'sys_secret', 'sys_setting_audit',
]);

describe('objects translation bundle ownership (ADR-0029 D8)', () => {
it('the en bundle contains no objects owned by other packages', () => {
const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o));
expect(
strays,
`bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` +
'their curated translations would be silently deleted on the next `os i18n extract`. ' +
'Migrate them to the owning package (cf. plugin-audit / service-realtime translations) or add them to the extract config + this list.',
).toEqual([]);
});

it('every owned object is present in the bundle (extract config regression)', () => {
const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects));
expect(
missing,
`objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`,
).toEqual([]);
});
});
Loading