Skip to content
72 changes: 72 additions & 0 deletions .changeset/audit-log-action-restore-retired.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered audit-log-action-restore-retired -->

**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.
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 25 additions & 8 deletions packages/plugins/plugin-audit/src/auth-event-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading
Loading