Skip to content

Commit 29c59f4

Browse files
committed
fix(plugin-audit): leave the lifecycle sweeper's watermark out of the audit ledger
Every completed sweep wrote its sys_lifecycle_state row through the generic audit writers, so each sweep fed sys_audit_log and sys_activity a row the next sweep archives. The archive integration tests of os migrate audit-key-material / audit-mail-bodies counted it as a second archived row. Claude-Session: https://claude.ai/code/session_016WV4cxAY2io7s5aVJa6YL4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9f4c1b commit 29c59f4

3 files changed

Lines changed: 33 additions & 1 deletion

File tree

‎.changeset/29918-lifecycle-sweep-watermark.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
'@objectstack/objectql': minor
44
'@objectstack/platform-objects': minor
55
'@objectstack/cli': minor
6+
'@objectstack/plugin-audit': minor
67
---
78

89
Clause-②: yes (widening) — `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (and so `PLATFORM_PROVIDED_OBJECT_NAMES` / `isPlatformProvidedObjectName`) lists one more real platform object, `sys_lifecycle_state`. Its readers resolve references against the roster and none refuses an author declaration, so a stack that references the object now resolves it instead of drawing the unknown-platform-name advisory; nothing that was accepted is refused.
@@ -13,4 +14,4 @@ The lifecycle sweep remembers when it last completed, so a kernel that reboots o
1314

1415
`LifecycleService` now keeps a watermark in a new engine-owned, tenant-less object, `sys_lifecycle_state` (declared and registered by `@objectstack/platform-objects`, one row per datastore, id `lifecycle`). At the initial delay the scheduler reads that one row: when the last completed sweep is younger than the sweep interval, the first sweep waits for the interval and the read is the only statement issued. An older or missing watermark, a kernel without the object, or a read that fails (logged at `warn`) sweeps at the initial delay as before. Every sweep that completes with no policy error and a finished reference audit advances the row; a failed or partial sweep leaves it alone. A datastore the process created from empty still defers its first sweep one interval, and now seeds the row. A direct `sweep()` call still always sweeps.
1516

16-
The row is internal state, not a setting: no settings key, no environment variable, no audit row per sweep and no data API (`apiEnabled: false`). `OS_LIFECYCLE_DISABLED=1` still arms nothing. `os migrate organization-ownership` inventories the new table as deployment-level state with no organization column.
17+
The row is internal state, not a setting: no settings key, no environment variable, no audit row per sweep and no data API (`apiEnabled: false`). `@objectstack/plugin-audit` leaves `sys_lifecycle_state` out of `sys_audit_log` and `sys_activity`, so a sweep writes no ledger row for its own clock. `OS_LIFECYCLE_DISABLED=1` still arms nothing. `os migrate organization-ownership` inventories the new table as deployment-level state with no organization column.

‎packages/plugins/plugin-audit/src/audit-writers.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,29 @@ describe('audit writers — operational plumbing is excluded (#5193, ADR-0057 D5
323323
}
324324
});
325325

326+
it('writes NO audit/activity row for the lifecycle sweeper\'s watermark, first insert or hourly update (ADR-0057 §3.3)', async () => {
327+
// `LifecycleService` inserts the `sys_lifecycle_state` row after its first
328+
// completed sweep and updates it after every one since. Audited, each
329+
// sweep fed the ledger a row the next sweep archives: cleanup re-feeding
330+
// the table it drains.
331+
const { engine, fire, created } = makeEngine({ ...SCHEMA, sys_lifecycle_state: ['id', 'last_swept_at'] });
332+
installAuditWriters(engine as any, 'test.audit');
333+
await fire('afterInsert', {
334+
object: 'sys_lifecycle_state',
335+
input: { id: 'lifecycle', last_swept_at: '2026-11-06T00:00:00.000Z' },
336+
result: { id: 'lifecycle', last_swept_at: '2026-11-06T00:00:00.000Z' },
337+
session: { isSystem: true },
338+
});
339+
await fire('afterUpdate', {
340+
object: 'sys_lifecycle_state',
341+
input: { id: 'lifecycle', last_swept_at: '2026-11-06T01:00:00.000Z' },
342+
previous: { id: 'lifecycle', last_swept_at: '2026-11-06T00:00:00.000Z' },
343+
result: { id: 'lifecycle', last_swept_at: '2026-11-06T01:00:00.000Z' },
344+
session: { isSystem: true },
345+
});
346+
expect(created).toEqual([]);
347+
});
348+
326349
it('pays no before-phase snapshot read — for the skipped object OR the business one (#6656)', async () => {
327350
// [#6656] This case used to assert the saving for `sys_job_queue` only,
328351
// with `crm_lead` as the CONTROL that still paid — because #5860 could

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,14 @@ const SKIP_OBJECTS = new Set<string>([
298298
// and keep their compliance value, so they stay audited (see #5202).
299299
'sys_upload_session', // chunked-upload progress (1 write per chunk)
300300
'ai_traces', // LLM trace telemetry
301+
// [ADR-0057 §3.3] The lifecycle sweeper's own clock state (one row per
302+
// datastore, `managedBy: 'engine-owned'`), written ONLY by objectql's
303+
// `LifecycleService` under SYSTEM_CTX after every completed sweep. Audited,
304+
// each sweep would feed `sys_audit_log` and `sys_activity` the very rows the
305+
// next sweep archives or reaps: cleanup re-feeding the tables it drains.
306+
// Not an act anyone performed; the ruling that created the object forbids
307+
// an audit row per sweep.
308+
'sys_lifecycle_state', // sweep watermark (1 write per sweep)
301309
]);
302310

303311
/**

0 commit comments

Comments
 (0)