Skip to content

Commit 3f4333e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21387-retire-role-arm
2 parents 73977ce + b7a13c7 commit 3f4333e

12 files changed

Lines changed: 2277 additions & 140 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
---
4+
5+
The object-schema field mask (ADR-0106 D1) now removes a denied field's references from the rest of the served object document, not only its `fields` entry.
6+
7+
Clause-②: no
8+
9+
A caller who cannot read a field was served a document without that field's definition, but other parts of the document could still name the field. Those parts are now projected too, on every exit that serves an object schema to a restricted caller: the by-name read, the list read and the layered view. What happens to each kind of position:
10+
11+
- **Object-level rule entries** (`validations`, `indexes`, `activityMilestones`). An entry that names or reads a denied field is dropped whole. The platform still evaluates the stored rule on every write.
12+
- **Role pointers** (`nameField`, `displayNameField`, `imageField`, `stageField`, `tenancy.tenantField`, `lifecycle.ttl.field`). A pointer to a denied field is deleted, so the role falls back to its default.
13+
- **Name lists** (`highlightFields`, `searchableFields`, `publicSharing.redactFields`, list-view column lists, `external.columnMap` entries, and a readable field's `relatedListColumns` / `dependsOn`). The denied entries are filtered out. A list-view column in object form is dropped when any of its facets names a denied field: its own `field`, its `prefix.field` or its `summary.field`. A `dependsOn` entry's `param` is the lookup target's key and is not read as a field here. A list left empty is deleted.
14+
- **The inline master-detail grid** (`inlineColumns`, `inlineAmountField`). These are declared on the child object's own `master_detail` field and name the child's own fields. A column that is a denied field, or is computed from one (`expr`), is dropped. A denied `inlineAmountField` is deleted.
15+
- **Expressions** (`titleFormat`, field-group `visibleWhen`, row-CRUD `visibleWhen` / `disabledWhen`, `publicSharing.eligibility`, lifecycle `onlyWhen`, and a readable field's formula `expression`, `visibleWhen` / `readonlyWhen` / `requiredWhen`, `relatedListFilter`, `defaultValue`, `autonumberFormat` and per-option `visibleWhen`). An expression that reads a denied field is deleted. The readable field itself stays.
16+
- **List views and actions.** An entry whose filter, sort, predicate, params or `patch` read a denied field is dropped. So is a list view whose key is a denied field's name.
17+
18+
In these positions an object key counts as a field reference only where keys are field names: a `FilterCondition`, a lifecycle `onlyWhen` map, an action's `patch`. A denied field named like a schema word (`type`, `source`, `name`) no longer removes every rule, view, action or CEL envelope. A dotted path whose root segment is a denied field counts as a reference to it, whether it is a field-keyed key, a name-list entry or a pointer. The mask also terminates on a cyclic document.
19+
20+
Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field in any string or key, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema`, `FieldSchema`, `InlineGridColumnSchema`, `ListColumnSchema`, `ColumnPrefixSchema` and `ColumnSummaryConfigSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers.
21+
22+
The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions, including list views (one with object-form columns), actions and the inline grid, and its formula field uses the real `expression` key. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the item reads take an item's ADR-0010 `_lock` from the same resolution the write doors enforce, so the read envelope and the doors agree on the artifact layer too (#21738)
6+
7+
Clause-②: no
8+
9+
The `_lock` gate of the write doors (save, publish, rollback, delete) resolves an item's lock from two layers in order: the packaged artifact's `_lock`, unless it is `'none'`, then the stored `sys_metadata` row's. The two item reads did not use that resolution. `getMetaItem` took the lock from its served document, onto which `mergeArtifactProtection` had copied any declared artifact `_lock`, `'none'` included. `getMetaItemLayered` (`GET /api/v1/meta/:type/:name/layers`) took it from the code layer whenever one existed. Now the gate, both reads' envelopes, the served body's lock fields and the `getMetaDiagnostics` per-type `locked` count all call one resolution, `resolveItemLock`.
10+
11+
**Read answers that change**, on both kernel topologies:
12+
13+
- An artifact that declares `_lock: 'none'` over a stored row that declares a lock (env-wide, or the organization's own row) now reads with the stored row's lock in `getMetaItem` and `getMetaItemLayered`. Under `'full'`, that is `editable: false` and `deletable: false`. The served body's `_lock`, the `getMetaItems` list item's `_lock` and the diagnostics `locked` count say the same. The doors already refused these writes with `403 ITEM_LOCKED`. An artifact's `'none'` declares no lock; it does not override an administrator's stored lock.
14+
- `getMetaItemLayered` for a packaged item whose artifact declares no `_lock`, under a stored row that declares one, now reads the stored row's lock, as `getMetaItem` and the doors already did. Before, it read `lock: 'none'`, `editable: true`.
15+
- `lockReason`, `lockSource` and `lockDocsUrl` are the binding layer's. When no layer binds, they are absent: a reason explains a refusal, and there is none. Before, they were whatever the served document carried, so an explicit `'none'` artifact's reason was reported. For the same reason, an explicit `'none'` artifact's `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl` are no longer copied onto a stored row's served body.
16+
- A `_lock` that only a copy the doors never read declares is no longer reported as binding. Examples are a MetadataService copy the dev watcher reloaded after boot, and an item registered at runtime with no package. Such an item now reads as the doors answer it.
17+
18+
**Unchanged.** Every write-door verdict, refusal code and refusal text: the gate still reads the stored row only when the artifact's lock does not bind, so a packaged lock is still answered without a store read. `provenance`, `packageId` and `packageVersion` on both reads, and the artifact's `_packageId`, `_packageVersion` and `_provenance` on served bodies. Which stored row each read serves. The one declared difference between the reads and the gate also stays: the reads still serve a row stored under the type's other (plural) spelling when no canonical row is in scope, and the gate does not read it. No export, accepted input, key or error code changes.

0 commit comments

Comments
 (0)