Skip to content

Commit adabe80

Browse files
os-zhuangclaude
andauthored
docs+skills: document the ADR-0057 data-lifecycle surface (#2786 follow-up) (#2818)
The lifecycle contract shipped in #2791 with no hand-written docs or skill coverage — anyone modeling an append-only object through the docs or the objectstack-data skill would still build the unbounded-growth bug the ADR exists to prevent. Four gaps closed: - data-modeling/objects.mdx: "Data Lifecycle (Retention & Rotation)" under Enterprise Features — classes, three valid policy examples (each verified against the real LifecycleSchema parse), enforcement rules, archive safety, telemetry-datasource separation, and the governance knobs. - kernel/services.mdx: `lifecycle` row in the Standard Services table. - data-modeling/drivers.mdx: SQLite space reclamation (auto_vacuum + reclaimSpace) and physical table rotation; PG/MySQL fallback semantics. - deployment/production-readiness.mdx: go-live checklist item — review the default retention windows / tenant overrides / archive datasource before launch. - skills/objectstack-data: new rules/lifecycle.md (decision tree, correct/incorrect examples, ops table) + SKILL.md property-table row and Quick Reference link, explicitly disambiguated from lifecycle *hooks*. Docs/skills only — no runtime change, no changeset (skip-changeset). Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9566cc commit adabe80

6 files changed

Lines changed: 218 additions & 0 deletions

File tree

content/docs/data-modeling/drivers.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ export OS_DATABASE_URL=file:./data/app.db
166166
export OS_DATABASE_URL=:memory:
167167
```
168168

169+
### Space Reclamation & Table Rotation (ADR-0057)
170+
171+
The SQL driver connects SQLite with `auto_vacuum=INCREMENTAL` and exposes
172+
`reclaimSpace()` (`PRAGMA incremental_vacuum`), which the platform
173+
LifecycleService calls after every sweep that deleted rows — so the database
174+
file actually shrinks instead of pinning at its high-water mark. Objects
175+
declaring `lifecycle.storage.strategy: 'rotation'` are physically
176+
time-sharded on SQLite: writes land in the current shard, reads go through a
177+
view under the table's name, and expired shards are reclaimed with a single
178+
`DROP TABLE`. On PostgreSQL/MySQL both are no-ops — those engines manage
179+
space with their own vacuum machinery, and rotation falls back to an
180+
equivalent age-based reap.
181+
169182
## Memory Driver
170183

171184
The in-memory driver keeps records in plain in-process objects (queried via

content/docs/data-modeling/objects.mdx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,73 @@ versioning: {
138138
}
139139
```
140140

141+
#### Data Lifecycle (Retention & Rotation)
142+
143+
Declares how long the object's data lives and how its space is reclaimed
144+
(ADR-0057). The platform **LifecycleService** (registered by
145+
`ObjectQLPlugin`, default-on) sweeps declared policies hourly. Objects
146+
without a `lifecycle` block keep permanent `record` semantics — nothing is
147+
ever deleted.
148+
149+
```typescript
150+
// High-frequency telemetry: rotation window + age reap
151+
lifecycle: {
152+
class: 'telemetry',
153+
retention: { maxAge: '14d' },
154+
storage: { strategy: 'rotation', shards: 14, unit: 'day' },
155+
}
156+
157+
// Ephemeral rows: TTL on the natural expiry field
158+
lifecycle: {
159+
class: 'transient',
160+
ttl: { field: 'expires_at', expireAfter: '1d' },
161+
}
162+
163+
// Compliance ledger: hot window, then cold storage
164+
lifecycle: {
165+
class: 'audit',
166+
retention: { maxAge: '90d' },
167+
archive: { after: '90d', to: 'archive', keep: '7y' },
168+
}
169+
```
170+
171+
| Key | Description |
172+
| :--- | :--- |
173+
| `class` | Persistence contract (see table below); `record` is the implicit default |
174+
| `retention.maxAge` | Age-based reap on `created_at` |
175+
| `ttl` | Per-row expiry: `field` + `expireAfter` |
176+
| `storage` | `{ strategy: 'rotation', shards, unit }` — time-shard + O(1) shard `DROP` (SQLite) |
177+
| `archive` | Cold-store hand-off: `after` (must equal `retention.maxAge`) + `to` (datasource name) + optional `keep` |
178+
| `reclaim` | Space reclamation after sweeps (default on for non-`record`) |
179+
180+
| Class | Contract | Typical use |
181+
| :--- | :--- | :--- |
182+
| `record` | Business truth — permanent, no policies allowed | accounts, invoices |
183+
| `audit` | Compliance ledger — retain → archive → delete | audit logs |
184+
| `telemetry` | High-frequency log — rotation / short retention | activity streams, job runs |
185+
| `transient` | Ephemeral state — TTL auto-expire | receipts, device codes |
186+
| `event` | Bus messages — very short TTL (hours) | scheduled fan-out |
187+
188+
Enforcement rules:
189+
190+
- A non-`record` class **must** declare at least one bounding policy
191+
(`retention`, `ttl`, or rotation `storage`) — rejected at parse time
192+
otherwise. Policies on `record` are rejected too.
193+
- Duration literals are `<n>` + `h`/`d`/`w`/`y` (e.g. `'6h'`, `'14d'`, `'7y'`);
194+
`archive.after` must equal `retention.maxAge`.
195+
- An `archive`-declared object is **never** hot-deleted before the archive
196+
copy succeeded. No datasource registered under the `archive.to` name ⇒
197+
rows are retained (safe default), not dropped.
198+
- Registering a datasource named `telemetry` routes every
199+
`telemetry`/`event`/`audit` object to it — separate storage, opt-in purely
200+
by the datasource's existence.
201+
202+
Operations knobs live in the `lifecycle` settings namespace: a runtime
203+
`enabled` switch, tenant-scoped `retention_overrides` (a regulated tenant
204+
sets years while dev keeps days), row `quotas` and `growth_alert_rows`
205+
(observe-and-alert only). `OS_LIFECYCLE_DISABLED=1` disables sweeping
206+
entirely.
207+
141208
### Record Name
142209

143210
Auto-generate unique record identifiers:

content/docs/deployment/production-readiness.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/framework/blob/main
8383
checklist in HARDENING.md).
8484
- [ ] Cross-tenant negative tests in CI.
8585
- [ ] Backup / restore drill documented and tested.
86+
- [ ] Data-retention windows reviewed (ADR-0057): the platform's default
87+
`lifecycle` declarations bound telemetry (activity 14d, job runs 30d,
88+
notifications 90d, audit 90d-hot); extend per environment/tenant via
89+
the `lifecycle.retention_overrides` setting **before** go-live if your
90+
compliance regime needs longer, and register an `archive` datasource
91+
if audit data must move to cold storage instead of being retained hot.
8692

