Skip to content

Commit 7ffc578

Browse files
fix(metadata-protocol): an object's references list a permission set named only by its fields keys (#23539) (#23554)
Fixes #23539 Clause-②: no ## Summary `findReferencesToMeta` serves `GET /api/v1/meta/object/:name/references`, the admin "Used by" panel. It now lists a permission set that names an object only in its `fields` keys. `PermissionSetSchema.fields` is keyed `OBJECT.FIELD`, and the security plugin applies a set's field rules to an object by that key prefix (`PermissionEvaluator.getFieldPermissions`). Before this change, such a set was missing from the object's answer. The panel could then read "Safe to delete." while the set still masked the object's fields. ## What changed - **`packages/metadata-protocol/src/reference-sites.ts`.** A composite-key seed, `COMPOSITE_KEY_REFERENCE_SITES`: `permission.fields` names an `object` through the text before a key's first `.`. It is exposed as a new index member, `compositeKeyByTarget`, and kept OUT of `byTarget`. - Both premises are derived from the schemas, in the `nameVocabularyRejectsKey` style, and fail closed. `permission` must still declare `fields` as a root record, and `ObjectSchema.name` must still reject `.`. If either stops holding, the seed drops out and the derivation pin goes red. - The seed's target is typed `'object'` alone. - `nameVocabularyRejectsKey`'s schema read is factored into `nameVocabularyVerdict`. Its answers are unchanged: the existing `unanswerableTargetTypes` pins still pass. - **`packages/metadata-protocol/src/protocol.ts`, two regions only.** - A new `collectCompositeKeyHeadHits`, placed beside `collectReferenceHits`. `referencedNamesAt` and `collectReferenceHits` are byte-unchanged. - It reads only the seeded root record's keys, and matches the text before the FIRST `.` (dot index above 0). - A key's field part is never read. A key with no object part (`.full_name`) names no object. - One row per site, at path `fields{key}`. - `findReferencesToMeta` folds the composite sites into the same one-read-per-source-type matchers. The new row's kind is `permission fields`. Every existing row, the response shape and the `field` target's 501 are unchanged. - **`duplicatePackage` is not touched,** and its `byTarget.get('object')` reading is unchanged. ### Why the seed is not a `byTarget` row This was measured in round 1 (report comment 6179812710, experiment E1) with a plain `{ permission, fields, object }` row in `byTarget`. The answer did not change, because the walk compares a WHOLE value with the object's name. And `duplicatePackage` broke. It reads `byTarget.get('object')` as a flat set of object-naming property names, so a copied object's own `fields` key was renamed. `protocol-duplicate-package-unprefixed-object.test.ts` went red, with `noteCopy.fields.ticket` undefined. The derivation pin now asserts that `byTarget('object')` has no `permission.fields` site. ### Why the split is the first `.` `ObjectSchema.name` and `FieldSchema.name` are both `^[a-z_][a-z0-9_]*$`, so neither can contain `.`. The evaluator (`key.startsWith(object + '.')`), lint `security-fls-unknown-field` and `duplicatePackage`'s field-rule rewrite all make the same split. Save-door readings (`saveMetaItem`, real kernel): - A deeper key on an in-stack object (`customer.full_name.extra`) is refused with 422 `security-fls-unknown-field`. - A bare key is refused with 422 `security-fls-unqualified-key`. - A key for an out-of-stack object (`no_such_object.x`) and an empty-object-part key (`.full_name`) are stored. ## Evidence (final head `8ca891b1d5`) - **Real-kernel pin**, `packages/objectql/src/protocol-references-permission-field-keys.test.ts`: ObjectKernel + ObjectQLPlugin + memory driver; every item passes the real save door. - On `main` (round 1, `9e67e008e1` over `7b64a3d73d`): `Tests 2 failed | 2 passed (4)`. The two failures were `expected [ Array(1) ] to include 'refsrc_customer_fls@fields{key}'` and `expected [] to deeply equal [ 'refsrc_invoice_fls@fields{key}' ]`. - After the fix: `Tests 4 passed (4)`. - The pin covers four cases. (1) The set is listed at `fields{key}`. (2) Negative control: a set whose keys name only another object is not listed, even where its FIELD part spells the asked object; it is listed once for the object it does name. (3) The `objects{key}` row is unchanged. (4) The `field` target still refuses with `NOT_IMPLEMENTED` / 501. - **`field` target byte-identity.** A real-kernel probe captured `findReferencesToMeta({ type: 'field', name: 'refsrc_customer.full_name' })` before and after the fix. Both runs gave `NOT_IMPLEMENTED 501 refusal:true`, message sha256 `7b4aee8698a564f69599ba38e1b3f54ec2623cd5336a8e57e267b093ea4b2457`. The probe was a throwaway file, deleted afterwards. - Before, `refsrc_customer` answered the grant row only. After, it answers that same row plus the new `fields{key}` row. - **Ablation.** The walk branch was deleted: the line calling `collectCompositeKeyHeadHits` in `findReferencesToMeta`. - Mutation: `ablation-replace.mjs`, anchor 1→0, blob `5be7b1c1adc7`→`09028903ed91`. - Positive control on the pristine build: the call marker is present in `dist/index.js` and `dist/index.cjs`. - The rebuild's ESM/CJS emitted. DTS refused with TS6133 (the helper was now unused), which does not affect the JS artifacts the suite loads. - `ablation-dist-preflight --absent`: marker absent from dist. - Pin: `Tests 2 failed | 2 passed (4)`, the same two listing assertions. - Restore: blob == HEAD with `git diff HEAD` empty. The rebuild re-emitted both declaration files (`check-dts-emitted 2/2`). Preflight: marker present in both JS files, and the tree is clean. Pin: `Tests 4 passed (4)`. - **Suites.** - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: `Test Files 236 passed | 3 skipped (239)`, `Tests 28412 passed | 19 skipped`. - `pnpm --filter @objectstack/objectql exec vitest run --project local`: `Test Files 419 passed (419)`, `Tests 8112 passed`. This includes `protocol-references`, `protocol-duplicate-package-unprefixed-object` and `protocol-duplicate-package-permission-field-keys`. - `reference-sites.derivation.test.ts`: `Tests 25 passed (25)`. - **Typecheck.** - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `--listFiles` includes `reference-sites.ts`, `protocol.ts` and `reference-sites.derivation.test.ts`. - `pnpm --filter @objectstack/objectql typecheck`: exit 0, with `check:test-typecheck: OK — 40 file(s) / 234 error(s) / 65 pinned signature(s) held`. - **Derived gates** at `8ca891b1d5`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the 5 changed paths. All 67 were run, each exit captured before any pipe. 66 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET: it needs about 70 packages built, which this worktree does not hold, so it is NOT MEASURED here and CI builds them. `pnpm check:lean-entry-closure` first exited 3 on a missing `packages/objectql/dist`; after an objectql build it was re-measured at exit 0. `--ran` reconciliation: `67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN`. The 11 wide-population families and the 6 workflow-valued families are CI's. ## Acceptance notes - The new row's path spelling `fields{key}` follows the existing `objects{key}` grammar. A set naming an object in both `objects` and `fields` answers two rows, one per path. That is the walk's existing one-row-per-path behaviour. - Noted, not filed: the `field` target's refusal text says every property that names a field holds the bare field name. `permission.fields` is the one derived field site whose keys are the composite form, so the sentence is inaccurate for that one site. The refusal itself stays honest, and the card keeps the `field` answer unmoved. - Noted, not filed: `duplicatePackage` reads `byTarget('object')` as a source-type-blind property-name set. This is why the seed lives in its own map, and the derivation pin guards it. - A save-door finding about the empty-object-part FLS key (`.full_name`) went to the seat for filing. It is out of this PR's scope. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b572289 commit 7ffc578

