Skip to content

Commit 4008e62

Browse files
feat(platform-objects): sys_personal_view, a reader's own named list views, with the owner's delete door (server step) (#29575)
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9RdqjLGpNWRr2BGJHkC5giq)_ Fixes #29443 Clause-②: yes (widening) Metadata-protocol: none Clause-② measured: on base (`origin/main` @ `81cd9eb0`), `sys_personal_view` does not exist, so every `/api/v1/data/sys_personal_view` call is refused as an unknown object. No registrar mounts `DELETE /api/v1/personal-views/:id` there (read from the source; not driven over HTTP on base). On head both are served, and the dogfood file below is 16/16. No existing answer narrows: the only grants added name the new object, no shipped set loses anything, and the generic data `DELETE` keeps answering what it answered (pinned). Metadata-protocol stays `none`. This is a platform object holding user data, not a key, value or metadata type an author writes, and `/meta` does not serve it (pinned). This closes the server step of #29443: read, create, rename, and the owner's own delete. Which view opens by default stays with the Console's existing per-object pin (see *Round 2*). The Console half follows on the card. ## 维护者速读 - **改了什么**:新增平台对象 `sys_personal_view`(个人视图)。一行就是某个用户为某个对象保存的一个命名列表视图,含所有者、对象名、名称和 ListView 配置。通用数据 API 可以读、建、改名。默认视图不在这里存,沿用 Console 已有的按对象置顶(`list.view.pinned`,#27594)。另外新增一个所有者删除门 `DELETE /api/v1/personal-views/:id`,只能删除调用者自己的视图。 - **为什么改**:依据 #28971 裁决 C,「我的视图」是用户数据,不是视图元数据。这是第一版的服务端一半,以 `sys_user_preference` 为模板。删除门按席位裁决 6583289013 建,沿用 #26161 字母 A 的形状。 - **风险与回滚**:这是纯新增对象和新增路由,已有对象和已有答复都不变。行级范围只到所有者本人;组织管理员的通配绕过对该对象已关闭,读不到、改不了、也删不了成员的视图(都答 404)。平台管理员(`admin_full_access`)保留通配:通过通用数据 API 能读、改、删任何成员的视图,和 `sys_user_preference` 的现状一致,已有测试钉住,并列为待决问题;只有本 PR 的删除门拒绝他删别人的视图(403)。外部成员本版没有授权,也列为待决问题。视图的所有者在保存时确定,任何非系统调用都不能改 `user_id`(403)。回滚就是 revert 本 PR,表留着无害。 - **先看**:`packages/platform-objects/src/identity/sys-personal-view.object.ts`;`packages/plugins/plugin-auth/src/personal-view-hooks.ts`;`packages/plugins/plugin-auth/src/personal-view-routes.ts`;`packages/plugins/plugin-security/src/objects/default-permission-sets.ts` 里的两处新条目。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run src/personal-view-hooks.test.ts src/personal-view-routes.test.ts`,预期 35 个全绿;`pnpm --filter @objectstack/dogfood exec vitest run test/personal-view-row-scope.dogfood.test.ts`,预期 16 个全绿。 - **需要你做的**:无。路由名 `/api/v1/personal-views/:id` 已由席位接受;两个待决问题(平台管理员的通配、外部成员)见文末。 ## What changes - **New platform object `sys_personal_view`** (`packages/platform-objects/src/identity/sys-personal-view.object.ts`). plugin-auth's identity manifest registers it right after `sys_user_preference`, on the same template. - Fields: `user_id` (owner, lookup `sys_user`), `object_name`, `label` (the name the owner gave it), `config` (json, spec's `ListView` shape), and `created_at` / `updated_at`. There is no default column: which view opens by default is the Console's existing pin (see *Round 2*). - It is `managedBy: 'system-data'` and `access: { default: 'private' }`, so a plain (non-super-user) `'*'` wildcard does not reach it. A wildcard carrying `viewAllRecords` / `modifyAllRecords` still does (ADR-0066 D2): `organization_admin`'s is closed below, and `admin_full_access`'s is not. - It declares the owner-only `defaultRowScope` `user_id == current_user.id` for every operation. - The table carries the organization column, so the Layer 0 wall bounds it too (ADR-0131). The regenerated tenancy census counts it in reach. - **Two write rules** (`packages/plugins/plugin-auth/src/personal-view-hooks.ts`). Both registrars register them: `AuthPlugin` on `kernel:ready`, and `IdentityObjectsPlugin.start()` on a kernel without auth. - **The body must be a `ListView` for the row's own object.** A create, and an update that sends `config` or `object_name`, parse the resulting body with spec's `ListViewSchema`; an update fills whichever of the two it did not send from the stored row. A body that does not parse is refused `400 VALIDATION_FAILED` (an existing code, through `validationFailure`), with one `fields[]` entry per issue, each path under `config`. Text that is not JSON is refused the same way, with the single field `config`. A body whose `data.object` names another object than `object_name` is refused the same way, on `config.data.object` (code `object_mismatch`). A rename that sends neither is not re-judged. - **The owner is fixed when the view is saved.** An update that changes `user_id` is refused `403 PERMISSION_DENIED` for every caller but the system, platform administrators included; an echo of the stored owner is no change. The row scope alone does not hold this for an organization member: `member_default`'s `'*'` update floor (`created_by == current_user.id`, positions `org_member`) is a held update policy, so the object's declared default does not apply to an update, and a re-parent's post-image still carries the member's own `created_by`. The round-2 battery found it (REST `PATCH user_id` answered 200). - **The owner's delete door, `DELETE /api/v1/personal-views/:id`** (`packages/plugins/plugin-auth/src/personal-view-routes.ts`). Seat ruling 6583289013; it takes the shape of #26161's unreact door, ruling 6422861164 letter A. `AuthPlugin` mounts it on the kernel's HTTP server, next to its auth routes. It removes one row, and only the caller's own: 1. The caller is resolved through the one composition, `resolveRequestExecutionContext`. 2. Anonymous is refused `401 UNAUTHENTICATED`. 3. A held sign-in gets its policy's `403`. 4. A query parameter is refused `400 VALIDATION_ERROR`. 5. The row is read **as the caller**. Another member, an organization administrator, and an unknown id all get `404 RECORD_NOT_FOUND`. 6. A row the caller can read but does not own (`user_id !== caller`; today only a platform administrator reads one) is refused loudly with `403 PERMISSION_DENIED`, and the row stays. This is the door's own answer; the platform administrator's generic data `DELETE` is not refused (see Grants). 7. Only then is the row removed, as the system, with `attributedUserId` set to the caller and inside the caller's organization. The answer is `200 { id, deleted: true }`. No grant moves: `member_default` is unchanged, and the generic data `DELETE` of the owner's own row is still `403` (pinned). **Route name:** `/api/v1/personal-views/:id`, the unreact door's convention (a plural resource noun under `/api/v1`, the record id in the path, no query). The seat accepted it in review. - Its ledger row: `packages/plugins/plugin-auth/src/personal-view-route-ledger.ts` (`gap`: the Console's My views list is the consumer, and the SDK has no method yet), with `personal-view-route-ledger.conformance.test.ts`. - Its `scripts/check-route-envelope.mjs` declaration (`responses: 0, ok: 0, err: 0`; every body goes through the shared pair). - Its `scripts/check-single-authz-resolver.mjs` delegator row. - Its removal is one new elevated write call site in the tenant audit census. `node scripts/tenant-audit-census.mjs --write` regenerated the artefacts (256 sites), and the hand-written figures on `content/docs/permissions/tenant-audit-census.mdx` follow it (255 → 256, 78 → 79, 145 → 146). - A server that registers no `DELETE` verb (a partial embedding, or a test double) skips the door with a warning that names it, instead of failing the boot. - **Grants** (`packages/plugins/plugin-security/src/objects/default-permission-sets.ts`): - `member_default`: read, create and edit on `sys_personal_view`, and **no delete**. The set is bound to the `everyone` anchor, which may not carry a delete bit (ADR-0090 D5). - `organization_admin` (and the derived `organization_admin_no_bypass`): an explicit `sys_personal_view` entry with no `viewAllRecords` / `modifyAllRecords`. For this object it overrides the wildcard's super-user bits, the `sys_audit_log` pattern. An organization administrator reads, writes and deletes only their own views. - `admin_full_access` is untouched (seat ruling 6587007982). Its wildcard carries `viewAllRecords` and `modifyAllRecords`, so through the generic data API it **reads, edits and deletes** every member's view, exactly as it does `sys_user_preference` today. Its pins forbid a per-object entry there, and the rescue path depends on it. Pinned as a recorded boundary at engine and REST level; listed as an open question below. Only the owner's door refuses it (`403`). - `external_default` gets **no** grant in this version: an external member cannot save a personal view. Listed as an open question for the Console half. - **The id** is the engine's record id (nanoid). It never contains `.`, so it can never be read as `object.key` view syntax (pinned). - **Not view metadata:** `/meta/view` does not list a personal view, and `/meta/view/<id>` answers 404 (pinned). There is no metadata type, no package export and no metadata write door. - **The only spec touch** (allowed by seat ruling, recorded in round 2): one line, `'sys_personal_view'` in `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (`packages/spec/src/system/constants/platform-object-names.ts`). Nothing else under `packages/spec` changes. - **Rosters:** - the CLI organization-ownership inventory row (`fate: 'attribution'`); - the i18n extract config; - the en / zh-CN / ja-JP / es-ES object bundles, regenerated and then translated by hand; - `scripts/platform-object-tenancy-census.json`, regenerated (92 registered / 47 in reach). - `content/docs/permissions/system-context.mdx`: row 26c owns the owner rule's system skip (`ctx.session.isSystem`), and the generated site register is rewritten with `--write` (166 read sites). - **Changeset** `.changeset/29443-personal-views-server.md`: patch for platform-objects, plugin-auth, plugin-security, cli **and spec** (the spec package publishes a changed constant, and AGENTS.md asks every publishing change to carry a changeset). Not in scope, and not touched: sharing, team views, admin management, a field picker, packaging, any `ui/**` change, #29037's `ViewScopeSchema.personal` / `sharing.type`, and a shared own-row door helper (review S3; the seat files it as a follow-up card). ## Round 2 (review issuecomment-6586971553, seat ruling issuecomment-6587007982) - **M1, no stored default.** The `is_default` column and the one-default hook are gone. The default is the Console's existing pin `LIST_PINNED_VIEW_PREFERENCE` (`list.view.pinned`, #27594, `ui/packages/app-shell/src/utils/workspacePreference.ts`). **Finding:** the pin can hold a personal view's id as it is. It stores `{"view": <id>, "v": 1}` for any non-empty string, and `ObjectView` resolves it with `resolveViewId(pinned, views.map(v => v.id), objectName)`, whose first step is an exact id match. So it works once the Console half lists personal views in `views`. A nanoid has no `.`, so the bare-key qualification step (`<object>.<key>`) never turns it into a metadata view's id. No second store. - **M2, platform administrator.** `admin_full_access` keeps its wildcard. Its generic GET, PATCH and DELETE of a member's view are pinned as a recorded boundary (engine and REST). The body, changeset and open question no longer say its delete is refused; only the door refuses it. - **S1.** The object's comment now says a wildcard with `viewAllRecords` / `modifyAllRecords` still reaches a private object (ADR-0066 D2). - **S2.** External members are out of scope for this version: `external_default` gets no grant. Open question for the Console half. - **Nits.** An update that changes `user_id` is refused `403 PERMISSION_DENIED` and the owner is kept (pinned, engine and REST; ablation 9). Pinning it over REST found that the row scope did not hold it for an organization member (see *Two write rules*), so a save rule holds it now. The same `created_by` floor applies to `sys_user_preference`: by reading the source, its `user_id` is not readonly and no hook guards it, so an organization member can likely re-parent a preference row too. That was not driven over REST, and it is reported as an out-of-scope finding, not changed here. A `config.data.object` that differs from `object_name` is refused `400 VALIDATION_FAILED` on `config.data.object`, on create and on update (pinned, engine and REST; ablation 8). - Round 1's forced edit to `durability-swallow-repair.test.ts` is reverted: with no personal-view `afterInsert` left, the SCIM-stamp fake passes unmodified (22/22). - Merging main brought #29007, which retires `titleFormat` on code-shipped objects; `sys_personal_view` now names its title by `nameField: 'label'` alone (the sweep in `managed-api-method-affordance-sweep.test.ts` caught it). - The route name was accepted; the spec roster line is recorded as allowed; S3 (a shared own-row door) is the seat's follow-up card. ## Open questions 1. **The platform administrator's reach.** Through the generic data API, `admin_full_access` reads, edits and deletes every member's personal view, exactly as it does `sys_user_preference` (pinned as a recorded boundary). Options: **A** keep it, and decide it with `sys_user_preference` as one question; **B** close it for both user-owned objects with a narrow engine-level exception, which needs the set's pins amended. Recommendation: A for this card, because the rescue path depends on the wildcard and the question is the same for both objects. 2. **External members.** `external_default` grants nothing on `sys_personal_view`, so an external member cannot save a view in this version. Whether they should is a question for the Console half. ## Why #28971 ruling C (comment 6570568131): a reader's named list views are user data, a user-owned platform object modelled on `sys_user_preference`, not a view metadata scope. Seat ruling 6583289013 put the owner's delete in this step. This PR is that server step; the Console half follows on the card. ## Tests - `packages/plugins/plugin-auth/src/personal-view-hooks.test.ts`: 27 tests on real ObjectQL + SQLite `SqlDriver` + `SecurityPlugin`. - **Owner:** saves, lists and renames their own views. The id has no `.`. An update handing a view to another user is refused 403, and the owner is kept; a platform administrator, whom no row scope binds, is refused by the save rule alone; an echo of the stored owner is accepted (control). - **Another member:** `findOne` returns null, `find` returns [], `count` is 0, and an update answers `404 RECORD_NOT_FOUND`. A create naming another owner is refused `403 PERMISSION_DENIED`. - **Admins:** `organization_admin` and `organization_admin_no_bypass` both see none of a member's views (404). An organization admin keeps their own views (control). The recorded boundary: `admin_full_access` reads, edits and deletes a member's view through the engine. - **Body:** an invalid `config` is refused `VALIDATION_FAILED`, with paths under `config`, on create and on update, and the stored body is unchanged. Also covered: non-JSON text, and a rename without `config`. - **The listed object:** a create whose `config.data.object` is another object, an update moving only `object_name`, and an update sending only a body for another object are each refused on `config.data.object`, with the row unchanged. Control: changing both together is accepted. - **The door's core** (`removeOwnPersonalView`): the owner's row is removed and the owner's other views stay. Another member gets 404 and the row stays. Both organization-admin sets get 404 and the row stays. The platform admin gets 403 and the row stays. An unknown id gets 404. The generic engine delete of the owner's own row is still 403. - `packages/plugins/plugin-auth/src/personal-view-routes.test.ts`: 8 tests on the door's shell. - It mounts exactly `DELETE /api/v1/personal-views/:id`. - An unreadable permission store answers its own 503. Anonymous gets 401. A held sign-in gets its policy's 403. A query parameter gets 400. No engine gets 503. An engine fault gets 500. In none of these is the engine asked. - On success, the removal runs as `{ isSystem, attributedUserId: caller, tenantId: caller's org }`. - `packages/plugins/plugin-auth/src/personal-view-route-ledger.conformance.test.ts`: 3 tests. Every mounted route has a ledger row, and every row is mounted. - `packages/qa/dogfood/test/personal-view-row-scope.dogfood.test.ts`: 16 tests through REST, on the showcase app booted with its default security. - **Owner:** saves, lists, reads and renames views, and cannot hand one to another member (403). - **Another member:** 404 / 404 / `RECORD_NOT_FOUND`, and the generic delete answers `403 PERMISSION_DENIED`. A planted row naming another owner gets 403. - **Organization admin** (granted through `sys_user_permission_set`): 404. - **Invalid body:** `400 VALIDATION_FAILED`; a body listing another object: `400` on `config.data.object`. - **The predicate guard:** a member whose field-level security hides `showcase_project.budget` can save a view filtering on `budget`, because a save does not re-judge field security. The list query `GET /data/showcase_project?$filter={"budget":{"$gt":0}}` is then refused 403, naming `budget`. - **`/meta/view`** does not serve it. - **The door, `[door]`:** the owner gets 200 and the row is gone. Another member gets 404 and the row stays. An organization admin gets 404 and the row stays. The platform admin's door delete gets 403, and the row stays. Recorded boundary: the platform admin's generic GET, PATCH and DELETE of a member's view succeed. Anonymous gets 401. The generic `DELETE /data/sys_personal_view/<own id>` is still 403. - **Forced test edits** (three; each is a list or a capture that the change itself moves): - `packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts`: the census of objects declaring `defaultRowScope` gains `DECLARED_AT_BIRTH = { sys_personal_view }`. It is kept separate from the ruling's 23 `DECLARERS`, which are unchanged. A new `it.each` asserts the literal and that no shipped set carries a copy. - `packages/platform-objects/src/apps/translations/bundle-ownership.test.ts`: `OWNED_OBJECTS` now names `sys_personal_view`. That is the guard's own instruction when the extract config gains an object. - `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`: the gate's union of ledgers reads `PERSONAL_VIEW_ROUTE_LEDGER`, because the door mounts on the `IHttpServer` port. Without it, "every mounted route is ledgered" was red on the full battery: `DELETE /api/v1/personal-views/:id`. The dogfood test task declares the imported source as an input in `scripts/cross-package-test-inputs.mjs` and `turbo.json`, the same as the storage, i18n and settings ledgers. <details><summary>Ablations (each red, then restored, <code>git diff HEAD</code> empty)</summary> | Mutation | Red | |---|---| | Remove the `organization_admin` entry for `sys_personal_view` | The `organization_admin` test (404 → row read). `organization_admin_no_bypass` stays green, as expected: that variant strips the bypass bits itself. | | Remove `defaultRowScope` (platform-objects `dist/` rebuilt, preflight confirmed absent) | 4 tests: member read, plant, both organization-admin cases | | Remove the `ListView` check (both hooks) | 3 validation tests | | Round 2: let a `config.data.object` that differs from `object_name` through | 3 listed-object tests; the both-together control stays green | | Round 2: switch off the fixed-owner rule | The platform-administrator re-parent test. Over REST, the head before the rule (ee23b8e) is the red: the battery's `[owner] cannot hand their view` answered 200 | | Force the predicate guard condition false in plugin-security (`dist/` rebuilt) | The dogfood `[predicate]` test (200 instead of 403) | | The door: drop the owner check (`row.user_id !== callerId`) | The platform-admin case (the row was removed) | | The door: read the row as the system instead of the caller | 3 tests: another member and both organization-admin sets (403 instead of 404) | | The door: `AuthPlugin` does not mount it (plugin-auth `dist/` rebuilt, preflight confirmed the marker) | 5 dogfood `[door]` tests. The generic-delete control stays green, as expected. | The `/meta` pin is structural, because no code path serves the object as metadata, so it has no mutation to ablate. </details> ## Verification Round 2, on head `deedb538` (main `81cd9eb0` merged; no conflicts): - Suites under `os-verify-lock`: spec `platform-object-names.test.ts` 12/12; platform-objects 110 files, 1512 tests; plugin-auth 215 files, 3693 passed / 10 skipped; plugin-security 241 files, 5203 passed / 45 skipped; cli `organization-ownership-inventory.test.ts` 26/26. Typecheck exit 0 for platform-objects, plugin-auth, plugin-security and cli. - `pnpm lint` (root eslint, `--no-inline-config`) on every changed file: exit 0. `check-i18n-bundles`: OK. `check:system-context-census`: OK (166 read sites). `check:doc-anchors`: OK. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` listed 133 commands. All 133 exit 0 on `deedb538` (`check:type-check-debt` run under `os-verify-lock` after the battery), reconciled with `--ran`: 133 derived, 133 run, 0 NOT-MEASURED. - Dogfood battery: `pnpm --filter @objectstack/dogfood test` under `os-verify-lock`, `VERDICT command-exit 0 · held the lock 1583s` (shared box), Test Files 399 passed / 3 skipped (402), Tests 3296 passed / 28 skipped (3324), no failures. `check:type-check-debt` after it: exit 0. - The battery on the earlier round-2 head `ee23b8ea` had exactly one red, `[owner] cannot hand their view` (200, not 403). That was the re-parent gap the fixed-owner rule closes. CI on `9f7adf09` and `d4773b47` found the census row and the retired `titleFormat`; both are fixed on this head. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmNjCFNPZLkGxdFdgAq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a9b959a commit 4008e62

