Repository navigation
Namespace object-valued properties by key prefix, not value type (closes #2430) - #2436
Conversation
#2430) Setting an object-valued property on any type but `task` silently lost the data: no error, no null, just absent from every read path. `normalize_flat_properties_to_namespace` classified a property as a *field* of its type or a *sibling namespace* by looking at the value's JSON type — an object meant "namespace". Since `create_node` resolved schema fields only for `task`, every other type normalized with an empty field-name set, so any object value was hoisted out of `properties[node_type]`. The flattener reads only the type's own namespace, so the field vanished. Classification now keys off the property *name* instead: `_`-prefixed keys (`_seed`, `_schema_version`) are internal bookkeeping and stay at the top level; every other key is a field of this type and is namespaced whatever its value. That prefix is already reserved on the read side — the flattener drops `_`-prefixed keys from every read surface — so this makes the write path agree with the read path rather than diverging from it. The rule needs no schema, which removes the ambiguity the schema lookup was being asked to resolve. The `~760ms database lookup` the old gate cited was measured against a store that no longer exists: `get_schema` is `get_node(node_type)`, a single primary-key read, measured here at 0.011ms (release) / 0.019ms (debug) per call — roughly 40,000x below the figure in the comment. With nothing left to gate on, schema defaults and validation now run for every type that declares a schema, so user-defined types get them too. Removes the `tool` seed pre-namespacing workaround, which existed only to dodge this misclassification. Declares the `idle` and `processing` values on the `ai-chat` `status` enum. Two orthogonal state machines share that key — the daemon's turn state and the PTY session lifecycle — but the schema declared only the latter. Validating non-`task` types on update surfaced the gap; the frontend's `AiChatStatus` already lists all four values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KwcVcLdvBUjSkUFmtFNiTY
The `_` prefix became load-bearing for field-vs-namespace classification, but
nothing enforced its reservation at schema-creation time. `create_schema` would
accept a field named `_internal_id`, a write would store it top-level, and every
read surface would drop it — relocating the silent data loss this branch exists
to close rather than closing it.
Rejects rather than warns. A name that merely shadows a reserved core property
warns because the field still works and only *may* be shadowed later; a
`_`-prefixed field is unreadable by construction, since
`flatten_namespaced_properties` drops those keys from every read path
unconditionally. Accepting a write guaranteed to be unreadable is the defect,
not a style preference.
The check lives in `validate_schema_field_name`, the single well-formedness gate
both `create_schema` and `update_schema`'s rename path already route through, so
it covers every route and recurses into nested and item fields for free. The
rename path matters most: it migrates every instance's property data as it
executes, so a rename to `_foo` would move real stored values into an unreadable
key before anything caught it.
Scoped to the stored key, which is the field name verbatim, so only a leading
`_` on the whole name is fatal — `custom:_internal` stores under a key beginning
`c`, survives the flattener, and stays legal. A test pins that the gate does not
over-reach into it.
Tests assert the failure mode: both routes to the bad state are rejected, and
each was confirmed to fail without the gate. Also adds the bulk_update
object-property round-trip, which reaches the normalizer by a different call
site than `update_node`, including an empty `{}` value — the one shape whose two
readings were genuinely ambiguous under the old value-type classification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KwcVcLdvBUjSkUFmtFNiTY
Review addressed🟡 BLOCKING —
|
The rejection named the field, the rule, and the consequence, but not the remedy. A caller reaching for `_internal_id` wants a private-looking field, and `custom:_internal_id` is exactly that and does round-trip — so the error now names it, turning a refusal into a redirect. The suggestion is built from the caller's own name (`custom:internal_id` for `_internal_id`) rather than a fixed example, so it can be pasted instead of translated. An all-underscore name trims to nothing and would otherwise suggest the equally invalid bare `custom:`, so that case falls back to the generic form. Verified the suggested name is actually accepted by the same validator, rather than only assuming it: a redirect that points somewhere invalid would be worse than no redirect. The test pins the suggestion so it cannot regress into mentioning a form the validator rejects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KwcVcLdvBUjSkUFmtFNiTY
Repairs a break on main. NOT part of #2432 — committed here only because it blocks every push, including this branch's. Two PRs merged that were individually correct and jointly broken. #2436 (object-valued properties) added object_valued_property_roundtrip_test with a Concert schema declaring `held_at → venue` and no reverse half. #2437 (closes #2433) then made reverseName and reverseCardinality required on every relationship. Each passed its own pre-push gate against the main it branched from; neither ran against the other. The result fails on main: Relationship 'held_at' (entry 0) is missing "reverseName" and "reverseCardinality". The fixture now names the edge from both ends, which is what #2437 asks of every declaration. Read from a venue, the concerts held there are "concerts", and a venue may host many of them, so reverseCardinality is "many" while the forward cardinality stays "one" — one venue per concert. That is a modeling choice, which is exactly why #2437 requires an author to make it rather than synthesizing "Concert (Held At)". Scoped deliberately: I scanned every relationship declaration in the Rust test, bench and CLI-test suites for the same omission. `held_at` is the only one, so this is the whole repair, not the first instalment of one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PtzaT8FoAP9ut43eveHUdK
`main` is currently red: `object_property_round_trips_through_relationship_get` declares `held_at -> venue` with no `reverseName`/`reverseCardinality`, which the validation added in #2437 rejects, so the test fails at schema creation before reaching what it exists to verify. Neither PR's gate could have caught this. #2436 added the fixture and #2437 added the rule; each was validated against a base that predated the other, and the squash merges landed 33 minutes apart. This is the same collision #2437's review caught between #2435 and #2437 — the third instance of a green gate on a stale base proving nothing about the merge result. A concert is held at one venue and a venue hosts many concerts, so the inverse is `concerts`/`many`. Claude-Session: https://claude.ai/code/session_01KHdtH4eZHiU7RzhCuHArQX Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #2430.
Setting an object-valued property on any type but
tasksilently lost the data — no error, nonull, just absent from every read path.The mechanism
normalize_flat_properties_to_namespacedecided whether a property was a field of its type or a sibling namespace by looking at the value's JSON type — an object meant "namespace". Becausecreate_noderesolved schema fields only fortask, every other type normalized against an empty field-name set, so any object value was hoisted out ofproperties[node_type]. The flattener reads only the type's own namespace, so the field vanished.Why not just look up the schema
The issue names three candidate fixes and asks for a measurement, because the gate that blocked the obvious one cited a hard number:
That number is stale by about four orders of magnitude.
get_schemaisget_node(node_type)— a single primary-key read on an indexed column. Measured against the current libsql/SQLite store, 500 iterations after warmup with 500 filler rows present:So there was nothing to gate on. The measurement harness was temporary and is not part of this PR.
The approach chosen
Rather than pay even that lookup to resolve an ambiguity, this removes the ambiguity. Classification now keys off the property name, not its value:
_-prefixed keys (_seed,_schema_version) are internal bookkeeping and stay at the top level, at a fixed type-independent path;That prefix was already reserved on the read side —
flatten_namespaced_propertiesdrops_-prefixed keys from every read surface — so this makes the write path agree with the read path rather than diverge from it. It needs no schema, so normalization is now schema-free everywhere and correct for user-defined types by construction.With the gate's premise gone, schema defaults and validation now run for every type that declares a schema, so user-defined types get them too (previously
task-only).Knock-on changes
toolseed pre-namespacing workaround inskill_pipeline.rs, which existed only to dodge this misclassification. Its properties are now plain flat fields like every other seed template; the stored shape is unchanged.idleandprocessingon theai-chatstatusenum. Two orthogonal state machines share that key — the daemon's turn state and the PTY session lifecycle — but the schema declared only the latter. Validating non-tasktypes on update surfaced the gap. The frontend'sAiChatStatusalready lists all four values, so this brings the backend into line with a contract that was already written down.cli.mdcaveat that told callers to avoid object properties.Tests
New
packages/core/tests/object_valued_property_roundtrip_test.rscovers a user-definedvenuetype with anaddressobject field, end to end through each read surface that flattens independently:get_node,query_nodes, andget_related_nodes(all three, since they reach the flattener by different routes)_-prefixed keys staying out of the type namespaceAll five were confirmed to fail against the old value-type classification and pass with the fix, so they genuinely pin the bug rather than just describing current behavior.
Frontend baseline 233 files / 4900 tests — unchanged after the fix. Full
test:allgreen (58 result groups, 0 failures);quality:fixclean including clippy-D warnings.🤖 Generated with Claude Code
https://claude.ai/code/session_01KwcVcLdvBUjSkUFmtFNiTY