5 files changed

Lines changed: 469 additions & 12 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): an object's "Used by" answer lists a permission set that names the object only in its `fields` keys (#23539)
6+
7+
**What was wrong.** `findReferencesToMeta` serves `GET /api/v1/meta/object/:name/references`, the admin "Used by" panel, which reads an empty answer as "Safe to delete." `PermissionSetSchema.fields` is keyed `<object>.<field>`, and the security plugin applies a set's field rules to an object by that key prefix. The reference index read `permission.fields` only as a site naming `field` items, never an object. So a set whose only rule for an object was a field rule (`objects: {}`, `fields: { 'customer.full_name': … }`) was missing from that object's answer, and the object could be cleared for deletion while the set still masked its fields.
8+
9+
**What the answer reads now.** Such a set is listed for the object that each `fields` key starts with, at path `fields{key}` with kind `permission fields`. The object part is the text before the key's first `.`, the same split the security plugin makes. A key whose field part spells another object's name is not a reference to that object. A key with no object part (`.full_name`) names no object.
10+
11+
Unchanged: every existing row of the answer, the response shape, the `field` target's `501 NOT_IMPLEMENTED` refusal, and `duplicatePackage`.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5379,6 +5379,48 @@ function collectReferenceHits(
53795379
return hits;
53805380
}
53815381

