Skip to content

Namespace object-valued properties by key prefix, not value type (closes #2430) - #2436

Merged
malibio merged 3 commits into
mainfrom
issue-2430-object-properties
Sep 7, 2026
Merged

malibio merged 3 commits into
mainfrom
issue-2430-object-properties

Conversation

@malibio

@malibio malibio commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #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.

nodespace node create --type venue --property address='{"city":"Berlin"}'
nodespace node get --json <id>   # address was gone

The mechanism

normalize_flat_properties_to_namespace decided 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". Because create_node resolved schema fields only for task, every other type normalized against 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.

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:

This avoids a ~760ms database lookup for every node creation.

That number is stale by about four orders of magnitude. get_schema is get_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:

per call vs. the claimed 760ms
release, schema hit 0.011 ms ~67,000x lower
release, schema miss 0.003 ms ~230,000x lower
debug, schema hit 0.019 ms ~40,000x lower
debug, schema miss 0.010 ms ~76,000x lower

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;
  • every other key is a field of this type and is namespaced whatever its value — object-valued fields included.

That prefix was already reserved on the read side — flatten_namespaced_properties drops _-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

  • Removed the tool seed pre-namespacing workaround in skill_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.
  • Declared idle and processing 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, so this brings the backend into line with a contract that was already written down.
  • Removed the cli.md caveat that told callers to avoid object properties.

Tests

New packages/core/tests/object_valued_property_roundtrip_test.rs covers a user-defined venue type with an address object field, end to end through each read surface that flattens independently:

  • get_node, query_nodes, and get_related_nodes (all three, since they reach the flattener by different routes)
  • an update round-trip, including that an untouched scalar sibling survives the deep merge
  • _-prefixed keys staying out of the type namespace

All 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:all green (58 result groups, 0 failures); quality:fix clean including clippy -D warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KwcVcLdvBUjSkUFmtFNiTY

malibio and others added 2 commits September 7, 2026 23:39
 #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
@malibio

malibio commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Review addressed

🟡 BLOCKING — _-prefixed schema field names silently swallowed — fixed

Confirmed the finding: create_schema accepted _internal_id, the write stored it top-level, and every read dropped it — the same silent-loss class this PR closes, relocated rather than closed.

Chose hard reject over warn. The distinction that decides it: a name shadowing a reserved core property warns because the field still works and only may be shadowed later. A _-prefixed field is unreadable by construction — flatten_namespaced_properties drops those keys from every read surface unconditionally — so accepting it means accepting a write guaranteed never to be readable. That is the defect itself, not a style question.

Placed in validate_schema_field_name (behaviors/mod.rs) rather than beside warn_reserved_property_names, because it is the single well-formedness gate that both create_schema and update_schema's rename path already route through, and it recurses into nested/item_fields for free. The rename route turned out to be the higher-stakes one: its existing comment notes the destination name must be checked before Phase 1, since a rename 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 name verbatim. Only a leading _ on the whole name is fatal: custom:_internal stores under a key beginning c, survives the flattener, and stays legal. Verified empirically, not by inspection, and pinned by a test so the gate cannot later over-reach into it.

Three tests added, asserting the failure mode:

  • schema_cannot_declare_an_underscore_prefixed_field — also asserts no half-created schema node is left behind
  • a_field_cannot_be_renamed_to_an_underscore_prefixed_name — asserts instance data was not migrated, i.e. the rejection landed pre-migration
  • a_namespaced_field_may_contain_an_underscore_after_the_prefix — the over-reach guard

Both gate tests were confirmed to fail with the check disabled and pass with it. Also verified nothing existing declares such a field, so the gate breaks no current schema or seed.

Also added (cheap, from the non-blocking list)

object_property_survives_a_bulk_update — bulk_update reaches the normalizer through a different call site than update_node, so it could drift. Includes an empty {} value, the one shape whose two readings ("object-valued field" vs "empty namespace") were genuinely ambiguous under the old value-type classification.

Skipped, per instruction — will be filed as follow-ups

  • ai-chat enum permits nonsense (archived + processing). Agreed; splitting into two properties is the correct end state and is out of scope here.
  • type: "object" declared but never validated — pre-existing, unrelated to this change.
  • markdown import / arrays-of-objects round-trips — left to the follow-up. Import is also the lower-risk path: import_nodes never calls validate_node_against_schema, so it is untouched by this PR's validation widening.

Verification

Full test:all green (61 result groups, exit 0); round-trip suite now 9/9; quality:fix clean including clippy -D warnings.

Thanks for the review — the blocking finding was a real hole, and catching that the fix's own mechanism opened a new door to the same failure was the right call.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KwcVcLdvBUjSkUFmtFNiTY

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
@malibio
malibio merged commit 2223bb1 into main Sep 7, 2026
@malibio
malibio deleted the issue-2430-object-properties branch September 7, 2026 22:11
malibio added a commit that referenced this pull request Sep 8, 2026
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
malibio added a commit that referenced this pull request Sep 8, 2026
`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Object-valued properties are silently dropped on non-task types

1 participant