32 files changed

Lines changed: 1931 additions & 34 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/spec': patch
6+
'@objectstack/cli': patch
7+
---
8+
9+
feat(platform-objects): `sys_personal_view`, a reader's own named list views (server step of the first version)
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
A reader can now keep named list views of their own, as user data rather than view metadata. The new platform object `sys_personal_view` holds one row per saved list:
15+
16+
- its owner (`user_id`);
17+
- the object it lists (`object_name`);
18+
- the name the owner gave it (`label`);
19+
- the list configuration, in spec's `ListView` shape (`config`).
20+
21+
It is registered beside `sys_user_preference`, on the same template, and the generic data API serves it at `/api/v1/data/sys_personal_view`. Which view opens by default is not stored here: the reader's existing per-object pin holds a view id, and a personal view's id is one more value it can hold.
22+
23+
- **Owner only.**
24+
- The object declares the owner-only default row scope `user_id == current_user.id` for every operation. The table carries the organization column, so the organization wall bounds it too.
25+
- Another member gets nothing: no read by id (`404`), nothing listed, and no update (`404 RECORD_NOT_FOUND`).
26+
- A create naming another owner is refused `403 PERMISSION_DENIED`.
27+
- A view's owner is fixed when it is saved: an update that changes `user_id` is refused `403 PERMISSION_DENIED` for every caller but the system, platform administrators included. A save rule holds this, because for an organization member the `member_default` update floor (`created_by == current_user.id`) applies to an update in place of the object's own row scope.
28+
- **No back door for organization administrators.** `organization_admin`, and the derived `organization_admin_no_bypass`, name the object without the wildcard's super-user bits. An organization administrator reads, writes and deletes only their own views.
29+
- **Platform administrators are not limited.** The bare `admin_full_access` wildcard carries `viewAllRecords` and `modifyAllRecords`, so through the generic data API a platform administrator reads, edits and deletes any member's view. This is the same as `sys_user_preference` today. Whether that should change is an open question.
30+
- **The body is a `ListView` for the row's own object, judged on every save.**
31+
- A create, and an update that sends `config` or `object_name`, parse the resulting body with spec's `ListViewSchema`.
32+
- A body that does not parse is refused `400 VALIDATION_FAILED`, with one `fields[]` entry per issue, each path under `config`.
33+
- A body whose `data.object` names a different object than `object_name` is refused the same way, on `config.data.object`.
34+
- No new error code.
35+
- **Field security is not re-judged on save.** A view whose filter names a field the reader cannot read is still saved. The list query that applies the filter is refused `403 PERMISSION_DENIED` by the existing predicate guard.
36+
- **Not view metadata.** `/meta` never serves a personal view, the metadata write door never writes one, and a package export never carries one. Its id is the engine's record id, so it never takes the `object.key` view syntax.
37+
- **The owner's delete door.** `DELETE /api/v1/personal-views/:id` removes one personal view, and only the caller's own.
38+
- The row is read as the caller, so another member or an organization administrator gets the unknown-id `404 RECORD_NOT_FOUND`.
39+
- A row the caller can read but does not own is refused `403 PERMISSION_DENIED`.
40+
- Only then is the row removed, as the system, attributed to the caller and inside the caller's organization. Success is `200` with `{ id, deleted: true }`.
41+
- The door takes no query parameter (`400 VALIDATION_ERROR`) and serves signed-in callers only (`401 UNAUTHENTICATED`).
42+
- `AuthPlugin` mounts it on the kernel's HTTP server, in the shape of the comment-reaction unreact door.
43+
- **Grants.**
44+
- `member_default` grants read, create and edit on the object, and no delete. That set is bound to the `everyone` anchor, which carries no delete bit (ADR-0090 D5).
45+
- A member's generic data `DELETE` on a personal view is still refused `403 PERMISSION_DENIED`. The door above grants nothing.
46+
- External members (`external_default`) get no grant in this version. Whether they can save views is an open question for the Console half.
47+
48+
`PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (`@objectstack/spec/system`) now lists `sys_personal_view`, so lint and cross-reference checks know the name is real. The CLI's organization-ownership inventory carries the new object's row.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ bugs — it is the protection or the side effect that silently does not happen.
143143
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `packages/plugins/plugin-security/src/system-write-guard.ts#assertEngineOwnedWriteAllowed` |
144144
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
145145
| 26b | API key restore guard does not refuse | plugin-auth | Get: a write setting `revoked: false` on a `sys_api_key` row an administrator's forced password reset revoked is admitted without the platform-administrator check a user-context restore must pass; the reason is still cleared. Lose: nothing a caller reaches: no system writer restores a key, and the mint, the last-used stamp and the forced reset itself never set `revoked: false` | `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` |
146+
| 26c | **Personal-view owner rule does not refuse** — a `sys_personal_view` update that changes `user_id` is admitted | plugin-auth | Get: a system writer can move a personal view to another owner (no shipped system writer does). Lose: nothing a caller reaches. A non-system update whose `user_id` differs from the stored owner is refused `403 PERMISSION_DENIED`, for every caller, a platform administrator included; an echo of the stored owner is no change. The rule exists because, for an organization member, `member_default`'s `'*'` update floor (`created_by == current_user.id`) applies to an update in place of the object's own row scope | `packages/plugins/plugin-auth/src/personal-view-hooks.ts#registerPersonalViewHooks` |
146147
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `stripSearchCompanionFromRead` |
147148
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `dependentCountIsDisclosable` |
148149
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `recordReferenceCheckElevation` |
@@ -574,6 +575,7 @@ and CI refuses one that differs from what the tree produces.
574575
| `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` | 1 | 26b |
575576
| `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | 1 | 12 |
576577
| `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` | 1 | 26 |
578+
| `packages/plugins/plugin-auth/src/personal-view-hooks.ts#registerPersonalViewHooks` | 1 | 26c |
577579
| `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` | 1 | 61b |
578580
| `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite` | 1 | 59 |
579581
| `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` | 1 | 1b |

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **51 of the 255 sites are spelled that way**. A
135+
forwarding shim cannot, and **51 of the 256 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **255** |
200+
| 175 write call sites | quoted in the merged changeset | **256** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 255** decidable, **78** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 102 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 256** decidable, **79** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 102 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
145 of 255 (57%) as decidably elevated, with 102 more whose elevation is a
220+
146 of 256 (57%) as decidably elevated, with 102 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 255`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 256`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,31 +233,31 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **255** |
236+
| write call sites on the application surface | **256** |
237237
| …whose object name is statically decidable | 177 |
238-
| …whose object name is chosen at run time | 78 |
238+
| …whose object name is chosen at run time | 79 |
239239
| …against an object with tenancy ENABLED | 176 |
240240
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 196 |
241+
| threading a tenant context | 197 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 51 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 145 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 146 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 102 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 193 |
252+
| receiver carried a readable engine type | 194 |
253253
| receiver erased, placed by the object NAME | 52 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257257
| object name spelled inline | 116 |
258258
| object name spelled through a `const` | 61 |
259259
| object name is an `object: string` parameter | 7 |
260-
| object name is some other run-time expression | 71 |
260+
| object name is some other run-time expression | 72 |
261261

