Skip to content

Commit 11b356f

Browse files
Apollon77claude
andauthored
fix(node): resolve id-keyed sibling values during client constraint validation (#3676)
ClientNode mirrors store subscription-delivered values under numeric attribute ids (primaryKey: "id") while initial defaults sit under property names. When a constraint referenced a sibling attribute (e.g. BooleanStateConfiguration's currentSensitivityLevel constraint "max supportedSensitivityLevels - 1"), the validator's NameResolver read the stale name-keyed default instead of the live id-keyed value, rejecting otherwise valid writes. - Datasource clone now also copies id-keyed entries (previously dropped because `#fields` only enumerates property names). - NameResolver.createDirectResolver prefers `struct[id]` when present and falls back to `struct[name]` for name-keyed datasources. Adds focused validator-layer tests for id-keyed siblings and full-stack ClientNode tests covering all three client-write paths (agent.state assignment, setStateOf, endpoint.set). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e47d7f3 commit 11b356f

5 files changed

Lines changed: 135 additions & 10 deletions

File tree

packages/node/src/behavior/state/managed/Datasource.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -713,12 +713,17 @@ class RootReference implements ValReference<Val.Struct>, Transaction.Participant
713713
const properties = (this.#values as Val.Dynamic)[Val.properties]
714714
? (this.#values as Val.Dynamic)[Val.properties](this.rootOwner, this.#session)
715715
: undefined;
716-
for (const index of this.#fields) {
717-
if (properties && index in properties) {
716+
// `#fields` only lists property names; client mirrors also store values at numeric attribute ids.
717+
const keys = new Set<string>(this.#fields);
718+
for (const key of Object.keys(old)) {
719+
keys.add(key);
720+
}
721+
for (const key of keys) {
722+
if (properties && key in properties) {
718723
// Property is dynamic anyway, so do nothing
719-
} else {
720-
this.#values[index] = old[index];
724+
continue;
721725
}
726+
this.#values[key] = old[key];
722727
}
723728

724729
// Point subreferences to the clone

packages/node/src/behavior/state/managed/NameResolver.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function NameResolver(
3838
if (!supervisor.memberNames.has(name)) {
3939
return;
4040
}
41-
return createDirectResolver();
41+
return createDirectResolver(findMemberId(supervisor.schema, name));
4242
}
4343

4444
// Only structs may provide named properties
@@ -49,18 +49,36 @@ export function NameResolver(
4949
// Read directly if the named property is supported by this schema. This is not indexed which is fine because:
5050
// 1. The spec uses this very lightly as of 1.4, and
5151
// 2. We only do this once and only for schema that utilizes this feature
52-
if (supervisor.membersOf(model as Schema).find(model => model.propertyName === name)) {
53-
return createDirectResolver();
52+
const member = supervisor.membersOf(model as Schema).find(model => model.propertyName === name);
53+
if (member) {
54+
return createDirectResolver(member.id);
5455
}
5556

5657
// Delegate to parent
5758
return createIndirectResolver();
5859

5960
/**
60-
* Create a reader that reads from this value.
61+
* Read a property by name, preferring the id-keyed slot when available — client mirrors
62+
* (`primaryKey: "id"`) hold live values at attribute ids while the property-name slot may be a stale default.
6163
*/
62-
function createDirectResolver() {
63-
return (val: Val) => (val as Val.Struct)?.[name];
64+
function createDirectResolver(id?: number) {
65+
if (id === undefined) {
66+
return (val: Val) => (val as Val.Struct)?.[name];
67+
}
68+
return (val: Val) => {
69+
const struct = val as Val.Struct | undefined;
70+
if (struct === undefined || struct === null) {
71+
return undefined;
72+
}
73+
if (id in struct) {
74+
return struct[id];
75+
}
76+
return struct[name];
77+
};
78+
}
79+
80+
function findMemberId(schema: Schema, name: string): number | undefined {
81+
return supervisor.membersOf(schema).find(model => model.propertyName === name)?.id;
6482
}
6583

6684
/**

packages/node/test/behavior/state/validation/constraintTest.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,36 @@ const AllTests = Tests({
7676
"accepts if reference value is null": { record: { test: 3, maxVal: null } },
7777
}),
7878

79+
// Client mirrors (primaryKey: "id") store live values at numeric ids; the resolver must prefer
80+
// them over the property-name slot, which can hold a stale initial default.
81+
"max with reference (sibling keyed by id)": Tests(
82+
Fields({ id: 0, constraint: "max MaxVal" }, { id: 1, name: "MaxVal", quality: "X" }),
83+
{
84+
"rejects if over (id-keyed sibling)": {
85+
record: { test: 5, 1: 4 },
86+
error: {
87+
type: ConstraintError,
88+
message:
89+
'Validating Test.test: Constraint "max maxVal": Value 5 is not within bounds defined by constraint',
90+
},
91+
},
92+
"accepts if under (id-keyed sibling)": {
93+
record: { test: 3, 1: 4 },
94+
},
95+
"prefers id-keyed live value over name-keyed stale default": {
96+
record: { test: 3, maxVal: 0, 1: 4 },
97+
},
98+
"rejects on id-keyed sibling even when name-keyed slot would accept": {
99+
record: { test: 5, maxVal: 10, 1: 4 },
100+
error: {
101+
type: ConstraintError,
102+
message:
103+
'Validating Test.test: Constraint "max maxVal": Value 5 is not within bounds defined by constraint',
104+
},
105+
},
106+
},
107+
),
108+
79109
compound: Tests(Fields({ constraint: "3 to 4, 6 to 7" }), {
80110
"rejects if under": {
81111
record: { test: 2 },

packages/node/test/behavior/state/validation/validation-test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { StatusResponseError } from "@matter/types";
2121
export function Fields(
2222
...definition: {
2323
name?: string;
24+
id?: number;
2425
type?: string;
2526
conformance?: string;
2627
constraint?: string;

packages/node/test/node/ClientNodeTest.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ import { GlobalAttributeState } from "#behavior/cluster/ClusterState.js";
99
import { ControllerBehavior } from "#behavior/system/controller/ControllerBehavior.js";
1010
import { DiscoveryError } from "#behavior/system/controller/discovery/DiscoveryError.js";
1111
import { BasicInformationBehavior, BasicInformationServer } from "#behaviors/basic-information";
12+
import {
13+
BooleanStateConfigurationClient,
14+
BooleanStateConfigurationServer,
15+
} from "#behaviors/boolean-state-configuration";
1216
import { IdentifyClient } from "#behaviors/identify";
1317
import { OnOffClient } from "#behaviors/on-off";
1418
import { WindowCoveringClient, WindowCoveringServer } from "#behaviors/window-covering";
19+
import { ContactSensorDevice } from "#devices/contact-sensor";
1520
import { OnOffLightDevice } from "#devices/on-off-light";
1621
import { WindowCoveringDevice } from "#devices/window-covering";
1722
import { Endpoint } from "#endpoint/Endpoint.js";
@@ -465,6 +470,72 @@ describe("ClientNode", () => {
465470
expect(ep1Server.state.identify.identifyTime).equals(5);
466471
});
467472

473+
describe("writes attribute whose constraint references a sibling attribute", () => {
474+
// currentSensitivityLevel has constraint "max supportedSensitivityLevels - 1"; with
475+
// supportedSensitivityLevels = 3, value 2 must be accepted via every client commit path.
476+
477+
const BscWithSensLevel = BooleanStateConfigurationServer.with("SensitivityLevel").set({
478+
supportedSensitivityLevels: 3,
479+
currentSensitivityLevel: 0,
480+
defaultSensitivityLevel: 0,
481+
});
482+
const ContactSensorWithSensLevel = ContactSensorDevice.with(BscWithSensLevel);
483+
484+
async function setup() {
485+
const site = new MockSite();
486+
const { controller, device } = await site.addCommissionedPair({
487+
device: {
488+
type: ServerNode.RootEndpoint,
489+
device: ContactSensorWithSensLevel,
490+
},
491+
});
492+
493+
const peer1 = await subscribedPeer(controller, "peer1");
494+
const ep1Client = peer1.parts.get("ep1")!;
495+
const ep1Server = device.parts.get(1)!;
496+
497+
return { site, ep1Client, ep1Server };
498+
}
499+
500+
it("via agent.state assignment", async () => {
501+
const { site, ep1Client, ep1Server } = await setup();
502+
await using _site = site;
503+
504+
await MockTime.resolve(
505+
ep1Client.act(agent => {
506+
agent.get(BooleanStateConfigurationClient).state.currentSensitivityLevel = 2;
507+
}),
508+
);
509+
510+
expect(ep1Server.stateOf(BscWithSensLevel).currentSensitivityLevel).equals(2);
511+
});
512+
513+
it("via setStateOf", async () => {
514+
const { site, ep1Client, ep1Server } = await setup();
515+
await using _site = site;
516+
517+
await MockTime.resolve(
518+
ep1Client.setStateOf(BooleanStateConfigurationClient, { currentSensitivityLevel: 2 }),
519+
);
520+
521+
expect(ep1Server.stateOf(BscWithSensLevel).currentSensitivityLevel).equals(2);
522+
});
523+
524+
it("via endpoint.set", async () => {
525+
const { site, ep1Client, ep1Server } = await setup();
526+
await using _site = site;
527+
528+
const typedClient = ep1Client as Endpoint<typeof ContactSensorWithSensLevel>;
529+
await MockTime.resolve(
530+
typedClient.set({
531+
booleanStateConfiguration: { currentSensitivityLevel: 2 },
532+
}),
533+
);
534+
535+
expect(ep1Server.stateOf(BscWithSensLevel).currentSensitivityLevel).equals(2);
536+
});
537+
});
538+
468539
it("emits Matter events", async () => {
469540
// *** SETUP ***
470541

0 commit comments

Comments
 (0)