Repository navigation
Object-valued properties are silently dropped on non-task types #2430
Description
What happens
Setting an object-valued property on any type other than task stores it where every read path's flattener will never look, so the field vanishes from output. Not mangled, not null — absent, with no error.
nodespace node create --type venue --property address='{"city":"Berlin"}'
nodespace node get --json <id> # address is gonereferences/cli.md advertises object values as supported input ("Values are parsed as JSON when possible (numbers, booleans, arrays, objects)"), so this is a documented input shape that silently loses data.
Mechanism
create_node (packages/core/src/services/node_service/crud.rs:99) resolves schema fields only for task; every other type normalizes with schema_fields: None.
With an empty schema_field_names, normalize_flat_properties_to_namespace (crud.rs:1704) classifies any object value as a namespace rather than a field:
if value.is_object() && !schema_field_names.contains(key.as_str()) {
// treated as a (dormant) namespace and hoisted out of the type keyThe property lands beside properties[node_type] instead of inside it. flatten_namespaced_properties then reads only the type's own namespace, so the field is dropped.
The codebase already documents this hazard verbatim at packages/agent/src/skill_pipeline.rs:482:
the normalizer misclassifies
parameter_schema(an object) as a dormant namespace, hoisting it out of the "tool" key soflatten_properties_for_apilater silently drops it.
Seed templates work around it by pre-namespacing. A user typing --property has no such escape hatch.
Scope
No built-in type loses data today: tool pre-namespaces, and schema's fields is an array (which survives the !is_object() filter). The exposure is latent, firing on any user-defined object field.
Why this needs a design decision, not a one-line fix
The obvious fix — resolve schema fields for all types — is explicitly rejected by a measured performance constraint at crud.rs:91:
Only fetch schema from DB for types with meaningful schema fields. Currently only "task" has schema-defined fields; text, date, etc. have no fields. This avoids a ~760ms database lookup for every node creation.
That comment's premise is now stale (user-defined schemas have fields too), but simply removing the gate would reintroduce a ~760ms regression on every node create. A real fix needs one of:
- a schema cache, so the lookup isn't per-create;
- narrowing the lookup to creates that actually carry an object-valued property;
- or distinguishing field-vs-namespace without a schema at all (e.g. an explicit namespace marker at rest).
Each needs fresh measurement against the 760ms claim, which predates the current store.
Acceptance criteria
- An object-valued property set on a non-
tasktype round-trips throughnode get,query, andrelationship get - No per-create schema lookup regression — measure against the ~760ms figure in
crud.rs:91and record the result (that number predates the current store; re-measure rather than trusting it) -
skill_pipeline.rs's pre-namespacing workaround is removed if the fix makes it unnecessary, or its comment updated to point here if not - The
cli.mdcaveat added by the flat-CLI-output change is removed once object values round-trip - Covers a user-defined type with an object field end-to-end, not just a unit test of the normalizer
Context
Found during code review of the PR that made CLI read output flat. That change is what makes this user-visible: the nesting used to be observable in raw output, so a dropped field was at least inspectable. Now output is authoritative and the loss is invisible, which is why that PR added an explicit docs caveat rather than claiming completeness.
Activity
malibio commentedon Sep 7, 2026
Correction to the scope above: the gap is wider than filed. It affects
node updatefor every type,taskincluded — not justcreateon non-task types.Both update call sites pass
Nonefor schema fields regardless of node type:With
None,schema_field_namesis empty, so the classifier atcrud.rs:1704hoists any object value out of the type namespace fortaskas well. It is CLI-reachable:parse_propertyJSON-parses the value, sonodespace node update <task-id> --property billing='{"city":"Berlin"}'hits it.Two consequences for the acceptance criteria:
update_node(both sites), not onlycreate_node. The 760ms performance constraint documented atcrud.rs:91is about create; whether it applies to update needs its own measurement.node updateon ataskround-trips.Found during re-review of the flat-CLI-output PR — the docs caveat there had incorrectly named
taskas safe and has been corrected to match the real scope.malibio commentedon Sep 7, 2026
Baseline — Frontend: 233 test files passed, 4900 tests passed (Duration 83.60s)
malibio commentedon Sep 7, 2026
Measured the schema lookup against the current store before designing around the ~760ms figure in crud.rs:91. get_schema is get_node(node_type) — one primary-key read. 500 iterations after warmup, 500 filler rows present: release 0.011ms/call (hit) and 0.003ms (miss); debug 0.019ms (hit) and 0.010ms (miss). That is 40,000-230,000x below the number in the comment, so the gate had nothing to protect. Fix removes the ambiguity rather than paying the lookup: classification now keys off the property name's underscore prefix, not the value's JSON type, so normalization needs no schema at all. Final: frontend 233 files / 4900 tests (unchanged vs baseline), full test:all green.
Namespace object-valued properties by key prefix, not value type (closes