Repository navigation
Split SchemaField into name/friendly_name/description - #2105
Conversation
friendly_name is a new non-optional-in-storage display label, freeing description to become genuine LLM-facing prose instead of doing double duty as both the UI header and the semantic explanation. - SchemaField gains friendly_name: String (nodespace-types/src/schema.rs), serde-default-tolerant on input so create_schema/update_schema callers (including the agent) can omit it without a rejected call. - derive_friendly_name() strips a namespace prefix and humanizes the rest (due_date -> Due date, custom:capacity -> Capacity); applied once, at the write boundary, in handle_create_schema/handle_update_schema (packages/core/src/schema/mod.rs) via apply_friendly_name_defaults(). No other reader ever falls back or null-branches on friendly_name. - SchemaField and SchemaProtectionLevel gain #[derive(Default)], with #[default] on SchemaProtectionLevel::User, resolving the "69 bare struct literals, no Default" trap flagged in the issue. All 68 existing construction sites (48 in core_schemas.rs, 20 across markdown.rs, schema_node.rs, schema/mod.rs, context_ops.rs, entity_types_block.rs, and nodespace-types/schema.rs) now set friendly_name explicitly. - Core schema descriptions for task and project rewritten as genuine LLM-facing prose (person's were already fine); friendly_name preserves the prior short labels for every field so no UI text changes except where intended (person headers now read Name/Email instead of the old verbose description text). - All UI label sites (table-view, kanban-view, query-editor-model, and the five property-form/nested-field-editor surfaces) now read friendly_name through one shared helper (lib/utils/schema-field-label.ts), replacing five independently-duplicated name-humanizing regexes. Value-humanizing helpers that are NOT field-name label sites (schema-property-form's/schema-field- leaf's enum-value formatters) were left in place — a different, legitimate concern. - Added a defensive log at the one place SchemaField JSON parse failures are already silently swallowed to an empty Vec (schema_node.rs::from_node), so a stale (pre-reset) local database is diagnosable instead of mysteriously losing all fields on a schema. Seeder decision: requires a database reset, not a seed_core_schemas_if_needed reconciliation pass. Reasoning in the PR description — the short version is that friendly_name's storage format change is not additive (it affects every pre-existing schema's field JSON, not just core schemas), so a reconciliation pass scoped to core schemas alone would leave every user-created custom schema in the same stale state while looking like a complete fix. Deliberately out of scope: packages/agent (a different engineer's lane) — the create_schema/update_schema tool JSON schemas still describe the old field-description contract, and skill guidance still teaches the old label-like description style. Tracked in a follow-up issue. Co-Authored-By: Claude <noreply@anthropic.com>
…m bugs
Two review passes found real defects in the derive_friendly_name mechanism,
not just polish:
- Namespace-prefix stripping could collide with an existing field TODAY, not
just hypothetically as the original doc comment claimed: adding
custom:status to a schema that already has a bare status field derived
"Status" for both, since the two have different storage keys but the same
stripped/humanized display text. apply_friendly_name_defaults now takes the
full existing+in-batch field set and disambiguates a *derived* collision by
appending the namespace (or the raw name when there is none) — an
explicitly-supplied friendly_name is never touched, even if it collides,
since that's the caller's deliberate choice.
- derive_friendly_name's camelCase boundary detection only handled a single
lower-to-upper transition, so an acronym directly adjacent to the next word
(employeeIDNumber) merged into one unsplit blob ("Idnumber") instead of
three words. Added the standard second boundary rule (split before the
last letter of an uppercase run when followed by a lowercase letter).
Also, in response to review findings:
- rename_schema_field doc comment now states explicitly that friendly_name is
never touched on rename (even when it was auto-derived from the old name
and is now stale) — a deliberate choice, since re-deriving it unconditionally
risks clobbering an explicit caller choice with no way to tell the two
apart after the fact.
- TS SchemaField gains unique/uniqueCaseInsensitive, which were already live
on the Rust struct (person.email sets both) but missing from the mirror.
- Cosmetic: removed a leftover double-blank-line in kanban-view.svelte from
the earlier fieldLabel() deletion.
Filed as a separate follow-up rather than fixed here: the second, structurally
identical .ok()-swallow site in nodespace-types::convert.rs (a crate that
deliberately carries no logging dependency, so the same fix doesn't
copy-paste cleanly, and the right fix needs a deliberate look at
nodes_to_typed_values's batch-collect error semantics first).
New regression tests: two derive_friendly_name unit tests (acronym-adjacent
camelCase, all-uppercase), and three integration tests covering collision
disambiguation across an existing+new field, self-collision within one
create_schema batch, and confirming an explicit colliding friendly_name is
never rewritten.
Co-Authored-By: Claude <noreply@anthropic.com>
|
APPROVE Independently re-verified after both review passes and the fix commit (569564c): Diff review: read the full diff against 64-call-site migration — confirmed complete, independently, a third time: Seeder decision — sound: requires a database reset, not Both real defects from the Rust review pass are fixed and independently re-verified as fixed:
Full test re-run (not just trusting CI/prior runs):
Merging. |
Summary
SchemaFieldhadname(storage/query key) anddescriptiondoing double duty as both the UI display label and the LLM-facing semantic explanation — two purposes in direct conflict (a good label is a bad LLM description). This adds a third field,friendly_name, as the dedicated display label, freeingdescriptionto become genuine LLM-facing prose.friendly_name: String— non-optional in storage, always populated. Every UI reader uses it unconditionally; no fallback todescription, no null-branching.create_schema/update_schema): if a caller omitsfriendly_name, it's derived fromname(due_date→Due date,custom:capacity→Capacity, namespace prefix stripped for display), and a derived value that would collide with another field's label in the same schema is disambiguated (see below).descriptionis now documented (Rust doc comment + TS JSDoc) as LLM-facing prose: meaning, purpose, usage, an example — consumed by schema retrieval/comprehension, never rendered as a label.Closes #2101.
Design decisions (documented for the reviewer, not just the diff)
1. The seeder question — requires a database reset, not seeder reconciliation
The issue asked me to explicitly resolve this and document the reasoning, and pointed at precedent:
06a94eee("Seeded content never reaches an existing install…") built a per-node versioned_seedreconciliation mechanism for exactly this class of gap, for prompt/skill/tool nodes.I did not extend that pattern (or
seed_core_schemas_if_needed) to reconcilefriendly_nameonto existing core schemas. Reasoning:06a94eeemechanism reconciles system-owned seeded content (prompt/skill/tool bodies) — content NodeSpace ships and can safely replace wholesale on a hash mismatch.friendly_nameis a type-level storage format change:SchemaFieldpreviously had no such field, and it's non-optional going forward. That's not a "this specific seeded node's content changed" problem, it's "every schema field ever persisted, core or user-created, is missing a piece of shape the type now expects."seed_core_schemas_if_neededreconciliation pass, even if built, would only touch the ~18 core schemas. It would do nothing for any user-created custom schema already in a dev's local database — which has the exact same gap (fields persisted before this change lackfriendlyName). Shipping a fix that visibly repairs core schemas while silently leaving custom schemas broken is worse for dev experience than an honest "reset your database," because it looks complete and isn't. (An independent review pass tracedSqliteStore::get_schema_node/get_all_schemasand confirmed both core and custom schemas go through the identical code path with nois_coredistinction — this claim isn't asserted on faith.)What actually happens on a non-reset database:
friendly_namehas#[serde(default)]at the type level (needed anyway socreate_schema/update_schemacallers can omit it), so a legacy field withoutfriendlyNamein its stored JSON deserializes successfully withfriendly_name: ""rather than failing the parse — no data loss, no crash. The visible symptom is blank column headers/labels on schemas that predate this change, which is a much softer failure mode than I initially expected before checking (I first assumed the wholefieldsVec would silently deserialize to empty via an existing.ok()-swallowing bug inSchemaNode::from_node— see below — but that only triggers on an outright parse error, and the#[serde(default)]prevents that specific one). Still: reset your local database after pulling this change to get real labels back on every schema.While tracing that swallow path, I found two structurally identical pre-existing sites that silently drop to an empty
Vec<SchemaField>on any deserialize failure with no logging:packages/core/src/models/schema_node.rs::SchemaNode::from_nodeand a distinctSchemaNode::from_nodeinpackages/nodespace-types/src/schema.rs(used byconvert.rs::node_to_typed_value, the canonical node→wire-JSON path for Tauri/MCP/HTTP). I fixed thepackages/coreone (atracing::warn!on parse failure). I deliberately did not fix thenodespace-typesone in this PR — that crate has no logging dependency by design, and the right fix (propagate the error vs. add a diagnostic hook vs. a downstream healthcheck) has real design tradeoffs againstnodes_to_typed_values's batch-collect()error semantics that deserve their own review, not a rushed addition here. Filed as #2107.2. The
Default/builder trap —#[derive(Default)]+ struct-update syntax, not a builderThe issue flagged 69 bare
SchemaFieldstruct literals with noDefaultand no builder, making every future field addition another 69-site change. An independent count (excluding function-signature lines that merely returnSchemaFieldand end in-> SchemaField {, which a naive grep forSchemaField {also matches) found 64 genuine construction sites: 48 incore_schemas.rs, plusmarkdown.rs(4),schema/mod.rs(1 helper),context_ops.rs(3),entity_types_block.rs(1 helper),schema_node.rs(1), andnodespace-types/src/schema.rs(6 test literals). All 64 updated.I added
#[derive(Default)]toSchemaField(and toSchemaProtectionLevel, via#[default]on theUservariant, since it's a required non-Optionfield ofSchemaField) rather than a hand-written builder. Reasoning: every optional field onSchemaField(13 of them) already has a sensible zero value (None/false), so#[derive(Default)]+ Rust's..Default::default()struct-update syntax gives every future field addition the same ergonomic win a builder would, with zero new methods to write or maintain. A builder would need ~13 setter methods to cover the same surface for no additional safety (there's no invariant here that a builder's fluent API would enforce that a plain struct literal can't).This PR necessarily touches all 64 sites anyway (
friendly_nameitself has no useful default — every site had to decide a real value), so I did not retrofit every existing literal to use..Default::default()for its already-Nonefields — that would have made this diff considerably larger for a purely cosmetic win on lines untouched by this change. What matters for the stated goal is that the capability now exists: the next field addition can be written asSchemaField { name: ..., friendly_name: ..., new_field: ..., ..Default::default() }at each site instead of enumerating all 17 fields, which is the actual "not a 69-site change" property being asked for — a new field only needs..Default::default(), not a new value at every site (unless that site wants a non-default value).3. 64-call-site migration — confirmed complete, independently re-verified
cargo check --workspace --testscompiles clean,cargo clippy --workspace --all-targetsis clean. A second, independent review pass re-derived the count from scratch (its own repo-wide grep + a brace-matching script, not trusting this description) and confirmed all 64 sites setfriendly_nameexplicitly, none rely on a silently-empty default. It also re-checked every TS/SvelteSchemaFieldobject-literal site the same way, including twoas SchemaFieldtype-assertion casts in test files that had been silently hiding a missing-required-field gap (friendlyName, and in one caseindexedtoo) — both replaced with a real default so the compiler actually checks the shape now.4. Core schema description rewrites
Rewrote
task's 6 fields andproject's 4 (the exact same terse-label pattern as task, not explicitly named in the issue but an obvious companion — e.g.project.statuswas literallySome("Status")) into real LLM-facing prose.person's descriptions were already genuine prose (that's the issue's own positive example) — only neededfriendly_nameadded (Name/Email). Left the remaining schemas (ai-chat,query,skill,database-settings,collection) with their existing descriptions, which were already explanatory prose rather than label-like one-word text, aside from addingfriendly_name(mechanically derived from the existing short label text where one existed, or humanized fromnameotherwise) — per the issue's own guidance to prioritize the mechanism over exhaustively rewriting every schema.5. Deliberately out of scope:
packages/agentA comment on #2101 expanded scope to the agent-facing
create_schema/update_schematool JSON schemas (packages/agent/src/local_agent/tools.rs) and the schema-authoring skill guidance (packages/agent/src/skill_pipeline.rs) — the LLM still needs to be toldfriendly_nameexists and thatdescriptionnow wants real prose. I did not touchpackages/agent— it's a different engineer's lane by this repo's ownership convention, and this PR stays insidepackages/core,packages/nodespace-types, andpackages/desktop-app. Filed as a follow-up: #2104.The core plumbing this depends on is already in place and tested:
SchemaFieldacceptsfriendlyNamefrom any caller's JSON today (optional, defaulted server-side when omitted), so #2104 is purely "teach the agent this parameter and the newdescriptionsemantics exist" — no furtherpackages/corechanges should be needed.Adversarial review — two passes, real defects found and fixed
Given the scope, I ran two independent review passes (Rust correctness/design vs. frontend/migration-completeness) before merging.
Frontend/migration reviewer: APPROVE, minor findings only (the 68→64 count above, the TS
unique/uniqueCaseInsensitivegap, a cosmetic double-blank-line) — all addressed in this PR.Rust reviewer: REQUEST CHANGES, and correctly so — it found two real bugs, not just polish:
update_schemaaddingcustom:statusto any schema that already has a barestatusfield derived "Status" for both — same displayed label, different storage keys, no way to tell them apart in any UI surface. Fixed:apply_friendly_name_defaultsnow takes the full existing-plus-in-batch field set and disambiguates a derived collision by appending the namespace ("Status (custom)") or the raw field name when there is none. An explicitly-suppliedfriendly_nameis never touched even if it collides — that's the caller's deliberate choice, not this function's to override.derive_friendly_name's camelCase splitting mishandled an acronym adjacent to another word —employeeIDNumbermerged into"Idnumber"instead of splitting into three words, because the boundary detector only handled a single lower→upper transition, not the acronym-run case. Fixed with the standard second boundary rule.Both fixes have dedicated regression tests (5 new: 2 unit tests for the acronym case, 3 integration tests for collision disambiguation — existing-field collision, in-batch self-collision, and confirming an explicit colliding value is never rewritten).
The reviewer's other findings: the
nodespace-typesunfixed swallow site (discussed above, filed as #2107) andrename_schema_fieldleavingfriendly_nameunrefreshed after a rename (documented as a deliberate tradeoff via a doc comment — re-deriving it unconditionally on rename risks clobbering an explicit caller choice with no way to distinguish the two cases).What changed
Rust
packages/nodespace-types/src/schema.rs—SchemaField.friendly_name: String(#[serde(default)]),derive_friendly_name()(with acronym-boundary handling),#[derive(Default)]onSchemaField/SchemaProtectionLevel, doc comments distinguishingname/friendly_name/description.packages/core/src/schema/mod.rs—apply_friendly_name_defaults()(defaulting + collision disambiguation), called fromhandle_create_schemaandhandle_update_schema'sadd_fieldspath with the appropriate existing-field context for each.packages/core/src/models/core_schemas.rs— all 48 field literals gainfriendly_name; task + project descriptions rewritten as LLM-facing prose.packages/core/src/models/schema_node.rs— diagnostic log on the pre-existing silent field-parse-failure swallow.packages/core/src/services/node_service/schema.rs— doc comment onrename_schema_field's friendly_name-staleness tradeoff.markdown.rs,schema/mod.rs,context_ops.rs,entity_types_block.rs,nodespace-types/src/schema.rstest module.TypeScript/Svelte
packages/desktop-app/src/lib/types/schema-node.ts—friendlyName: string(required),unique/uniqueCaseInsensitive(were missing from the mirror), updated JSDoc.packages/desktop-app/src/lib/utils/schema-field-label.ts— new sharedlabelForField()helper (single source of truth for "how do I get a field's UI label").table-view.svelte,kanban-view.svelte,query-editor-model.ts/query-editor.svelte,schema-property-form.svelte,task-schema-form.svelte,generic-schema-form.svelte,nested-field-editor.svelte,nested-property-modal.svelte,schema-field-leaf.svelte— all now calllabelForField()instead offield.description || <local regex>. Five independently-duplicated name-humanizing regexes deleted. Value-humanizing helpers that format an actual value (not a field name) —schema-property-form's/schema-field-leaf's enum-value fallback formatters — were left in place; that's a different, still-legitimate concern, independently re-verified line-by-line by the review pass.table-view.svelte's stale doc comment (claimed "field.label", no such field existed) corrected.SchemaFielddescriptors innested-field-editor.svelte(array-item recursion) now carryfriendlyName.Tests
derive_friendly_nameunit tests (10 cases, including the acronym-adjacent-word fix and an all-uppercase case),SchemaFieldserde round-trip tests, 7handle_create_schema/handle_update_schemaintegration tests covering write-boundary defaulting (omitted → derived, explicit → respected) and collision disambiguation (existing-field collision, in-batch self-collision, explicit-value-never-rewritten).labelForField()unit tests, atable-view.svelteheader-rendering test using the person schema as the concrete "friendly_name not description" regression case (Name/Emailheaders, verbose description text asserted absent).SchemaField(64 Rust sites + ~15 TS test files) to supplyfriendly_name; two TS test helpers previously used anas SchemaFieldcast that silently hid a missing-required-field gap — replaced with a real default.Testing
cargo test -p nodespace-types -p nodespace-core --lib: all pass (1152 innodespace-core, 45 innodespace-types, includes all new tests above)cargo test -p nodespace-agent -p nodespace-daemon -p nodespace-cli --lib: all pass (470 / 13 / 147), confirming no regression in crates that consumecore_schemas.rsoutput but weren't touchedcargo clippy --workspace --all-targets: cleancargo fmt --check(touched crates): cleanbunx vitest run(full suite, cold cache): 213 files / 4666 tests passbunx svelte-check: 0 errors across 2272 filesbunx eslint: cleanAcceptance criteria
SchemaFieldcarriesname,friendly_name,descriptionwith distinct documented purposesfriendly_namealways populated in storage; derived fromnameat the write boundary when omittedfriendly_namein any UI surfaceName/Email; task headers unchangedfriendly_namevia one shared helper; duplicated regex removedSchemaFieldgainsDefaultso future field additions are not 69-site changesdeny_unknown_fieldsstill satisfied; Rust and TS types in syncfriendly_name— via thepackages/corewrite boundary today (any caller, human or agent, can already passfriendlyNameincreate_schema/update_schemaJSON); the agent's own tool-schema exposure of that parameter is Expose friendly_name in create_schema/update_schema tool schemas + skill guidance (agent lane, follow-up to #2101) #2104, deliberately deferred per the lane boundary aboveCo-Authored-By: Claude noreply@anthropic.com