8793
## What's NOT in the runtime (yet)
8894

content/docs/kernel/services.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ The core ecosystem defines several standard service contracts:
9090
| `auth` | `IAuthService` | `plugin-auth` |
9191
| `api-registry` | `ApiRegistry` | `@objectstack/core` |
9292
| `cache` | `ICacheService` | Redis, Memcached, or in-memory |
93+
| `lifecycle` | `LifecycleService` (`@objectstack/objectql`) | Registered by `ObjectQLPlugin` — enforces object `lifecycle` declarations (ADR-0057 retention/rotation/archival); call `sweep()` for an on-demand pass |
9394

9495
The logger is not a registered service — it is exposed directly as `ctx.logger`
9596
(the `Logger` contract). Inter-plugin events also do not go through a service:

skills/objectstack-data/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ database table and exposes automatic CRUD APIs.
8181
| `titleFormat` || **Retired (ADR-0079)** — a render-only template the server can't return or query. Use `nameField`; for a composite title, designate a `returnType: 'text'` formula field as `nameField` |
8282
| `enable` || Capability flags (trackHistory, searchable, apiEnabled, etc.) |
8383
| `fieldGroups` || Ordered list of logical field groups for forms/detail pages (see [Field Groups](#field-groups-mvp)) |
84+
| `lifecycle` | `record` semantics (permanent) | Data retention/rotation/archival contract (ADR-0057). **Required for append-only, high-write-rate objects** — a `telemetry`/`transient`/`event`/`audit` class must declare a bounding policy or parsing fails (see [Data Lifecycle & Retention](./rules/lifecycle.md)) |
8485

8586
### Object Capabilities (`enable`)
8687

@@ -205,6 +206,7 @@ For comprehensive documentation with incorrect/correct examples:
205206
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
206207
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
207208
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
209+
- **[Data Lifecycle & Retention](./rules/lifecycle.md)**`lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
208210
- **[Lifecycle Hooks](./rules/hooks.md)** — Hook quick reference (→ see [references/data-hooks.md](./references/data-hooks.md) for the full 14-event guide)
209211
- **[Datasources & Federation](./rules/datasources.md)**`defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
210212

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Data Lifecycle & Retention
2+
3+
Guide for declaring how long an object's data lives and how its space is
4+
reclaimed (ADR-0057). Not to be confused with **lifecycle hooks**
5+
(`beforeInsert` / `afterUpdate` …) — those run business logic on data
6+
operations; *this* page is about retention, rotation, and archival of the
7+
rows themselves.
8+
9+
## Why This Exists
10+
11+
Every object without a `lifecycle` block is **immortal**: rows are never
12+
deleted, and on SQLite the file never shrinks. That's correct for business
13+
records — and a guaranteed disk leak for anything append-only. A scheduled
14+
flow writing telemetry every 20 seconds once grew a dev database from 2 MB
15+
to 260 MB with zero business data in it.
16+
17+
**Rule: any append-only, high-write-rate object (event log, run history,
18+
delivery outbox, ephemeral tokens) MUST declare a `lifecycle` block.**
19+
The platform LifecycleService sweeps declared policies hourly, deletes
20+
expired rows under a system context, and reclaims driver space.
21+
22+
## Lifecycle Classes
23+
24+
| Class | Contract | Typical use |
25+
|:------|:---------|:------------|
26+
| `record` | Business truth — permanent; policies FORBIDDEN | accounts, orders, invoices |
27+
| `audit` | Compliance ledger — retain → archive → delete | audit trails |
28+
| `telemetry` | High-frequency log — rotation / short retention | activity streams, run history |
29+
| `transient` | Ephemeral state — TTL auto-expire | receipts, codes, sessions |
30+
| `event` | Bus messages — very short TTL (hours) | scheduled fan-out |
31+
32+
## Syntax
33+
34+
```typescript
35+
import { ObjectSchema, Field } from '@objectstack/spec/data';
36+
37+
// ✅ Telemetry stream: bounded by a rotation window
38+
export const ApiCallLog = ObjectSchema.create({
39+
name: 'api_call_log',
40+
lifecycle: {
41+
class: 'telemetry',
42+
retention: { maxAge: '14d' }, // reap past 14 days (created_at)
43+
storage: { strategy: 'rotation', shards: 14, unit: 'day' }, // O(1) shard DROP on SQLite
44+
},
45+
fields: {
46+
endpoint: Field.text({}),
47+
status: Field.number({}),
48+
},
49+
});
50+
51+
// ✅ Ephemeral token: TTL on its own expiry field
52+
export const ImportTicket = ObjectSchema.create({
53+
name: 'import_ticket',
54+
lifecycle: {
55+
class: 'transient',
56+
ttl: { field: 'expires_at', expireAfter: '1d' }, // reap 1 day after expires_at
57+
},
58+
fields: {
59+
expires_at: Field.datetime({}),
60+
},
61+
});
62+
63+
// ✅ Compliance ledger: hot window, then cold storage
64+
export const ConsentLog = ObjectSchema.create({
65+
name: 'consent_log',
66+
lifecycle: {
67+
class: 'audit',
68+
retention: { maxAge: '90d' },
69+
archive: { after: '90d', to: 'archive', keep: '7y' }, // must: after === maxAge
70+
},
71+
fields: {
72+
subject: Field.text({}),
73+
},
74+
});
75+
```
76+
77+
Duration literals: `<n>` + `h` / `d` / `w` / `y` — e.g. `'6h'`, `'14d'`,
78+
`'12w'`, `'7y'`.
79+
80+
## Validation Rules (rejected at parse time)
81+
82+
```typescript
83+
// ❌ Non-record class with no bounding policy — grows forever, exactly the bug
84+
lifecycle: { class: 'telemetry' }
85+
86+
// ❌ Policies on record-class — business truth is permanent
87+
lifecycle: { class: 'record', retention: { maxAge: '30d' } }
88+
89+
// ❌ Archive window detached from the hot window
90+
lifecycle: { class: 'audit', retention: { maxAge: '90d' }, archive: { after: '30d', to: 'archive' } }
91+
92+
// ❌ Free-form durations
93+
lifecycle: { class: 'telemetry', retention: { maxAge: '2 weeks' } } // use '2w'
94+
```
95+
96+
## Safety Semantics
97+
98+
- **No `lifecycle` block = today's behavior.** Nothing is deleted. `record`
99+
is the implicit default.
100+
- **Archive-then-delete is atomic-ish and safe by default**: an object
101+
declaring `archive` is never hot-deleted before the copy to the archive
102+
datasource succeeded. If no datasource is registered under the `archive.to`
103+
name, rows are simply retained — a compliance ledger cannot be destroyed
104+
by declaring a lifecycle.
105+
- **Rotation** physically time-shards the table on SQLite (writes hit the
106+
current shard, reads go through a view under the object's name, expired
107+
shards are DROPped). Other dialects enforce the same window with an
108+
age-based reap.
109+
- **Separation**: registering a datasource named `telemetry` moves every
110+
`telemetry`/`event`/`audit` object onto it — telemetry bloat can't touch
111+
the business DB.
112+
113+
## Operations
114+
115+
| Knob | Where | Effect |
116+
|:-----|:------|:-------|
117+
| `enabled` | `lifecycle` settings namespace | Runtime master switch |
118+
| `retention_overrides` | settings, **tenant-scoped** | Per-object window overrides — a regulated tenant sets `'2y'` while dev keeps days |
119+
| `quotas` / `quota_defaults` | settings | Row ceilings; breaches ALERT (never delete beyond declared policy) |
120+
| `growth_alert_rows` | settings | Per-sweep growth spike alert |
121+
| `OS_LIFECYCLE_DISABLED=1` | env | Disables sweeping entirely |
122+
123+
## Decision Tree
124+
125+
1. Users create/edit it and it represents business state? → `record` (omit the block).
126+
2. Is it a compliance/audit trail? → `audit` + `retention`, add `archive` if cold storage exists.
127+
3. Written by machines at high frequency, value decays in days/weeks? → `telemetry` + `retention` (add rotation `storage` for the hottest tables).
128+
4. Meaningless after a deadline (tokens, receipts, read-state)? → `transient` + `ttl` on the natural expiry field.
129+
5. Bus/fan-out messages? → `event` + a short `ttl` (hours).

0 commit comments

Comments
 (0)