Skip to content

Commit 17e4f52

Browse files
feat(spec)!: retire rowLevelSecurity[].tags — no mainstream platform tags a row-level policy (ADR-0049) (#20353)
Fixes #20321 Clause-②: no (narrowing) ## What this does Retires `rowLevelSecurity[].tags` (`RowLevelSecurityPolicySchema.tags`) by the `spec-property-retirement` route. It is ADR-0049 enforce-or-remove, graded **RETIRE** by triage (`5860425529`) under the maintainer's criterion on #18900 (`5727134555`). Triage's verdict, verbatim: 「Row-level policies carry no tag attribute in the mainstream: Salesforce sharing rules, Dataverse security roles and PostgreSQL RLS policies. Compliance reporting keys on the rule itself. ⇒ **RETIRE**.」 Retirement is immediate, with no staged window. The Clause-② line is the seat's measured answer, `no (narrowing)`, which supersedes the claim's `yes` (`5860732909`, triage's execution note). `scripts/pm/clause2-line.mjs` defines the value as the answer to 「本卡放宽接受集或扩大公开面吗」. Nothing widens: the tombstone narrows the accept set, the D2 conversion only heals stored rows the load path already accepted, and no export is added. A policy that carried `tags` parsed before this change and is refused after it, which is the `(narrowing)` arm. The changeset carries the same line. ## Accept/refuse changes (all pinned) | input | before | after | pin | |:--|:--|:--|:--| | `RowLevelSecurityPolicySchema` with `tags` (any value, `[]` included) | parsed, stored, read by nothing | refused: `invalid_type` at `['tags']`, with the prescription | `rls-tags-retirement.test.ts`, `rls.test.ts` | | `permission` write door (`getMetadataTypeSchema('permission')` = `PermissionSetSchema`) with a policy carrying `tags` | accepted | refused: `invalid_type` at `['rowLevelSecurity', 0, 'tags']`, with the prescription | `rls-tags-retirement.test.ts` | | `defineStack` with such a permission set | accepted | refused: `STACK_SCHEMA_INVALID` / `422`, issue at `['permissions', 0, 'rowLevelSecurity', 0, 'tags']`, with the prescription | `rls-tags-retirement.test.ts` | | the same policy / set / stack **without** `tags` | accepted | accepted, byte-identical output, no `tags` materialized | CONTROL cases in the same file | | a near-miss `tag` | refused as unknown; the did-you-mean offered `tags` | refused as unknown (`unrecognized_keys`); the tombstone is never offered (`acceptsNothing`) | `rls-tags-retirement.test.ts` | | a stored permission row carrying `tags` | stored verbatim | stripped on rehydration by the D2 `permission-rls-tags-removed`, then accepted by the write door | `rls-tags-retirement.test.ts` | ### The new refusal text, verbatim (`RLS_POLICY_TAGS_RETIRED` in `rls.zod.ts`) > `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. The generated `.describe()` is that text prefixed with `[REMOVED] ` (the `retiredKey()` helper). It replaces `Policy categorization tags` in `content/docs/references/security/rls.mdx` and `permission.mdx`. A repo-wide grep for the old describe string finds no pin anywhere. ## Measured before changing anything (Zone 2 of the dispatch) Each reading has a lit control on the same ref. | where | ref | readers of a policy's `tags` | writers of a policy's `tags` | lit control | |:--|:--|:--|:--|:--| | objectstack `plugin-security` / `lint` / `rest` / `objectql` / `runtime` / `metadata*` / `services` src | `a78f731a` | 0. The only `tags` hits are record-field CEL (`size(record.tags)`), OpenAPI route `tags`, and the docs-audience `d.tags` in `meta-item-read-gate.ts`, and none of them is a policy. | 0 in `examples/**` and in the plugin-security producers (`default-permission-sets.ts`, `bootstrap-platform-admin.ts`, `platform-*-policies.ts`, `permission-set-projection.ts`, `security-plugin.ts`) | `.positions` read 35 times in plugin-security src; `rowLevelSecurity` present in all 7 producer files | | objectui at the `.objectui-sha` pin | `f8a9d0fb` | 0. `PermissionAdvancedFacets.tsx` has 0 `tags` (its seed is `{name,object,operation,using,check,enabled}`, and `RETIRED_RLS_KEYS` is `['priority']`). `PermissionPreview.tsx` renders `${rls.length} RLS rules`. `permission-slice.ts` / `clientValidation.ts` have 0. | 0 | facet: `.using` 3, `.enabled` 2, `priority` lit | | objectui `main` | `972c1685` | 0 in all 3 non-test RLS files | 0 | facet: `priority` 3, `.using` 3 | | cloud `main` | `96eb092f` | 0 | 0 (`apps/ee-group-showcase` security sources) | `rowLevelSecurity` 1 in each file | So the dispatch's mechanism assumptions 1 and 2 hold. On assumption 3: `RowLevelSecurityPolicySchema` is a `strictObject`, so this is not the ADR-0104 silent-strip case. The def is reachable from the `permission` root, and the precedent one key over (`priority`) is a `retiredKey()` tombstone on this same closed shape. That tombstone route keeps the liveness row, which stays `dead`. ## What changed - `packages/spec/src/security/rls.zod.ts`: `tags` becomes `retiredKey(RLS_POLICY_TAGS_RETIRED)`. The const is declared above the lazy schema, per the `OS_EAGER_SCHEMAS` TDZ rule. The docblock records the census and the mainstream reading. The suggestion-pool comment now names both tombstones. - ADR-0087: - D2 conversion `permission-rls-tags-removed` in `conversions/registry.ts` (`toMajor: 18`, `retiredFromLoadPath: true`). It is a `stripKeys` delete over `permissions[].rowLevelSecurity[]`, copy-on-write, with a fixture of 1 notice that is disjoint from every other entry. - The D2 is wired into `MIGRATIONS_BY_MAJOR[18].conversionIds`, and the step rationale is extended. - `migrations/entries/retired-keys/18.security__RowLevelSecurityPolicy__tags.ts` holds the exact key `security/RowLevelSecurityPolicy:tags`. - The family D3 entry is `migrations/entries/semantic/18.permission-rls-tags-retired.ts`, per ruling B on #17152: one D3 entry per retirement family, even when D2 is lossless. - `registry.ts` generated regions were regenerated with `gen:migration-registry`. `spec-changes.json` and the upgrade guide are byte-identical, because major-18 entries do not project yet (`check:spec-changes` / `check:upgrade-guide` green). - Liveness: `liveness/permission.json`'s `rowLevelSecurity.tags` row stays `dead` under its tombstone. It gets `verifiedAt: 2026-09-27`, evidence pointing at the tombstone, and a REMOVED note in the `priority` row's house style. The `permission` row of `liveness/README.md` gains one sentence. Counts are unchanged, because the row goes dead to dead. - Generated: `authorable-surface/security.json` now reads `security/RowLevelSecurityPolicy:tags [RETIRED]`. Two reference pages were regenerated (`check:generated` named only `check:docs` stale). - Tests: - `rls.test.ts` fixture triage: - Two incidental authorings were dropped: the complete-policy and multi-tenant cases. - `should validate tags` was **replaced** by a refusal pin, because it pinned exactly the retired branch. - The GDPR case now asserts that the purpose lives in `description` and in the predicate, and that no `tags` comes back. - The minimal-policy case asserts that no `tags` is materialized. - New `rls-tags-retirement.test.ts` (14 cases) covers every door above, the tsc channel (`@ts-expect-error`, compiled by `tsconfig.test.json`), the D2 (stored-row rehydration, per-policy scope, measured idempotence, load-path retirement) and the registration. It also has a **tree-scoped absence** leg with a structural matcher: an object or YAML mapping whose own keys include `tags`, `operation` and `using`/`check`. The leg has an anti-vacuity battery over 14 specimens and walks the 5 roots already declared for `@objectstack/spec#test`. It is listed in `vitest.repo-tests.json`. - `.changeset/20321-rls-policy-tags-retired.md`: `@objectstack/spec` `minor`, with a **BREAKING** banner, FROM → TO, and the ADR-0087 disposition `registered permission-rls-tags-removed, permission-rls-tags-retired`. No form or i18n edit: `permission.form.ts` edits `rowLevelSecurity` as one `json` widget, with no per-key input. No skill teaches the key. There is no hand-written doc mention: `content/docs/permissions/rls.mdx:231`'s "tags each with `kernelTier`" is prose about the explain engine. No objectui change is needed: the pin reads nothing of the key and does not import `RowLevelSecurityPolicy['tags']`. ## Verification, at `62fd232a73` Heavy runs went through `scripts/pm/os-verify-lock.sh`. The spec `dist` was rebuilt at this head before any dist-reading gate. - `pnpm --filter @objectstack/spec build`: VERDICT command-exit 0, tree clean afterwards (no artifact drift). - `pnpm --filter @objectstack/spec run typecheck` → exit 0. `check:test-typecheck`: 53 files, 255 errors, 142 pinned signatures held. So the `@ts-expect-error` compiles in a real program. - `vitest run --project local`: exit 0, **554 files / 16357 tests passed**, 1 todo. - `vitest run --project repo`: exit 0, **34 files / 618 tests passed**. This includes the new retirement file (14/14). - Consumer suites (contract-face fixture triage), after building their closures: - `@objectstack/lint` `vitest run`: exit 0, **111 files / 4304 tests**. - `@objectstack/plugin-security` `vitest run`: exit 0, **141 files / 2990 tests**. - **Reverse verification** (one-time, no file left behind; a trap deletes the probe; `git status` is clean afterwards): a probe in `plugin-security/src` resolves `@objectstack/spec/security` through its exports to the **built** `dist/security/index.d.mts`. - Typing `{ …, tags: ['gdpr'] }` as `RowLevelSecurityPolicy` → exit 2, `error TS2322: Type 'string[]' is not assignable to type 'undefined'` at the `tags` column. - The same literal without `tags` → exit 0. - The expected direction was red, and red was observed. - The first attempt was a null op, stated rather than hidden: TS 6 refused the command-line file with TS5112 until `--ignoreConfig` was passed. - The dispatch gates were re-derived on the actual change set (`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `62fd232a73`, 113 lines). All were run with exit codes recorded, then reconciled: `--ran` → **113 derived, 112 run, 1 NOT-MEASURED, 0 UNRUN**. All 112 run exit 0. That includes `check:liveness` (86 tombstones reached, all graded with an allowed status), `check:generated` (15/15 current), `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:adr-0087-registration --base origin/main`, `check:changeset-no-major`, `check:authorable-surface`, `check:api-surface`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:nul-bytes` and `check:type-check-debt` (re-measure, 4 entries, none above record). - The roster gates whose roster sits under a changed directory were also run: `check-changeset-fixed`, `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. All exit 0. NOT MEASURED: `check:dual-build-cjs-loads`. Reason: PREREQUISITE NOT MET, exit 3. It loads every package's built entry, and 38 workspace packages are unbuilt in this worktree (apps, connectors, most services). That is a whole-workspace build, which is CI's `Build Core` job. NOT MEASURED: `packages/cli` `integration` tier (`migrate-meta.e2e.test.ts` replays the chain). This diff touches no `bin/` or spawn entry, so it is declared to CI. NOT MEASURED: `packages/qa/dogfood` expression conformance. `tags` carries no expression surface, and the ledger's RLS `covers` rows name only `.using` / `.check`. `main` gained one commit (`d3958bac`, driver-sql only) after the base `a78f731a`. It is disjoint from this diff and was not merged in. ## Acceptance notes (observations, not filed) - objectui's RLS facet strips only `RETIRED_RLS_KEYS = ['priority']` on load. A stored permission row carrying `tags` is healed before it reaches the editor, because the D2 replays at rehydration, so no path to a refused save is measured. This is an inference only. Carrier: none. - `.claude/skills/spec-property-retirement` §0 still says benign display metadata (`description`, `tags`, `icon`) should never be retired. This card was graded RETIRE under the maintainer's later #18900 criterion, which asks about the mainstream capability rather than readers. That skill line now contradicts ruled practice for this family. It is a governed surface and the PM's call. Carrier: none. - `authorable-surface.base.json` still lists the key without `[RETIRED]`. That is by design: only `gen:authorable-surface-base` writes that file, and `check:authorable-surface` is green. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent db74b16 commit 17e4f52

14 files changed

Lines changed: 858 additions & 28 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire `rowLevelSecurity[].tags` — no mainstream platform tags a row-level policy, and nothing here ever read one (#20321)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below).
12+
13+
`tags` is removed from the row-level security policy (`RowLevelSecurityPolicySchema`,
14+
the entries of a permission set's `rowLevelSecurity`). ADR-0049
15+
enforce-or-remove, graded RETIRE by the maintainer's criterion for
16+
declared-but-unenforced families — does a mainstream platform have the
17+
capability? None does: Salesforce sharing rules, Dataverse security roles and
18+
PostgreSQL RLS policies carry no tag attribute, and compliance reporting there
19+
keys on the rule itself.
20+
21+
The key promised "categorization and reporting" for governance and compliance.
22+
Nothing ever read it. Measured before removal, each against a lit control: the
23+
RLS compiler reads a policy's `name`, `object`, `operation`, `positions`,
24+
`enabled` and predicates, never `tags`; objectui's permission preview renders
25+
the policy COUNT and its policy editor neither seeds nor reads the key; cloud
26+
has no reader. No example, default permission set or cloud source wrote it.
27+
28+
### FROM → TO
29+
30+
| removed | what to write instead |
31+
| --- | --- |
32+
| `rowLevelSecurity[].tags` | delete the key. To limit whom a policy applies to, list the positions in `positions` — a tag never did that. To say why a policy exists, use `description`. |
33+
34+
**The one-line fix: delete `tags:` from every row-level security policy.**
35+
`os migrate meta --from 17` lists the mechanical edits for existing sources;
36+
apply them by hand.
37+
38+
⚠️ Runtime behaviour is deliberately **unchanged**. No access decision ever
39+
depended on a tag, so removing the key removes no behaviour. What changes is the
40+
answer an author gets: a policy carrying `tags` is now refused at parse, with the
41+
prescription, instead of being stored with no effect. An author who wrote a tag
42+
such as `managers_only` believing it scoped the policy now learns that only
43+
`positions` does.
44+
45+
### The retirement kit
46+
47+
- **A `retiredKey()` tombstone** on `RowLevelSecurityPolicySchema` (the
48+
`priority` posture one key over): `tsc` types the key `never`, and every parse
49+
raises the prescription rather than a bare unknown-key verdict. The shape's
50+
did-you-mean never offers it: a near-miss `tag` is refused as unknown.
51+
- **D2 conversion `permission-rls-tags-removed`** (step 18, retired from the load
52+
path): a lossless delete over `permissions[].rowLevelSecurity[]`, so a stored
53+
permission row that still carries the key replays clean through the
54+
rehydration seam, while a live author is refused rather than rewritten.
55+
- **`RETIRED_KEYS_BY_MAJOR[18]`**: `security/RowLevelSecurityPolicy:tags`, and
56+
the family's D3 entry `permission-rls-tags-retired`, which states what the
57+
strip cannot decide — any report, audit filter or review process built on the
58+
belief that policy tags were read needs another path.
59+
- **The liveness row stays**, `dead`, under its tombstone (the key is still in
60+
the walked shape); `authorable-surface/security.json` carries it as
61+
`security/RowLevelSecurityPolicy:tags [RETIRED]`, and the generated reference
62+
pages print the prescription in place of the old describe.
63+
- **No deprecation window**, per the project's startup-stage posture.
64+
65+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec`
66+
is published, so this is breaking for consumers no telemetry was consulted for.
67+
68+
<!-- adr-0087: registered permission-rls-tags-removed, permission-rls-tags-retired -->

‎content/docs/references/security/permission.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ const result = AdminScopeSchema.parse(data);
172172
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
173173
| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active |
174174
| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
175-
| **tags** | `string[]` | optional | Policy categorization tags |
175+
| **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
176176

177177
### Nested Shape: `PermissionSet.adminScope`
178178

‎content/docs/references/security/rls.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ const result = RLSEvaluationResultSchema.parse(data);
180180
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
181181
| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active |
182182
| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
183-
| **tags** | `string[]` | optional | Policy categorization tags |
183+
| **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
184184

185185

186186
---

‎packages/spec/authorable-surface/security.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@
150150
"security/RowLevelSecurityPolicy:operation",
151151
"security/RowLevelSecurityPolicy:positions",
152152
"security/RowLevelSecurityPolicy:priority [RETIRED]",
153-
"security/RowLevelSecurityPolicy:tags",
153+
"security/RowLevelSecurityPolicy:tags [RETIRED]",
154154
"security/RowLevelSecurityPolicy:using",
155155
"security/SharingRule:_lock",
156156
"security/SharingRule:_lockDocsUrl",

‎packages/spec/liveness/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,7 @@ marker where the Notes cell goes, never a guess at what belongs there.
906906
| flow | dead count = **5 tombstone entries** + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove |
907907
| action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions). **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep. **#13036** makes the dead set three: `execute` joins it, re-classified `live` → `dead` 2026-08-29 with no key added or removed. Its `live` verdict rested on a `.transform` lowering `execute` → `target` that protocol 17 (#3855) removed along with the alias; the key has been a `retiredKey` tombstone since 2026-07-28, so the row stays (the `rls.priority` precedent) while the verdict does not. The rot was invisible to every citation check — the pointer was in range, in the right file, and the file names the key — and the entry carried no `verifiedAt`, so nothing ever re-asked |
908908
| hook | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove |
909-
| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling) |
909+
| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling). `rowLevelSecurity.tags` REMOVED 2026-09-27 (#20321, ADR-0049 — graded RETIRE by the maintainer's criterion: no mainstream platform tags a row-level policy; a `retiredKey` tombstone, so the row stays `dead` beside `priority`'s) |
910910
| position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 |
911911
| agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release); **#18304** re-classifies `tools` `live` -> `dead` with no key added or removed — the row asserted `live` on a key `agent.zod.ts` had tombstoned in protocol 17 (#3894), and it sat that way from the 2026-06 audit because its citation was EXEMPT from resolution rather than resolved (`packages/services/service-ai/...` matched `FOREIGN_PATH_PREFIXES`; the `cloud` realm marker that replaced it in #13309 is equally unresolvable, so no gate could ever fail on it). The load-bearing evidence is local and re-measurable — the `retiredKey` tombstone plus the `agent-tools-to-skills` strip cover authored and stored input respectively, so nothing can carry a value for any consumer to read; the cloud zero-consumer census (cloud @cb8ee7ff, #13272, 2026-09-15) is attributed, not re-taken. `live-elsewhere` is refused for want of a foreign enforcer, not left undeclared |
912912
| tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources |

‎packages/spec/liveness/permission.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,9 @@
224224
},
225225
"tags": {
226226
"status": "dead",
227-
"evidenceScope": "cross-repo",
228-
"verifiedAt": "2026-08-10",
229-
"note": "CORRECTED 2026-07-30 (was live with no evidence): no reader in either repo — governance/compliance reporting never consumes policy tags. RE-TESTED AND UPHELD 2026-08-10 (#7427) under the previews ruling (#7131), same measurement as this block's `label`: at objectui @e9ab52f9 the permission preview reads `rowLevelSecurity` as an array and renders its LENGTH (PermissionPreview.tsx:111, :164), never a policy's fields, so no tag value reaches a human there. Benign organizational metadata — not authorWarn'd."
227+
"verifiedAt": "2026-09-27",
228+
"evidence": "packages/spec/src/security/rls.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)",
229+
"note": "REMOVED 2026-09-27 (#20321, ADR-0049 enforce-or-remove — graded RETIRE by the maintainer's criterion for declared-but-unenforced families: no mainstream platform tags a row-level policy; Salesforce sharing rules, Dataverse security roles and PostgreSQL RLS policies carry no tag attribute, and compliance reporting there keys on the rule itself). Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources and stored permission rows by the protocol-18 conversion `permission-rls-tags-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). The dead verdict it replaces was measured twice (CORRECTED 2026-07-30; RE-TESTED 2026-08-10, #7427) and re-measured before removal on 2026-09-27: no reader in this repo, in objectui at the .objectui-sha pin f8a9d0fb or at main 972c1685 (the permission preview renders the policy COUNT; the RLS facet neither seeds nor reads the key), or in cloud main 96eb092f, each with a lit control. To say whom a policy applies to, use `positions`; to say why it exists, use `description`."
230230
}
231231
}
232232
}

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11616,6 +11616,101 @@ const currencyConfigPrecisionRemoved: MetadataConversion = {
1161611616
},
1161711617
};
1161811618

