From 00a79d51eb0c61e39c76678374ad2237903fa169 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:46:09 +0000 Subject: [PATCH] fix(security): retire delegated_from from sys_user_permission_set (ADR-0049 enforce-or-remove) The runtime delegation gate is structurally scoped to sys_user_position, so on the permission-set grant table the column was declared and data-door- writable while no runtime consumer read it. Maintainer ruling 2026-08-18: REMOVE. Lint D3 rule scoped to the position table; docs per-object grant- column table updated (#9876); ADR-0087 semantic ledger entry ups-delegated-from-column-retired registered. Fixes #9730 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../lint-d3-delegation-rule-position-only.md | 18 +++++ .../ups-delegated-from-column-retired.md | 47 ++++++++++++++ content/docs/permissions/authorization.mdx | 26 +++++--- docs/protocol-upgrade-guide.md | 3 + ...e-security-posture.runtime-surface.test.ts | 24 +++++-- .../src/validate-security-posture.test.ts | 22 ++++++- .../lint/src/validate-security-posture.ts | 17 ++++- .../src/objects/rbac-objects.test.ts | 24 ++++++- .../objects/sys-user-permission-set.object.ts | 28 +++++--- .../src/translations/en.objects.generated.ts | 4 -- .../translations/es-ES.objects.generated.ts | 4 -- .../translations/ja-JP.objects.generated.ts | 4 -- .../translations/zh-CN.objects.generated.ts | 4 -- packages/spec/spec-changes.json | 14 ++++ .../17.ups-delegated-from-column-retired.ts | 65 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 61 +++++++++++++++++ 16 files changed, 322 insertions(+), 43 deletions(-) create mode 100644 .changeset/lint-d3-delegation-rule-position-only.md create mode 100644 .changeset/ups-delegated-from-column-retired.md create mode 100644 packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts diff --git a/.changeset/lint-d3-delegation-rule-position-only.md b/.changeset/lint-d3-delegation-rule-position-only.md new file mode 100644 index 0000000000..5a91efafcc --- /dev/null +++ b/.changeset/lint-d3-delegation-rule-position-only.md @@ -0,0 +1,18 @@ +--- +"@objectstack/lint": minor +--- + +fix(lint): the ADR-0091 D3 "delegation row needs a reason" rule is scoped to `sys_user_position` (#9730) + +`delegated_from` was retired from `sys_user_permission_set` (ADR-0049 +enforce-or-remove, maintainer ruling 2026-08-18), so the security-posture +lint's D3 dual-audit rule no longer reads the key on that table — linting a +retired column would imply it still exists, and on that table this rule was +the column's *only* enforcement, which is exactly the advisory-security shape +the ruling removed. A seed row that still carries the key is refused loudly +downstream by the engine's schema preflight (`400 INVALID_FIELD`). + +The D2 rule (a seed grant whose `valid_until` is already past or unparseable +is dead on arrival) still covers **both** grant tables — `valid_until` remains +declared and resolution-enforced on both. Only the two rules' object scopes +diverge; no rule id, severity or message changed. diff --git a/.changeset/ups-delegated-from-column-retired.md b/.changeset/ups-delegated-from-column-retired.md new file mode 100644 index 0000000000..3a4bd8bef4 --- /dev/null +++ b/.changeset/ups-delegated-from-column-retired.md @@ -0,0 +1,47 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/spec": minor +--- + +fix(security): **BREAKING** — `sys_user_permission_set` retires the `delegated_from` column (ADR-0049 enforce-or-remove, #9730) + +Maintainer ruling 2026-08-18: **REMOVE**. The runtime delegation gate is +structurally scoped to `sys_user_position` — `isDelegationWrite` returns `false` +for every other object, so `assertSelfDelegation` was unreachable for +`sys_user_permission_set` — and the explain engine reads delegation provenance +from position rows only. On the permission-set grant table the column was +therefore declared and data-door-writable while **no runtime consumer read +it**: its only enforcement was the authoring-time lint rule requiring a +`reason` on delegation rows, which a row written through the generic data door +never meets. A declared-but-unenforced writable column on a security object is +the declare-not-enforce trap in its pure form — an author stamping +`delegated_from` on a permission-set grant believed they constrained +delegation, and nothing refused or honoured it. Producers measured at zero: +the only object literals naming both the table and the column were lint test +fixtures. + +Migration (FROM → TO): + +| Wrote | Write instead | +|---|---| +| `delegated_from` on a `sys_user_permission_set` seed row or data-door write | Delete the key. Provenance prose belongs in `reason` (still declared on both grant tables); actual delegation-of-duty belongs on `sys_user_position`, where `delegated_from` remains declared **and** runtime-enforced (ADR-0091 D3). | + +One-line fix: delete `delegated_from` from any authored `sys_user_permission_set` row. + + + +Enforcement after the removal is loud, not silent: the engine's schema +preflight refuses an undeclared field with `400 INVALID_FIELD` before the +driver or any hook runs, so a stale seed or client write is told exactly what +to remove. Physical columns on already-deployed databases are untouched +(ADR-0045 schema sync is additive); the platform stops declaring, projecting +and accepting the column. The sibling `sys_user_position.delegated_from` — the +enforced half of ADR-0091 D3 — is untouched, pinned by test. If +permission-set-granularity delegation ever becomes a real need, the column is +re-declared **with a runtime reader in the same PR** — declare-and-enforce or +don't declare. + +The docs' per-object grant-column table (`content/docs/permissions/ +authorization.mdx`) now records the retirement, and the security-posture +lint's D3 rule is scoped to the position table (see the `@objectstack/lint` +changeset). diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 066f4b8b71..f8aab398e7 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -297,11 +297,17 @@ Every user-grant row (`sys_user_position`, `sys_user_permission_set`) carries optional **effective-dating columns** — `valid_from` / `valid_until` (half-open `[from, until)`, UTC; null = unbounded). -ADR-0091 D1 declares four further nullable columns on both grant tables: -`reason`, `delegated_from`, `last_certified_at`, `certified_by`. They are -declared together and **enforced separately**, so they are listed here one by -one rather than as one set of audit columns — what a value in any of them is -worth depends on the column *and* on which grant table it sits on. Access +ADR-0091 D1 declared four further nullable columns on both grant tables: +`reason`, `delegated_from`, `last_certified_at`, `certified_by`. Three of the +four are still declared on both; `delegated_from` now exists **only on +`sys_user_position`** — it was retired from `sys_user_permission_set` under +ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): the runtime +delegation gate is structurally scoped to the position table, so on the +permission-set table the column was writable provenance no runtime consumer +ever read. The columns are declared together but **enforced separately**, so +they are listed here one by one rather than as one set of audit columns — +what a value in any of them is worth depends on the column *and* on which +grant table it sits on. Access recertification is a compliance surface (SOX / ISO 27001 access review), where "the platform maintains this column" and "the platform stores what you write here" are very different statements: @@ -309,7 +315,7 @@ here" are very different statements: | Column | On `sys_user_position` | On `sys_user_permission_set` | |---|---|---| | `reason` | **Enforced at runtime.** The D3 delegation gate rejects a delegation insert whose row carries no non-empty `reason` — the dual-audit half described below. | **Written by the platform, read by nothing.** The org-admin grant auto-derived from a membership grade stamps its own provenance here; no gate, resolver or lint reads the value back. | -| `delegated_from` | **Enforced at runtime, and load-bearing.** Stamping it is what *makes* a write a delegation: the gate requires it to name the writer and refuses to re-delegate a row that itself arrived by delegation, and the explain engine attributes the position "via delegation from X, until Y". | **Authoring lint only — no runtime reader.** Both readers opposite are guarded on `sys_user_position` (the gate's delegation branch, and explain's delegation attribution alike). A seed row that sets it must still carry a `reason` (below), but at runtime the column is provenance you record, not provenance the platform checks or acts on. | +| `delegated_from` | **Enforced at runtime, and load-bearing.** Stamping it is what *makes* a write a delegation: the gate requires it to name the writer and refuses to re-delegate a row that itself arrived by delegation, and the explain engine attributes the position "via delegation from X, until Y". | **Not declared — retired.** Removed under ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): both runtime readers opposite are guarded on `sys_user_position`, so here the column was provenance an author could record and nothing checked or acted on. A row written with the key today is refused as an undeclared field (`400 INVALID_FIELD`). If permission-set-granularity delegation is ever wanted, the column returns together with a runtime reader in the same change. | | `last_certified_at` / `certified_by` | **Inert** — the ADR-0091 D5 recertification *substrate*, storage and nothing more. | **Inert** — identically. | The D5 pair is worth spelling out, because it is the pair a compliance reader @@ -334,9 +340,11 @@ contributor state ("held until 2026-08-01 — expired"), so "why did access disappear" is self-answering. Two authoring lint rules cover seed grants: one mirrors D2 — a seed grant whose `valid_until` is already past (or unparseable) is dead on arrival (error) — and one mirrors the D3 dual audit: a delegation -row (`delegated_from`) without `reason` is an error. The second runs on **both** -grant tables, so on `sys_user_permission_set`, where no runtime gate reads -`delegated_from`, it is the only enforcement that column has. +row (`delegated_from`) without `reason` is an error. The first runs on **both** +grant tables; the second runs on `sys_user_position` only, the one table that +declares `delegated_from` — on `sys_user_permission_set` this lint used to be +the retired column's only enforcement, which is precisely why the column is +gone rather than still linted. **Delegation of duty (职务代理, ADR-0091 D3)** builds on this substrate and is enforced today. A position opts in with `delegatable: true`; a holder may then diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index e27cddbac8..c988e6f7b4 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -547,6 +547,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - **`ui-widget-i18n-family-retired`** — `ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)` → (removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second) - Why not automatic: `ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055. - Done when: No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, `WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, `WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, `NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or `LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes: a stack that parsed before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / `FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the "a workaround should not outlive its reason" half of the objectui#3169 tripwire, designed to go red exactly here), and `packages/types/src/widget.ts`'s "Renamed off the spec's `WidgetManifest` name" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage. +- **`ups-delegated-from-column-retired`** — `sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched` → nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes "via delegation from X, until Y". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables + - Why not automatic: Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 "delegation row needs a reason" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. + - Done when: No authored stack seeds `delegated_from` on a sys_user_permission_set record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for delegated_from next to sys_user_permission_set — delete the key from any seed row; a row that was recording genuine hand-over provenance should say it in `reason` instead, which the platform stores on both grant tables. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant actual delegation-of-duty, author it where it is enforced: a sys_user_position insert with delegated_from = the writer, a mandatory future valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the delegated-admin gate then validates the whole shape at runtime. - **`view-filter-rule-value-shaped-by-operator`** — `ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged` → an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" becomes value: ["won"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse - Why not automatic: A publish-time gate catching up to a query-time one, not a new rule. #5869 / PR #6209 closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: "won" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because #5685 already ruled on the opposite error: a schema stricter than the runtime "in ways the runtime deliberately allows" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: ["a","b"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: ""` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: "" would become the predicate [""] (a real filter on the empty string) rather than the "not filled in yet" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112. - Done when: Grep your authored views, pages and related-list components for a filter rule whose operator is in, not_in or between (including the alias spellings nin / notIn / notin) and whose value is not an array of the right arity, then wrap or complete it. `os validate` / `os lint` now report each one by path with the operator, the received shape and the corrected shape, so the sweep is mechanical rather than by eye. Two checks are worth doing where it looks unnecessary: a rule reading `operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was meant to select rather than mechanically rewriting it to [""], which is a real and different predicate. And a view that already carried one of these shapes was never returning filtered rows: it answered 400 INVALID_FILTER on render (#5869), so re-check what the view is supposed to show rather than assuming the old result set was correct. diff --git a/packages/lint/src/validate-security-posture.runtime-surface.test.ts b/packages/lint/src/validate-security-posture.runtime-surface.test.ts index f9da08f220..552c8545eb 100644 --- a/packages/lint/src/validate-security-posture.runtime-surface.test.ts +++ b/packages/lint/src/validate-security-posture.runtime-surface.test.ts @@ -458,13 +458,28 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830 ); const undocumentedDelegation = { - object: 'sys_user_permission_set', - records: [{ user_id: 'u1', permission_set: 'billing', delegated_from: 'u2' }], + // sys_user_position — the ONLY table the D3 rule covers since #9730 + // retired `delegated_from` from sys_user_permission_set. + object: 'sys_user_position', + records: [{ user_id: 'u1', position: 'billing_approver', delegated_from: 'u2' }], }; const delegatedReal = runRuntimeAuthoringRules({ type: 'seed', item: undocumentedDelegation }); expect(delegatedReal.errors.map((f) => f.rule)).toEqual([SECURITY_DELEGATION_MISSING_REASON]); expect(delegatedReal.errors[0].severity).toBe('error'); + // [#9730] The retired half, pinned at the same real gate: a + // sys_user_permission_set seed carrying the retired `delegated_from` key + // draws NO D3 finding — the column does not exist on that table any more, + // and the lint no longer implies it does. (The stale key itself is refused + // downstream by the engine's schema preflight as an undeclared field.) + const retiredKeyOnPermissionSet = { + object: 'sys_user_permission_set', + records: [{ user_id: 'u1', permission_set: 'billing', delegated_from: 'u2' }], + }; + const retiredReal = runRuntimeAuthoringRules({ type: 'seed', item: retiredKeyOnPermissionSet }); + expect(retiredReal.errors).toEqual([]); + expect(retiredReal.advisories).toEqual([]); + // The pre-#7576 spelling, shown to be the inert state it was: the seed // lands on a key no rule reads, and the gate reports a clean write. const onTheOldKey = validateSecurityPosture({ objects: [], seeds: [expiredGrant] }); @@ -481,8 +496,9 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830 records: [{ user_id: 'u1', position: 'field_ops', valid_until: '2099-01-01T00:00:00Z' }], }; const cleanDelegation = { - object: 'sys_user_permission_set', - records: [{ user_id: 'u1', permission_set: 'billing', delegated_from: 'u2', reason: 'vacation stand-in' }], + // sys_user_position: the one table delegation rows live on since #9730. + object: 'sys_user_position', + records: [{ user_id: 'u1', position: 'field_ops', delegated_from: 'u2', valid_until: '2099-01-01T00:00:00Z', reason: 'vacation stand-in' }], }; for (const item of [cleanGrant, cleanDelegation]) { const real = runRuntimeAuthoringRules({ type: 'seed', item }); diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index a577adc90e..e0b1a674da 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -607,6 +607,22 @@ describe('validateSecurityPosture · book audience (ADR-0046 §6.7 / ADR-0090)', }); }); + it('the D3 reason rule is scoped to sys_user_position — `delegated_from` is retired from sys_user_permission_set (#9730)', () => { + // Maintainer ruling 2026-08-18 (ADR-0049 enforce-or-remove, REMOVE): the + // runtime delegation gate never read `delegated_from` on + // `sys_user_permission_set`, so this rule was that column's ONLY + // enforcement — authoring-advisory security. The column no longer exists + // on that object; a seed row still carrying the key is refused by the + // engine's schema preflight (400 INVALID_FIELD), not re-linted here as if + // the column were still declared. D2 (valid_until) still covers this + // object — asserted by the unparseable-valid_until case above. + const findings = validateSecurityPosture( + { data: [{ object: 'sys_user_permission_set', records: [{ user_id: 'u1', permission_set_id: 'ps1', delegated_from: 'u2' }] }] }, + { nowMs: NOW }, + ); + expect(findings.filter((f) => f.rule === SECURITY_DELEGATION_MISSING_REASON)).toEqual([]); + }); + it('stays silent on unbounded grants and non-grant seed objects', () => { expect( validateSecurityPosture( @@ -998,7 +1014,11 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: Record }, { label: 'delegation-missing-reason', - stack: { data: [{ object: 'sys_user_permission_set', records: [{ user_id: 'u1', permission_set: 'ps', delegated_from: 'u2' }] }] }, + // sys_user_position, NOT sys_user_permission_set: `delegated_from` was + // retired from the permission-set table (#9730), and the D3 branch is + // scoped to the position table with it — a permission-set fixture can no + // longer reach this push site at all. + stack: { data: [{ object: 'sys_user_position', records: [{ user_id: 'u1', position: 'approver', delegated_from: 'u2' }] }] }, }, ]; diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index e72a8c8779..38f68f05df 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -583,7 +583,19 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // Both rules mirror runtime enforcement (D2 resolution-time filtering; the // D3 delegation gate), per the ADR-0049 "no advisory security" discipline: // the lint moves the failure from silent-dead-grant to author-time fix-it. + // The two rules deliberately do NOT share one object scope: D2 covers both + // grant tables (`valid_until` is declared and resolution-enforced on both), + // while D3 is scoped to `sys_user_position` only — `delegated_from` was + // RETIRED from `sys_user_permission_set` (#9730, maintainer ruling + // 2026-08-18, ADR-0049 enforce-or-remove: the runtime delegation gate is + // structurally scoped to the position table, so on the permission-set table + // this rule was the column's ONLY enforcement — authoring-advisory security + // on a column no runtime consumer read). A seed row that still carries the + // key there is refused by the engine's schema preflight (400 INVALID_FIELD) + // as an undeclared field, which is louder and located; linting the retired + // key here again would imply the column still exists. const GRANT_SEED_OBJECTS = new Set(['sys_user_position', 'sys_user_permission_set']); + const DELEGATION_SEED_OBJECTS = new Set(['sys_user_position']); const nowMs = opts?.nowMs ?? Date.now(); for (const [i, seed] of asArray(stack.data).entries()) { const seedObject = typeof seed.object === 'string' ? seed.object : ''; @@ -624,9 +636,10 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } } // D3: delegation rows (delegated_from set) MUST carry a reason — the - // dual-audit half the runtime gate also rejects. + // dual-audit half the runtime gate also rejects. Position table only: + // `delegated_from` is not declared on `sys_user_permission_set` (#9730). const delegatedFrom = rec.delegated_from; - if (delegatedFrom != null && delegatedFrom !== '') { + if (DELEGATION_SEED_OBJECTS.has(seedObject) && delegatedFrom != null && delegatedFrom !== '') { const reason = rec.reason; if (typeof reason !== 'string' || reason.trim().length === 0) { findings.push({ diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index d2500d389f..aed704da90 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { SysPosition, SysPermissionSet, SysCapability, defaultPermissionSets } from './index.js'; +import { SysPosition, SysPermissionSet, SysCapability, SysUserPermissionSet, SysUserPosition, defaultPermissionSets } from './index.js'; /** * RBAC object + default-permission-set assertions. Moved here with the objects @@ -268,3 +268,25 @@ describe('sys_capability — ADR-0066 D1 capability registry', () => { expect(nameIdx?.unique).toBe('organization'); }); }); + +describe('sys_user_permission_set — `delegated_from` is retired (#9730, ADR-0049)', () => { + it('no longer declares `delegated_from` — the runtime delegation gate never read it here', () => { + // Maintainer ruling 2026-08-18 (REMOVE): the delegation gate's + // `isDelegationWrite` is structurally scoped to `sys_user_position`, so on + // this object the column was declared and data-door-writable while no + // runtime consumer read it — authoring-lint-only enforcement on a security + // object. Re-declaring the key here without a runtime reader in the same + // change is the exact defect the ruling removed; this pin makes that + // re-growth loud. + expect(SysUserPermissionSet.fields).not.toHaveProperty('delegated_from'); + }); + + it('the ADR-0091 D3 declaration on the sibling `sys_user_position` is untouched', () => { + // The ruling removes the UNENFORCED half only. Position-table delegation + // stays declared AND enforced (delegated-admin gate, explain engine, lint). + const f: any = (SysUserPosition.fields as any).delegated_from; + expect(f).toBeDefined(); + expect(f.type).toBe('lookup'); + expect(f.reference).toBe('sys_user'); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 9b30c77187..5982129da6 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -90,13 +90,19 @@ export const SysUserPermissionSet = ObjectSchema.create({ 'Agent grants carry the task/run attribution here (D6).', }), - delegated_from: Field.lookup('sys_user', { - label: 'Delegated From', - required: false, - description: - '[ADR-0091 D3] The delegator whose authority this row carries. ' + - 'A row with delegated_from set is not itself delegatable and not self-renewable.', - }), + // [#9730] `delegated_from` was RETIRED from this object (maintainer ruling + // 2026-08-18, ADR-0049 enforce-or-remove). The runtime delegation gate is + // structurally scoped to `sys_user_position` (`delegated-admin-gate.ts` + // `isDelegationWrite`), so on THIS table the column was enforced at + // authoring time only while staying data-door-writable — a declared-but- + // unenforced surface on a security object, with zero producers measured + // across packages/, apps/ and examples/. The sibling declaration on + // `sys_user_position` is untouched and fully enforced (gate + explain + // engine + lint). If delegation at permission-set granularity ever becomes + // a real need, it is re-declared WITH a runtime reader in the same PR — + // declare-and-enforce or don't declare. A write that still carries the key + // is refused loudly by the engine's schema preflight (400 INVALID_FIELD). + // Ledger: `ups-delegated-from-column-retired` (ADR-0087 semantic entry). // [#9046] ADR-0091 D5 calls these two columns the recertification // "substrate", and they are exactly that and nothing more. A whole-tree @@ -105,9 +111,11 @@ export const SysUserPermissionSet = ObjectSchema.create({ // generated i18n bundles that carry their strings. No producer, no // consumer - nothing stamps them, nothing reads them, and no surface // derives "never certified" or "certification stale". Their siblings on - // this object are not like that: valid_from/valid_until are enforced by - // isGrantActive at resolution time, and reason/delegated_from are read by - // the delegated-admin gate and the security-posture lint. + // this object are not like that in the same way: valid_from/valid_until + // are enforced by isGrantActive at resolution time, and `reason` is + // stamped by the platform's own writer (auto-org-admin-grant provenance). + // (`delegated_from` used to be listed here too — it was retired from this + // object, see the [#9730] note above.) // // The old descriptions ("When this grant was last attested in a // recertification review", "Reviewer who last attested this grant") stated diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index a1e34584ae..1a06d25c48 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -323,10 +323,6 @@ export const enObjects: NonNullable = { label: "Reason", help: "[ADR-0091 D1] Why this grant exists. Free text; REQUIRED on delegation (D3) and break-glass (D4) rows. Agent grants carry the task/run attribution here (D6)." }, - delegated_from: { - label: "Delegated From", - help: "[ADR-0091 D3] The delegator whose authority this row carries. A row with delegated_from set is not itself delegatable and not self-renewable." - }, last_certified_at: { label: "Last Certified At", help: "[ADR-0091 D5] Reserved for a future access-recertification workflow, which would stamp here when this grant was last attested. Inert today: no platform code writes this column and none reads it — no resolution path, gate or lint consults it, and nothing derives \"never certified\" or \"certification stale\" from it. Null therefore means the workflow does not exist, not that this grant went unreviewed." diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 6426c410dc..0f9dc149e0 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -323,10 +323,6 @@ export const esESObjects: NonNullable = { label: "Motivo", help: "[ADR-0091 D1] Por qué existe esta concesión. Texto libre; OBLIGATORIO en filas de delegación (D3) y de acceso de emergencia (D4). Las concesiones de agente registran aquí la atribución de tarea/ejecución (D6)." }, - delegated_from: { - label: "Delegado desde", - help: "[ADR-0091 D3] El delegante cuya autoridad porta esta fila. Una fila con delegated_from no es a su vez delegable ni autorrenovable." - }, last_certified_at: { label: "Última certificación", help: "[ADR-0091 D5] Reservada para un futuro flujo de recertificación de accesos, que registraría aquí cuándo se atestiguó por última vez esta concesión. Hoy es inerte: ningún código de la plataforma escribe esta columna ni la lee; ninguna ruta de resolución, control ni regla de lint la consulta, y nada deriva de ella «nunca certificada» ni «certificación obsoleta». Por tanto, un valor nulo significa que el flujo no existe, no que esta concesión no se haya revisado." diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index a6c48a8a88..6144f228fc 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -323,10 +323,6 @@ export const jaJPObjects: NonNullable = { label: "理由", help: "[ADR-0091 D1] この付与が存在する理由。自由記述。委任(D3)とブレークグラス(D4)の行では必須。エージェント付与はここにタスク/実行の帰属を記録します(D6)。" }, - delegated_from: { - label: "委任元", - help: "[ADR-0091 D3] この行が引き継ぐ権限の委任元。delegated_from が設定された行は、それ自体を再委任することも自己更新することもできません。" - }, last_certified_at: { label: "最終認証日時", help: "[ADR-0091 D5] 将来のアクセス再認証ワークフロー用に予約された列で、その際にこの付与が最後に証明された日時を記録します。現時点では不活性です。プラットフォームはこの列に書き込まず、読み取るコードもありません。解決経路もゲートも lint も参照せず、「未認証」や「認証が古い」を導出する箇所もありません。したがって Null はワークフローが存在しないことを意味するだけで、この付与が未レビューであることを意味しません。" diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index ebb7887397..5ca1fa9850 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -323,10 +323,6 @@ export const zhCNObjects: NonNullable = { label: "原因", help: "[ADR-0091 D1] 该授权存在的原因。自由文本;委派(D3)和紧急提权(D4)记录必填。智能体授权在此记录任务/运行归属(D6)。" }, - delegated_from: { - label: "委派自", - help: "[ADR-0091 D3] 该记录所承载权限的委派人。设置了 delegated_from 的记录本身不可再委派,也不可自助续期。" - }, last_certified_at: { label: "最近认证时间", help: "[ADR-0091 D5] 为未来的访问权限重新认证(定期复核)流程预留:届时用于记录此授予最近一次被认证的时间。当前该列是惰性的:平台不会写入它,也没有任何代码读取它——没有任何解析路径、门禁或 lint 会参考它,也没有任何地方据此推导出「从未认证」或「认证已过期」。因此为空只表示该流程尚不存在,并不表示此授予未经复核。" diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 37c741dff6..0a6c3cb327 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -978,6 +978,13 @@ "toMajor": 17, "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055." }, + { + "surface": "sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched", + "replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables", + "migrationId": "ups-delegated-from-column-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + }, { "surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged", "replacement": "an ARRAY for in / not_in (a single value becomes a one-element list: value: \"won\" becomes value: [\"won\"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse", @@ -2035,6 +2042,13 @@ "toMajor": 17, "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055." }, + { + "surface": "sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched", + "replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables", + "migrationId": "ups-delegated-from-column-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + }, { "surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged", "replacement": "an ARRAY for in / not_in (a single value becomes a one-element list: value: \"won\" becomes value: [\"won\"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse", diff --git a/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts new file mode 100644 index 0000000000..f6beff545e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ups-delegated-from-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the ' + + 'platform grant table declared by plugin-security (packages/plugins/plugin-security/' + + 'src/objects/sys-user-permission-set.object.ts). The sibling declaration on ' + + 'sys_user_position is untouched', + replacement: + 'nothing on this table — delete the key from any authored `sys_user_permission_set` ' + + 'seed row (stack `data` entries) or data-door write that still carries it. ' + + 'Delegation semantics live on `sys_user_position`, where `delegated_from` remains ' + + 'declared AND runtime-enforced: the delegated-admin gate is what makes a position ' + + 'insert a delegation, and the explain engine attributes "via delegation from X, ' + + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' + + '(free text), which remains declared on both grant tables', + reason: + 'Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The ' + + 'runtime delegation gate is structurally scoped to sys_user_position ' + + '(`isDelegationWrite` returns false for every other object, so ' + + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' + + 'reads delegation provenance from sys_user_position rows only. On ' + + 'sys_user_permission_set the column was therefore declared and data-door-writable ' + + 'while NO runtime consumer read it — its only enforcement was an authoring-time ' + + 'lint (the D3 "delegation row needs a reason" rule), which a row written through ' + + 'the generic data door never meets. That is declared-but-unenforced in its pure ' + + 'form, on a security object: an author who stamped delegated_from on a ' + + 'permission-set grant believed they constrained delegation, and nothing refused or ' + + 'honoured it. Producers measured at zero — the only object literals naming both ' + + 'the table and the column were lint test fixtures. ' + + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' + + 'bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in ' + + 'RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are ' + + 'expected byte-identical), and the disposition is a SEMANTIC entry rather than a ' + + 'D2 conversion. A conversion over stack `data` seed records would be mechanically ' + + 'expressible, but no conversion in the chain rewrites seed rows today and the ' + + 'measured author base is zero; the loud channel already exists at runtime — the ' + + 'engine schema preflight refuses an undeclared field with 400 INVALID_FIELD ' + + 'before the driver or any hook runs — so this entry carries the prescription and ' + + 'the refusal carries the enforcement. ' + + '⚠️ Existing physical columns are deliberately untouched: schema sync is additive ' + + '(ADR-0045), so a deployed database keeps the column; the platform stops ' + + 'declaring, projecting or accepting it. Zero producers means no rows are expected ' + + 'to carry a value; no backfill or destructive DDL is required or wanted. ' + + 'If delegation at permission-set granularity ever becomes a real need, the column ' + + 'is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' + + 'or do not declare.', + acceptanceCriteria: + 'No authored stack seeds `delegated_from` on a sys_user_permission_set record, and ' + + 'no client write to that table carries the key. Concretely: (1) grep your stack ' + + 'sources for delegated_from next to sys_user_permission_set — delete the key from ' + + 'any seed row; a row that was recording genuine hand-over provenance should say it ' + + 'in `reason` instead, which the platform stores on both grant tables. (2) Boot and ' + + 'load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD ' + + 'naming the column — that refusal is the enforced channel, not a silent drop. ' + + '(3) If you meant actual delegation-of-duty, author it where it is enforced: a ' + + 'sys_user_position insert with delegated_from = the writer, a mandatory future ' + + 'valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the ' + + 'delegated-admin gate then validates the whole shape at runtime.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 048392053e..5ff2ab96a0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4653,6 +4653,67 @@ const step17: MigrationStep = { + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no ' + 'longer exist. Both are prescribed responses to this removal, not collateral damage.', }, + { + id: 'ups-delegated-from-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the ' + + 'platform grant table declared by plugin-security (packages/plugins/plugin-security/' + + 'src/objects/sys-user-permission-set.object.ts). The sibling declaration on ' + + 'sys_user_position is untouched', + replacement: + 'nothing on this table — delete the key from any authored `sys_user_permission_set` ' + + 'seed row (stack `data` entries) or data-door write that still carries it. ' + + 'Delegation semantics live on `sys_user_position`, where `delegated_from` remains ' + + 'declared AND runtime-enforced: the delegated-admin gate is what makes a position ' + + 'insert a delegation, and the explain engine attributes "via delegation from X, ' + + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' + + '(free text), which remains declared on both grant tables', + reason: + 'Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The ' + + 'runtime delegation gate is structurally scoped to sys_user_position ' + + '(`isDelegationWrite` returns false for every other object, so ' + + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' + + 'reads delegation provenance from sys_user_position rows only. On ' + + 'sys_user_permission_set the column was therefore declared and data-door-writable ' + + 'while NO runtime consumer read it — its only enforcement was an authoring-time ' + + 'lint (the D3 "delegation row needs a reason" rule), which a row written through ' + + 'the generic data door never meets. That is declared-but-unenforced in its pure ' + + 'form, on a security object: an author who stamped delegated_from on a ' + + 'permission-set grant believed they constrained delegation, and nothing refused or ' + + 'honoured it. Producers measured at zero — the only object literals naming both ' + + 'the table and the column were lint test fixtures. ' + + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' + + 'bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in ' + + 'RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are ' + + 'expected byte-identical), and the disposition is a SEMANTIC entry rather than a ' + + 'D2 conversion. A conversion over stack `data` seed records would be mechanically ' + + 'expressible, but no conversion in the chain rewrites seed rows today and the ' + + 'measured author base is zero; the loud channel already exists at runtime — the ' + + 'engine schema preflight refuses an undeclared field with 400 INVALID_FIELD ' + + 'before the driver or any hook runs — so this entry carries the prescription and ' + + 'the refusal carries the enforcement. ' + + '⚠️ Existing physical columns are deliberately untouched: schema sync is additive ' + + '(ADR-0045), so a deployed database keeps the column; the platform stops ' + + 'declaring, projecting or accepting it. Zero producers means no rows are expected ' + + 'to carry a value; no backfill or destructive DDL is required or wanted. ' + + 'If delegation at permission-set granularity ever becomes a real need, the column ' + + 'is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' + + 'or do not declare.', + acceptanceCriteria: + 'No authored stack seeds `delegated_from` on a sys_user_permission_set record, and ' + + 'no client write to that table carries the key. Concretely: (1) grep your stack ' + + 'sources for delegated_from next to sys_user_permission_set — delete the key from ' + + 'any seed row; a row that was recording genuine hand-over provenance should say it ' + + 'in `reason` instead, which the platform stores on both grant tables. (2) Boot and ' + + 'load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD ' + + 'naming the column — that refusal is the enforced channel, not a silent drop. ' + + '(3) If you meant actual delegation-of-duty, author it where it is enforced: a ' + + 'sys_user_position insert with delegated_from = the writer, a mandatory future ' + + 'valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the ' + + 'delegated-admin gate then validates the whole shape at runtime.', + }, { id: 'view-filter-rule-value-shaped-by-operator', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a