262262
### Subtractions the census could NOT defend — enforced
263263

@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-31 at `d06a02507`.
310+
Measured on 2026-11-02 at `ac273cf07`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 830 |
315-
| engine-shaped types recognised | 96 |
316-
| declared objects in the registry | 124 |
317-
| same-named calls subtracted as non-engine | 214 |
314+
| tracked non-test sources scanned | 852 |
315+
| engine-shaped types recognised | 99 |
316+
| declared objects in the registry | 125 |
317+
| same-named calls subtracted as non-engine | 227 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 255 |
36+
| Write call sites | 256 |
3737
| Object name statically decidable | 177 |
38-
| Object name chosen at run time | 78 |
38+
| Object name chosen at run time | 79 |
3939
| Against a tenancy-enabled object | 176 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 196 |
41+
| Threading a tenant context | 197 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 145 |
46+
| Threading a decidably elevated context | 146 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 102 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-31 at `d06a02507`.
93+
Measured on 2026-11-02 at `ac273cf07`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 830 |
98-
| engine-shaped types recognised | 96 |
99-
| declared objects in the registry | 124 |
100-
| same-named calls subtracted as non-engine | 214 |
97+
| tracked non-test sources scanned | 852 |
98+
| engine-shaped types recognised | 99 |
99+
| declared objects in the registry | 125 |
100+
| same-named calls subtracted as non-engine | 227 |
101101

