Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .changeset/tenant-rename-beats-extension-scalar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
'@objectstack/objectql': patch
'@objectstack/spec': patch
---

A tenant's own object rename now wins over a package extension's scalar in the object fold (#8460).

`mergeObjectDefinitions` applied an `objectExtensions` entry's `label` /
`pluralLabel` / `description` last, onto whatever base it was handed — and
ADR-0029 D9.2 makes the tenant's overlay that base. So a rename saved through
Studio answered `200`, was visible under `?layers=true`, and was overwritten
inside the fold before any read served it.

Per the 2026-08-13 maintainer ruling (ADR-0029 D9.2a), an extender's scalar now
applies only while the fold's base still carries the packaged owner's value; a
base that has diverged was authored by the tenant, and the extender yields. This
is the same comparison-based mechanism #8284 established for the i18n catalog
one layer up — the same predicate, now exported from `@objectstack/spec` and
imported by the registry rather than re-spelled — so one rule covers both
layers: an explicit override beats a packaged default.

No provenance flags and no migration: the question is answered from two values
at fold time. The accepted cost is deliberate — a package can no longer relabel
an object a tenant has deliberately renamed.
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,6 +472,68 @@ the one the ruling asked for:
nobody asked for, arriving as a silent re-appearance of deleted fields.
- **A second `own` is refused** — see § 4.

##### D9.2a — AMENDMENT (2026-08-13, #8460): an extender's SCALAR yields to a diverged base

D9.2 above says the fold runs "exactly as it does today", and for `fields`,
`validations` and `indexes` it still does. For the three **scalars** — `label`,
`pluralLabel`, `description` — it no longer does, and this clause is the
difference.

**Ruling (maintainer, 2026-08-13, option A — "tenant wins"):** an extender's
scalar applies only while the fold's base still carries the **packaged owner's**
value. A base whose scalar has diverged from the owner's has been authored by
the tenant, and the extender **yields**.

Why this had to be decided rather than left to D9.2's "last writer wins": D9.2
makes the tenant's overlay the *base* of the fold, so last-writer-wins meant a
code package's `objectExtensions` scalar overwrote the tenant's own Studio
rename *inside* the fold. The tenant's value was then absent from the document
every read serves — `PUT /meta/object/:name` answered `200`, `?layers=true`
showed the saved value under `overlay`, and no read a writable form derives from
ever showed it (#8037, #8027/#8045, and the severe half of #8284).

The mechanism is **comparison-based provenance**, and is deliberately the *same*
mechanism [#8284](https://github.com/objectstack-ai/objectstack/issues/8284)
established one layer up for the i18n catalog — the same predicate, imported by
`SchemaRegistry` from `@objectstack/spec`, not a second copy free to drift. One
sentence now governs both layers: **an explicit override beats a packaged
default.**

Binding consequences:

- **No provenance flag and no migration.** Nothing is stamped on the document;
the question is answered from two values at fold time. A flag threaded through
the fold was explicitly rejected.
- **The comparison is against the packaged owner ALONE**, never against the
owner with extenders already folded on (D9.6's `resolveOwnerLayer`) — that
body reports every extender's scalar as "unchanged" and would yield nothing,
ever.
- **Computed once, over the base the fold starts from**, never re-derived from
the running merge. Re-deriving would make one extender's scalar look
"authored" to the next and silently invert extender-vs-extender precedence,
which D9.3 reserves to declared priority.
- **Conservative edges** (inherited from the shared predicate): an absent base,
a non-string or empty value, and inexact equality all mean "no opinion", so
this can only ever *withhold* an extender's scalar from a value that provably
diverged. A tenant who renames an object to exactly the packaged string is a
no-op, by construction.
- **Idempotence (#8027) is preserved.** A base that already carries an
extender's scalar reads as diverged, so the extenders yield and the value
stays what the first fold produced — the same answer, reached by yielding
instead of by re-applying.
- **The accepted cost is the point, not a regression:** a package can no longer
relabel an object a tenant has deliberately renamed. There is **no escape
hatch**, by ruling. Note the honest edge this implies: because the write path
persists the served body verbatim (ADR-0005 §Validation), a tenant who
round-trips an object *without* renaming it freezes the extender's current
scalar into the overlay row, and a later change to the package's extension
scalar will not reach that tenant. That follows from comparison-based
provenance with no flags, which is what the ruling required; it is recorded
here rather than papered over.
- Options B (status quo — the extension keeps winning) and C (refuse the write)
were considered and **rejected**. Dropping scalars from the fold entirely
(#8284's arm B) remains rejected and is not this clause.

#### D9.3 — selection is by KIND; priority stays descriptive

`contributors.sort((a, b) => a.priority - b.priority)` (`:1189`) totals the whole
Expand Down
119 changes: 112 additions & 7 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,11 @@ import {
ITEM_KEY_DISCRIMINATORS,
readDiscriminatorValue as discriminatorValue,
} from '@objectstack/metadata-core';
import { SystemFieldName } from '@objectstack/spec/system';
// [#8460] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather
// than re-spelled: the object FOLD asks the same question one layer down (has
// this scalar been authored away from the packaged default?), and the ruling
// required the same mechanism, not a second comparison shape.
import { SystemFieldName, scalarOverridesPackagedBase } from '@objectstack/spec/system';
import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/types';
import { postureEnforcesWall } from '@objectstack/spec/security';
import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-companion.js';
Expand DownExpand Up@@ -111,11 +115,30 @@ export function parseFQN(fqn: string): { namespace: string | undefined; shortNam
};
}

/**
* The three SCALAR props {@link mergeObjectDefinitions} resolves last-writer-wins
* — the exact set the #8284 and #8460 rulings both cover, and the same three
* {@link scalarOverridesPackagedBase} answers for.
*/
const OBJECT_FOLD_SCALAR_KEYS = ['label', 'pluralLabel', 'description'] as const;

type ObjectFoldScalarKey = (typeof OBJECT_FOLD_SCALAR_KEYS)[number];

/**
* Deep merge two ServiceObject definitions.
* Fields are merged additively. Other props: later value wins.
*
* [#8460] …except that "later value wins" is now conditional for the three
* SCALARS. `tenantAuthored` names the scalars the fold's BASE has authored away
* from the packaged owner's value; an extender yields on those. See
* {@link SchemaRegistry.tenantAuthoredScalars} for why the set is computed once
* over the base rather than re-derived from the running `merged`.
*/
function mergeObjectDefinitions(base: ServiceObject, extension: Partial<ServiceObject>): ServiceObject {
function mergeObjectDefinitions(
base: ServiceObject,
extension: Partial<ServiceObject>,
tenantAuthored?: ReadonlySet<ObjectFoldScalarKey>,
): ServiceObject {
const merged = { ...base };

// Merge fields additively
Expand All@@ -133,10 +156,16 @@ function mergeObjectDefinitions(base: ServiceObject, extension: Partial<ServiceO
merged.indexes = [...(base.indexes || []), ...extension.indexes];
}

// Override scalar props (last writer wins)
if (extension.label !== undefined) merged.label = extension.label;
if (extension.pluralLabel !== undefined) merged.pluralLabel = extension.pluralLabel;
if (extension.description !== undefined) merged.description = extension.description;
// Override scalar props (last writer wins) — [#8460] unless the base has been
// authored by the tenant, in which case the extender's packaged default yields.
const yields = (key: ObjectFoldScalarKey): boolean => tenantAuthored?.has(key) === true;
if (extension.label !== undefined && !yields('label')) merged.label = extension.label;
if (extension.pluralLabel !== undefined && !yields('pluralLabel')) {
merged.pluralLabel = extension.pluralLabel;
}
if (extension.description !== undefined && !yields('description')) {
merged.description = extension.description;
}

return merged;
}
Expand DownExpand Up@@ -1576,15 +1605,91 @@ export class SchemaRegistry {
contributors: ObjectContributor[],
baseDefinition: ServiceObject,
): ServiceObject {
// [#8460] Computed ONCE, over the base the fold starts from — never
// re-derived from the running `merged`, which would make an extender's own
// scalar look "authored" to the next extender and silently invert
// extender-vs-extender precedence (D9.3: declared numbers order peers).
const tenantAuthored = this.tenantAuthoredScalars(contributors, baseDefinition);
let merged = { ...baseDefinition };
for (const contrib of contributors) {
if (contrib.ownership === 'extend') {
merged = mergeObjectDefinitions(merged, contrib.definition);
merged = mergeObjectDefinitions(merged, contrib.definition, tenantAuthored);
}
}
return merged;
}

/**
* [#8460] Which of the three fold scalars the BASE layer carries a
* TENANT-AUTHORED value for — i.e. one that no longer equals the packaged
* owner's.
*
* Maintainer ruling, 2026-08-13 (option A, "tenant wins"): an extender's
* scalar applies only while the fold's base still carries the packaged
* owner's value; a diverged base has been authored by the tenant and the
* extender yields. This **amends ADR-0029 D9.2**, whose fold was
* unconditionally last-writer-wins on the scalars, and it is deliberately the
* SAME comparison-based mechanism the #8284 fix applies one layer up — the
* predicate is literally {@link scalarOverridesPackagedBase}, imported from
* `@objectstack/spec`, not a second copy of the shape. One sentence at both
* layers: *an explicit override beats a packaged default.*
*
* ⛔ No provenance flag is stamped on the document and no migration is
* implied: the question is answered from two values, at fold time.
*
* The accepted cost is the point, not a regression — a package can no longer
* relabel an object a tenant has deliberately renamed. There is no escape
* hatch for it, by ruling.
*
* Conservative in the same three ways the predicate is (absent base, non-string
* or empty value, exact equality), plus two of this layer's own:
*
* - **no `own` contributor → no opinion.** An orphan overlay (D9.5) keeps
* today's fold exactly.
* - **base IS the packaged owner → no opinion, by reference.** That is
* {@link resolveObject} with no overlay registered and every
* {@link resolveOwnerLayer} call (D9.6), so the common shape pays one
* identity comparison and cannot change answer. It is an OPTIMISATION, not
* a correctness edge: comparing that base against itself answers "not
* diverged" for every key anyway. ⛔ So it must never be relaxed into
* something weaker than identity — a value-equality test here would start
* answering for bodies that merely LOOK like the owner.
*
* ⛔ The owner is read as the `own` CONTRIBUTOR, deliberately NOT through
* {@link getPackagedObjectOwner} — whose extra `isCodeArtifactBody` test
* (D9.8) would make this decline to protect a RUNTIME-authored object, i.e.
* exactly the object whose owner row the tenant wrote by hand. The two agree
* wherever a packaged owner exists, which is every shape #8460 measured; they
* differ only on a tenant-authored owner, and there the ruled sentence still
* reads the same way — the tenant's own row is the explicit override and a
* package's `objectExtensions` entry is the packaged default. The rejected
* alternative is the trap PR #8454 named one layer up, in its own form:
* comparing against a body that already has extenders folded onto it
* ({@link resolveOwnerLayer}) would report every extender's scalar as
* "unchanged" and yield nothing, ever.
*
* IDEMPOTENCE is preserved, which matters because
* {@link foldObjectExtendersOnto} is documented as idempotent (#8027) and two
* shipped call sites really do hand it an already-folded base. A base that
* already carries an extender's scalar reads as "diverged", so every extender
* yields and the value stays exactly what the first fold produced — the same
* answer, reached by yielding instead of by re-applying.
*/
private tenantAuthoredScalars(
contributors: ObjectContributor[],
baseDefinition: ServiceObject,
): ReadonlySet<ObjectFoldScalarKey> | undefined {
const owner = contributors.find((c) => c.ownership === 'own')?.definition;
if (!owner || owner === baseDefinition) return undefined;
let authored: Set<ObjectFoldScalarKey> | undefined;
for (const key of OBJECT_FOLD_SCALAR_KEYS) {
if (scalarOverridesPackagedBase(owner, key, baseDefinition[key])) {
(authored ??= new Set()).add(key);
}
}
return authored;
}

/**
* [#7556] Fold this object's `extend` contributors onto a base body the
* CALLER supplies — the same fold {@link resolveObject} (D9.2) and
Expand Down
Loading
Loading