From ca08444d501342b6518e8964602f6c4628f88dda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:33:15 +0000 Subject: [PATCH 1/5] refactor(plugin-audit)!: retire restore from the sys_audit_log action enum (#8315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `restore` was declared by `sys_audit_log.action` with no writer anywhere in the repo, and the record-level writer structurally cannot produce it: `actionFor()` returns `'create' | 'update' | 'delete' | null`. Retired under the maintainer ruling of 2026-08-12 on #7675, carried by triage — the same operation #8147 performed on `export` / `permission_change`. Also corrects the false coverage comment in `auth-event-audit.ts`, which named `restore` among the actions the writer emits, and narrows the `writes_only` list-view filter to what `actionFor()` can actually emit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../audit-log-action-restore-retired.md | 72 +++++++++++++++++ .../plugin-audit/src/auth-event-audit.ts | 33 ++++++-- .../sys-audit-log-retired-actions.test.ts | 68 +++++++++++++++- .../src/objects/sys-audit-log.object.ts | 18 ++++- .../17.audit-log-action-restore-retired.ts | 80 +++++++++++++++++++ 5 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 .changeset/audit-log-action-restore-retired.md create mode 100644 packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts 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/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/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.', +}; From d2a3b2111cda72d0341d0cab201f3899391e46ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:41:02 +0000 Subject: [PATCH 2/5] chore(spec): regenerate the ADR-0087 registry projections for audit-log-action-restore-retired (#8315) `registry.ts`'s semantic table is GENERATED from `entries/` (#7297), and `spec-changes.json` + `docs/protocol-upgrade-guide.md` are projections of that registry. The entry file alone leaves the id absent from all three, which is exactly what `check-adr-0087-registration` reads when it resolves a changeset's `registered ` marker. Ran, in order: pnpm --filter @objectstack/spec gen:migration-registry pnpm --filter @objectstack/spec gen:spec-changes pnpm --filter @objectstack/spec gen:upgrade-guide Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- docs/protocol-upgrade-guide.md | 3 + packages/spec/spec-changes.json | 14 +++++ packages/spec/src/migrations/registry.ts | 76 ++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a1d8e2f64b..682100eb34 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -334,6 +334,9 @@ It also removes the three pass-through-only list-view display keys `striped` / ` - **`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/spec/spec-changes.json b/packages/spec/spec-changes.json index a8f7c4e8ff..b88f28ebb7 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -552,6 +552,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)", @@ -1554,6 +1561,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/registry.ts b/packages/spec/src/migrations/registry.ts index 6fa8341efb..50f160d62c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1886,6 +1886,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', From 02694c4701667ee186eb49031baac8625213fe9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:41:31 +0000 Subject: [PATCH 3/5] chore(plugin-audit): regenerate translation bundles after the restore retirement (#8315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four locales lose the `sys_audit_log.action.restore` option label. Produced by `node scripts/check-i18n-bundles.mjs --write --filter=audit` (which runs the built CLI's `os i18n extract` with the flags the config's own docstring documents) — not hand-edited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../plugin-audit/src/translations/en.objects.generated.ts | 1 - .../plugin-audit/src/translations/es-ES.objects.generated.ts | 1 - .../plugin-audit/src/translations/ja-JP.objects.generated.ts | 1 - .../plugin-audit/src/translations/zh-CN.objects.generated.ts | 1 - 4 files changed, 4 deletions(-) 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: "配置变更", From c985c4a61037f1b4b9e242b0916e3a4f73496fe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:34:53 +0000 Subject: [PATCH 4/5] chore(spec): regenerate the ADR-0087 projections after merging main (#8315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with main was textually conflict-free, but a conflict-free merge of two independently-regenerated projections is not the generator's output. Measured on this tree: the plain merge result was MISSING two sibling PRs' entries from `spec-changes.json` and `docs/protocol-upgrade-guide.md` — `view-export-options-pdf-removed` (#8010 / PR #8324) and `engine-dotted-projection-refused` (#7589 / PR #8327). Both are present in origin/main's copies of those artifacts; git dropped them while reporting no conflict. `registry.ts` spliced correctly and regenerated byte-identical (78 semantic entries) — the loss was confined to the two prose projections. Not a silent class: against the un-regenerated merge, `check:spec-changes` and `check:upgrade-guide` both FAIL (exit 1) while `check:migration-registry` passes. So this would have been caught — in the merge queue, as an ejection. Regenerating before arming is what makes it cost nothing. Ran on the merged tree, merge committed first: pnpm --filter @objectstack/spec gen:migration-registry pnpm --filter @objectstack/spec gen:spec-changes pnpm --filter @objectstack/spec gen:upgrade-guide Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- docs/protocol-upgrade-guide.md | 10 ++++++++++ packages/spec/spec-changes.json | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 682100eb34..72b2582901 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -240,6 +240,8 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, It also removes the three pass-through-only list-view display keys `striped` / `bordered` / `virtualScroll` (#7176, ADR-0049 enforce-or-remove, maintainer ruling 2026-08-10). All three were graded live on reads that turned out to be forwarding copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the key onto the next node, and the chain ends at ObjectGrid, which never spells any of the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in effect; per the ruling, if objectui wants one of these as real behavior, that is an implementation card filed first, and the key stays retired pending it. +Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, maintainer ruling 2026-08-12). PDF export was declined platform-side (#1301 NOT_PLANNED), so the member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, exactly the key set the renderer reads, ending the state where no declaration was both type-legal and functional — with the legacy bare array still accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum's error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed", plus a union-level dispatch so the refusal is the top-level message in either authored form. The strip keeps an emptied `formats` array rather than deleting the declaration. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -264,6 +266,7 @@ It also removes the three pass-through-only list-view display keys `striped` / ` | `flow-inert-keys-removed` | `flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId` | flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only | | `view-inert-keys-removed` | `view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria` | view keys removed (#3896 close-out): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live) | retired — `migrate meta` only | | `view-list-passthrough-keys-removed` | `view.list.striped / view.list.bordered / view.list.virtualScroll` | view list keys removed (#7176): 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (pass-through-only; ADR-0049 enforce-or-remove) | retired — `migrate meta` only | +| `view-export-options-pdf-removed` | `view.list.exportOptions / view.listViews.*.exportOptions` | list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn) | retired — `migrate meta` only | | `dashboard-inert-keys-removed` | `dashboard.aria / dashboard.performance / dashboard.widgets[].performance` | dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them) | retired — `migrate meta` only | | `dashboard-widget-responsive-removed` | `dashboard.widgets[].responsive` | dashboard widget key 'responsive' removed (#4876 — no renderer ever applied per-widget breakpoint overrides; page.components[].responsive is unaffected) | retired — `migrate meta` only | | `dashboard-widget-action-aria-removed` | `dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria` | dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description) | retired — `migrate meta` only | @@ -394,6 +397,13 @@ What makes this one cheaper to meet than its two siblings, and worth saying beca - **`driver-sql-distinct-bare-filter-typed`** — `SqlDriver.distinct() third argument — any value` → a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope - Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320. - Done when: No caller passes a non-object to `distinct()`'s third argument. A scalar there is now a compile error (`TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'`); rewrite it as the bare filter it was always meant to be — `'completed'` becomes `{ status: 'completed' }`. ⚠️ That is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the answer changes once fixed, and the changed answer is the one the call always meant. An untyped JS caller gets no compile error and no behaviour change — for them this entry is the only notice that the spelling never filtered anything. A query envelope or a FilterArray in that slot still compiles and is rejected at run time with INVALID_FILTER / 400. +- **`engine-dotted-projection-refused`** — `engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress` → read the related record with `expand` (`{ expand: { account: { object: '', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve (#7537); or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress has prescribed since #7532, and the sort axis since #6924 + - Why not automatic: #7532 (PR #7588) closed the PROJECTION axis' dotted leg at the REST ingress (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed (#7589): a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `"account"."name"` against a table that was never joined, the DB answers `no such column`, and the driver's #3821 recovery ladder retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation. + +Ruled 2026-08-12 on #7589 (Option B): a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths "via populate"; #7601 measured that NO populate step exists — after PR #7617 that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side #3821 ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the sort axis (#7095) records for the same catch. + +This is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration. #7589, #7532, #7601, #3821, #5918, ADR-0112. + - Done when: No `engine.find` / `engine.findOne` call site passes a dotted `fields` entry, no flow `get_record` config authors one, and no saved report's `query.fields` names one — grep flow definitions and report definitions for a `fields` entry containing a `.`, and rewrite each to `expand` (keeping the reference column projected) or to a denormalised stored column. Reads complete with no `INVALID_FIELD` whose message says "follows the relationship" or "a dotted path", and no "Failed to expand relationship field" warning whose error text does. - **`engine-find-formula-order-by-refused`** — `engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress` → denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column - Why not automatic: #4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index b88f28ebb7..4dd6e5a0da 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -188,6 +188,12 @@ "conversionId": "view-list-passthrough-keys-removed", "toMajor": 17 }, + { + "surface": "view.list.exportOptions / view.listViews.*.exportOptions", + "to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)", + "conversionId": "view-export-options-pdf-removed", + "toMajor": 17 + }, { "surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance", "to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)", @@ -678,6 +684,13 @@ "toMajor": 17, "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320." }, + { + "surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress", + "replacement": "read the related record with `expand` (`{ expand: { account: { object: '', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve (#7537); or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress has prescribed since #7532, and the sort axis since #6924", + "migrationId": "engine-dotted-projection-refused", + "toMajor": 17, + "rationale": "#7532 (PR #7588) closed the PROJECTION axis' dotted leg at the REST ingress (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed (#7589): a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `\"account\".\"name\"` against a table that was never joined, the DB answers `no such column`, and the driver's #3821 recovery ladder retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.\n\nRuled 2026-08-12 on #7589 (Option B): a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths \"via populate\"; #7601 measured that NO populate step exists — after PR #7617 that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side #3821 ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the sort axis (#7095) records for the same catch.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration. #7589, #7532, #7601, #3821, #5918, ADR-0112." + }, { "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress", "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column", @@ -1267,6 +1280,12 @@ "conversionId": "view-list-passthrough-keys-removed", "toMajor": 17 }, + { + "surface": "view.list.exportOptions / view.listViews.*.exportOptions", + "to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)", + "conversionId": "view-export-options-pdf-removed", + "toMajor": 17 + }, { "surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance", "to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)", @@ -1687,6 +1706,13 @@ "toMajor": 17, "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320." }, + { + "surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress", + "replacement": "read the related record with `expand` (`{ expand: { account: { object: '', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve (#7537); or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress has prescribed since #7532, and the sort axis since #6924", + "migrationId": "engine-dotted-projection-refused", + "toMajor": 17, + "rationale": "#7532 (PR #7588) closed the PROJECTION axis' dotted leg at the REST ingress (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed (#7589): a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `\"account\".\"name\"` against a table that was never joined, the DB answers `no such column`, and the driver's #3821 recovery ladder retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.\n\nRuled 2026-08-12 on #7589 (Option B): a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths \"via populate\"; #7601 measured that NO populate step exists — after PR #7617 that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side #3821 ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the sort axis (#7095) records for the same catch.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration. #7589, #7532, #7601, #3821, #5918, ADR-0112." + }, { "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress", "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column", From 5f8870beaa2ec9c1d8ae011137e5f105f15b13dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:43:58 +0000 Subject: [PATCH 5/5] chore(spec): regenerate the ADR-0087 projections after merging main again (#8315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third lap of merge-then-regenerate; main moved 25 commits while CI was green and the arm was held. The conflict-free merge again left the two driver-managed prose projections stale, this time missing `datasource-config-url-userinfo-refused` (#8082 / PR #8341): present in origin/main's copies, absent after the merge, restored by regeneration. Round-2's prediction held exactly. `registry.ts` is byte-identical to the plain merge (79 semantic entries) because it is NOT_DRIVER_MANAGED and #8341 added a NEW entry file — the #7297 per-entry-file sharding removes the conflict at the source. The two `merge=os-regen` projections are the ones that needed the generator: spec-changes.json +14 lines, protocol-upgrade-guide.md +3. Regeneration only — no code change, no changeset reclassification, no ratchet. Merge committed first, then: pnpm --filter @objectstack/spec gen:migration-registry pnpm --filter @objectstack/spec gen:spec-changes pnpm --filter @objectstack/spec gen:upgrade-guide Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 72b2582901..ad7e2a2ac7 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -376,6 +376,9 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - **`datasource-config-inline-credential-refused`** — `datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)` → the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference - Why not automatic: A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead. - Done when: Every datasource parses with no `config.password` / `config.authToken` key; each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no cleartext credential remains in any stored `sys_metadata` row or authored source. +- **`datasource-config-url-userinfo-refused`** — `datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)` → the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference + - Why not automatic: The #7990 closure refused the inline credential KEYS, and #8078 measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there (#8082, maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (#8078, measured). + - Done when: Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no userinfo password segment); each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no URL-embedded credential remains in any stored `sys_metadata` row or authored source. - **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }` - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 4dd6e5a0da..505439b6c7 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -649,6 +649,13 @@ "toMajor": 17, "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." }, + { + "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", + "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", + "migrationId": "datasource-config-url-userinfo-refused", + "toMajor": 17, + "rationale": "The #7990 closure refused the inline credential KEYS, and #8078 measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there (#8082, maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (#8078, measured)." + }, { "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", @@ -1671,6 +1678,13 @@ "toMajor": 17, "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." }, + { + "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", + "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", + "migrationId": "datasource-config-url-userinfo-refused", + "toMajor": 17, + "rationale": "The #7990 closure refused the inline credential KEYS, and #8078 measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there (#8082, maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (#8078, measured)." + }, { "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`",