Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9498bbf
feat(platform-objects): sys_personal_view, a reader's own named list …
claude Nov 2, 2026
63ec215
test(plugin-auth, dogfood): pin sys_personal_view row scope, ListView…
claude Nov 2, 2026
e171d3b
chore(changeset): sys_personal_view server half; pin the platform adm…
claude Nov 2, 2026
d50dce3
test(plugin-security): sys_personal_view declares its row scope from …
claude Nov 2, 2026
9dbe749
test(platform-objects): the translation bundle-ownership list names s…
claude Nov 2, 2026
201a114
test(plugin-auth): the SCIM-stamp durability test captures the stamp …
claude Nov 2, 2026
e54cd7c
chore(census): regenerate the platform-object tenancy census for sys_…
claude Nov 2, 2026
2de3306
Merge origin/main into claude/project-thread-xtjloc
claude Nov 2, 2026
6c75d71
feat(plugin-auth): the owner's delete door for a personal view, DELET…
claude Nov 2, 2026
ac273cf
Merge origin/main into claude/project-thread-xtjloc
claude Nov 2, 2026
3eb0494
fix(plugin-auth): a server with no DELETE verb skips the personal vie…
claude Nov 2, 2026
4cc5a57
test(dogfood): the live-mount parity gate reads the personal-view doo…
claude Nov 2, 2026
805f048
Merge origin/main into claude/project-thread-xtjloc
claude Nov 2, 2026
5ec466e
fix(plugin-auth): personal views drop the stored default and check th…
claude Nov 2, 2026
c14a628
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
9e88bf2
test(plugin-auth): restore the SCIM durability fake; no personal-view…
claude Nov 2, 2026
ee23b8e
docs(plugin-auth): personal-view hook comment names the round-2 rule
claude Nov 2, 2026
f1ffca8
fix(plugin-auth): a personal view's owner never changes on update
claude Nov 2, 2026
35fd316
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
9f7adf0
docs(changeset): personal views name the fixed-owner rule
claude Nov 2, 2026
d4773b4
docs(permissions): system-context census row for the personal-view ow…
claude Nov 2, 2026
5f2692f
fix(platform-objects): sys_personal_view drops the retired titleFormat
claude Nov 2, 2026
deedb53
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/29443-personal-views-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@objectstack/platform-objects': patch
'@objectstack/plugin-auth': patch
'@objectstack/plugin-security': patch
'@objectstack/spec': patch
'@objectstack/cli': patch
---

feat(platform-objects): `sys_personal_view`, a reader's own named list views (server step of the first version)

Clause-②: yes (widening)
Metadata-protocol: none

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:

- its owner (`user_id`);
- the object it lists (`object_name`);
- the name the owner gave it (`label`);
- the list configuration, in spec's `ListView` shape (`config`).

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.

- **Owner only.**
- 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.
- Another member gets nothing: no read by id (`404`), nothing listed, and no update (`404 RECORD_NOT_FOUND`).
- A create naming another owner is refused `403 PERMISSION_DENIED`.
- 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.
- **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.
- **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.
- **The body is a `ListView` for the row's own object, judged on every save.**
- A create, and an update that sends `config` or `object_name`, parse the resulting body with spec's `ListViewSchema`.
- A body that does not parse is refused `400 VALIDATION_FAILED`, with one `fields[]` entry per issue, each path under `config`.
- A body whose `data.object` names a different object than `object_name` is refused the same way, on `config.data.object`.
- No new error code.
- **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.
- **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.
- **The owner's delete door.** `DELETE /api/v1/personal-views/:id` removes one personal view, and only the caller's own.
- The row is read as the caller, so another member or an organization administrator gets the unknown-id `404 RECORD_NOT_FOUND`.
- A row the caller can read but does not own is refused `403 PERMISSION_DENIED`.
- 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 }`.
- The door takes no query parameter (`400 VALIDATION_ERROR`) and serves signed-in callers only (`401 UNAUTHENTICATED`).
- `AuthPlugin` mounts it on the kernel's HTTP server, in the shape of the comment-reaction unreact door.
- **Grants.**
- `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).
- A member's generic data `DELETE` on a personal view is still refused `403 PERMISSION_DENIED`. The door above grants nothing.
- External members (`external_default`) get no grant in this version. Whether they can save views is an open question for the Console half.

