Skip to content

Commit 2574911

Browse files
committed
fix(spec): re-derive the banned-keys arm on the retired-arm slot
`#19084` collapsed `TraceSamplingConfig.composite[].condition` to a record, so the ban lands on `condition` itself rather than on a union arm, the ledger rows are spelled `composite.element.condition`, and a CEL envelope is now refused by the runtime too. The live-seam pins and the changeset's accept-set sentence are re-derived on that tree. Also: the changeset declares `Clause-②: yes`, matching the corrected claim and the ruling; and the empty-key-list branch records the real reason it drops — `enum: []` is an invalid schema, not a vacuous rule. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f66984f commit 2574911

5 files changed

Lines changed: 68 additions & 43 deletions

File tree

‎.changeset/18670-project-banned-keys.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,25 @@
22
"@objectstack/spec": minor
33
---
44

5-
**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the banned-key rule the tracing sampling filter enforces, so a validator reading the published files stops answering PASS on `{ "dialect": "cel" }` at `TraceSamplingConfig.composite[].condition` — the card's own worked instance of a published file saying yes to metadata the runtime refuses (#18670 item 2, the fourth and last of the ruling's named arms).
5+
**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the banned-key rule the tracing sampling filter enforces, so a validator reading the published files stops answering PASS on `{ "dialect": "cel" }` at `TraceSamplingConfig.composite[].condition` — the card's own worked instance of a published file saying yes to metadata the runtime refuses (#18670 item 2, the fourth of the ruling's named arms).
66

7-
Clause-②: no
7+
Clause-②: yes (narrowing)
88

99
One named pattern joins the closed list, and only one:
1010

11-
- **`banned-keys` — "no document may carry any of these keys"**, emitted as `propertyNames` with a `not` over the banned names. The structured-filter arm of `TraceSamplingConfig.composite[].condition` refuses an object carrying `dialect`, because an object that carries one is an expression attempt and belongs to the union's other arm. The published file now says so.
11+
- **`banned-keys` — "no document may carry any of these keys"**, emitted as `propertyNames` with a `not` over the banned names. `TraceSamplingConfig.composite[].condition` is a structured filter of match criteria that refuses an object carrying `dialect`, because such an object is an expression attempt and this slot's expression arm was retired in 17.5.0. The published file now says so.
1212

1313
**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 202 entries / 553 sites to **200 entries / 551 sites**:
1414

1515
| row | before | after |
1616
|:---|:---|:---|
17-
| `system/TraceSamplingConfig` | `sites: ["composite.element.condition.options[0]"]` | **deleted** — the schema drops nothing now |
18-
| `system/TracingConfig` | `sites: ["sampling.composite.element.condition.options[0]"]` | **deleted** — same site, reached through the parent |
17+
| `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — the schema drops nothing now |
18+
| `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent |
1919

20-
2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. Generator census after: 551 dropped across 200 published schemas, **369 projected** — 232 `non-blank-string`, 133 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable.
20+
2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. Generator census after: 551 dropped across 200 published schemas, **357 projected** — 224 `non-blank-string`, 129 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable.
2121

22-
**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value — a banned key present with a `null` value is present on both sides. Across the published tree, **1528 of the 1530 per-schema files are byte-identical**; the two that move gain the ban and lose the matching `x-dropped-refinements` row, and nothing else in either file changes. A dialect-bearing document the runtime still accepts — `{ "dialect": "cel", "source": "record.amount > 10" }` — is still accepted by the file, through the union's expression arm, which is untouched.
22+
**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value — a banned key present with a `null` value is present on both sides. The accept set at the slot is **unchanged in both directions**: every document the runtime takes (`{}`, `{ "service": "api" }`, any filter carrying no `dialect` key) the file still takes, and every document the runtime refuses the file now refuses too — a `dialect`-bearing object of any shape, the CEL envelope included, since that arm is retired and nothing here revives it. Across the published tree, **1528 of the 1530 per-schema files are byte-identical**; the two that move gain the ban and lose the matching `x-dropped-refinements` row, and nothing else in either file changes.
2323

