Skip to content

feat(platform): ADR-0057 system data lifecycle + P0 telemetry-growth stopgap - #2079

Merged
os-zhuang merged 1 commit into
mainfrom
fix/data-lifecycle-p0-and-adr
Jun 21, 2026
Merged

feat(platform): ADR-0057 system data lifecycle + P0 telemetry-growth stopgap#2079
os-zhuang merged 1 commit into
mainfrom
fix/data-lifecycle-p0-and-adr

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Why

A 20-second scheduled digest flow in app-showcase grew dev.db to 260 MB+ over a multi-day pnpm devall platform-generated telemetry, zero business data. Forensics:

  • A fresh seeded DB is 2.3 MB; the bloated one was 261 MB (~110×).
  • No business table grew (same seed rows every boot). Growth was entirely in append-only platform tables.
  • Each 20s digest tick fanned out to sys_job_run + sys_notification (+delivery +receipt) + sys_inbox_messageand every one was mirrored into both sys_audit_log and sys_activity (~21 rows/tick, audit+activity ≈ 76%).
  • SQLite shipped auto_vacuum=NONE, so the file never shrinks even after deletes.

Root causes, by leverage: (1) no retention contract on platform objects; (2) audit+activity dual-write amplifying every internal mutation; (3) auto_vacuum=NONE; (4) demo-tuned 20s interval.

What

docs/adr/0057-system-data-lifecycle-and-retention.md — makes data lifecycle a first-class, declarable, runtime-enforced metadata concern (classify → declare → enforce via a platform-owned LifecycleService Reaper/Rotator/Archiver → reclaim → gate). Builds on ADR-0052 (bounded contexts) along the orthogonal time axis it never specified. Benchmarked against Salesforce (Big Objects / Field Audit Trail), ServiceNow (Table Rotation / Cleaner), Dataverse (Elastic TTL / data lake), OutSystems, Mendix. Rollout P0→P4.

P0 stopgap (this PR):

  • plugin-audit — exclude operational/plumbing objects (sys_job, sys_job_run, sys_automation_run, sys_notification*, sys_inbox_message, sys_http_delivery, ai_traces, sys_device_code) from the audit+activity writer SKIP_OBJECTS. Kills the dominant amplifier.
  • driver-sql — default SQLite auto_vacuum=INCREMENTAL so freed pages can be reclaimed (Reaper hook lands in P1). New DBs only; existing files adopt it after one VACUUM.
  • app-showcase — digest interval 20s→60s, flagged demo-only with cost rationale.

Note: I deliberately did not skip by context.isSystem — the seed loader and server-side automations write business records with isSystem:true, so that would punch a real compliance hole in the ledger. The precise axis is the object's lifecycle class, not who wrote it.

Verification

Fresh showcase boot in this branch:

  • 0 invalid_transition errors (the red noise that started this investigation).
  • Over ~165s the digest still fires: sys_job_run / sys_notification / sys_notification_delivery / sys_notification_receipt / sys_inbox_message each +2 (2 ticks @ 60s).
  • sys_audit_log and sys_activity grew by 0 (were +120 each in a comparable pre-fix window).
  • PRAGMA auto_vacuum reads back as 2 (INCREMENTAL).
  • plugin-audit tests 18/18 pass; full turbo build69/69.

Combined effect: ~21 rows/tick @ 20s → 5 rows/tick @ 60s (≈93% lower row-growth rate), and space is now reclaimable.

🤖 Generated with Claude Code