5382+
/**
5383+
* Every place a stored document names `targetName` as the HEAD of a composite
5384+
* record key, through a composite-key site of the derived index
5385+
* (`REFERENCE_SITES.compositeKeyByTarget`; the one site today is a permission
5386+
* set's `fields`, keyed `<object>.<field>`).
5387+
*
5388+
* A separate walk, not a branch inside {@link collectReferenceHits}: that walk
5389+
* matches a WHOLE value and serves every other site, so it stays as it is. This
5390+
* one reads only the record each site names, at the document ROOT where the
5391+
* site's premise is derived (`compositeKeySiteHolds`, `./reference-sites.ts`),
5392+
* and only its keys.
5393+
*
5394+
* A key names the target when the text before its FIRST separator equals the
5395+
* target's name — the split the security plugin makes for field rules
5396+
* (`key.startsWith(object + '.')`), exact because no object name contains a `.`.
5397+
* A key with no separator, or one at index 0 (`.full_name`), names no object.
5398+
* The field part is never read, so a key whose FIELD part spells the target
5399+
* (`invoice.customer`) is not a reference to it. One hit per site: the reported
5400+
* path (`fields{key}`) does not carry the key, so a second matching key is the
5401+
* same row.
5402+
*/
5403+
function collectCompositeKeyHeadHits(
5404+
item: unknown,
5405+
separators: ReadonlyMap<string, string>,
5406+
targetName: string,
5407+
): Array<{ path: string; property: string }> {
5408+
const hits: Array<{ path: string; property: string }> = [];
5409+
if (!item || typeof item !== 'object' || Array.isArray(item)) return hits;
5410+
for (const [property, separator] of separators) {
5411+
const record = (item as Record<string, unknown>)[property];
5412+
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
5413+
for (const key of Object.keys(record)) {
5414+
const at = key.indexOf(separator);
5415+
if (at > 0 && key.slice(0, at) === targetName) {
5416+
hits.push({ path: `${property}{key}`, property });
5417+
break;
5418+
}
5419+
}
5420+
}
5421+
return hits;
5422+
}
5423+
53825424
/**
53835425
* Phase 3a-destructive: detect changes between an existing object schema
53845426
* and an incoming overlay that would break runtime data — removed fields,
@@ -30295,8 +30337,17 @@ export class ObjectStackProtocolImplementation implements
3029530337
throw err;
3029630338
}
3029730339

30298-
const sites = REFERENCE_SITES.byTarget.get(singularTarget);
30299-
if (!sites || sites.length === 0) {
30340+
const sites = REFERENCE_SITES.byTarget.get(singularTarget) ?? [];
30341+
// [#23539] Sites that name the target in the HEAD of a composite record
30342+
// key — a permission set's `fields`, keyed `<object>.<field>`, for target
30343+
// `object` and nothing else. They live in their own map, never in
30344+
// `byTarget`, because `duplicatePackage` reads `byTarget.get('object')` as
30345+
// a flat set of object-naming property names (see
30346+
// COMPOSITE_KEY_REFERENCE_SITES in `./reference-sites.ts`). Without them a
30347+
// set whose only rule for an object was a field rule was missing here,
30348+
// and the "Used by" panel could clear that object for deletion.
30349+
const compositeSites = REFERENCE_SITES.compositeKeyByTarget.get(singularTarget) ?? [];
30350+
if (sites.length === 0 && compositeSites.length === 0) {
3030030351
return { references: [] };
3030130352
}
3030230353

@@ -30312,7 +30363,23 @@ export class ObjectStackProtocolImplementation implements
3031230363
}
3031330364
properties.set(site.property, site.target);
3031430365
}
30315-
const matchers = [...propertiesByFromType].map(([fromType, properties]) => ({ fromType, properties }));
30366+
// Composite-key sites share that one read: property → separator, per
30367+
// source type, and a source type they alone name still gets its matcher.
30368+
const separatorsByFromType = new Map<string, Map<string, string>>();
30369+
for (const site of compositeSites) {
30370+
let separators = separatorsByFromType.get(site.fromType);
30371+
if (!separators) {
30372+
separators = new Map<string, string>();
30373+
separatorsByFromType.set(site.fromType, separators);
30374+
}
30375+
separators.set(site.property, site.separator);
30376+
if (!propertiesByFromType.has(site.fromType)) propertiesByFromType.set(site.fromType, new Map());
30377+
}
30378+
const matchers = [...propertiesByFromType].map(([fromType, properties]) => ({
30379+
fromType,
30380+
properties,
30381+
separators: separatorsByFromType.get(fromType),
30382+
}));
3031630383

3031730384
const seen = new Set<string>(); // dedup key: `${fromType}|${itemName}|${path}`
3031830385
const out: Array<{ type: string; name: string; label?: string; path: string; kind: string }> = [];
@@ -30359,7 +30426,9 @@ export class ObjectStackProtocolImplementation implements
3035930426
// Don't list an item as a reference to itself unless the
3036030427
// self-reference is meaningful (e.g. object→field path).
3036130428
const isSelfReference = matcher.fromType === singularTarget && sourceName === targetName;
30362-
for (const { path, property } of collectReferenceHits(raw, matcher.properties, targetName)) {
30429+
const hits = collectReferenceHits(raw, matcher.properties, targetName);
30430+
if (matcher.separators) hits.push(...collectCompositeKeyHeadHits(raw, matcher.separators, targetName));
30431+
for (const { path, property } of hits) {
3036330432
// An item naming ITSELF through a scalar property is
3036430433
// the item, not a dependent. Collection-valued paths
3036530434
// are kept, because an item that lists itself among

‎packages/metadata-protocol/src/reference-sites.derivation.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,31 @@ describe('[#9327] the TARGET-side gap is named too, and it is a sibling not a wi
196196
});
197197
});
198198

199+
describe('[#23539] a permission set\'s `fields` keys name their object through a composite-key site', () => {
200+
it('THE PIN: exactly one composite-key site, and it is permission.fields → object on "."', () => {
201+
// `PermissionSetSchema.fields` is keyed `<object>.<field>`, a fact stated
202+
// in prose only, so it is a seed — and its premises are derived: it
203+
// drops out (turning this red) if `permission` stops declaring `fields`
204+
// as a root record, or if `ObjectSchema.name` ever admits a ".".
205+
// ⚠️ GROWS → a second hand-written key form; argue it, do not append it.
206+
const all = [...REFERENCE_SITES.compositeKeyByTarget.entries()].flatMap(([target, sites]) =>
207+
sites.map((s) => ({ target, fromType: s.fromType, property: s.property, separator: s.separator })));
208+
expect(all).toEqual([{ target: 'object', fromType: 'permission', property: 'fields', separator: '.' }]);
209+
});
210+
211+
it('it is NOT a byTarget row — `duplicatePackage` reads byTarget("object") as object-naming property names', () => {
212+
// Measured with the row placed there: every copied object's own
213+
// `fields` record (keyed by bare field name) had a key renamed after a
214+
// renamed object. Its absence here is that copy's guard.
215+
expect(hasSite('object', 'permission', 'fields')).toBe(false);
216+
});
217+
218+
it('the `field` target is unmoved: permission.fields still names field items, and `field` still refuses', () => {
219+
expect(hasSite('field', 'permission', 'fields')).toBe(true);
220+
expect(REFERENCE_SITES.unanswerableTargetTypes).toEqual(['field']);
221+
});
222+
});
223+
199224
describe('[#9190] derived reference sites — derivation is a pure function of the schemas', () => {
200225
it('two derivations of the same schemas agree exactly', () => {
201226
// The module-load singleton must not be doing anything a caller cannot
@@ -209,6 +234,7 @@ describe('[#9190] derived reference sites — derivation is a pure function of t
209234
.sort();
210235
expect(flatten(a)).toEqual(flatten(b));
211236
expect(flatten(a)).toEqual(flatten(REFERENCE_SITES));
237+
expect([...a.compositeKeyByTarget]).toEqual([...REFERENCE_SITES.compositeKeyByTarget]);
212238
});
213239
});
214240

‎packages/metadata-protocol/src/reference-sites.ts‎

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,27 @@ export interface ReferenceSite {
132132
readonly target: string;
133133
}
134134

135+
/**
136+
* A site whose record KEYS name the target only in their HEAD: each key is the
137+
* target's name, then `separator`, then a part this index does not read. See
138+
* {@link COMPOSITE_KEY_REFERENCE_SITES}.
139+
*/
140+
export interface CompositeKeyReferenceSite extends ReferenceSite {
141+
/** Ends the target's name inside a key; the name is the text before its FIRST occurrence. */
142+
readonly separator: string;
143+
}
144+
135145
/** The derived index, plus the honest record of what could not be derived. */
136146
export interface ReferenceSiteIndex {
137147
/** Target metadata type → every site that may point at it. */
138148
readonly byTarget: ReadonlyMap<string, readonly ReferenceSite[]>;
149+
/**
150+
* Target metadata type → every site that names it as the HEAD of a
151+
* composite record key. Deliberately a SEPARATE map rather than more rows
152+
* in {@link byTarget}: read {@link COMPOSITE_KEY_REFERENCE_SITES} on why
153+
* those rows there would corrupt `duplicatePackage`.
154+
*/
155+
readonly compositeKeyByTarget: ReadonlyMap<string, readonly CompositeKeyReferenceSite[]>;
139156
/**
140157
* Declared types whose shape could NOT be read, so their references are
141158
* genuinely not computable rather than absent. Pinned by a test — see the
@@ -262,27 +279,127 @@ const COMPOSITE_KEYED_TARGET_TYPES: readonly {
262279
* answerable. Refusing those would be this card's harm inverted.
263280
*/
264281
function nameVocabularyRejectsKey(type: DeclaredMetadataType, separator: string): boolean {
282+
const verdict = nameVocabularyVerdict(type, separator);
283+
return verdict === 'unreadable' || verdict === 'rejects';
284+
}
285+
286+
/** A declared type's JSON Schema, or `undefined` when it resolves no schema or cannot be converted. */
287+
function metadataTypeJsonSchema(type: string): JsonSchemaNode | undefined {
265288
const schema = getMetadataTypeSchema(type);
266-
if (!schema) return true;
267-
let json: JsonSchemaNode;
289+
if (!schema) return undefined;
268290
try {
269-
json = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' }) as JsonSchemaNode;
291+
return z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' }) as JsonSchemaNode;
270292
} catch {
271-
return true;
293+
return undefined;
272294
}
295+
}
296+
297+
/**
298+
* What `type`'s declared `name` vocabulary says about `separator`:
299+
* `'rejects'` (a readable pattern no name containing it can match), `'admits'`
300+
* (a readable pattern some such name matches), `'unconstrained'` (no pattern,
301+
* or one that does not compile) or `'unreadable'` (no schema to read). The two
302+
* callers read the unknowns in OPPOSITE directions, each toward its own safe
303+
* side, so the four answers stay distinct here.
304+
*/
305+
function nameVocabularyVerdict(
306+
type: string,
307+
separator: string,
308+
): 'rejects' | 'admits' | 'unconstrained' | 'unreadable' {
309+
const json = metadataTypeJsonSchema(type);
310+
if (!json) return 'unreadable';
273311
const nameNode = (json.properties as Record<string, JsonSchemaNode> | undefined)?.name;
274312
const pattern = nameNode?.pattern;
275-
if (typeof pattern !== 'string') return false;
313+
if (typeof pattern !== 'string') return 'unconstrained';
276314
let compiled: RegExp;
277315
try {
278316
compiled = new RegExp(pattern);
279317
} catch {
280-
return false;
318+
return 'unconstrained';
281319
}
282320
// A two-segment probe is enough: if the declared vocabulary admits the
283321
// separator at all, some composite key is representable and the type is
284322
// not unanswerable BY KEY FORM.
285-
return !compiled.test(`a${separator}b`);
323+
return compiled.test(`a${separator}b`) ? 'admits' : 'rejects';
324+
}
325+
326+
/**
327+
* Sites whose record KEYS are COMPOSITE and name the target in their HEAD —
328+
* today exactly one: `PermissionSetSchema.fields`, keyed `<object>.<field>`.
329+
*
330+
* ## The miss this closes
331+
*
332+
* The naming rule reads `permission.fields` as a site for `field` items (its
333+
* property spells that type's plural) and never for an object. Every key of it
334+
* names an object too: the security plugin reads a set's rules for an object
335+
* by the key prefix `<object>.` (`PermissionEvaluator.getFieldPermissions`). So
336+
* a set whose ONLY rule for an object was a field rule was missing from that
337+
* object's answer, and the "Used by" panel could clear the object as "Safe to
338+
* delete." while the set still masked its fields — the harm this module's
339+
* header names.
340+
*
341+
* ## Why a seed, and why NOT more rows in `byTarget`
342+
*
343+
* The key form is stated in prose only — `fields` is `z.record(z.string(), …)`
344+
* with the `<entity_name>.<field_name>` shape in its TSDoc, and its JSON Schema
345+
* is indistinguishable from `object.fields`, which is keyed by the BARE field
346+
* name. That is the {@link COMPOSITE_KEYED_TARGET_TYPES} class (a protocol fact
347+
* no machine can read), and it gets the same treatment: a seed small enough to
348+
* argue, with its premises DERIVED (see {@link compositeKeySiteHolds}).
349+
*
350+
* It is kept out of {@link ReferenceSiteIndex.byTarget} on purpose, measured
351+
* both ways with a plain `{ permission, fields, object }` row there:
352+
*
353+
* - the answer did not change: the walk compares a WHOLE value with the
354+
* target's name, and `refsrc_customer.full_name` is never `refsrc_customer`;
355+
* - `duplicatePackage` broke: it reads `byTarget.get('object')` as a flat set of
356+
* property NAMES that name an object anywhere in any item, so `fields` joined
357+
* it and every copied object's own `fields` record — keyed by bare field
358+
* name — had a key renamed after a renamed object. That copy rewrites a
359+
* permission set's `fields` keys through its own shape-keyed step instead.
360+
*
361+
* ⛔ Do NOT grow this to "improve coverage", and do not move it into
362+
* `byTarget`. The durable close is an annotation on the producer's record key
363+
* in `packages/spec`, which derivation would then read like any other schema
364+
* fact.
365+
*
366+
* The target is typed `'object'` alone: the walk splits a key on its FIRST
367+
* separator, which is exact only because no object name can contain one — a
368+
* premise argued for the object type, not for every type.
369+
*/
370+
const COMPOSITE_KEY_REFERENCE_SITES: readonly {
371+
readonly fromType: DeclaredMetadataType;
372+
readonly property: string;
373+
readonly target: 'object';
374+
readonly separator: string;
375+
}[] = [
376+
{ fromType: 'permission', property: 'fields', target: 'object', separator: '.' },
377+
];
378+
379+
/**
380+
* Do a composite-key seed's two premises still hold in the declared schemas?
381+
*
382+
* 1. the source type still declares `property` at its ROOT as a record — the
383+
* keys are what is read, so a renamed or reshaped property must not keep a
384+
* site that reads nothing, or reads something else;
385+
* 2. the target's `name` vocabulary provably REJECTS the separator, so the
386+
* text before a key's first separator is the whole target name and never a
387+
* prefix of one (`ObjectSchema.name` is `^[a-z_][a-z0-9_]*$`).
388+
*
389+
* Both fail CLOSED, the opposite direction from {@link nameVocabularyRejectsKey}:
390+
* an unreadable or unconstrained shape drops the site, because a split that
391+
* cannot be proven exact would attribute a key to the wrong object. A seed that
392+
* drops out turns the derivation pin red rather than shortening answers quietly.
393+
*/
394+
function compositeKeySiteHolds(site: (typeof COMPOSITE_KEY_REFERENCE_SITES)[number]): boolean {
395+
if (nameVocabularyVerdict(site.target, site.separator) !== 'rejects') return false;
396+
const json = metadataTypeJsonSchema(site.fromType);
397+
if (!json) return false;
398+
const defs = (json.$defs as Record<string, JsonSchemaNode> | undefined) ?? {};
399+
const node = (json.properties as Record<string, JsonSchemaNode> | undefined)?.[site.property];
400+
return alternatives(node, defs, new Set(), 0).some(
401+
(alt) => alt.type === 'object' && !!alt.additionalProperties && typeof alt.additionalProperties === 'object',
402+
);
286403
}
287404

288405
/** `external_catalog` → `externalCatalog`. Identity for a type with no underscore. */
@@ -515,7 +632,22 @@ export function deriveReferenceSites(): ReferenceSiteIndex {
515632
.map(({ type }) => type)
516633
.sort();
517634

518-
return { byTarget, unwalkableSourceTypes: unwalkable.sort(), unanswerableTargetTypes: unanswerableTargets };
635+
// Composite-key sites, filtered the same way: both ends declared, and both
636+
// premises still true of the schemas. See COMPOSITE_KEY_REFERENCE_SITES.
637+
const compositeKeyByTarget = new Map<string, CompositeKeyReferenceSite[]>();
638+
for (const site of COMPOSITE_KEY_REFERENCE_SITES) {
639+
if (!declared.has(site.fromType) || !declared.has(site.target) || !compositeKeySiteHolds(site)) continue;
640+
const bucket = compositeKeyByTarget.get(site.target);
641+
if (bucket) bucket.push({ ...site });
642+
else compositeKeyByTarget.set(site.target, [{ ...site }]);
643+
}
644+
645+
return {
646+
byTarget,
647+
compositeKeyByTarget,
648+
unwalkableSourceTypes: unwalkable.sort(),
649+
unanswerableTargetTypes: unanswerableTargets,
650+
};
519651
}
520652

521653
/**

0 commit comments

Comments
 (0)