From 39c62c92de25589ba4e0e13280f6d4835cb4d98b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:31:28 +0000 Subject: [PATCH] refactor(plugin-audit)!: retire export/permission_change from the sys_audit_log action enum (#8147) Retires the two action values with no writer anywhere in the repo, per the maintainer ruling of 2026-08-12 on #7675. Narrows the auth_events and config_changes list-view filters, regenerates the translation bundles, and registers the retirement under ADR-0087 as `audit-log-action-enum-retired`. `import` is deliberately NOT retired: plugin-auth's admin user-import writes a real run-level row with that action, pinned by dogfood case W4. Escalated on the issue for a maintainer ruling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .changeset/audit-log-action-enum-retired.md | 71 ++++++++++ docs/protocol-upgrade-guide.md | 3 + .../sys-audit-log-retired-actions.test.ts | 128 ++++++++++++++++++ .../src/objects/sys-audit-log.object.ts | 30 +++- .../src/translations/en.objects.generated.ts | 2 - .../translations/es-ES.objects.generated.ts | 2 - .../translations/ja-JP.objects.generated.ts | 2 - .../translations/zh-CN.objects.generated.ts | 2 - .../__tests__/raw-sql-object-routing.test.ts | 8 +- packages/spec/spec-changes.json | 14 ++ .../17.audit-log-action-enum-retired.ts | 76 +++++++++++ packages/spec/src/migrations/registry.ts | 72 ++++++++++ 12 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 .changeset/audit-log-action-enum-retired.md create mode 100644 packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts diff --git a/.changeset/audit-log-action-enum-retired.md b/.changeset/audit-log-action-enum-retired.md new file mode 100644 index 0000000000..36d8dd8964 --- /dev/null +++ b/.changeset/audit-log-action-enum-retired.md @@ -0,0 +1,71 @@ +--- +"@objectstack/plugin-audit": minor +"@objectstack/spec": minor +"@objectstack/service-analytics": patch +--- + +refactor(plugin-audit)!: retire `export` and `permission_change` from the `sys_audit_log` action enum — two declared actions nothing has ever written (#8147, #7675, ADR-0049/ADR-0087) + + + +**BREAKING** (shipped as `minor` under the launch-window lockstep convention). + +`sys_audit_log.action` declared ten actions. Two of them named events this +platform does not record, and has never recorded. Enumerating every +`sys_audit_log` writer in the repo finds exactly two: + +- `plugin-audit/src/audit-writers.ts` — the generic hook writer, whose + `actionFor()` maps `afterInsert`/`afterUpdate`/`afterDelete` to + `create`/`update`/`delete` and **nothing else**; +- `plugin-auth/src/admin-import-users.ts` — the admin user-import run-level row. + +Neither has ever emitted `export` or `permission_change`. The cost was not a +dormant string: `sys_audit_log` ships **list views** filtered on those values and +the platform dashboard ships **metric widgets** counting them, so an operator got +a permanently empty "Permission Changes" tile and an Auth view whose filter could +never match, while an auditor reading the enum believed the platform captured +permission changes and data exports. That is false compliance on a compliance +surface — the sharpest form of ADR-0049 declared-≠-enforced. + +Maintainer ruling 2026-08-12 (#7675) split the finding in two: build the cheap +writers (`login`/`logout` in #8144, `config_change` in #8145) and retire the enum +values with no feature behind them. 原则记录:空 widget + 永远查不到东西的过滤器 +是可见产品缺陷;审计面宁窄勿谎。 + +### Migration: FROM → TO + +| Wrote | Write instead | +|:--|:--| +| a filter, saved query or dashboard on `action = 'permission_change'` | filter the permission objects' own `create` / `update` rows by `object_name` — a grant or binding write is an ordinary record write and the generic writer already ledgers it | +| a filter, saved query or dashboard on `action = 'export'` | delete it — no export feature ever wrote an audit row, so it returned nothing on every deployment | +| a `switch` / badge map with arms for either value | delete those arms; an exhaustive `switch` over the action type now fails to compile if they stay | + +Every such query returned an empty result set before this change and returns the +same empty result set after it. What changed is that the contract stops promising +otherwise. + +⚠️ **Existing rows are untouched and must stay untouched.** The enum is not +enforced on this object — `validateRecord` skips `readonly` fields and every +`sys_audit_log` field is `readonly: true` — so stored history parses and reads +back exactly as written. Audit history is append-only; do not migrate or delete +rows to satisfy a schema narrowing. + +### Also in this change + +- `auth_events` list view: filter narrowed to `['login', 'logout']`. +- `config_changes` list view: `export` dropped from the filter. +- `plugin-audit`'s generated translation bundles regenerated for all four locales. +- ADR-0087 registration as the semantic migration `audit-log-action-enum-retired` + (D3 step 17). An enum-VALUE retirement, so nothing lands in + `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are byte-identical by + construction — no authorable key and no def changed. + +### `import` is deliberately NOT retired + +The 2026-08-12 ruling named `import` alongside the other two on the stated +premise 无此 feature. That premise is measurably false and the value stays: +`plugin-auth`'s admin user-import writes a real run-level row on every run +(`action: 'import'`, `record_id: null`), pinned by case W4 of +`packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts`. Retiring +it would make the enum deny a value the platform writes — and silently, since +the enum is unenforced here. Referred back for a maintainer ruling on #8147. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a5e4f7c06f..92c27a2cbb 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -328,6 +328,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`apimethod-enum-shrink`** — `data.object.enable.apiMethods (the eight legacy non-primitive values)` → the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six - Why not automatic: The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350). - Done when: No authored `enable.apiMethods` array names a legacy value; `objectstack validate` passes. Run the reporter codemod first and read its widening flags before applying anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For every object where `history` became `get` or `search` became `list`, confirm the broader operation is one the API should genuinely expose; where it is not, the answer is not a different value in this enum but a permission set that withholds the operation. Where the six primitives are all present, prefer deleting the key: that is equivalent to default-open and it tracks future primitives, whereas a hand-listed six silently stops granting anything added later. `restore` / `purge` are deleted with no replacement — if trash-like behaviour was being relied on, that capability left in #2377 and this entry is not where it returns. +- **`audit-log-action-enum-retired`** — `sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view` → nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise + - Why not automatic: Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8147. + - Done when: No consumer filters `sys_audit_log` on `action = "export"` or `action = "permission_change"` expecting rows: both were empty everywhere before this change, so a query that returned data has not been identified and a query that returned nothing behaves identically. Concretely, check three places. (1) Saved queries, dashboards and reports over `sys_audit_log`: a filter naming either value should be deleted, not re-pointed — for permission auditing, filter the permission objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching on the action string (a badge map, a label switch, an `if (row.action === ...)`): the arms for these two values are now unreachable and should go, and a `switch` with an exhaustiveness check over the enum type will now fail to compile if they stay — that compile error is the enforced channel for TypeScript consumers. (3) Custom objects or plugins inserting `sys_audit_log` rows with either value: this is the only case that needs a real decision, because the write will NOT be refused (readonly fields are not validated) — it will simply be a row whose action the object no longer declares. Pick a declared value or open an issue for the action you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is append-only and stays exactly as written. - **`auth-config-unadvertised-reserved-features`** — `api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised) - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481. - Done when: No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; a client that gated UI on either now treats the capability as absent rather than reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts `/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call directly. diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts new file mode 100644 index 0000000000..20e5b1a7bf --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysAuditLog } from './index.js'; + +/** + * #8147 — `export` and `permission_change` are RETIRED from the + * `sys_audit_log.action` enum (maintainer ruling 2026-08-12 on #7675, ADR-0049 + * enforce-or-remove, registered under ADR-0087 as `audit-log-action-enum-retired`). + * + * This file exists because **nothing else in the repo can detect a regression + * here.** The enum is not enforced on writes at all: `validateRecord` skips + * `readonly` fields (`record-validator.ts`, insert branch) and every + * `sys_audit_log` field is `readonly: true`, so re-adding a value refuses + * nothing and rejects nothing. The generated translation bundles are the only + * other committed artifact that moves with the enum, and they only pin that the + * bundle and the enum AGREE — regenerate both and the drift disappears. An + * object field has no `retiredKey()` tombstone to reject the name at authoring + * time the way a spec property does, so this pin IS the tombstone for the + * platform-owned declaration (the `sys_comment` retired-fields precedent, #4756). + * + * The expectations below are written as literals on purpose. A test that read + * the allowed set out of the object and asserted the object matched it could + * not fail — expectation and reality would derive from the same source. + * + * If a future change genuinely needs one of these actions back, it arrives + * WRITER-FIRST — the emission point, its tests, and the list view/widget that + * surfaces it — and updates this file deliberately, never as collateral. + */ + +const RETIRED_ACTIONS: ReadonlyArray = [ + [ + 'permission_change', + 'permission-object writes are already on the ledger as ordinary `create` / `update` ' + + 'rows written by the generic hook writer; a second semantically-duplicate row is ' + + 'not minted. Filter the permission objects by `object_name` instead.', + ], + [ + 'export', + 'no export feature has ever written an audit row — `actionFor()` in audit-writers.ts ' + + 'emits create/update/delete and nothing else. A filter on this value matched ' + + 'nothing on every deployment that has ever run.', + ], +]; + +/** Option values declared by the `action` select field. */ +function actionValues(): string[] { + const field = (SysAuditLog as { fields?: Record }) + .fields?.action; + const options = (field?.options ?? []) as Array; + return options.map((o) => (typeof o === 'string' ? o : String(o.value))); +} + +/** Every value named by every `action` filter across every shipped list view. */ +function filteredActionValues(): Array<{ view: string; value: string }> { + const views = (SysAuditLog as { + listViews?: Record }>; + }).listViews ?? {}; + const out: Array<{ view: string; value: string }> = []; + for (const [view, def] of Object.entries(views)) { + for (const clause of def.filter ?? []) { + if (clause.field !== 'action') continue; + const values = Array.isArray(clause.value) ? clause.value : [clause.value]; + for (const v of values) out.push({ view, value: String(v) }); + } + } + return out; +} + +describe('sys_audit_log — retired actions stay retired (#8147)', () => { + it.each(RETIRED_ACTIONS)( + '%s is not declared by the action enum', + (action, prescription) => { + expect( + actionValues(), + `sys_audit_log.action '${action}' was retired under ADR-0049 (#8147) — ${prescription}`, + ).not.toContain(action); + }, + ); + + it.each(RETIRED_ACTIONS)( + '%s is not named by any shipped list-view filter', + (action, prescription) => { + const offenders = filteredActionValues().filter((f) => f.value === action); + expect( + offenders, + `a list view filters on the retired action '${action}' (${offenders + .map((o) => o.view) + .join(', ')}) — it can never match. ${prescription}`, + ).toEqual([]); + }, + ); + + it('every list-view action filter names a value the enum still declares', () => { + const declared = new Set(actionValues()); + const dangling = filteredActionValues().filter((f) => !declared.has(f.value)); + expect( + dangling, + 'a list view filters `action` on a value the enum does not declare, so the view is ' + + 'permanently empty — the visible product defect the 2026-08-12 ruling named ' + + '(空 widget + 永远查不到东西的过滤器是可见产品缺陷). Narrow the filter with the enum.', + ).toEqual([]); + }); + + /** + * The deliberate NON-retirement. The 2026-08-12 ruling named `import` + * alongside the other two on the premise 无此 feature, and that premise is + * false: `plugin-auth`'s admin user-import writes a run-level row + * (`admin-import-users.ts` — `action: 'import'`, `record_id: null`, + * `object_name: 'sys_user'`) on every run, and case W4 of + * `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts` + * asserts that row exists. + * + * Retiring it would make the enum deny a value the platform writes, silently + * — see the file docblock on why nothing would go red. This assertion is the + * detector. If a maintainer rules that `import` should go, the WRITER and the + * dogfood case go first, and this line goes with them. + */ + it('import is still declared — it has a live writer (#8147 escalation)', () => { + expect( + actionValues(), + "sys_audit_log.action 'import' must stay declared: plugin-auth's admin user-import " + + 'writes a real run-level row with this action on every run (admin-import-users.ts), ' + + 'pinned by dogfood case W4. Removing it makes the enum deny a value the platform ' + + 'writes — and silently, because readonly fields are never validated.', + ).toContain('import'); + }); +}); diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index 02527f95ae..23ec63abd6 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -62,7 +62,10 @@ export const SysAuditLog = ObjectSchema.create({ label: 'Auth', data: { provider: 'object', object: 'sys_audit_log' }, columns: ['created_at', 'action', 'user_id'], - filter: [{ field: 'action', operator: 'in', value: ['login', 'logout', 'permission_change'] }], + // `permission_change` removed (#8147): the value is retired from the enum, + // so the filter would have matched nothing for the rest of time. Permission + // object writes are already on the ledger as ordinary create/update rows. + filter: [{ field: 'action', operator: 'in', value: ['login', 'logout'] }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, @@ -72,7 +75,13 @@ export const SysAuditLog = ObjectSchema.create({ label: 'Config', data: { provider: 'object', object: 'sys_audit_log' }, columns: ['created_at', 'action', 'object_name', 'user_id'], - filter: [{ field: 'action', operator: 'in', value: ['config_change', 'export', 'import'] }], + // `export` removed (#8147) — retired from the enum, nothing ever wrote it. + // `import` KEPT deliberately: `plugin-auth`'s admin user-import writes a + // real run-level row (`admin-import-users.ts`, `action: 'import'` with + // `record_id: null`), pinned by the W4 case in + // `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts`. + // This view is the only shipped surface that lists those rows. + filter: [{ field: 'action', operator: 'in', value: ['config_change', 'import'] }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, @@ -97,8 +106,23 @@ export const SysAuditLog = ObjectSchema.create({ group: 'Event', }), + // ADR-0087 retirement (#8147, ruling 2026-08-12): `export` and + // `permission_change` left this enum. Neither had a writer anywhere in the + // repo — the only two `sys_audit_log` writers are `audit-writers.ts` (whose + // `actionFor` emits create/update/delete and nothing else) and + // `plugin-auth`'s admin user-import. A declared action nothing ever writes + // is an empty widget and a filter that can never match: 审计面宁窄勿谎. + // Permission-object writes are already on the ledger as create/update rows. + // + // ⚠ `import` was named in the same ruling but is NOT retired: it has a live, + // deliberate writer (`plugin-auth/src/admin-import-users.ts`, run-level row + // with `record_id: null`) pinned by dogfood case W4. Retiring it would make + // this enum deny a value the platform writes on every admin import run — + // and silently, because every field here is `readonly: true` and + // `validateRecord` skips readonly fields, so nothing would ever go red. + // See #8147 for the escalation. action: Field.select( - ['create', 'update', 'delete', 'restore', 'login', 'logout', 'permission_change', 'config_change', 'export', 'import'], + ['create', 'update', 'delete', 'restore', 'login', 'logout', 'config_change', 'import'], { label: 'Action', required: true, diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index c1d4501c63..6a59d7cbda 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -27,9 +27,7 @@ export const enObjects: NonNullable = { restore: "restore", login: "login", logout: "logout", - permission_change: "permission_change", config_change: "config_change", - export: "export", import: "import" } }, diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index 4467271cbe..7773be9f91 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -27,9 +27,7 @@ export const esESObjects: NonNullable = { restore: "Restaurar", login: "Inicio de sesión", logout: "Cierre de sesión", - permission_change: "Cambio de permisos", config_change: "Cambio de configuración", - export: "Exportar", import: "Importar" } }, diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index c02ec175ac..a970414b1d 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -27,9 +27,7 @@ export const jaJPObjects: NonNullable = { restore: "復元", login: "ログイン", logout: "ログアウト", - permission_change: "権限変更", config_change: "構成変更", - export: "エクスポート", import: "インポート" } }, diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 135d38ca51..114b71f393 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -27,9 +27,7 @@ export const zhCNObjects: NonNullable = { restore: "恢复", login: "登录", logout: "登出", - permission_change: "权限变更", config_change: "配置变更", - export: "导出", import: "导入" } }, diff --git a/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts b/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts index 267af58df0..7b64739edf 100644 --- a/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts +++ b/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts @@ -146,7 +146,11 @@ const TELEMETRY = 'telemetry'; const auditRows: Rows = [ { action: 'login', id: 'a1' }, { action: 'login', id: 'a2' }, - { action: 'permission_change', id: 'a3' }, + // `delete`, not `permission_change` (#8147): that value was retired from the + // `sys_audit_log` action enum, and a fixture seeding a value the object no + // longer declares is a green-looking lie. `delete` is written for real by + // plugin-audit's generic hook writer. + { action: 'delete', id: 'a3' }, ]; const auditDataset = DatasetSchema.parse({ @@ -207,7 +211,7 @@ describe('executeRawSql auto-bridge routes by object (#5033)', () => { expect(viaRawSql.rows).toEqual(viaAggregate.rows); expect(viaRawSql.rows).toEqual([ { action: 'login', event_count: 2 }, - { action: 'permission_change', event_count: 1 }, + { action: 'delete', event_count: 1 }, ]); }); diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index ad0ad39fb8..cb17779347 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -539,6 +539,13 @@ "toMajor": 17, "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)." }, + { + "surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view", + "replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise", + "migrationId": "audit-log-action-enum-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8147." + }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", "replacement": "(removed — no replacement flag; the capabilities are not advertised)", @@ -1521,6 +1528,13 @@ "toMajor": 17, "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)." }, + { + "surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view", + "replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise", + "migrationId": "audit-log-action-enum-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8147." + }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", "replacement": "(removed — no replacement flag; the capabilities are not advertised)", diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts new file mode 100644 index 0000000000..61a86fa6ae --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'audit-log-action-enum-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + "sys_audit_log.action — the values 'export' and 'permission_change' left the select " + + 'enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/' + + 'sys-audit-log.object.ts). The same two values also left the shipped list-view ' + + "filters on that object: 'permission_change' from the auth_events view and 'export' " + + 'from the config_changes view', + replacement: + 'nothing, for either value — both are removed rather than renamed, because neither ' + + 'named an event this platform records. For permission changes, read the ordinary ' + + '`create` / `update` rows on the permission objects themselves: a grant or binding ' + + 'write is an ordinary record write and the generic audit writer already ledgers it, ' + + 'so a second semantically-duplicate row was never minted. For `export` there is no ' + + 'replacement and nothing is lost: no export feature ever wrote an audit row. A ' + + 'consumer filtering `sys_audit_log` on either value was reading an empty result set ' + + 'on every deployment, and still is — what changed is that the contract no longer ' + + 'promises otherwise', + reason: + 'Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the ' + + 'cheap writers get built (#8144 login/logout, #8145 config_change) and the enum ' + + 'values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的' + + '过滤器是可见产品缺陷;审计面宁窄勿谎. ' + + 'The defect was false compliance on a COMPLIANCE surface, which is the sharpest form ' + + 'of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the ' + + 'platform captured permission changes and data exports, and the shipped list views ' + + 'and dashboard widgets showed them a filter and a tile for exactly those events. ' + + 'Both were permanently empty. Measured by enumerating every `sys_audit_log` writer ' + + 'in the repo — there are exactly two: plugin-audit`s generic hook writer, whose ' + + '`actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing ' + + 'else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or ' + + '`permission_change`. ' + + 'This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement ' + + 'in the two ways `hook-body-crypto-hash-removed`, ' + + '`dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` ' + + 'already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) ' + + 'and the four surface ratchets are expected to be byte-identical (no def changed). ' + + 'It differs from all three in being a SEMANTIC entry rather than a D2 conversion, ' + + 'and the reason is that there is no source to rewrite: `sys_audit_log` is a ' + + 'platform-owned, append-only object whose every field is `readonly: true`. Nobody ' + + 'authors an audit row and nobody authors this enum — the values appear only in rows ' + + 'the runtime writes and in queries consumers send. A conversion rewrites authored ' + + 'metadata or a stored `sys_metadata` row; this surface is neither, so the ' + + 'disposition is the one `BatchOptions.validateOnly` and the notification cursor ' + + 'already take in this major. ' + + '⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a ' + + 'row with either value keeps it, and keeps reading it back: the enum is not enforced ' + + 'on this object at all (`validateRecord` skips `readonly` fields, and every field ' + + 'here is readonly), so nothing rejects stored history and no backfill is required or ' + + 'wanted. Deleting audit history to satisfy a schema narrowing would be the one ' + + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8147.', + acceptanceCriteria: + 'No consumer filters `sys_audit_log` on `action = "export"` or ' + + '`action = "permission_change"` expecting rows: both were empty everywhere before ' + + 'this change, so a query that returned data has not been identified and a query that ' + + 'returned nothing behaves identically. Concretely, check three places. (1) Saved ' + + 'queries, dashboards and reports over `sys_audit_log`: a filter naming either value ' + + 'should be deleted, not re-pointed — for permission auditing, filter the permission ' + + 'objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching ' + + 'on the action string (a badge map, a label switch, an `if (row.action === ...)`): ' + + 'the arms for these two values are now unreachable and should go, and a `switch` with ' + + 'an exhaustiveness check over the enum type will now fail to compile if they stay — ' + + 'that compile error is the enforced channel for TypeScript consumers. (3) Custom ' + + 'objects or plugins inserting `sys_audit_log` rows with either value: this is the ' + + 'only case that needs a real decision, because the write will NOT be refused ' + + '(readonly fields are not validated) — it will simply be a row whose action the ' + + 'object no longer declares. Pick a declared value or open an issue for the action ' + + 'you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is ' + + 'append-only and stays exactly as written.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2426819c63..147a0f5498 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1804,6 +1804,78 @@ const step17: MigrationStep = { + 'replacement — if trash-like behaviour was being relied on, that capability left in ' + '#2377 and this entry is not where it returns.', }, + { + id: 'audit-log-action-enum-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + "sys_audit_log.action — the values 'export' and 'permission_change' left the select " + + 'enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/' + + 'sys-audit-log.object.ts). The same two values also left the shipped list-view ' + + "filters on that object: 'permission_change' from the auth_events view and 'export' " + + 'from the config_changes view', + replacement: + 'nothing, for either value — both are removed rather than renamed, because neither ' + + 'named an event this platform records. For permission changes, read the ordinary ' + + '`create` / `update` rows on the permission objects themselves: a grant or binding ' + + 'write is an ordinary record write and the generic audit writer already ledgers it, ' + + 'so a second semantically-duplicate row was never minted. For `export` there is no ' + + 'replacement and nothing is lost: no export feature ever wrote an audit row. A ' + + 'consumer filtering `sys_audit_log` on either value was reading an empty result set ' + + 'on every deployment, and still is — what changed is that the contract no longer ' + + 'promises otherwise', + reason: + 'Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the ' + + 'cheap writers get built (#8144 login/logout, #8145 config_change) and the enum ' + + 'values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的' + + '过滤器是可见产品缺陷;审计面宁窄勿谎. ' + + 'The defect was false compliance on a COMPLIANCE surface, which is the sharpest form ' + + 'of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the ' + + 'platform captured permission changes and data exports, and the shipped list views ' + + 'and dashboard widgets showed them a filter and a tile for exactly those events. ' + + 'Both were permanently empty. Measured by enumerating every `sys_audit_log` writer ' + + 'in the repo — there are exactly two: plugin-audit`s generic hook writer, whose ' + + '`actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing ' + + 'else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or ' + + '`permission_change`. ' + + 'This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement ' + + 'in the two ways `hook-body-crypto-hash-removed`, ' + + '`dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` ' + + 'already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) ' + + 'and the four surface ratchets are expected to be byte-identical (no def changed). ' + + 'It differs from all three in being a SEMANTIC entry rather than a D2 conversion, ' + + 'and the reason is that there is no source to rewrite: `sys_audit_log` is a ' + + 'platform-owned, append-only object whose every field is `readonly: true`. Nobody ' + + 'authors an audit row and nobody authors this enum — the values appear only in rows ' + + 'the runtime writes and in queries consumers send. A conversion rewrites authored ' + + 'metadata or a stored `sys_metadata` row; this surface is neither, so the ' + + 'disposition is the one `BatchOptions.validateOnly` and the notification cursor ' + + 'already take in this major. ' + + '⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a ' + + 'row with either value keeps it, and keeps reading it back: the enum is not enforced ' + + 'on this object at all (`validateRecord` skips `readonly` fields, and every field ' + + 'here is readonly), so nothing rejects stored history and no backfill is required or ' + + 'wanted. Deleting audit history to satisfy a schema narrowing would be the one ' + + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8147.', + acceptanceCriteria: + 'No consumer filters `sys_audit_log` on `action = "export"` or ' + + '`action = "permission_change"` expecting rows: both were empty everywhere before ' + + 'this change, so a query that returned data has not been identified and a query that ' + + 'returned nothing behaves identically. Concretely, check three places. (1) Saved ' + + 'queries, dashboards and reports over `sys_audit_log`: a filter naming either value ' + + 'should be deleted, not re-pointed — for permission auditing, filter the permission ' + + 'objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching ' + + 'on the action string (a badge map, a label switch, an `if (row.action === ...)`): ' + + 'the arms for these two values are now unreachable and should go, and a `switch` with ' + + 'an exhaustiveness check over the enum type will now fail to compile if they stay — ' + + 'that compile error is the enforced channel for TypeScript consumers. (3) Custom ' + + 'objects or plugins inserting `sys_audit_log` rows with either value: this is the ' + + 'only case that needs a real decision, because the write will NOT be refused ' + + '(readonly fields are not validated) — it will simply be a row whose action the ' + + 'object no longer declares. Pick a declared value or open an issue for the action ' + + 'you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is ' + + 'append-only and stays exactly as written.', + }, { id: 'auth-config-unadvertised-reserved-features', surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink',