diff --git a/.changeset/audit-log-action-restore-retired.md b/.changeset/audit-log-action-restore-retired.md new file mode 100644 index 0000000000..c498157116 --- /dev/null +++ b/.changeset/audit-log-action-restore-retired.md @@ -0,0 +1,72 @@ +--- +"@objectstack/plugin-audit": minor +"@objectstack/spec": minor +--- + +refactor(plugin-audit)!: retire `restore` from the `sys_audit_log` action enum — the last declared action with no writer anywhere (#8315, #7675, ADR-0049/ADR-0087) + + + +**BREAKING** (shipped as `minor` under the launch-window lockstep convention). + +`sys_audit_log.action` declared `restore`. Nothing has ever written it, and the +record-level audit writer **structurally cannot**: `actionFor()` in +`plugin-audit/src/audit-writers.ts` is typed +`'create' | 'update' | 'delete' | null` and its caller early-returns on `null`. +A tree-wide search finds no other producer. There is no undelete capability +behind the value either — soft delete/restore is unbuilt and parked (#1883, +#3146). + +This is the third and last value retired from this enum under the maintainer +ruling of 2026-08-12 on #7675 (原则记录:空 widget + 永远查不到东西的过滤器是可见 +产品缺陷;审计面宁窄勿谎), after `export` and `permission_change` in #8147. + +### What made it a card and not a tidy-up + +Two shipped declarations asserted the opposite, so a declaration-reading audit +scored the action as covered: + +- the `writes_only` list view offered `restore` as a filter value — a choice an + operator can pick that returns nothing, on every deployment that has ever run; +- the module docblock of `plugin-audit/src/auth-event-audit.ts` named `restore` + among the actions the writer emits — the ADR-0049 declared-≠-enforced shape in + its purest form: a sentence sitting next to a mechanism, contradicted by that + mechanism's own type signature, with nothing in CI able to tell. + +Both are corrected. The invariant the comment was really claiming — *every +declared action has a writer* — is now a pin test with the writer inventory +written as literals, instead of prose. + +### Migration: FROM → TO + +| Wrote | Write instead | +|:--|:--| +| a filter, saved query or dashboard on `action = 'restore'` | delete it — no restore event has ever been recorded, so it returned nothing on every deployment | +| a `switch` / badge map / filter-dropdown option for `restore` | delete that arm; an exhaustive `switch` over the action type now fails to compile if it stays | + +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. + +⚠️ **Not a product stance against undelete.** If the restore capability lands +(#1883 / #3146 restart), this value returns **with its writer** — the emission +point, its tests, and the view that surfaces it — never as a bare enum row again. + +### Also in this change + +- `writes_only` list view: filter narrowed to `['create', 'update', 'delete']`, + which is now exactly what `actionFor()` can emit. +- `plugin-audit`'s generated translation bundles regenerated for all four locales + (via `check-i18n-bundles --write` — not hand-edited). +- ADR-0087 registration as the semantic migration + `audit-log-action-restore-retired` (D3 step 17), a separate entry from #8147's + `audit-log-action-enum-retired` per `entries/README.md`. 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. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index b8dad38e4d..ad7e2a2ac7 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -337,6 +337,9 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - **`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. +- **`audit-log-action-restore-retired`** — `sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles` → nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row + - Why not automatic: The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (#8011) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected (#1883 pm:on-hold, #3146 status:parked). If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, 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, #8315, #7675, #8147. + - Done when: No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was empty on every deployment before this change and behaves identically after it. Concretely, check three places. (1) Saved queries, dashboards and reports over `sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — there is no action that carries the meaning, because the platform records no restore event. (2) Any code branching on the action string (a badge map, a label switch, an option list in an audit-log filter UI): the `restore` arm is unreachable and should go, and a `switch` with an exhaustiveness check over the enum type will now fail to compile if it stays — that compile error is the enforced channel for TypeScript consumers. An option in a FILTER dropdown is the user-visible half and matters most: it offers an operator a choice that returns nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this value: the write will NOT be refused (readonly fields are not validated), so it silently becomes 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/auth-event-audit.ts b/packages/plugins/plugin-audit/src/auth-event-audit.ts index bea3113f3a..be215bd654 100644 --- a/packages/plugins/plugin-audit/src/auth-event-audit.ts +++ b/packages/plugins/plugin-audit/src/auth-event-audit.ts @@ -6,14 +6,31 @@ * * ## The gap this closes * - * `sys_audit_log.action` declares ten values. `audit-writers.ts` subscribes to - * the ObjectQL wildcard `before*`/`after*` CRUD lifecycle, so it can emit - * `create`/`update`/`delete`/`restore` and nothing else — four of the declared - * values had no writer anywhere in the repo. Two of them are auth session - * events, and the whole trace a sign-in left behind was an **unattributed** - * `update sys_user` row diffing `last_login_at` (`user_id` null). The shipped - * `auth_events` list view and two `system_overview` dashboard widgets were - * therefore permanently empty, by construction. + * `sys_audit_log.action` declared ten values when this landed. `audit-writers.ts` + * subscribes to the ObjectQL wildcard `before*`/`after*` CRUD lifecycle, and its + * `actionFor()` returns `'create' | 'update' | 'delete' | null` — so it emits + * exactly those three and nothing else. Several of the declared values had no + * writer anywhere in the repo. Two of them are auth session events, and the + * whole trace a sign-in left behind was an **unattributed** `update sys_user` + * row diffing `last_login_at` (`user_id` null). The shipped `auth_events` list + * view and two `system_overview` dashboard widgets were therefore permanently + * empty, by construction. + * + * ⚠ CORRECTION (#8315). This paragraph used to read that `audit-writers.ts` + * "can emit `create`/`update`/`delete`/`restore` and nothing else". The + * `restore` in that list was never true: `actionFor()`'s return type has no + * `restore` arm and the caller early-returns on `null`, so the record-level + * writer structurally cannot produce it, and no other writer in the repo did + * either. The claim survived because it is a COMMENT — the exact #8011 shape, + * a declaration sitting next to a mechanism with nothing enforcing it — and a + * declaration-reading audit stopped here and scored `restore` as covered. + * `restore` has since been retired from the enum (#8315); the sentence above + * now names what `actionFor()`'s signature actually says. + * + * The enum-side invariant this paragraph is really about — every declared + * action has a writer — is pinned mechanically in + * `objects/sys-audit-log-retired-actions.test.ts`, not asserted here. A comment + * is not a control; that is the whole lesson of this correction. * * ## Why the row is built HERE and not at the auth seam * 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 index 20e5b1a7bf..60b9e9a6aa 100644 --- 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 @@ -8,6 +8,16 @@ import { SysAuditLog } from './index.js'; * `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`). * + * #8315 — `restore` joins them, under the same ruling carried by triage and + * registered as `audit-log-action-restore-retired`. It was the last unresolved + * value from #7675's survey, and the least ambiguous of the three: `actionFor()` + * in `audit-writers.ts` returns `'create' | 'update' | 'delete' | null`, so the + * record-level writer could not have produced it even by accident. What made it + * worth a card rather than a tidy-up is that two shipped declarations asserted + * the opposite — the `writes_only` list view offered it as a filter value, and + * `auth-event-audit.ts`'s module docblock named it as one of the actions the + * writer covers. Both are corrected here and in that file. + * * 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 @@ -41,6 +51,33 @@ const RETIRED_ACTIONS: ReadonlyArray = [ + ['create', 'plugin-audit/src/audit-writers.ts — actionFor(afterInsert)'], + ['update', 'plugin-audit/src/audit-writers.ts — actionFor(afterUpdate)'], + ['delete', 'plugin-audit/src/audit-writers.ts — actionFor(afterDelete)'], + ['login', 'plugin-audit/src/auth-event-audit.ts — createAuthEventAuditSink (#8144)'], + ['logout', 'plugin-audit/src/auth-event-audit.ts — createAuthEventAuditSink (#8144)'], + ['config_change', 'service-settings/src/config-change-audit.ts — CONFIG_CHANGE_ACTION (#8145)'], + ['import', 'plugin-auth/src/admin-import-users.ts — run-level row, record_id null'], ]; /** Option values declared by the `action` select field. */ @@ -73,7 +110,8 @@ describe('sys_audit_log — retired actions stay retired (#8147)', () => { (action, prescription) => { expect( actionValues(), - `sys_audit_log.action '${action}' was retired under ADR-0049 (#8147) — ${prescription}`, + `sys_audit_log.action '${action}' was retired under ADR-0049 (#8147 / #8315) ` + + `— ${prescription}`, ).not.toContain(action); }, ); @@ -102,6 +140,34 @@ describe('sys_audit_log — retired actions stay retired (#8147)', () => { ).toEqual([]); }); + /** + * The positive invariant, and the one that would have caught this whole + * family at the source (#8315). `auth-event-audit.ts` carried it as a + * SENTENCE — "`create`/`update`/`delete`/`restore` and nothing else" — which + * was false the day it was written and stayed false through two cards, + * because a comment enforces nothing (#8011). This is the same claim with a + * detector under it. + * + * Set equality on purpose, in both directions. Adding a value to the enum + * without a writer goes red here, which is the defect #7675 surveyed; adding + * a writer without declaring its action also goes red, which is the strictly + * worse inverse — a row the platform writes and the object does not declare. + */ + it('the action enum declares exactly the actions that have a writer', () => { + const declared = [...actionValues()].sort(); + const withWriters = ACTIONS_WITH_WRITERS.map(([action]) => action).sort(); + expect( + declared, + 'sys_audit_log.action and the writer inventory in this file disagree. A declared ' + + 'action with no writer is a permanently empty list view and a lie on a ' + + 'compliance surface (审计面宁窄勿谎, ruling 2026-08-12); an undeclared action ' + + 'that IS written is worse still, because the row exists and the contract ' + + 'denies it — and nothing rejects it, since every field here is `readonly` and ' + + '`validateRecord` skips readonly fields. Writers on record:\n' + + ACTIONS_WITH_WRITERS.map(([a, w]) => ` ${a} ← ${w}`).join('\n'), + ).toEqual(withWriters); + }); + /** * The deliberate NON-retirement. The 2026-08-12 ruling named `import` * alongside the other two on the premise 无此 feature, and that premise is 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 23ec63abd6..42c602a6e1 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 @@ -52,7 +52,10 @@ export const SysAuditLog = ObjectSchema.create({ label: 'Writes', data: { provider: 'object', object: 'sys_audit_log' }, columns: ['created_at', 'action', 'object_name', 'record_id', 'user_id'], - filter: [{ field: 'action', operator: 'in', value: ['create', 'update', 'delete', 'restore'] }], + // `restore` removed (#8315): the value is retired from the enum, so the + // filter would have matched nothing for the rest of time. The three that + // remain are exactly what `actionFor()` in `audit-writers.ts` can emit. + filter: [{ field: 'action', operator: 'in', value: ['create', 'update', 'delete'] }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, @@ -114,6 +117,17 @@ export const SysAuditLog = ObjectSchema.create({ // is an empty widget and a filter that can never match: 审计面宁窄勿谎. // Permission-object writes are already on the ledger as create/update rows. // + // ADR-0087 retirement (#8315, the same ruling carried by triage): `restore` + // left this enum for the same reason and by the same measurement. It was + // never a near-miss — `actionFor()` in `audit-writers.ts` returns + // `'create' | 'update' | 'delete' | null`, so the record-level writer + // STRUCTURALLY cannot produce it, and no other writer in the repo emits it + // either. ⚠ This is not a product stance against undelete: soft + // delete/restore is an unbuilt capability parked on #1883 (`pm:on-hold`) + // and #3146 (`status:parked`). If it lands, this value returns WITH its + // writer — the emission point, its tests, and the view that surfaces it — + // never as a bare enum row again. + // // ⚠ `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 @@ -122,7 +136,7 @@ export const SysAuditLog = ObjectSchema.create({ // `validateRecord` skips readonly fields, so nothing would ever go red. // See #8147 for the escalation. action: Field.select( - ['create', 'update', 'delete', 'restore', 'login', 'logout', 'config_change', 'import'], + ['create', 'update', 'delete', '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 6a59d7cbda..7ff79a5579 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -24,7 +24,6 @@ export const enObjects: NonNullable = { create: "create", update: "update", delete: "delete", - restore: "restore", login: "login", logout: "logout", config_change: "config_change", 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 7773be9f91..adbc7f6b8f 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 @@ -24,7 +24,6 @@ export const esESObjects: NonNullable = { create: "Crear", update: "Actualizar", delete: "Eliminar", - restore: "Restaurar", login: "Inicio de sesión", logout: "Cierre de sesión", config_change: "Cambio de configuración", 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 a970414b1d..7125e80552 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 @@ -24,7 +24,6 @@ export const jaJPObjects: NonNullable = { create: "作成", update: "更新", delete: "削除", - restore: "復元", login: "ログイン", logout: "ログアウト", config_change: "構成変更", 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 114b71f393..2732f28dc3 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 @@ -24,7 +24,6 @@ export const zhCNObjects: NonNullable = { create: "创建", update: "更新", delete: "删除", - restore: "恢复", login: "登录", logout: "登出", config_change: "配置变更", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index d70d7692dc..505439b6c7 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -558,6 +558,13 @@ "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": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles", + "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row", + "migrationId": "audit-log-action-restore-retired", + "toMajor": 17, + "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (#8011) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected (#1883 pm:on-hold, #3146 status:parked). If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, 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, #8315, #7675, #8147." + }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", "replacement": "(removed — no replacement flag; the capabilities are not advertised)", @@ -1580,6 +1587,13 @@ "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": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles", + "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row", + "migrationId": "audit-log-action-restore-retired", + "toMajor": 17, + "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (#8011) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected (#1883 pm:on-hold, #3146 status:parked). If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, 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, #8315, #7675, #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-restore-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts new file mode 100644 index 0000000000..1e5f5e768d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The third and last value retired from `sys_audit_log.action` in this major, +// and a separate entry from `audit-log-action-enum-retired` on purpose: two +// cards registering DIFFERENT entries merge clean, two cards editing the SAME +// entry collide in git — which is the layout `entries/README.md` chose on a +// registry where a dropped entry produces no error anywhere. +export const entry: SemanticMigration = { + id: 'audit-log-action-restore-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 value 'restore' left the select enum declared by " + + 'plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). ' + + 'It also left the shipped writes_only list-view filter on that object, and the ' + + 'generated option label in all four plugin-audit translation bundles', + replacement: + 'nothing — the value is removed rather than renamed, because it never named an event ' + + 'this platform records. There is no undelete or restore capability to point at: ' + + 'deletes are hard deletes, and the record-level audit writer maps the ObjectQL ' + + 'lifecycle to `create` / `update` / `delete` only. A consumer filtering ' + + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' + + 'and still is — what changed is that the contract no longer promises otherwise. If ' + + 'you were counting on a restore trail, the capability itself is the missing piece ' + + '(#1883, #3146), not this enum row', + reason: + 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' + + "value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 " + + 'widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. ' + + '`restore` is the least ambiguous member of the family: the record-level writer ' + + "could not have produced it even by accident, because `actionFor()` in " + + "audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller " + + 'early-returns on null. A tree-wide search finds no other producer. ' + + 'What made it a card rather than a tidy-up is that TWO shipped declarations ' + + 'asserted the opposite, so a declaration-reading audit scored the action as ' + + 'covered: the `writes_only` list view offered it as a filter value, and the module ' + + 'docblock of auth-event-audit.ts named it among the actions the writer emits. The ' + + 'comment is the ADR-0049 declared-≠-enforced shape in its purest form (#8011) — a ' + + 'sentence next to a mechanism, contradicted by the type signature of that very ' + + 'mechanism, with nothing in CI able to tell. Both declarations are corrected in one ' + + 'change, and the invariant behind the comment (every declared action has a writer) ' + + 'now has a pin test under it rather than prose. ' + + 'Bookkeeping is identical to the sibling entry, for the same reasons: an ' + + 'enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY ' + + 'changed) and leaves the four surface ratchets byte-identical (no def changed), and ' + + 'it is a SEMANTIC entry rather than a D2 conversion because there is no source to ' + + 'rewrite — `sys_audit_log` is a platform-owned, append-only object whose every ' + + 'field is `readonly: true`, so nobody authors an audit row and nobody authors this ' + + 'enum. ' + + '⚠️ This is a statement about the WRITER, not a product stance against undelete. ' + + 'Soft delete/restore is parked, not rejected (#1883 pm:on-hold, #3146 ' + + 'status:parked). If that capability lands, this value returns WITH its writer — the ' + + 'emission point, its tests, and the view that surfaces it — never as a bare enum ' + + 'row again. ' + + '⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: ' + + 'the enum is not enforced on this object at all (`validateRecord` skips `readonly` ' + + 'fields), so any stored row keeps parsing and reading back, 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, #8315, ' + + '#7675, #8147.', + acceptanceCriteria: + 'No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was ' + + 'empty on every deployment before this change and behaves identically after it. ' + + 'Concretely, check three places. (1) Saved queries, dashboards and reports over ' + + '`sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — ' + + 'there is no action that carries the meaning, because the platform records no ' + + 'restore event. (2) Any code branching on the action string (a badge map, a label ' + + 'switch, an option list in an audit-log filter UI): the `restore` arm is ' + + 'unreachable and should go, and a `switch` with an exhaustiveness check over the ' + + 'enum type will now fail to compile if it stays — that compile error is the ' + + 'enforced channel for TypeScript consumers. An option in a FILTER dropdown is the ' + + 'user-visible half and matters most: it offers an operator a choice that returns ' + + 'nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this ' + + 'value: the write will NOT be refused (readonly fields are not validated), so it ' + + 'silently becomes 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 ca8e8ec26d..0fb3594570 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1902,6 +1902,82 @@ const step17: MigrationStep = { + 'you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is ' + 'append-only and stays exactly as written.', }, + // The third and last value retired from `sys_audit_log.action` in this major, + // and a separate entry from `audit-log-action-enum-retired` on purpose: two + // cards registering DIFFERENT entries merge clean, two cards editing the SAME + // entry collide in git — which is the layout `entries/README.md` chose on a + // registry where a dropped entry produces no error anywhere. + { + id: 'audit-log-action-restore-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 value 'restore' left the select enum declared by " + + 'plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). ' + + 'It also left the shipped writes_only list-view filter on that object, and the ' + + 'generated option label in all four plugin-audit translation bundles', + replacement: + 'nothing — the value is removed rather than renamed, because it never named an event ' + + 'this platform records. There is no undelete or restore capability to point at: ' + + 'deletes are hard deletes, and the record-level audit writer maps the ObjectQL ' + + 'lifecycle to `create` / `update` / `delete` only. A consumer filtering ' + + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' + + 'and still is — what changed is that the contract no longer promises otherwise. If ' + + 'you were counting on a restore trail, the capability itself is the missing piece ' + + '(#1883, #3146), not this enum row', + reason: + 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' + + "value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 " + + 'widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. ' + + '`restore` is the least ambiguous member of the family: the record-level writer ' + + "could not have produced it even by accident, because `actionFor()` in " + + "audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller " + + 'early-returns on null. A tree-wide search finds no other producer. ' + + 'What made it a card rather than a tidy-up is that TWO shipped declarations ' + + 'asserted the opposite, so a declaration-reading audit scored the action as ' + + 'covered: the `writes_only` list view offered it as a filter value, and the module ' + + 'docblock of auth-event-audit.ts named it among the actions the writer emits. The ' + + 'comment is the ADR-0049 declared-≠-enforced shape in its purest form (#8011) — a ' + + 'sentence next to a mechanism, contradicted by the type signature of that very ' + + 'mechanism, with nothing in CI able to tell. Both declarations are corrected in one ' + + 'change, and the invariant behind the comment (every declared action has a writer) ' + + 'now has a pin test under it rather than prose. ' + + 'Bookkeeping is identical to the sibling entry, for the same reasons: an ' + + 'enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY ' + + 'changed) and leaves the four surface ratchets byte-identical (no def changed), and ' + + 'it is a SEMANTIC entry rather than a D2 conversion because there is no source to ' + + 'rewrite — `sys_audit_log` is a platform-owned, append-only object whose every ' + + 'field is `readonly: true`, so nobody authors an audit row and nobody authors this ' + + 'enum. ' + + '⚠️ This is a statement about the WRITER, not a product stance against undelete. ' + + 'Soft delete/restore is parked, not rejected (#1883 pm:on-hold, #3146 ' + + 'status:parked). If that capability lands, this value returns WITH its writer — the ' + + 'emission point, its tests, and the view that surfaces it — never as a bare enum ' + + 'row again. ' + + '⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: ' + + 'the enum is not enforced on this object at all (`validateRecord` skips `readonly` ' + + 'fields), so any stored row keeps parsing and reading back, 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, #8315, ' + + '#7675, #8147.', + acceptanceCriteria: + 'No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was ' + + 'empty on every deployment before this change and behaves identically after it. ' + + 'Concretely, check three places. (1) Saved queries, dashboards and reports over ' + + '`sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — ' + + 'there is no action that carries the meaning, because the platform records no ' + + 'restore event. (2) Any code branching on the action string (a badge map, a label ' + + 'switch, an option list in an audit-log filter UI): the `restore` arm is ' + + 'unreachable and should go, and a `switch` with an exhaustiveness check over the ' + + 'enum type will now fail to compile if it stays — that compile error is the ' + + 'enforced channel for TypeScript consumers. An option in a FILTER dropdown is the ' + + 'user-visible half and matters most: it offers an operator a choice that returns ' + + 'nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this ' + + 'value: the write will NOT be refused (readonly fields are not validated), so it ' + + 'silently becomes 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',