Repository navigation
Commit f4069c5
Fixes #22250
Clause-②: yes (widening)
## What this does
For one caller, the object metadata read and the data route disagreed
about a field that caller's field-level permission marks unreadable. The
`sortability` projection served beside the schema on `GET
/meta/object/NAME` marked that field `sortable: true` for a
platform-admin or organization-admin caller, while the data route
refuses a sort or filter on it with `403 PERMISSION_DENIED`.
After this PR, the `sortability` projection is derived from the caller's
own field permission for every caller class. The object definition
itself (`item.fields`) is unchanged: whole for the exempt classes,
because Studio and Setup authoring need it, and masked for everyone
else. The data route and the permission read are unchanged. The spec's
description of the projection is amended to match.
## Measured first: the exemption and its stated reason
- **Where it is.** `isObjectSchemaMaskExempt` in
`packages/metadata-core/src/object-schema-fls.ts` (ADR-0106 D4). It
exempts `isSystem` and any caller holding `manage_metadata`,
`studio.access` or `setup.access`. The platform-admin set holds all
three. An organization admin holds `setup.access`.
`resolveObjectSchemaMaskPosture` answers such a caller `passthrough` /
`exempt` before it asks the security service.
- **Its stated reason.** ADR-0106 D4: "Studio/Setup authoring requires
the full schema." The code adds the write-gate derivation: a projected
read sent back by a `PUT` would delete the fields it withheld. So this
is the triage ruling's first branch: keep the definition read for
authoring, and make the runtime projections agree with the caller's
field permission.
- **Dispatch assumption M2 was partly wrong.** The sortability
projection (`translateMetaEnvelope` in
`packages/rest/src/meta-item-read-gate.ts`, which both transports call)
already read the post-mask document. For an exempt caller, though, that
document is the unmasked one, so the projection inherited the exemption.
- **Picker-facing field lists on the server.** `sortability` is the only
runtime projection the object read serves beside the definition. The
list read and the layered view serve definitions only, with no
projection. `item.fields` is itself the definition the ruling keeps for
authoring. objectui handles its pickers against the permission read on
its side (objectui#11925, objectui#11943).
## Door table: `examples/app-showcase`, real boot
Setup: `showcase_client_liaison` (which marks `showcase_project.budget`,
`spent` and `budget_remaining` as `readable: false`) is bound to the
`everyone` position. Callers: the seeded platform admin; `phone.demo`
promoted to organization `admin`; `auditor.demo` as a member.
The table was measured at base `c8bb3c8d9c` and at head `8b412a83e2`,
then re-measured on the final head `a337d0cc53`, which carries
`805f1ba09a`'s change to the exemption predicate's source and the spec
text. Every head cell below reads the same on `8b412a83e2` and on
`a337d0cc53`.
| Door | Platform admin, base → head | Organization admin, base → head |
Member (control), base → head |
|---|---|---|---|
| `GET /auth/me/permissions`, `budget` | `readable: false` → same |
`readable: false` → same | `readable: false` → same |
| `GET /data/showcase_project` | field absent from rows → same | field
absent → same | field absent → same |
| `GET /data/showcase_project?sort=budget` (and `?budget=100`) | `403
PERMISSION_DENIED` → same | `403` → same | `403` → same |
| `GET /meta/object/showcase_project`, `item.fields.budget` | present
(22 fields) → present (22) | present (22) → present (22) | absent (19) →
absent (19) |
| same read, `sortability.fields.budget` | `{ sortable: true }` (23
entries) → **absent** (20 entries) | `{ sortable: true }` (23) →
**absent** (20) | absent (20) → absent (20) |
| same read, `ETag` | `"39ce56dc"` → `"39ce56dc~3c4b8092"` |
`"39ce56dc"` → `"39ce56dc~3c4b8092"` | `"39ce56dc~32c59ec2"` → same |
| `GET /meta/object/showcase_project/layers` (authoring) | `effective`
22 fields with `budget` → same | same → same | 19 without `budget` →
same |
At head, all three classes are served byte-equal `sortability`.
Conditional reads, the same on both heads:
- The platform admin's own ETag answers `304`, and so does the
organization admin's own ETag (on `a337d0cc53`).
- The pre-change ETag `"39ce56dc"` answers `200`.
- The member sending the admin's ETag gets `200`.
After unbinding the set, the unrestricted admin's ETag is `"39ce56dc"`
again (byte-identical, ADR-0106 D3), and `budget` is `{ sortable: true
}` with 23 entries.
## How
- **`@objectstack/metadata-core` (`object-schema-fls.ts`).** The
`exempt` posture of a caller exempt by capability, in a deployment with
a `security` service, carries a lazy `runtime` question. That question
asks the same `getMetadataReadableFields` the mask asks a non-exempt
caller, at most once. It never rejects: a throw or an `undefined` answer
settles `undetermined`, with one `warn`, so D4's "a sick security
service cannot fault an exempt caller" holds.
`resolveObjectSchemaRuntimeView(document, posture)` returns the view the
runtime projections are derived from: the served document for every
other caller, and for an exempt caller the `fields` their permission
reads, plus a fingerprint of what it withholds. Each withheld name in
that fingerprint carries a qualifier (`@` occurs in no machine name), so
an exempt caller and a projected caller denied the same field never
share a validator. `isObjectSchemaMaskExempt` now delegates its
capability half to a private helper, which the resolver asks alone. That
adds no second elevation read (`check:system-context-census`).
- **`@objectstack/rest`.**
- `translateMetaEnvelope` takes an optional `runtimeDocument` (default:
the served document) and derives `sortability` from it.
- `createMetaItemAnswer` step 5 computes the runtime view and passes it
through the `translateEnvelope` port, which gains an optional third
parameter.
- In `rest-server.ts`, the cached arm of `GET /meta/:type/:name` treats
a posture carrying `runtime` like a projecting posture: the protocol is
not handed `If-None-Match`, the runtime view's fingerprint is folded
into the ETag, and the `304` is judged against that folded value. The
private `translateMetaEnvelope` and `metaItemAnswerSources` forward the
view.
- **`@objectstack/runtime` (`domains/meta.ts`).** Only
`answerMetaItem`'s `translateEnvelope` port, which forwards
`runtimeDocument`. Every dispatcher item read already goes through
`createMetaItemAnswer`.
## Spec text (cross-lane, `domain:spec`)
Declared by the seat on `domain:spec`'s seat post. The change is to text
only: no shape, key, enum or accept-set change.
- **`packages/spec/src/api/sortability.zod.ts`.**
- The `ObjectSortabilitySchema.fields` `.describe()` now says: "The
domain is the served field map plus the always-provisioned `id`, less
any field the caller's own field permission withholds: a caller exempt
from the object-schema mask (ADR-0106 D4) is served such fields in
`item` and gets no entry for them; for every other caller the served map
already excludes them." The list of absent names gains "a field the
caller may not read".
- The module docblock's category list gains case 5, "Withheld by the
caller's field permission". Case 1's domain sentence carries the same
exception, which removes nothing for every caller but a D4-exempt one.
- `resolveObjectSortability`'s docblock says that, for a D4-exempt
caller, it runs on the document narrowed to the fields the caller's own
permission reads.
- **Generated.** `content/docs/references/api/sortability.mdx` and
`content/docs/references/api/protocol.mdx` were regenerated by `pnpm
--filter @objectstack/spec check:generated --fix`. That forced a spec
build, found only `check:docs` stale (14 of 15 green), and regenerated
it. A second `check:generated` run then reported all 15 artifacts
current. Nothing generated was hand-edited.
- **Changeset.** No `@objectstack/spec` entry was added, because no
changeset gate requires one for a description-only change:
- `Check Changeset` (`pr-automation.yml`) counts the changesets a PR
adds;
- `check-changeset-no-major.mjs`, driven offline with this body as its
event, reads `Clause-②: yes (widening)` and is satisfied by
`metadata-core` and `rest` graded `minor`, and it lists no ungraded spec
package;
- `check-empty-changeset.mjs` is green.
## Tests
- **`packages/metadata-core/src/object-schema-fls.test.ts`** (new
describe block):
- per class (platform admin, organization admin, member): the runtime
view holds only the readable fields, the exempt definition is served
whole, and the member's view is the served document by reference;
- one permission gives one view whatever the class; no validator is
shared with the definition mask;
- an unrestricted exempt caller is byte-identical; the question is lazy
and asked at most once, and never for a document without `fields`;
- `undefined` and a throw both settle undetermined with a `warn`;
`isSystem` and a deployment with no service carry no question.
One existing assertion went from `toEqual` to `toMatchObject` plus a
`typeof runtime === 'function'` check, because the exempt posture now
carries the function. Its "asks nothing" pin (`asked === 0`) is kept.
- **`packages/rest/src/meta-object-sortability-caller-fls.test.ts`**
(new), per class, on the cached (default) and uncached branches:
- the definition is unchanged, `sortability` never names the unreadable
field, and it is equal across classes;
- the member control still agrees with `resolveObjectSortability(item)`;
- the cached arm's ETag is fingerprinted, the protocol is not handed the
conditional, the admin's own ETag answers `304`, the member's ETag does
not cross over, and an unrestricted admin keeps `"v1"` and its `304`;
- a throwing service answers `200` with the definition and the
unprojected `sortability`.
-
**`packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts`**
(new): the same per-class pins on the dispatcher's protocol-backed and
registry-backed item reads.
**Reverse verification (from committed state, on-disk proof by
`scripts/ablation-replace.mjs`):**
- **A.** `translateMetaEnvelope` was mutated to derive `sortability`
from the served document (the behaviour before this PR). The REST suite
went 5 failed / 8 passed. After `pnpm --filter @objectstack/rest build`,
`ablation-dist-preflight` found the marker in `dist`, and the runtime
suite went 4 failed / 4 passed. Restore: blob equal to `HEAD`, `git diff
HEAD` empty; rebuild; preflight `--absent` with a clean tree; 13/13 and
8/8 green.
- **B.** The exempt posture was mutated to never carry `runtime`. The
metadata-core file went 7 failed / 29 passed, all assertion failures.
Restored, blob equal to `HEAD`.
The first B attempt used an undefined identifier and went red on a
`ReferenceError`, so that reading is void. The second was refused by the
tool because the replacement contained the anchor, so nothing was
mutated.
## Local verification (final head `a337d0cc53`)
All suites ran through the shared verify lock. Exit codes were read from
its `VERDICT` line.
- **This round (spec text and regenerated docs).**
- `@objectstack/spec` test: 625 files, 18711 passed and 1 todo.
`typecheck` exit 0, which includes `check:scripts-typecheck` and
`check:test-typecheck`.
- `dispatch-gates --commands` derives 111 commands for the full touched
path set (11 paths). All 111 ran and exited 0.
- `--ran` reconciliation: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN
(a derived zero: every entry records its exit code).
- The derivation warns that one file it reads,
`scripts/platform-object-tenancy-census.json`, changed on `main` since
this branch's merge base, so that family read this branch's copy.
- Narrowed lint on `sortability.zod.ts`:
- `eslint --no-inline-config --format json` reports 1 file, 0 errors and
0 warnings.
- The effective config (`--print-config`) enables 5 syntax rules and
`parserOptions.project` is `null`.
- With no type-aware linting, the change cannot move a verdict on a file
it does not touch.
- **Earlier round (unchanged since).**
- `@objectstack/metadata-core`: 18 files, 425 passed; `typecheck` exit
0.
- `@objectstack/rest` at `3768ffad20`: 268 files, 5142 passed and 327
skipped; `typecheck` exit 0.
- `@objectstack/runtime`: 340 files, 5508 passed and 19 skipped at
`805f1ba09a`; `typecheck` exit 0 at `3768ffad20`.
- Narrowed lint over those 7 files: 0 errors and 0 warnings.
- Gates: 64 of 64 exited 0 at `3768ffad20`.
- **Merge.** `main` was merged once, at `9865012548` (#22265 touched
`rest-server.ts` and `runtime/src/domains/meta.ts` in a different
region). Against the current `origin/main`, `git merge-tree
--write-tree` exits 0 and no file overlaps, so no second merge was made.
## Answered by the seat
- **Clause-②.** The measurement decides: `yes (widening)`, with `minor`
for `metadata-core` and `rest` and `patch` for `runtime`. The claim's
line was revised to match. Two transport-local copies of the posture
logic were refused.
- **The spec text.** It rides this PR, not a separate card, with
`packages/spec` lifted for exactly this text. It is declared cross-lane
on `domain:spec`'s seat post.
## Acceptance notes
These are observations only, read from source and not measured. No card
is filed for them.
- **objectui's docblock.** objectui's
`packages/core/src/utils/column-sortability.ts` repeats the sentence
this PR amends ("the served field map plus the always-provisioned
`id`"). That is another repo and is not edited here. Carrier: objectui
`domain:ui` seat 1 (objectui#11925's filer).
- **Partially masked fields.** For any caller, `sortability` keys on
readability, while the security service's query guard also refuses a
predicate or sort on a field served partially masked (`maskingRule`).
Per `getQueryableFields`'s docblock, a masked field is readable but not
queryable. No in-repo app declares a `maskingRule` today (showcase
`permission-sets.ts`). Whoever touches `resolveObjectSortability` next
should weigh it.
- **External objects.** On an ADR-0015 `external` object, an
author-declared platform-injected column that the caller cannot read is
re-added by `unprovisionedInjectedColumns` as `sortable: true` plus a
caveat once the mask removes the declaration. This is the same for every
class.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e4111c commit f4069c5
11 files changed
Lines changed: 771 additions & 42 deletions
File tree
- .changeset
- content/docs/references/api
- packages
- metadata-core/src
- rest/src
- runtime/src/domains
- spec/src/api
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1360 | 1360 | | |
1361 | 1361 | | |
1362 | 1362 | | |
1363 | | - | |
| 1363 | + | |
1364 | 1364 | | |
1365 | 1365 | | |
1366 | 1366 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
| 40 | + | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
43 | | - | |
44 | | - | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
45 | 48 | | |
46 | 49 | | |
47 | 50 | | |
| |||
62 | 65 | | |
63 | 66 | | |
64 | 67 | | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
65 | 77 | | |
66 | 78 | | |
67 | 79 | | |
| |||
132 | 144 | | |
133 | 145 | | |
134 | 146 | | |
135 | | - | |
| 147 | + | |
136 | 148 | | |
137 | 149 | | |
138 | 150 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
18 | 19 | | |
19 | 20 | | |
20 | 21 | | |
| |||
164 | 165 | | |
165 | 166 | | |
166 | 167 | | |
167 | | - | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
168 | 172 | | |
169 | 173 | | |
170 | 174 | | |
| |||
291 | 295 | | |
292 | 296 | | |
293 | 297 | | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
0 commit comments