24-
**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the key list is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. The predicate judges OWN properties and never `key in value`: `in` walks the prototype chain, so a ban on a name `Object.prototype` carries would refuse `{}` itself while `propertyNames` accepts it, and that is a disagreement about a JSON document rather than an edge outside the domain. A ban over an OPEN set of names — every key starting with `$`, which is what `data/filter.zod.ts`'s normalized field condition refuses — is deliberately not this arm: its keys are a finite list, and a list that merely sampled an open set would be wider than the rule, so that site stays unprojected and keeps its annotation.
24+
**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the key list is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. The predicate judges OWN properties and never `key in value`: `in` walks the prototype chain, so a ban on a name `Object.prototype` carries would refuse `{}` itself while `propertyNames` accepts it, and that is a disagreement about a JSON document rather than an edge outside the domain. A ban over an OPEN set of names — every key starting with `$`, which is what `data/filter.zod.ts`'s normalized field condition refuses — is deliberately not this arm: its keys are a finite list, and a list that merely sampled an open set would be wider than the rule, so those sites stay unprojected and keep their annotation.
2525

2626
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author can write is removed, renamed or re-spelled: no spec key, no export and no config field changes, and the accepted set of metadata documents is byte-for-byte what it was. What changed is a machine-readable DECLARATION catching up with the runtime it always described, so there is nothing for `objectstack migrate meta` to rewrite and no stored representation to convert. -->