…stopgap
A 20s scheduled digest flow in app-showcase grew dev.db to 260MB+ over a
multi-day `pnpm dev` — all platform-generated telemetry, zero business data.
Root causes: (1) no retention contract on platform objects, (2) audit+activity
dual-write amplifying every internal mutation (~76% of row growth), (3) SQLite
auto_vacuum=NONE so the file never shrinks.
ADR-0057 makes data lifecycle a declarable, runtime-enforced metadata concern
(classify → declare → enforce via LifecycleService → reclaim → gate), building
on ADR-0052's bounded contexts. This commit ships the P0 stopgap:
- plugin-audit: exclude operational/plumbing objects (job_run, notification_*,
inbox_message, http_delivery, ai_traces, automation_run, device_code, job)
from the audit+activity writer. Kills the dominant amplifier.
- driver-sql: default SQLite auto_vacuum=INCREMENTAL so freed pages can be
reclaimed (Reaper hook lands in P1).
- app-showcase: digest interval 20s→60s, flagged demo-only with cost rationale.
Verified on a fresh showcase boot: 0 invalid_transition errors; over ~165s the
digest still fires (job_run/notification/inbox +2 each) but sys_audit_log and
sys_activity no longer grow at all (were +120 each in a comparable window);
auto_vacuum reads back as INCREMENTAL. plugin-audit tests 18/18 pass; full
build 69/69.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJun 20, 2026 2:34pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation size/m labels Jun 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/plugin-audit.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/core/plugins.mdx(via @objectstack/driver-sql)
  • content/docs/concepts/implementation-status.mdx(via @objectstack/driver-sql, @objectstack/plugin-audit)
  • content/docs/concepts/packages.mdx(via @objectstack/plugin-audit)
  • content/docs/concepts/terminology.mdx(via @objectstack/driver-sql)
  • content/docs/getting-started/cli.mdx(via @objectstack/plugin-audit)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql)
  • content/docs/guides/driver-configuration.mdx(via @objectstack/driver-sql)
  • content/docs/guides/packages.mdx(via @objectstack/driver-sql, @objectstack/plugin-audit)
  • content/docs/guides/production-readiness.mdx(via @objectstack/plugin-audit)
  • content/docs/protocol/objectos/index.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/objectos/lifecycle.mdx(via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 61ef9ad into mainJun 21, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the fix/data-lifecycle-p0-and-adr branch June 21, 2026 08:26
os-zhuang added a commit that referenced this pull request Jul 11, 2026
…Archiver, datasource separation, governance (#2786) (#2791)
* docs(adr): ADR-0057 header/rollout sync — P0 shipped (#2079), P1–P4 tracked in #2786
The ADR header still said 'Proposed' with no pointer to the shipped P0
implementation or the new P1–P4 tracking issue. Per repo convention
(cf. ADR-0030/0055/0069 headers), reflect implementation reality:
- Status: Accepted — P0 shipped with this ADR (#2079); P1–P4 proposed,
tracked in #2786
- §4 Rollout: link #2079 on the P0 row; add the #2786 tracking pointer
Doc-only change; no code or policy content altered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
* feat(lifecycle): ADR-0057 P1 — lifecycle contract, LifecycleService Reaper, liveness gate, dogfood growth gate (#2786)
The platform can declare an object's structure, validations and permissions —
but not how long its data lives. P1 makes data lifecycle a first-class,
declarable, runtime-enforced metadata concern:
spec:
- `lifecycle` object property (LifecycleSchema): class (record | audit |
telemetry | transient | event) + retention.maxAge / ttl / storage(rotation)
/ archive / reclaim, with duration literals ('6h', '14d', '7y')
- §3.5 enforce-or-remove invariants in a superRefine: a non-record class with
no bounding policy is rejected; record-class policies are rejected; the
archive window must start where the hot window ends
- IDataDriver.reclaimSpace?() contract for post-sweep space reclamation
objectql:
- LifecycleService (Reaper), registered by ObjectQLPlugin as a platform
service (default-on, OS_LIFECYCLE_DISABLED=1 / lifecycle.enabled=false to
opt out): hourly sweep deletes rows past retention.maxAge (created_at) and
past ttl.field + expireAfter, bounds rotation-declared telemetry by
shards×unit until the P2 Rotator lands, and never hot-deletes an
archive-declared audit ledger before the Archiver copies it out
- Sweeps run bulk multi-deletes under a system context — at most ONE
afterDelete hook per object per sweep, and telemetry sys_* objects are in
the audit writer's SKIP_OBJECTS, so cleanup never re-feeds the tables it
drains (the ADR-0052 self-audit trap)
driver-sql:
- reclaimSpace(): SQLite `PRAGMA incremental_vacuum` (the P0 ADR text
claimed this hook shipped; it had not — this closes it), no-op on
Postgres/MySQL
platform objects (11 annotated): sys_activity 14d+rotation,
sys_audit_log 90d→archive 7y, sys_metadata_audit 365d→archive,
sys_job_run/sys_automation_run/sys_http_delivery 30d, notification pipeline
(sys_notification / delivery / receipt / inbox) one 90d window,
sys_device_code expires_at+1d.
gates:
- spec-liveness: object/lifecycle classified + bound to a dogfood proof
(HIGH_RISK_CLASSES 'data-lifecycle')
- dogfood storage-growth gate (@proof: adr0057-lifecycle-bounded-growth):
boots showcase, proves contract coverage, reaps a backdated runaway
writer, asserts record-class data untouched and archive-declared ledgers
retained
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
* feat(lifecycle): ADR-0057 P2 — Rotator: physical time-sharding with O(1) shard DROP (#2786)
High-frequency telemetry declared with lifecycle.storage.strategy='rotation'
is now physically time-sharded on SQLite (the ServiceNow Table Rotation
model, ADR-0057 §3.3):
driver-sql:
- rotateShards(objectDef, nowMs): idempotent Rotator entry — ensures the
current shard (<table>__r<key>: day/week/month UTC keys), adopts a legacy
pre-rotation table as its first shard (no data loss), column-syncs every
retained shard so the UNION stays uniform, DROPs shards past the
shards×unit time window (O(1) reclaim), and rebuilds the read view
- The base name becomes a READ-ONLY UNION ALL view (SQLite views reject
writes and RETURNING), so every write path redirects shard-wise instead:
create/bulkCreate/upsert target the current shard; by-id update/delete
probe shards newest-first; updateMany/deleteMany/bulkDelete fan out and
sum — reads (find/count/aggregate) are untouched
- Per-shard bookkeeping aliases (datetime/date/json/boolean coercion, tenant
scope, timestamp stamping) so a shard builder behaves exactly like the view
- initObjects routes rotation-declared objects to the Rotator (the plain
create/alter DDL path would collide with the view)
- supportsRotation gates on the dialect; declared indexes get per-shard names
objectql LifecycleService:
- Rotator invocation each sweep when the driver supports it; the fallback
age-based reap covers every other dialect so the declared bound holds
everywhere, only the reclamation mechanics differ
- An explicit retention.maxAge still trims inside live shards after rotation
(shard drops are unit-granular; this also immediately bounds a legacy
table the Rotator just adopted whole)
- Dropped shards trigger the datasource incremental-vacuum pass
Transient TTL expiry (the other P2 line item) shipped with the P1 Reaper —
TTL and age reaping are one mechanism (§3.3).
Proof: sql-driver-rotation.test.ts drives the full shard lifecycle (create →
rotate → window drop → adoption → cross-shard writes); the dogfood
storage-growth gate now shards a probe stream through the real
engine→driver path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
* feat(lifecycle): ADR-0057 P3 — datasource separation + Archiver cold store (#2786)
Separation (§3.6): objects whose lifecycle.class is telemetry / event /
audit now route to a dedicated 'telemetry' datasource whenever one is
registered — engine getDriver() step 3, between datasourceMapping rules and
the package-manifest defaultDatasource. Opt-in purely by the datasource's
existence: no 'telemetry' driver ⇒ resolution is byte-for-byte what it was.
Even on SQLite that is a separate file, so platform-generated growth can
never again pollute the business DB. 'transient' deliberately stays on the
primary: those objects are user-session data and some (better-auth's
sys_device_code) are accessed outside the engine — splitting their storage
would split their brain.
Archiver (§3.3): audit-class objects with 'archive' declared now get the
full retain → archive → delete flow instead of the P1 retain-only skip:
- copies rows past archive.after to the archive datasource in bounded
batches (500 × 20/sweep — a backlog drains across sweeps), as per-row
idempotent upserts so an interrupted sweep re-converges
- hot-deletes exactly the copied ids (bulkDelete), then the reclaim pass
vacuums the hot store
- archive.keep prunes the cold store itself
- the cold schema is mirrored via idempotent syncSchema
- no archive datasource registered ⇒ rows retained, object reported
'archive-pending' — a compliance ledger is never dropped unarchived
Proof: engine-lifecycle-datasource.test.ts (routing incl. explicit-datasource
precedence), Archiver unit tests (copy/delete id-exactness, keep-pruning,
missing-datasource retention), and a dogfood case that provisions a real
second SQL store mid-run and watches the ledger drain hot → cold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
* feat(lifecycle): ADR-0057 P4 — governance: quotas, growth alerts, tenant retention overrides (#2786)
Adds the operations layer over the P1-P3 enforcement machinery, resolved
from a new 'lifecycle' settings namespace (registered by ObjectQLPlugin at
kernel:ready when a SettingsService is present; absent settings = declared
policies apply unmodified):
- enabled — runtime master switch (on top of OS_LIFECYCLE_DISABLED)
- retention_overrides — per-object window overrides { object: { maxAge,
expireAfter } }. TENANT-scoped: the sweep enumerates organizations, and a
tenant whose override is genuinely tenant-scoped (not inherited) gets its
own cutoff on its own rows, while the global pass covers everyone else
INCLUDING NULL-org rows (a bare $nin would silently skip them). This is
ADR-0057 §3.2 verbatim: regulated tenants set years, dev sets days — the
same knob at different settings. An unparseable override keeps the
declared window (never fails open into unbounded growth).
- quotas / quota_defaults — per-object and per-class row ceilings
- growth_alert_rows — per-sweep growth spike threshold
Quota and growth breaches are OBSERVE-AND-ALERT only (report.alerts +
onAlert sink, default logger warning) — governance never deletes beyond the
declared policy; an operator decides.
Every settings read is best-effort and snapshotted once per sweep; the
tenant scan is capped (200) and skipped entirely on single-tenant kernels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
* docs(adr)+chore: ADR-0057 marked P0–P4 implemented; changeset for the lifecycle release (#2786)
- ADR-0057 header + §4 rollout table now record P1–P4 as implemented in
#2791; §3.3 gains the archive-safety hard rule and §3.6 the as-implemented
'telemetry' datasource routing note (transient exclusion rationale).
- Changeset: minor bumps across spec / objectql / driver-sql /
driver-sqlite-wasm / platform-objects / metadata-core / service-messaging /
service-automation / plugin-audit, documenting the bounded-by-default
behavior change and the override knobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
* chore(spec): regenerate api-surface snapshot for the ADR-0057 lifecycle exports
The 5 additions (LifecycleSchema / LifecycleClassSchema / Lifecycle /
LifecycleClass / LIFECYCLE_DURATION_REGEX) are the intentional P1 spec
surface; the api-surface gate flagged them as uncommitted snapshot drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNBzMWmSECrbiEDdVzwBt3
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang