Repository navigation
Commit 7518ee3
fix(service-settings)!: the user rung answers only its owner; a user-scoped write must name its user (#22246)
Fixes #22168
Clause-②: no (narrowing)
`SettingsService` now treats the user rung as belonging to one user, on
both its read side and its write side. This is the defensive fix the
seat review on the card ruled, in two halves. One file changes
(`packages/services/service-settings/src/settings-service.ts`), with a
pin file and a `minor` changeset that declares the narrowing.
## What changes
**Read half: `resolveKeyFromRows`.** The cascade walk now takes the
caller's user id. The user rung answers only a row whose `user_id`
equals that id. A resolve whose context names no user (no `userId`, or
an empty one) has no user rung: it answers from the tenant rung, then
global, then the manifest default, exactly as if no user row existed.
`get` and `getMany` pass the id through, with one helper
(`callerUserIdOf`) as the single definition of "present".
`getNamespace`, `createClient`, `runAction`, `resetNamespace` and
`setMany`'s own re-resolve all reach the cascade through `get` or
`getMany`, so every read path gets the same answer. The engine-bound
store and the no-engine in-memory fallback share the pick, so both are
covered.
**Write half: `setMany`, ruled A.** A key declared `scope: 'user'` in a
batch whose context names no user is refused with
`SettingsValidationError` (`SETTINGS_VALIDATION`). Each such key gets
one `fields` entry: `field` names the key, `code: 'invalid_value'` and
`constraint: { scope: 'user' }`. The check runs in the pre-flight,
before validation, before any row load for that key and before any
write, and it refuses the whole batch. A `null` reset of such a key is
refused too: the only row it could touch has no owner, and no reader
returns that row any more.
**Unchanged:**
- A caller with a user id reads and writes only their own rows.
- Tenant and global keys resolve and write the same with or without a
user id.
- The settings HTTP routes are unaffected. Their context comes from the
verified authz resolution, which either names a user or carries no
permissions, and the manifest permission gate runs before any row load.
- The tenant rung's organization comparison is untouched.
#15211 is not addressed here.
## Why `SETTINGS_VALIDATION` and not a new code
I checked the service's existing error vocabulary first, as the dispatch
asked. `SettingsValidationError` already refuses a whole batch before
any write, with one `FieldError` per key. It also already carries a
refusal that is not about the submitted value: the manifest-side
`visible` predicate, under `invalid_value`, ADR-0114's slot for
"rejected for a reason no other member names". `SETTINGS_FORBIDDEN` is
defined as the HTTP-boundary capability refusal, and its message names a
capability. `SETTINGS_LOCKED` drives the lock affordances. A new code
would also need a ledger row in `packages/spec`, which is outside this
card's fence.
## Reach (the round-1 census, re-read on this base)
The shipped settings manifests declare no `scope: 'user'` key at
manifest or specifier level. That was true at the claim's base and still
is at `0e9371f0c6`. So no in-repo caller meets either half today. The
in-process writers are the HTTP routes and the service's own `set` and
`resetNamespace`, and the HTTP door always carries a user. The change is
latent and defensive. It takes effect the first time a manifest declares
a user-scoped key.
## Tests
Measured at `fd24546953`. Every run went through
`scripts/pm/os-verify-lock.sh`.
- New pin file `settings-user-rung-owner.pin.test.ts`, 65 cases, run on
both stores:
- a resolve with no user id never answers a user-scoped row, on each
read path (`get`, `getMany`, `getNamespace`, `createClient`,
`runAction`), for an empty context, a tenant-only context and an
empty-string user id;
- the fall-through order (tenant, then global, then the default) for a
resolve with no user id;
- a reset with no user id counts no user row and writes nothing;
- the user-rung pick compares the owner itself, even when the store
returns more rows than the query asked for;
- a write of a user-scoped key with no user id is refused, with the
envelope (`code` plus `fields`), stores nothing and emits no change
event, and a mixed batch is refused whole;
- controls: per-user reads and writes, the owner's row above the tenant
rung, and tenant and global keys with and without a user id.
- Before the fix, on the base source: 34 failed and 26 passed. Every
negative pin was red and every control was green. One control was split
before commit because it also asserted a read with no user. The two
load-bearing pins were added in the second commit; ablations M1 and M2
below make them red.
- With the fix: `pnpm --filter @objectstack/service-settings exec vitest
run --maxWorkers=2` gave 39 files and 702 tests passed. `pnpm --filter
@objectstack/service-settings typecheck` gave exit 0, and `--listFiles`
shows the pin file is part of that program.
- Ablation, run from the committed state through
`scripts/ablation-replace.mjs` (anchor hit once, blob changed, restore
proven equal to HEAD, `git diff HEAD` empty), with a bash `trap` restore
as well. Every leg turned red, and no control failed:
- M1, the presence check removed from the user-rung pick: 39 of 65
failed. These were the no-user read pins on both stores, the
fall-through, the reset and the over-answer pin. An ownerless legacy row
is what makes this check load-bearing.
- M2, the owner comparison removed: 5 of 65 failed. These were the
over-answer pin on each read path. With the current loaders a present
user id only loads its own rows, so this is the pin that holds the
comparison.
- M12, both removed: 39 of 65 failed.
- M3, the refusal disarmed: 8 of 65 failed. These were the no-user write
pins on both stores and the batch pin.
## Acceptance notes
- **The load with no user id still reads every user's user rows**
(`loadScopedRows` and the in-memory filter). With the pick narrowed,
those rows are inert for the cascade. Narrowing the load itself is
defence in depth and is outside this card's fence. Carrier: none.
- **Ownerless user rows** written before this change, by a write with no
user id, stay in the store. No reader returns them now. The service
cannot clear one either, because a `null` write with no user id is
refused. No in-repo writer could have produced one, since no shipped
manifest declares a user-scoped key.
- **`resetNamespace` on a user-scoped key whose effective value comes
from the tenant or global rung** writes `null` at the key's declared
(user) rung, so the upper value stays in force even though it is counted
as cleared. This was there before this change and is a reading, not
measured. With no user id, that path now meets the write refusal instead
of storing an ownerless row. Carrier: none.
- **Docs:** the "Writes fail loudly" table in
`content/docs/protocol/kernel/config-resolution.mdx` has no row for this
refusal. That file is outside this card's fence. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent dbb7be2 commit 7518ee3
3 files changed
Lines changed: 377 additions & 12 deletions
File tree
- .changeset
- packages/services/service-settings/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
109 | 124 | | |
110 | 125 | | |
111 | 126 | | |
| |||
1283 | 1298 | | |
1284 | 1299 | | |
1285 | 1300 | | |
1286 | | - | |
1287 | | - | |
| 1301 | + | |
| 1302 | + | |
| 1303 | + | |
| 1304 | + | |
1288 | 1305 | | |
1289 | 1306 | | |
1290 | 1307 | | |
| |||
1379 | 1396 | | |
1380 | 1397 | | |
1381 | 1398 | | |
| 1399 | + | |
1382 | 1400 | | |
1383 | | - | |
| 1401 | + | |
1384 | 1402 | | |
1385 | 1403 | | |
1386 | 1404 | | |
1387 | 1405 | | |
1388 | 1406 | | |
1389 | 1407 | | |
1390 | | - | |
| 1408 | + | |
1391 | 1409 | | |
1392 | 1410 | | |
1393 | | - | |
| 1411 | + | |
1394 | 1412 | | |
1395 | 1413 | | |
1396 | 1414 | | |
| |||
1401 | 1419 | | |
1402 | 1420 | | |
1403 | 1421 | | |
| 1422 | + | |
| 1423 | + | |
| 1424 | + | |
| 1425 | + | |
| 1426 | + | |
1404 | 1427 | | |
1405 | 1428 | | |
1406 | 1429 | | |
1407 | 1430 | | |
1408 | 1431 | | |
1409 | 1432 | | |
| 1433 | + | |
1410 | 1434 | | |
1411 | 1435 | | |
1412 | 1436 | | |
| |||
1438 | 1462 | | |
1439 | 1463 | | |
1440 | 1464 | | |
1441 | | - | |
1442 | | - | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
| 1470 | + | |
1443 | 1471 | | |
1444 | 1472 | | |
1445 | 1473 | | |
| |||
1674 | 1702 | | |
1675 | 1703 | | |
1676 | 1704 | | |
| 1705 | + | |
| 1706 | + | |
| 1707 | + | |
| 1708 | + | |
| 1709 | + | |
| 1710 | + | |
| 1711 | + | |
| 1712 | + | |
| 1713 | + | |
| 1714 | + | |
| 1715 | + | |
| 1716 | + | |
| 1717 | + | |
| 1718 | + | |
| 1719 | + | |
| 1720 | + | |
| 1721 | + | |
| 1722 | + | |
| 1723 | + | |
| 1724 | + | |
| 1725 | + | |
| 1726 | + | |
| 1727 | + | |
| 1728 | + | |
1677 | 1729 | | |
1678 | 1730 | | |
1679 | 1731 | | |
| |||
1684 | 1736 | | |
1685 | 1737 | | |
1686 | 1738 | | |
1687 | | - | |
| 1739 | + | |
| 1740 | + | |
| 1741 | + | |
| 1742 | + | |
| 1743 | + | |
| 1744 | + | |
| 1745 | + | |
| 1746 | + | |
| 1747 | + | |
| 1748 | + | |
1688 | 1749 | | |
1689 | 1750 | | |
1690 | 1751 | | |
| |||
1701 | 1762 | | |
1702 | 1763 | | |
1703 | 1764 | | |
1704 | | - | |
| 1765 | + | |
| 1766 | + | |
| 1767 | + | |
| 1768 | + | |
1705 | 1769 | | |
1706 | 1770 | | |
1707 | 1771 | | |
| |||
1720 | 1784 | | |
1721 | 1785 | | |
1722 | 1786 | | |
1723 | | - | |
| 1787 | + | |
| 1788 | + | |
| 1789 | + | |
| 1790 | + | |
| 1791 | + | |
1724 | 1792 | | |
1725 | 1793 | | |
1726 | 1794 | | |
| |||
1731 | 1799 | | |
1732 | 1800 | | |
1733 | 1801 | | |
| 1802 | + | |
| 1803 | + | |
| 1804 | + | |
1734 | 1805 | | |
1735 | 1806 | | |
1736 | 1807 | | |
| |||
1761 | 1832 | | |
1762 | 1833 | | |
1763 | 1834 | | |
1764 | | - | |
| 1835 | + | |
| 1836 | + | |
1765 | 1837 | | |
1766 | | - | |
| 1838 | + | |
1767 | 1839 | | |
1768 | 1840 | | |
1769 | 1841 | | |
| |||
0 commit comments