‎.scratch/seam2.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { TraceSamplingConfigSchema } from '../packages/spec/src/system/tracing.zod';
2+
import { projectPublishedJsonSchema } from '../packages/spec/scripts/lib/refinement-projection';
3+
import { collectDroppedRefinements } from '../packages/spec/scripts/lib/dropped-refinements';
4+
5+
for (const io of ['output', 'input'] as const) {
6+
try {
7+
const n = projectPublishedJsonSchema(TraceSamplingConfigSchema, { io }) as any;
8+
console.log(io, 'OK ->', JSON.stringify(n.properties.composite.items.properties.condition.allOf));
9+
} catch (e) { console.log(io, 'THREW', (e as Error).message.slice(0, 60)); }
10+
}
11+
const parses = (condition: unknown): boolean =>
12+
TraceSamplingConfigSchema.safeParse({ type: 'composite', composite: [{ strategy: 'always_on', condition }] }).success;
13+
for (const doc of [{}, { amount: { $gt: 10 } }, { dialect: 'cel' }, { dialect: null }, { dialect: 'cel', source: 'record.amount > 10' }, 'record.amount > 10']) {
14+
console.log(JSON.stringify(doc), 'runtime=', parses(doc));
15+
}
16+
const c = collectDroppedRefinements('system/TraceSamplingConfig', TraceSamplingConfigSchema as never);
17+
console.log('dropped', JSON.stringify(c.dropped.map((s) => s.path)));
18+
console.log('projected', c.projected.map((s) => s.path + ' ' + JSON.stringify(s.declaredPatterns)).join(' | '));
19+
console.log('undecidable', JSON.stringify(c.undecidable.map((s) => s.path)));

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 200,
66
"droppedRefinementSites": 551,
7-
"refinementSitesThatDidProject": 369,
7+
"refinementSitesThatDidProject": 357,
88
"refinementSitesWithNoJsonFormToCompare": 9
99
},
1010
"entries": {

‎packages/spec/scripts/lib/refinement-projection.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ function emitDependentRequired(
174174
* trade the rule this arm adds for the key-type rule the node already stated.
175175
* An identical rule already present is left alone rather than duplicated, so
176176
* the arm is idempotent the way `emitNonBlankString` is.
177+
*
178+
* An EMPTY key list emits nothing, and the reason is stronger than "it would
179+
* ban nothing": `enum` is specified as a non-empty array, so `{ not: { enum:
180+
* [] } }` is an INVALID SCHEMA rather than a vacuous one — ajv refuses it with
181+
* "enum must have non-empty array", which would take the whole published file
182+
* down with it instead of leaving a keyword nobody reads.
177183
*/
178184
function emitBannedKeys(jsonSchema: JsonObject, keys: readonly string[]): void {
179185
if (keys.length === 0) return;

‎packages/spec/scripts/refinement-projection.test.ts‎

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,11 @@ describe('banned-keys: one key list, read twice', () => {
528528
expect(node.allOf).toEqual([{ propertyNames: { not: { enum: ['dialect'] } } }]);
529529
});
530530

531-
it('drops an empty key list rather than publishing a rule that bans nothing', () => {
531+
it('drops an empty key list \u2014 `enum: []` is an INVALID schema, not a vacuous one', () => {
532+
// ⛔ Not "it would ban nothing": `enum` is specified as a non-empty array,
533+
// so `{ not: { enum: [] } }` fails validator schema-compilation outright
534+
// (ajv: "enum must have non-empty array") and would take the whole
535+
// published file down rather than sit there unread.
532536
const node: Record<string, unknown> = { type: 'object' };
533537
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: [] });
534538
expect(node).toEqual({ type: 'object' });
@@ -582,13 +586,21 @@ describe('banned-keys: one key list, read twice', () => {
582586
});
583587

584588
describe("the LIVE seam: the card's own worked instance stops saying yes", () => {
585-
/** The published structured-filter arm of `TraceSamplingConfig.composite[].condition`. */
586-
const structuredFilterArm = (): Record<string, unknown> => {
587-
const node = publish(TraceSamplingConfigSchema, 'input');
589+
/**
590+
* The published `TraceSamplingConfig.composite[].condition` node.
591+
*
592+
* ⭐ It is the node ITSELF, not a union arm. #18118 retired this slot's CEL
593+
* expression arm (PR #19084), so the union collapsed to the structured-filter
594+
* record it always had beside it — which is why the ban lands directly on
595+
* `condition` and is still conjoined through `allOf`: a record states its own
596+
* `propertyNames: { type: 'string' }`, and that key-TYPE rule is not the one
597+
* this arm adds.
598+
*/
599+
const conditionNode = (): Record<string, unknown> => {
600+
const node = publish(TraceSamplingConfigSchema);
588601
const composite = (node.properties as Record<string, Record<string, unknown>>).composite;
589602
const item = composite.items as Record<string, Record<string, Record<string, unknown>>>;
590-
const condition = item.properties.condition as unknown as Record<string, unknown>;
591-
return (condition.anyOf as Record<string, unknown>[])[0];
603+
return item.properties.condition as unknown as Record<string, unknown>;
592604
};
593605

594606
/** A `TraceSamplingConfig` that parses, with only `condition` varying. */
@@ -599,65 +611,53 @@ describe("the LIVE seam: the card's own worked instance stops saying yes", () =>
599611
}).success;
600612

601613
it('states the ban, and keeps the record shape it always stated', () => {
602-
const arm = structuredFilterArm();
603-
expect(arm.type).toBe('object');
604-
expect(arm.propertyNames).toEqual({ type: 'string' });
605-
expect(arm.allOf).toEqual([{ propertyNames: { not: { enum: ['dialect'] } } }]);
614+
const node = conditionNode();
615+
expect(node.type).toBe('object');
616+
expect(node.propertyNames).toEqual({ type: 'string' });
617+
expect(node.allOf).toEqual([{ propertyNames: { not: { enum: ['dialect'] } } }]);
606618
});
607619

608620
it("the card's own specimen — `{ dialect: 'cel' }` — is refused by BOTH sides now", () => {
609621
const doc = JSON.parse('{"dialect":"cel"}') as Record<string, unknown>;
610622
expect(parses(doc)).toBe(false);
611-
expect(bannedKeysSatisfied(structuredFilterArm(), doc)).toBe(false);
623+
expect(bannedKeysSatisfied(conditionNode(), doc)).toBe(false);
612624
});
613625

614-
it('⛔ no document this arm ACCEPTS is refused by the emitted keywords', () => {
615-
const arm = structuredFilterArm();
616-
const rule = bannedKeys(['dialect']);
626+
it('⛔ the runtime and the emitted keywords agree on every document in the corpus', () => {
627+
const node = conditionNode();
617628
const corpus: Array<Record<string, unknown>> = [
618629
{},
619630
{ amount: { $gt: 1 } },
620-
{ 'account.name': { $eq: 'acme' } },
631+
{ service: 'api', attributes: { 'http.route': '/v1/orders' } },
621632
{ dialect: 'cel' },
622633
{ dialect: null },
634+
// Since #18118 retired the expression arm, a healthy CEL envelope is
635+
// refused at this slot too — so the two sides agree here as well, where
636+
// before the retirement the union's other arm accepted it.
623637
{ dialect: 'cel', source: 'record.amount > 10' },
624638
];
625639
for (const doc of corpus) {
626640
const asJson = JSON.parse(JSON.stringify(doc)) as Record<string, unknown>;
627641
// Equality, not implication: this arm is exact, so a one-sided pin would
628642
// pass a projection that had stopped narrowing at all.
629643
expect(
630-
bannedKeysSatisfied(arm, asJson),
644+
bannedKeysSatisfied(node, asJson),
631645
`disagreement on ${JSON.stringify(asJson)}`,
632-
).toBe(rule(asJson));
646+
).toBe(parses(asJson));
633647
}
634648
});
635649

636-
it('⛔ and the EXPRESSION the runtime still accepts is still accepted by the file', () => {
637-
// The union's other arm is what carries a dialect-bearing document, so
638-
// narrowing the structured-filter arm refuses nothing the runtime accepts.
639-
const doc = { dialect: 'cel', source: 'record.amount > 10' };
640-
expect(parses(doc)).toBe(true);
641-
const node = publish(TraceSamplingConfigSchema, 'input');
642-
const composite = (node.properties as Record<string, Record<string, unknown>>).composite;
643-
const item = composite.items as Record<string, Record<string, Record<string, unknown>>>;
644-
const condition = item.properties.condition as unknown as Record<string, unknown>;
645-
const envelope = (condition.anyOf as Record<string, unknown>[])[1];
646-
const objectArm = (envelope.anyOf as Record<string, unknown>[])[1];
647-
expect(objectArm.required).toEqual(['dialect', 'source']);
648-
});
649-
650650
it('LIT CONTROL — a structured filter with no `dialect` is accepted by both', () => {
651651
const doc = JSON.parse('{"amount":{"$gt":10}}') as Record<string, unknown>;
652652
expect(parses(doc)).toBe(true);
653-
expect(bannedKeysSatisfied(structuredFilterArm(), doc)).toBe(true);
653+
expect(bannedKeysSatisfied(conditionNode(), doc)).toBe(true);
654654
});
655655

656656
it('its ledger row is gone because the site now reads `projected`, naming the arm', () => {
657657
const census = collectDroppedRefinements('system/TraceSamplingConfig', TraceSamplingConfigSchema);
658-
const site = census.projected.find((s) => s.path === 'composite.element.condition.options[0]');
658+
const site = census.projected.find((s) => s.path === 'composite.element.condition');
659659
expect(site?.declaredPatterns).toEqual(['banned-keys']);
660-
expect(census.dropped.map((s) => s.path)).not.toContain('composite.element.condition.options[0]');
660+
expect(census.dropped).toEqual([]);
661661
});
662662
});
663663

0 commit comments

Comments
 (0)