11619+
/**
11620+
* RLS-policy `tags` removed (protocol 18, #20321 — ADR-0049 enforce-or-remove,
11621+
* graded RETIRE by the maintainer's criterion for declared-but-unenforced
11622+
* families: does a mainstream platform have the capability?).
11623+
*
11624+
* The key promised "categorization and reporting" for governance and
11625+
* compliance, and nothing ever read it: the RLS compiler reads a policy's
11626+
* `name`, `object`, `operation`, `positions`, `enabled` and predicates, and
11627+
* nothing else acts on its tags (objectui's permission preview renders the
11628+
* policy COUNT; its policy editor neither seeds nor reads the key). No
11629+
* mainstream platform tags a row-level policy — Salesforce sharing rules,
11630+
* Dataverse security roles and PostgreSQL RLS policies carry no such
11631+
* attribute. So the delete is lossless: no access decision changes, and
11632+
* nothing that consumes a policy loses an input. Sibling of `permission-rls-priority-removed` (one
11633+
* major earlier, same carrier, same walk).
11634+
*
11635+
* `retiredFromLoadPath`: the schema tombstones the key (`retiredKey`, tsc
11636+
* `never` + the parse-time prescription), so a live author is refused at parse
11637+
* rather than silently rewritten. The entry exists so a stored permission row
11638+
* that still carries the key replays clean through
11639+
* `applyConversionsToStoredItem`, and so `os migrate meta --from 17` lists the
11640+
* mechanical edits for author sources. `stripKeys` deletion is idempotent by
11641+
* construction.
11642+
*/
11643+
const permissionRlsTagsRemoved: MetadataConversion = {
11644+
id: 'permission-rls-tags-removed',
11645+
toMajor: 18,
11646+
retiredFromLoadPath: true,
11647+
surface: 'permission.rowLevelSecurity[].tags',
11648+
summary:
11649+
"RLS-policy key 'tags' removed (#20321, ADR-0049 — nothing ever read a policy's tags and no "
11650+
+ 'mainstream platform tags a row-level policy; dropping it changes no access decision)',
11651+
apply(stack, emit) {
11652+
return mapCollection(stack, 'permissions', (ps, path) => {
11653+
const rls = (ps as { rowLevelSecurity?: unknown }).rowLevelSecurity;
11654+
if (!Array.isArray(rls)) return ps;
11655+
let touched = false;
11656+
const next = rls.map((policy, i) => {
11657+
if (!isDict(policy)) return policy;
11658+
const stripped = stripKeys(policy, ['tags'], emit, `${path}.rowLevelSecurity[${i}]`);
11659+
if (stripped !== policy) touched = true;
11660+
return stripped;
11661+
});
11662+
return touched ? { ...ps, rowLevelSecurity: next } : ps;
11663+
});
11664+
},
11665+
fixture: {
11666+
before: {
11667+
permissions: [{
11668+
name: 'compliance_reviewer',
11669+
label: 'Compliance Reviewer',
11670+
rowLevelSecurity: [
11671+
{
11672+
name: 'reviewed_cases',
11673+
object: 'crm_case',
11674+
operation: 'select',
11675+
using: "status == 'closed'",
11676+
tags: ['compliance', 'gdpr'],
11677+
},
11678+
// A policy WITHOUT the key rides through untouched — the strip
11679+
// dispatches on key presence.
11680+
{
11681+
name: 'own_cases',
11682+
object: 'crm_case',
11683+
operation: 'select',
11684+
using: 'owner == current_user.id',
11685+
},
11686+
],
11687+
}],
11688+
},
11689+
after: {
11690+
permissions: [{
11691+
name: 'compliance_reviewer',
11692+
label: 'Compliance Reviewer',
11693+
rowLevelSecurity: [
11694+
{
11695+
name: 'reviewed_cases',
11696+
object: 'crm_case',
11697+
operation: 'select',
11698+
using: "status == 'closed'",
11699+
},
11700+
{
11701+
name: 'own_cases',
11702+
object: 'crm_case',
11703+
operation: 'select',
11704+
using: 'owner == current_user.id',
11705+
},
11706+
],
11707+
}],
11708+
},
11709+
// One notice: the one policy carrying the key.
11710+
expectedNotices: 1,
11711+
},
11712+
};
11713+
1161911714
export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConversion[]>> = {
1162011715
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
1162111716
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
@@ -11728,6 +11823,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
1172811823
viewOverlayOwnerHiddenRemoved,
1172911824
formLayoutInlineGridToVertical,
1173011825
currencyConfigPrecisionRemoved,
11826+
permissionRlsTagsRemoved,
1173111827
],
1173211828
};
1173311829

0 commit comments

Comments
 (0)