102102
## Every site
103103

@@ -166,6 +166,7 @@ Measured on 2026-10-31 at `d06a02507`.
166166
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team` | enabled | elevated | 1 |
167167
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team_member` | enabled | elevated | 1 |
168168
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_user_permission_set` | enabled | elevated | 1 |
169+
| `packages/plugins/plugin-auth/src/personal-view-routes.ts` | `delete` | `PERSONAL_VIEW_OBJECT` | undecidable | elevated | 1 |
169170
| `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 |
170171
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
171172
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,10 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
795795
// Exchange rates (`@objectstack/platform-objects`, #27185): an organization's
796796
// administrators author its own rate table; no parent row anchors a rate.
797797
{ object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
798+
// A reader's own named list views (`@objectstack/platform-objects`, ruling
799+
// 6570568131 on #28971): user data on the preference template, each row in
800+
// its owner's organization; no parent row anchors a view.
801+
{ object: 'sys_personal_view', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
798802
{ object: 'sys_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] },
799803
{ object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] },
800804

‎packages/platform-objects/scripts/i18n-extract.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import {
127127
SysTwoFactor,
128128
SysDeviceCode,
129129
SysUserPreference,
130+
SysPersonalView,
130131
SysOauthApplication,
131132
SysOauthAccessToken,
132133
SysOauthRefreshToken,
@@ -268,6 +269,7 @@ const config: ObjectStackDefinition = defineStack({
268269
SysTwoFactor,
269270
SysDeviceCode,
270271
SysUserPreference,
272+
SysPersonalView,
271273
SysOauthApplication,
272274
SysOauthAccessToken,
273275
SysOauthRefreshToken,

‎packages/platform-objects/src/apps/translations/bundle-ownership.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const OWNED_OBJECTS = new Set([
2121
'sys_user', 'sys_session', 'sys_account', 'sys_verification', 'sys_organization',
2222
'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit',
2323
'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code',
24-
'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token',
24+
'sys_user_preference', 'sys_personal_view', 'sys_oauth_application', 'sys_oauth_access_token',
2525
'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_oauth_resource',
2626
'sys_oauth_client_resource', 'sys_oauth_client_assertion', 'sys_jwks',
2727
// identity — external SSO provider (admin-facing, better-auth-managed)

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,38 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
14141414
}
14151415
}
14161416
},
1417+
sys_personal_view: {
1418+
label: "Personal View",
1419+
pluralLabel: "Personal Views",
1420+
description: "A reader's own named list views (My Views). Visible only to the owner.",
1421+
fields: {
1422+
id: {
1423+
label: "Personal View ID"
1424+
},
1425+
created_at: {
1426+
label: "Created At"
1427+
},
1428+
updated_at: {
1429+
label: "Updated At"
1430+
},
1431+
user_id: {
1432+
label: "Owner",
1433+
help: "The user this view belongs to. Only this user reads or changes it."
1434+
},
1435+
object_name: {
1436+
label: "Object",
1437+
help: "The object this view lists (its machine name)."
1438+
},
1439+
label: {
1440+
label: "Name",
1441+
help: "The name the owner gave this view."
1442+
},
1443+
config: {
1444+
label: "View Configuration",
1445+
help: "The list configuration, in spec's ListView shape. Validated on every save."
1446+
}
1447+
}
1448+
},
14171449
sys_oauth_application: {
14181450
label: "OAuth Application",
14191451
pluralLabel: "OAuth Applications",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,38 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
14141414
}
14151415
}
14161416
},
1417+
sys_personal_view: {
1418+
label: "Vista personal",
1419+
pluralLabel: "Vistas personales",
1420+
description: "Las vistas de lista con nombre propio de un lector (Mis vistas). Solo las ve su propietario.",
1421+
fields: {
1422+
id: {
1423+
label: "ID de vista personal"
1424+
},
1425+
created_at: {
1426+
label: "Fecha de creación"
1427+
},
1428+
updated_at: {
1429+
label: "Fecha de actualización"
1430+
},
1431+
user_id: {
1432+
label: "Propietario",
1433+
help: "El usuario al que pertenece esta vista. Solo este usuario puede leerla o cambiarla."
1434+
},
1435+
object_name: {
1436+
label: "Objeto",
1437+
help: "El objeto que lista esta vista (su nombre técnico)."
1438+
},
1439+
label: {
1440+
label: "Nombre",
1441+
help: "El nombre que el propietario dio a esta vista."
1442+
},
1443+
config: {
1444+
label: "Configuración de la vista",
1445+
help: "La configuración de la lista, en la forma ListView de spec. Se valida en cada guardado."
1446+
}
1447+
}
1448+
},
14171449
sys_oauth_application: {
14181450
label: "Aplicación OAuth",
14191451
pluralLabel: "Aplicaciones OAuth",

0 commit comments

Comments
 (0)