`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.
2 changes: 2 additions & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ bugs — it is the protection or the side effect that silently does not happen.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down Expand Up @@ -574,6 +575,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` | 1 | 26b |
| `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | 1 | 12 |
| `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` | 1 | 26 |
| `packages/plugins/plugin-auth/src/personal-view-hooks.ts#registerPersonalViewHooks` | 1 | 26c |
| `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` | 1 | 61b |
| `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite` | 1 | 59 |
| `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` | 1 | 1b |
Expand Down
34 changes: 17 additions & 17 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.

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

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

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **255** |
| 175 write call sites | quoted in the merged changeset | **256** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 255** decidable, **78** undecidable |
| 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 |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 256** decidable, **79** undecidable |
| 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 |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

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

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

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

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

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 193 |
| receiver carried a readable engine type | 194 |
| receiver erased, placed by the object NAME | 52 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

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

### Subtractions the census could NOT defend — enforced

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

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

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

{/* END GENERATED: tenant-audit-census */}
19 changes: 10 additions & 9 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 255 |
| Write call sites | 256 |
| Object name statically decidable | 177 |
| Object name chosen at run time | 78 |
| Object name chosen at run time | 79 |
| Against a tenancy-enabled object | 176 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 196 |
| Threading a tenant context | 197 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 51 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 145 |
| Threading a decidably elevated context | 146 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 102 |

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

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

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

## Every site

Expand Down Expand Up @@ -166,6 +166,7 @@ Measured on 2026-10-31 at `d06a02507`.
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team_member` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_user_permission_set` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/personal-view-routes.ts` | `delete` | `PERSONAL_VIEW_OBJECT` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 |
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,10 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
// Exchange rates (`@objectstack/platform-objects`, #27185): an organization's
// administrators author its own rate table; no parent row anchors a rate.
{ object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
// A reader's own named list views (`@objectstack/platform-objects`, ruling
// 6570568131 on #28971): user data on the preference template, each row in
// its owner's organization; no parent row anchors a view.
{ object: 'sys_personal_view', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
{ object: 'sys_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] },
{ object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] },

Expand Down
2 changes: 2 additions & 0 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import {
SysTwoFactor,
SysDeviceCode,
SysUserPreference,
SysPersonalView,
SysOauthApplication,
SysOauthAccessToken,
SysOauthRefreshToken,
Expand Down Expand Up @@ -268,6 +269,7 @@ const config: ObjectStackDefinition = defineStack({
SysTwoFactor,
SysDeviceCode,
SysUserPreference,
SysPersonalView,
SysOauthApplication,
SysOauthAccessToken,
SysOauthRefreshToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const OWNED_OBJECTS = new Set([
'sys_user', 'sys_session', 'sys_account', 'sys_verification', 'sys_organization',
'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit',
'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code',
'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token',
'sys_user_preference', 'sys_personal_view', 'sys_oauth_application', 'sys_oauth_access_token',
'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_oauth_resource',
'sys_oauth_client_resource', 'sys_oauth_client_assertion', 'sys_jwks',
// identity — external SSO provider (admin-facing, better-auth-managed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,38 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
}
},
sys_personal_view: {
label: "Personal View",
pluralLabel: "Personal Views",
description: "A reader's own named list views (My Views). Visible only to the owner.",
fields: {
id: {
label: "Personal View ID"
},
created_at: {
label: "Created At"
},
updated_at: {
label: "Updated At"
},
user_id: {
label: "Owner",
help: "The user this view belongs to. Only this user reads or changes it."
},
object_name: {
label: "Object",
help: "The object this view lists (its machine name)."
},
label: {
label: "Name",
help: "The name the owner gave this view."
},
config: {
label: "View Configuration",
help: "The list configuration, in spec's ListView shape. Validated on every save."
}
}
},
sys_oauth_application: {
label: "OAuth Application",
pluralLabel: "OAuth Applications",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,38 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
}
}
},
sys_personal_view: {
label: "Vista personal",
pluralLabel: "Vistas personales",
description: "Las vistas de lista con nombre propio de un lector (Mis vistas). Solo las ve su propietario.",
fields: {
id: {
label: "ID de vista personal"
},
created_at: {
label: "Fecha de creación"
},
updated_at: {
label: "Fecha de actualización"
},
user_id: {
label: "Propietario",
help: "El usuario al que pertenece esta vista. Solo este usuario puede leerla o cambiarla."
},
object_name: {
label: "Objeto",
help: "El objeto que lista esta vista (su nombre técnico)."
},
label: {
label: "Nombre",
help: "El nombre que el propietario dio a esta vista."
},
config: {
label: "Configuración de la vista",
help: "La configuración de la lista, en la forma ListView de spec. Se valida en cada guardado."
}
}
},
sys_oauth_application: {
label: "Aplicación OAuth",
pluralLabel: "Aplicaciones OAuth",
Expand Down
Loading
Loading