Skip to content

Commit 6729e10

Browse files
feat(plugin-audit,plugin-security)!: sys_audit_log loses its injected organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) (#22266)
Part of #15207 Clause-②: no (narrowing) The claim (`6055795594`) declared the value `yes` with the narrowing arm and asked the dev to measure the built declaration closure. Measured: `check:api-surface` on the rebuilt `@objectstack/spec` reports the public surface unchanged, and no package in this diff adds an export, a published key or an accepted value; the diff only narrows (the ledger refuses `organization_id`, and `organization_admin`'s ledger grant loses its superuser bits). So the value is `no`, the arm stays `(narrowing)`, and the changeset carries the same line. It is the reading item (1) declared for the same shape. Item (4) of #15207 is not built here, so the card stays open. ## Scope: item (2) only ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so "the organization an audit row is *about* becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor", and the object "is governed by object permission, not by the wall". The seat's ruling is option A of report `6042515710`. Item (1) landed as #22107 and item (3) as #22166. No stored row moves here (ADR-0131 D14): the column's fate is C7's (#15211), below. **Patch round** (seat ruling `6058257824`, which widens the claim's file surface): - the changeset now states the `single`-posture consequence; - a global settings change's `config_change` row carries no `tenant_id`; - the inert `organization_id` stamps are removed in `platform-admin-standing-audit.ts` and `config-change-audit.ts`, and the predating comments are corrected, including `managed-object-write-denies.ts`' docblock. The open question on a `single` deployment holding more than one organization is ruled A (ADR-0131 D8 and §1.2(3)). ## What changes - **`sys_audit_log`** (`plugin-audit/src/objects/sys-audit-log.object.ts`) declares `systemFields: { tenant: false }`. The registry injects no `organization_id`, and a new table is provisioned without it. `tenant_id` (lookup to `sys_organization`) is unchanged and is now the only organization column; its help text says what it is. The four translation bundles and the README follow. - **The plugin-audit writers** (`audit-writers.ts`, `read-audit.ts`, `auth-event-audit.ts`) keep stamping `tenant_id` as before and drop their conditional `organization_id` stamp, which the registered schema can no longer satisfy. No fallback read or write of the retired column remains (D14). - **The settings writer** (`service-settings/src/config-change-audit.ts`): a GLOBAL-scope change is a deployment-level action about no organization, so its `config_change` row carries no `tenant_id`, whatever organization the writing session has active. Tenant- and user-scope changes keep the writer's organization. Its `organization_id` field probe and stamp are gone. The `SettingsAuditSink.tenantId` TSDoc in `settings-service.types.ts` says the same. - **The platform-admin standing writer** (`plugin-security/src/platform-admin-standing-audit.ts`): the `declaresOrganizationId` input and its stamp are gone, and the call site in `bootstrap-platform-admin.ts` with them. `tenant_id` stays NULL by ruling, and its comment block now reads against the column-less ledger. No behaviour moves: the registered ledger declares no such column after this PR. - **`managed-object-write-denies.ts`'s docblock** no longer cites the ledger as reached by the wildcard's superuser bits. `organization_admin` names it explicitly, without them. - **The read scope, option A** (`plugin-security/src/objects/default-permission-sets.ts`): - a platform row policy `sys_audit_log_org`: `tenant_id == current_user.organization_id`, operation `select`; - spread into `organization_admin` (and so its derived no-bypass variant), `viewer_readonly` and `member_default`. A set that holds no policy for an object leaves it unfiltered, so the policy goes where the shipped reads are. `viewer_readonly`'s wildcard reads the ledger. `member_default` is the baseline every authenticated human holds, so a ledger read an application set grants is scoped too. This is the placement `scimProjectionRowScope` already uses; - an explicit `sys_audit_log` entry in `organization_admin`: read only, with no `viewAllRecords` / `modifyAllRecords`; - stripped under `single` by the existing provenance rule (ADR-0105 D3), with no new code. - **Retention** (`objectql/src/lifecycle/lifecycle-service.ts`). `tenantWindowsFor` now returns the partition column with the windows. It is `organization_id` where the object has a provisioned one (unchanged). Otherwise it is the ledger's attribution field, from a one-row, name-keyed table `ATTRIBUTION_PARTITION_COLUMNS` (`sys_audit_log` → `tenant_id`), honoured only where the author really declares the field. The reaper and the archiver name only that column. - **`view_all_audit_log`** (`spec/src/security/capabilities.ts`): the description and its comment are re-premised on the attribution field and the row scope. `eval-user.zod.ts` lists the name only and restates nothing. - **ADR-0087**: the D3 entry `18.sys-audit-log-organization-column-retired.ts`, one step-18 rationale fragment, and the regenerated `registry.ts`. - **Censuses**: `scripts/platform-object-tenancy-census.json` regenerated by its own tool (in reach 50 → 49, out 34 → 35, `systemFields.tenant: false` 9 → 10). The tenant-audit and system-context censuses are green and did not move. ## Premise readings (each measured before code) - **P1, holds, with one refinement.** The ledger holds rows about deployment-level actions with no organization: - `platform_admin_standing_change`: `plugin-security` `bootstrap-platform-admin.ts` through `buildPlatformAdminStandingRow`, `tenant_id` always NULL, by ruling; - `import`: `plugin-auth` `admin-import-users.ts`, the run-level row of a platform-admin endpoint, no `tenant_id`; - administrative `create` / `update` on `sys_user`: `plugin-auth` `admin-user-endpoints.ts`, platform-admin endpoints, no `tenant_id`; - `config_change`: `service-settings` `config-change-audit.ts`. Refinement: it stamped the writing context's organization, so a global-scope key written by a session with an active organization carried that organization. Ruled into this PR (`6058257824`): a global-scope change now carries no `tenant_id`. - **P2, holds, measured** through the real permission compiler (a real `SecurityPlugin` over a real `ObjectQL` and SQL driver, with the shipped sets). With the explicit entry removed and the policy kept, an organization admin under `isolated` reads every organization's rows. Mechanism: `systemFields.tenant === false` makes `meta.tenancyDisabled` true, so `posturePermits` holds in `computeLayeredRlsFilter` and the wildcard's superuser bypass skips Layer 1. - **P3, holds.** `security-plugin.ts#collectRLSPolicies` drops a policy when `!this.orgScopingEnabled && isPlatformTenantPolicy(policy)`. `orgScopingEnabled` is `postureEnforcesWall(this.tenancyPosture)`. The provenance set is `PLATFORM_TENANT_POLICY_KEYS` in `platform-tenant-policies.ts`, built from the shipped sets' policies whose `using` names `current_user.organization_id`. The new policy is in that set (pinned). - **P4.** Writers that stamp `tenant_id`: - the record mirror (`audit-writers.ts`): the record's organization, else the session's; - the record-view writer (`read-audit.ts`): the record's organization, else the session's; - the sign-in writer (`auth-event-audit.ts`): the session's organization; - the settings writer (`config-change-audit.ts`): the writing context's organization, and none for a global-scope change since this round. Each stamped the injected column with the same value. Explicit NULL: the platform-admin standing writer. Not stamped: the two `plugin-auth` administrative writers. Writers that update existing rows: `stored-metadata-body-migration.ts` and the CLI's `audit-metadata-bodies` rewrite `old_value` / `new_value` only. A row with no `tenant_id` matches no organization under the new policy. Under a wall it is served to platform administrators only; under `single` it is served to every ledger reader. ## Who reads what (measured; rows `a1` about org A, `b1` about org B, `d1` about no organization) | posture | caller | before | after | |---|---|---|---| | `isolated` | organization admin of A | `a1` | `a1` | | `isolated` | viewer of A | not measured | `a1` | | `isolated` | platform admin (active org A) | `a1` | `a1 b1 d1` | | `isolated` | platform admin, no active organization | not measured | `a1 b1 d1` | | `isolated` | organization admin, no active organization | not measured | none | | `single` (one organization) | organization admin, both variants | `a1 d1` | `a1 d1` | | `group` (member of A and B, active A) | organization admin | `a1 b1` | `a1` | | `group` | platform admin | `a1 b1` | `a1 b1 d1` | | `single` holding two organizations | organization admin, viewer, platform admin | `a1 d1` | `a1 b1 d1` | | `isolated` | organization admin of A, reading a global settings change (`g1`, no `tenant_id`) and a tenant-scope change about A (`t1`) | not measured | `t1` | | `isolated` | platform admin, the same two rows | not measured | `g1 t1` | The `group` and two-organization `single` rows are readings, not pins. Under `group` the policy scopes to the ACTIVE organization, not the membership union; the seat accepted that as the fail-closed direction. A `single` deployment that holds two organizations boots today, reported at boot. Before this change, the SQL driver's native organization arm still narrowed the ledger there; with no column it cannot, and the policy is stripped under `single`. The seat ruled this A (ADR-0131 D8, §1.2(3)), and the changeset states it with its remedy, a walled posture. ## Pins (refused and still-accepted case each) - **Organization scope** (`plugin-security/src/sys-audit-log-row-scope.test.ts`): - an organization admin of A reads `a1` and not `b1` or `d1`; - CONTROL: the same read with the policy removed from every set serves all three; - the explicit entry is load-bearing: without it, the bypass reads all three; - a viewer is scoped the same way; - a global settings change's row (`config_change` on `sys_platform_setting`, no `tenant_id`) is not served to an organization admin, and a tenant-scope change about its organization is. The platform admin reads both. - **The settings writer** (`service-settings/src/config-change-audit.test.ts`): - the `#8145 … WIRES the generic sink` case on a global manifest, written by a session with `org_1` active, asserts `tenant_id` null and no `organization_id`; - CONTROL: a tenant-scope write keeps `org_1`. - **Platform scope**: a platform admin reads all three, the deployment-level row included. CONTROL: before the change, the wall hid `d1` (and `b1`) from the platform admin. - **`single`**: the policy is a platform tenant policy by provenance, and both organization-admin variants read exactly what the pre-change read served. - **The write** (`plugin-audit/src/objects/sys-audit-log-attribution.test.ts`, a real kernel with SQLite): - a deployment-level row with no `tenant_id` is written with no refusal; - a write still naming `organization_id` is refused `INVALID_FIELD` / 400; - a filter on it is refused `INVALID_FILTER` / 400; - the record mirror stamps the record's organization into `tenant_id` and writes no `organization_id`. - **Retention** (`objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts`, a real `ObjectQL` registry): - the reaper and the archiver partition a tenant's override on `tenant_id`, and the global pass keeps the NULL rows; - no `INVALID_FILTER`, so a tenant's override no longer stops the table's reap; - CONTROLS: the ledger without the field and another column-less object carrying a `tenant_id` both run one global pass; the ledger with the injected column partitions on `organization_id`. - **DDL**: the injection plan carries no `organization_id`. The provisioned SQLite table (introspected after a real schema sync) has `tenant_id` and no `organization_id`. CONTROL: an ordinary object on the same sync has the column. ## Reverse verification (one-off, `scripts/ablation-replace.mjs`, each restore read blob == HEAD with `git diff HEAD` empty) - **A.** Delete the `sys_audit_log_org` policy literal (anchor 1 → 0). Exactly four red: the organization-admin pin, the viewer pin, the provenance pin and the `member_default` roster pin. 28 green, the controls and the platform-admin pins included. - **B.** Delete `organization_admin`'s explicit ledger entry. Exactly the organization-admin pin red; 8 green, the viewer pin included. - **C.** Delete the `ATTRIBUTION_PARTITION_COLUMNS` row. Exactly the reap and archive pins red; 4 green. - **D.** Make the settings writer stamp `tenant_id: entry.tenantId ?? null` for every scope again. Exactly the global `WIRES` case red; 16 green, the tenant-scope control included. The subjects are imported by relative source path, so no `dist` is in the path. ## Fate for C7's inventory (#15211, ADR-0131 D10 fate 1) `sys_audit_log.organization_id`: **drop the column, once its values are confirmed in `tenant_id`; report the rows where they differ.** By the writer census, every writer that stamped the column stamped the same value into `tenant_id` (or NULL into both). The check is NULL-safe: a row differs when exactly one of the two is NULL, or both are set and unequal. A zero count means `os migrate apply --allow-destructive` drops the orphan the boot drift report already names. Any other count is listed with the row ids, never guessed and never dropped. Until then, schema sync is additive and the column stays as an orphan that nothing reads or writes. ## Files outside the claim's file surface - `packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts`. Its arming control read the ledger's `organization_id`. It is re-keyed to `tenant_id`, its prose names the row scope, and one test title says "superuser read bypass" for "wall bypass". With it, all 13 ledger dogfood files pass. - In the patch round, beyond the files `6058257824` names, two changes the named edits require: - `service-settings/src/settings-service.types.ts`: the `SettingsAuditSink.tenantId` TSDoc restated the removed stamp; - `plugin-security/src/bootstrap-platform-admin.ts`: the one caller passing the removed `declaresOrganizationId` input. - Within the claimed packages but not named in the claim: plugin-audit's three writer test files, translation bundles and README; plugin-security's `rbac-objects.test.ts` roster pin; objectql's federated reader census (`federated-injected-column-readers.test.ts`), whose `#reap` / `#archiveObject` rows go because those passes now name only the column `tenantWindowsFor` returns. ## Verification (at `b865914be5`) The patch round touched `service-settings` and `plugin-security` (source and tests), the changeset, and no objectql, spec or plugin-audit source. The objectql and spec runs were taken in round one, at `ef87292b75`, whose files they read are unchanged since. - service-settings: 38 files / 642 passed. plugin-security: 179 files / 3749 passed / 45 skipped. plugin-audit: 41 files / 649 passed. objectql (round one): 382 files / 7531 passed. - spec: `--project local`, 625 files / 18661 passed; `--project repo` `step18-rationale-merge` + `conversions-major18-merge`, 21 passed; `check:generated` 15 of 15 up to date; `check:api-surface` unchanged. - dogfood: the 13 files that touch the ledger, 93 passed, re-run at this head. - Typecheck exit 0 for service-settings and plugin-security at this head; for plugin-audit, objectql, spec and dogfood in round one. Test layers are included. - Gates: `dispatch-gates --commands` (no paths) derives 104 families at this head. All 104 ran with their exit codes recorded, and the `--ran` reconciliation reads a derived zero NOT-MEASURED. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 31 changed `.ts` files gives 31 results, 0 errors, 0 warnings. The population is read from `eslint.config.mjs`'s `packages/**` and `**/*` globs. That config enables no type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is CI's. ## Acceptance notes - Out of reach of the row policy: an application set that grants the superuser read bypass on the ledger (`viewAllRecords` on it or on a wildcard) skips Layer 1, as it does on every object the wall does not cover. No example app ships such a grant. - The organization row scope is the active organization's under `group`, and stripped under `single`. Both are ruled; the changeset states each, with the remedy for a multi-organization `single` deployment. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c52e43 commit 6729e10

34 files changed

Lines changed: 1065 additions & 257 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/service-settings': minor
5+
'@objectstack/spec': minor
6+
'@objectstack/objectql': patch
7+
---
8+
9+
feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7)
10+
11+
Clause-②: no (narrowing)
12+
13+
<!-- adr-0087: registered sys-audit-log-organization-column-retired -->
14+
15+
**BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line.
16+
17+
Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim.
18+
19+
- **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`.
20+
- **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is.
21+
- **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`.
22+
- **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`.
23+
- **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate.
24+
25+
**What moves for consumers.**
26+
27+
- **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote.
28+
- **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover.
29+
- **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture.
30+
- **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ.

‎packages/objectql/src/federated-injected-column-readers.test.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,24 +149,16 @@ const READERS: Record<string, Row> = {
149149
},
150150
'lifecycle/lifecycle-service.ts#tenantWindowsFor :: organization_id': {
151151
disposition: 'skips',
152-
why: 'the column the partition predicates name, asked about before any partition is built',
152+
why:
153+
'the column the partition predicates name, asked about before any partition is built; the reap and ' +
154+
'archive passes name only the column this decision returns (#15207), so they hold no seam of their own',
153155
},
154156
'lifecycle/lifecycle-service.ts#tenantWindowsFor :: resolveInjectedColumnProvenance()': {
155157
disposition: 'skips',
156158
why:
157159
"an object with no organization_id at all (provenance 'absent': no injection, no declaration), " +
158160
'federated or local, has no tenant partition either',
159161
},
160-
'lifecycle/lifecycle-service.ts#reap :: organization_id': {
161-
disposition: 'skips',
162-
via: 'tenantWindowsFor',
163-
why: 'the per-tenant reap passes, built only from the windows the shared decision returns',
164-
},
165-
'lifecycle/lifecycle-service.ts#archiveObject :: organization_id': {
166-
disposition: 'skips',
167-
via: 'tenantWindowsFor',
168-
why: 'the per-tenant archive passes, built only from the windows the shared decision returns',
169-
},
170162

171163
// ── Readers that already asked whether the object is federated ──────────
172164
'engine.ts#buildDriverOptions :: isFederatedObject()': {
@@ -548,8 +540,6 @@ describe('[#21918] every engine reader of an injected column has a disposition t
548540
expect([...skipping].sort()).toEqual([
549541
'engine.ts#cascadeDeleteRelations',
550542
'engine.ts#planCascadeAtomicity',
551-
'lifecycle/lifecycle-service.ts#archiveObject',
552-
'lifecycle/lifecycle-service.ts#reap',
553543
'lifecycle/lifecycle-service.ts#tenantWindowsFor',
554544
]);
555545
});
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15207] Per-tenant retention on the compliance ledger partitions on its
5+
* attribution field.
6+
*
7+
* ADR-0131 D7 takes the injected `organization_id` off `sys_audit_log`: the
8+
* organization a row is ABOUT stays in the plain attribution field
9+
* `tenant_id`, and a row about a deployment-level action leaves it NULL. A
10+
* tenant-scope `lifecycle.retention_overrides` entry naming the ledger must
11+
* still give that tenant its own window, so the per-tenant passes select on
12+
* `tenant_id`, and the global pass keeps everyone else, the NULL rows
13+
* included. A column-less object answered no partition since the plumbing
14+
* tables lost their column; without the attribution partition the ledger's
15+
* tenant override would silently stop applying.
16+
*
17+
* Every case runs on a REAL `ObjectQL` engine and registry, so the object the
18+
* sweep reads is the one the registry registered, after its system-field
19+
* injection. The stub driver provisions each table from that registered
20+
* object's fields and refuses a filter on a column the table lacks, as the SQL
21+
* driver does, so a pass naming the retired column fails here as it would
22+
* there.
23+
*/
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { ObjectQL } from '../engine.js';
27+
import { LifecycleService } from './lifecycle-service.js';
28+
import { parseLifecycleDuration } from './duration.js';
29+
30+
const FIXED_NOW = 1_700_000_000_000;
31+
const PACKAGE_ID = 'lifecycle-attribution-partition';
32+
const ATTRIBUTION = { tenant_id: { type: 'lookup', reference: 'sys_organization' } };
33+
34+
/** The ledger as ADR-0131 D7 declares it: no tenant column, the attribution field declared. */
35+
const LEDGER = {
36+
name: 'sys_audit_log',
37+
systemFields: { tenant: false },
38+
fields: { action: { type: 'text' }, ...ATTRIBUTION },
39+
lifecycle: { class: 'audit', retention: { maxAge: '90d' } },
40+
};
41+
/** The same ledger with the archiver declared, the shape it ships with. */
42+
const LEDGER_ARCHIVED = {
43+
...LEDGER,
44+
lifecycle: { class: 'audit', retention: { maxAge: '90d' }, archive: { after: '90d', to: 'archive', keep: '7y' } },
45+
};
46+
/** CONTROL: the ledger without the attribution field — nothing to partition on. */
47+
const LEDGER_WITHOUT_FIELD = { ...LEDGER, fields: { action: { type: 'text' } } };
48+
/** CONTROL: a column-less object carrying a field of the same name — the name alone decides nothing. */
49+
const OTHER_WITH_FIELD = {
50+
name: 'sys_job_run',
51+
systemFields: { tenant: false },
52+
fields: { status: { type: 'text' }, ...ATTRIBUTION },
53+
lifecycle: { class: 'telemetry', retention: { maxAge: '90d' } },
54+
};
55+
56+
const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } };
57+
58+
const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString();
59+
60+
/** One organization keeps its rows longer than the declared window. */
61+
function fakeSettings() {
62+
const tenantValues: Record<string, Record<string, unknown>> = {
63+
org_reg: { retention_overrides: { sys_audit_log: { maxAge: '365d' }, sys_job_run: { maxAge: '365d' } } },
64+
};
65+
return {
66+
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
67+
const tenantId = ctx?.tenantId as string | undefined;
68+
if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) {
69+
return { value: tenantValues[tenantId][key], source: 'tenant' };
70+
}
71+
return { value: undefined, source: 'default' };
72+
},
73+
};
74+
}
75+
76+
/** Every column a filter names: the non-operator keys, at any depth of `$or` / `$and`. */
77+
function filteredColumns(where: unknown): string[] {
78+
if (Array.isArray(where)) return where.flatMap(filteredColumns);
79+
if (!where || typeof where !== 'object') return [];
80+
return Object.entries(where as Record<string, unknown>).flatMap(([key, value]) =>
81+
key.startsWith('$') ? filteredColumns(value) : [key],
82+
);
83+
}
84+
85+
async function lifecycleEngine(object: Record<string, unknown>) {
86+
const engine = new ObjectQL();
87+
const name = object.name as string;
88+
/** The table's columns: the REGISTERED object's fields, as schema sync provisions them, plus the key. */
89+
const columnsOf = (table: string): Set<string> => {
90+
const registered = engine.registry.getObject(table) as { fields?: Record<string, unknown> } | undefined;
91+
return new Set(['id', ...Object.keys(registered?.fields ?? {})]);
92+
};
93+
/** The `where` of every read the driver served for the swept table — the archiver reads the hot store directly. */
94+
const driverReads: unknown[] = [];
95+
const driver = {
96+
name: 'memory',
97+
version: '0.0.0',
98+
supports: {},
99+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
100+
async execute() { return null; },
101+
async find(table: string, ast: { where?: unknown } | undefined) {
102+
if (table === 'sys_organization') return [{ id: 'org_reg' }];
103+
const missing = filteredColumns(ast?.where).find((column) => !columnsOf(table).has(column));
104+
if (missing !== undefined) {
105+
throw Object.assign(
106+
new Error(`A filter on object '${table}' names a column the database could not resolve (${missing}).`),
107+
{ code: 'INVALID_FILTER', status: 400 },
108+
);
109+
}
110+
if (table === name) driverReads.push(ast?.where);
111+
return [];
112+
},
113+
async findOne() { return null; },
114+
async count() { return 0; },
115+
async create(_t: string, data: Record<string, unknown>) { return { id: 'r_1', ...data }; },
116+
async update(_t: string, id: string, data: Record<string, unknown>) { return { id, ...data }; },
117+
async delete() { return true; },
118+
async bulkCreate(_t: string, rows: unknown[]) { return rows; },
119+
async bulkUpdate() { return []; },
120+
async bulkDelete() {},
121+
async upsert(_t: string, row: Record<string, unknown>) { return row; },
122+
async syncSchema() {},
123+
};
124+
const archive = {
125+
...driver,
126+
name: 'archive',
127+
async find() { return []; },
128+
async deleteMany() { return 0; },
129+
};
130+
131+
engine.registerDriver(driver as unknown as Parameters<ObjectQL['registerDriver']>[0], true);
132+
engine.registerDriver(archive as unknown as Parameters<ObjectQL['registerDriver']>[0], false);
133+
await engine.init();
134+
engine.registry.registerObject(object as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);
135+
engine.registry.registerObject(ORG_OBJECT as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);
136+
const find = vi.spyOn(engine, 'find');
137+
return {
138+
engine,
139+
driverReads,
140+
/** The `where` of every candidate read the reaper issued through the engine, as it issued them. */
141+
reapReads: () =>
142+
find.mock.calls.filter((call) => call[0] === name).map((call) => (call[1] as { where?: unknown } | undefined)?.where),
143+
};
144+
}
145+
146+
function sweepOnce(engine: ObjectQL) {
147+
return new LifecycleService({
148+
getEngine: () => engine,
149+
logger: { info: () => {}, warn: () => {}, debug: () => {} },
150+
now: () => FIXED_NOW,
151+
initialDelayMs: 1,
152+
sweepIntervalMs: 10,
153+
getSettings: () => fakeSettings(),
154+
referenceAudit: { enabled: false },
155+
}).sweep();
156+
}
157+
158+
/** The two passes a partitioned sweep issues: the tenant's own window, then everyone else's. */
159+
const partitioned = (column: string) => [
160+
{ created_at: { $lt: isoCutoff('365d') }, [column]: 'org_reg' },
161+
{ created_at: { $lt: isoCutoff('90d') }, $or: [{ [column]: { $nin: ['org_reg'] } }, { [column]: null }] },
162+
];
163+
const onePass = [{ created_at: { $lt: isoCutoff('90d') } }];
164+
165+
describe('LifecycleService.sweep — the ledger partitions per-tenant retention on its attribution field (#15207)', () => {
166+
it('premise: the registered ledger has tenant_id and no organization_id', async () => {
167+
const { engine } = await lifecycleEngine(LEDGER);
168+
const fields = Object.keys((engine.registry.getObject('sys_audit_log') as { fields?: object })?.fields ?? {});
169+
expect(fields).toContain('tenant_id');
170+
expect(fields).not.toContain('organization_id');
171+
});
172+
173+
it('reap: the tenant gets its own window on the rows about it, and the global pass keeps the NULL rows', async () => {
174+
const box = await lifecycleEngine(LEDGER);
175+
const report = await sweepOnce(box.engine);
176+
expect(report.errors).toEqual([]);
177+
expect(box.reapReads()).toEqual(partitioned('tenant_id'));
178+
});
179+
180+
it('archive: the archiver selects the same two partitions from the hot store', async () => {
181+
const box = await lifecycleEngine(LEDGER_ARCHIVED);
182+
const report = await sweepOnce(box.engine);
183+
expect(report.errors).toEqual([]);
184+
expect(box.driverReads).toEqual(partitioned('tenant_id'));
185+
expect(report.swept.find((e) => e.object === 'sys_audit_log')?.policy).toBe('archive');
186+
});
187+
188+
it('CONTROL: the ledger without the attribution field runs one global pass, never a phantom partition', async () => {
189+
const box = await lifecycleEngine(LEDGER_WITHOUT_FIELD);
190+
const report = await sweepOnce(box.engine);
191+
expect(report.errors).toEqual([]);
192+
expect(box.reapReads()).toEqual(onePass);
193+
});
194+
195+
it('CONTROL: another column-less object with a field of the same name runs one global pass', async () => {
196+
const box = await lifecycleEngine(OTHER_WITH_FIELD);
197+
const report = await sweepOnce(box.engine);
198+
expect(report.errors).toEqual([]);
199+
expect(box.reapReads()).toEqual(onePass);
200+
});
201+
202+
it('CONTROL: the ledger WITH the injected column partitions on it, as every walled object does', async () => {
203+
const { systemFields: _optOut, ...withColumn } = LEDGER;
204+
const box = await lifecycleEngine(withColumn);
205+
const report = await sweepOnce(box.engine);
206+
expect(report.errors).toEqual([]);
207+
expect(box.reapReads()).toEqual(partitioned('organization_id'));
208+
});
209+
});

0 commit comments

Comments
 (0)