Skip to content

Commit 33ebd34

Browse files
os-zhuangclaude
andauthored
feat(lifecycle): retention.onlyWhen status predicate + declarative automation run-history retention + i18n bundle ownership guard (#2834) (#2837)
* fix(i18n): prune moved objects from platform-objects bundles + ownership guard (#2834 ⑤) The committed *.objects.generated.ts bundles still carried sys_audit_log / sys_activity / sys_comment / sys_presence — objects that moved to plugin-audit / service-realtime (ADR-0029 K2/D8) along with their OWN translation bundles and extract configs. The leftovers made every `os i18n extract` re-run a translation-loss hazard: the extractor prunes objects outside its config's import set, deleting curated zh-CN content. - Regenerated the four objects bundles: the moved objects' blocks are gone (verified line-by-line — all 131 deleted zh-CN lines exist either relocated in the new files or in the owning plugin's bundles: ZERO loss), and pending drift keys (e.g. sys_user failed_login_count) are absorbed. - New bundle-ownership guard test: an object present in the bundle but not owned by the extract config turns the build red instead of dying silently on the next regeneration; the inverse direction catches dropped imports. - Extract-config comments updated from "until the next regeneration" to the completed state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 * feat(lifecycle): retention.onlyWhen status predicate + declarative automation run-history retention (#2834) Mixed tables — live workflow state interleaved with prunable history — could not use declarative retention: the Reaper's age cutoff would reap suspended approval runs. This closes that gap and retires the last per-plugin age sweep. - spec: `lifecycle.retention.onlyWhen` — row filter (per-field equality or {$in: [...]}) the retention window is scoped to; rows outside it are retained regardless of age. superRefine rejects combining it with rotation storage (shard DROPs ignore filters) or archive (the Archiver moves rows by age alone). - objectql: the Reaper merges onlyWhen into every retention delete, including the tenant-override and global-remainder passes. - service-automation: sys_automation_run declares retention { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } } — the platform Reaper owns AGE retention; paused rows never match. Retired the plugin's own sweep loop: ObjectStoreSuspendedRunStore.pruneHistory, the DEFAULT_RUN_HISTORY_RETENTION_DAYS export, and the runHistoryRetentionDays / runHistorySweepMs options are removed (launch-window breaking-as-minor). The write-time per-flow overflow cap (runHistoryMaxPerFlow) stays — a count bound the declarative contract can't express. - docs + skill: onlyWhen in the objects.mdx key table, mixed-table example and decision-tree entry in skills/objectstack-data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 526805e commit 33ebd34

18 files changed

Lines changed: 785 additions & 1338 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/service-automation': minor
5+
---
6+
7+
ADR-0057 (#2834): `retention.onlyWhen` status predicate — mixed tables can scope the age reap.
8+
9+
- **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).
10+
- **objectql**: the LifecycleService Reaper merges `onlyWhen` into every retention delete, including tenant-override passes.
11+
- **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.

content/docs/data-modeling/objects.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,19 @@ lifecycle: {
166166
retention: { maxAge: '90d' },
167167
archive: { after: '90d', to: 'archive', keep: '7y' },
168168
}
169+
170+
// Mixed table (live workflow state + terminal history): scope the reap
171+
lifecycle: {
172+
class: 'telemetry',
173+
retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
174+
}
169175
```
170176

171177
| Key | Description |
172178
| :--- | :--- |
173179
| `class` | Persistence contract (see table below); `record` is the implicit default |
174180
| `retention.maxAge` | Age-based reap on `created_at` |
181+
| `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` |
175182
| `ttl` | Per-row expiry: `field` + `expireAfter` |
176183
| `storage` | `{ strategy: 'rotation', shards, unit }` — time-shard + O(1) shard `DROP` (SQLite) |
177184
| `archive` | Cold-store hand-off: `after` (must equal `retention.maxAge`) + `to` (datasource name) + optional `keep` |

packages/objectql/src/lifecycle/lifecycle-service.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,29 @@ describe('LifecycleService.sweep — Reaper', () => {
9898
expect(report.swept[0].policy).toBe('ttl');
9999
});
100100

101+
it('merges retention.onlyWhen into the reap filter (mixed tables, #2834)', async () => {
102+
const { engine, deletes } = captureEngine([
103+
{
104+
name: 'sys_automation_run',
105+
lifecycle: {
106+
class: 'telemetry',
107+
retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
108+
} as any,
109+
},
110+
]);
111+
112+
const report = await service(engine).sweep();
113+
114+
// Age cutoff AND the status predicate — a paused run older than the
115+
// window must never match the delete.
116+
expect(deletes).toHaveLength(1);
117+
expect(deletes[0].where).toEqual({
118+
created_at: { $lt: isoCutoff('30d') },
119+
status: { $in: ['completed', 'failed'] },
120+
});
121+
expect(report.swept[0].policy).toBe('retention');
122+
});
123+
101124
it('never touches record-class or undeclared objects', async () => {
102125
const { engine, deletes } = captureEngine([
103126
{ name: 'crm_account' },
@@ -498,6 +521,39 @@ describe('LifecycleService.sweep — governance (P4)', () => {
498521
expect(deletes).toHaveLength(2);
499522
});
500523

524+
it('retention.onlyWhen survives tenant-scoped overrides on every pass', async () => {
525+
const { engine, deletes } = captureEngine([
526+
{
527+
name: 'sys_automation_run',
528+
lifecycle: {
529+
class: 'telemetry',
530+
retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
531+
} as any,
532+
},
533+
]);
534+
(engine as any).find = async (object: string) =>
535+
object === 'sys_organization' ? [{ id: 'org_reg' }] : [];
536+
const settings = fakeSettings(
537+
{},
538+
{ org_reg: { retention_overrides: { sys_automation_run: { maxAge: '2y' } } } },
539+
);
540+
541+
await service(engine, { getSettings: () => settings }).sweep();
542+
543+
const predicate = { status: { $in: ['completed', 'failed'] } };
544+
expect(deletes[0].where).toEqual({
545+
created_at: { $lt: isoCutoff('2y') },
546+
organization_id: 'org_reg',
547+
...predicate,
548+
});
549+
expect(deletes[1].where).toEqual({
550+
created_at: { $lt: isoCutoff('30d') },
551+
$or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }],
552+
...predicate,
553+
});
554+
expect(deletes).toHaveLength(2);
555+
});
556+
501557
it('raises quota and growth alerts (observe-only — no extra deletes)', async () => {
502558
const onAlert = vi.fn();
503559
const count = vi.fn(async () => 1_500);

packages/objectql/src/lifecycle/lifecycle-service.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,9 @@ export class LifecycleService {
495495
// live shards — and immediately bounds a legacy table the Rotator just
496496
// adopted whole into its first shard.
497497
const windowMs = this.effectiveWindowMs(ov.maxAge, parseLifecycleDuration(lc.retention.maxAge), object);
498-
outcomes.push(await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report));
498+
outcomes.push(
499+
await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report, lc.retention.onlyWhen),
500+
);
499501
} else if (lc.storage?.strategy === 'rotation' && !rotated && !lc.ttl) {
500502
// Rotation declared but the driver can't shard physically: the shard
501503
// window IS the bound — enforce the same window with an age-based reap
@@ -591,12 +593,16 @@ export class LifecycleService {
591593
field: string,
592594
windowMs: number,
593595
report: LifecycleSweepReport,
596+
onlyWhen?: Record<string, unknown>,
594597
): Promise<number | undefined> {
595598
const cutoff = new Date(this.now() - windowMs).toISOString();
596599
const overrideKey = policy === 'ttl' ? 'expireAfter' : 'maxAge';
597600
const tenantWindows = (this.governance.tenantOverrides.get(object) ?? []).filter(
598601
(t) => typeof t[overrideKey] === 'string',
599602
);
603+
// `retention.onlyWhen` narrows every delete to the declared row filter —
604+
// rows outside it (live workflow state) are retained regardless of age.
605+
const scope = onlyWhen ?? {};
600606

601607
let total: number | undefined = 0;
602608
const accumulate = (res: unknown) => {
@@ -608,7 +614,7 @@ export class LifecycleService {
608614
if (tenantWindows.length === 0) {
609615
accumulate(
610616
await engine.delete(object, {
611-
where: { [field]: { $lt: cutoff } },
617+
where: { [field]: { $lt: cutoff }, ...scope },
612618
multi: true,
613619
context: { ...SYSTEM_CTX },
614620
}),
@@ -621,7 +627,7 @@ export class LifecycleService {
621627
const tCutoff = new Date(this.now() - tMs).toISOString();
622628
accumulate(
623629
await engine.delete(object, {
624-
where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId },
630+
where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope },
625631
multi: true,
626632
context: { ...SYSTEM_CTX },
627633
}),
@@ -637,6 +643,7 @@ export class LifecycleService {
637643
{ organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } },
638644
{ organization_id: null },
639645
],
646+
...scope,
640647
},
641648
multi: true,
642649
context: { ...SYSTEM_CTX },

packages/platform-objects/scripts/i18n-extract.config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ import {
5959
// ── Audit ─────────────────────────────────────────────────────────────────
6060
// sys_audit_log / sys_activity / sys_comment moved to @objectstack/plugin-audit
6161
// and sys_presence to @objectstack/service-realtime (ADR-0029 K2 / D8). Their
62-
// i18n extraction now lives in those packages; the already-generated bundles
63-
// here keep working until the next regeneration. sys_attachment stays here
62+
// i18n extraction lives in those packages (each has its own
63+
// scripts/i18n-extract.config.ts + src/translations bundles) and the leftover
64+
// copies here were pruned in the #2834 ⑤ regeneration — verified line-by-line
65+
// against the owning plugins' bundles before committing. The
66+
// bundle-ownership test guards this invariant from here on: this package's
67+
// bundles carry ONLY this package's objects. sys_attachment stays here
6468
// pending the storage-domain decomposition (it belongs with service-storage).
6569
import {
6670
SysNotification,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated
4+
// object-translation bundles must carry ONLY objects this package's extract
5+
// config actually imports. When an object moves to another package (audit,
6+
// realtime, …), its translations move to that package's own bundles — a
7+
// leftover copy here silently DIES on the next `os i18n extract` run, taking
8+
// curated translations with it (the sys_audit_log incident). This test turns
9+
// that silent loss into a red build: an object present in the bundle but not
10+
// in the ownership list below means either (a) the extract config gained an
11+
// object — add it here — or (b) a moved object's keys were left behind —
12+
// migrate them to the owning package's bundles, then regenerate.
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { enObjects } from './en.objects.generated.js';
16+
17+
// Objects the extract config (scripts/i18n-extract.config.ts) imports —
18+
// keep the two lists in sync when adding/moving platform objects.
19+
const OWNED_OBJECTS = new Set([
20+
// identity
21+
'sys_user', 'sys_session', 'sys_account', 'sys_verification', 'sys_organization',
22+
'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit',
23+
'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code',
24+
'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token',
25+
'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_jwks',
26+
// audit / messaging-adjacent (still owned here)
27+
'sys_notification', 'sys_attachment', 'sys_email', 'sys_email_template',
28+
'sys_saved_report', 'sys_report_schedule', 'sys_job', 'sys_job_run', 'sys_job_queue',
29+
// metadata
30+
'sys_metadata', 'sys_metadata_history', 'sys_view_definition', 'sys_metadata_audit',
31+
// system
32+
'sys_setting', 'sys_secret', 'sys_setting_audit',
33+
]);
34+
35+
describe('objects translation bundle ownership (ADR-0029 D8)', () => {
36+
it('the en bundle contains no objects owned by other packages', () => {
37+
const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o));
38+
expect(
39+
strays,
40+
`bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` +
41+
'their curated translations would be silently deleted on the next `os i18n extract`. ' +
42+
'Migrate them to the owning package (cf. plugin-audit / service-realtime translations) or add them to the extract config + this list.',
43+
).toEqual([]);
44+
});
45+
46+
it('every owned object is present in the bundle (extract config regression)', () => {
47+
const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects));
48+
expect(
49+
missing,
50+
`objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`,
51+
).toEqual([]);
52+
});
53+
});

0 commit comments

Comments
 (0)