Repository navigation
Description
Overview
Several fields that the core schemas declare as closed enums are carried as String in the Rust wire and model types. Give each a Rust enum in nodespace-types, so Rust code names the values instead of comparing strings and the generated TypeScript gets a real union instead of string.
Problem Statement
ADR-086 says closed vocabularies are enums, defined once in nodespace-types. task.status, project.status, priority, query.generated_by, SchemaField.type and the AI-chat turn outcome follow that. These do not:
| Field | Schema values | Rust type today |
|---|---|---|
ai-chat.provider |
native, openai-compat, pty |
Option<String> (the list is the AI_CHAT_PROVIDERS string table) |
ai-chat.turn_status |
idle, processing |
String |
ai-chat.session_status |
active, archived |
String |
ai-chat.messages[].role |
user, assistant, tool_call, system |
String |
EdgeField.field_type (an edge field's type) |
the SchemaFieldType vocabulary |
String |
Consequences:
- Rust compares literals (
"processing","idle","pty") in the daemon, core behaviours and the AI-chat model, so a typo or a renamed value compiles. - The generated TypeScript types these fields as
string. The frontend keeps a hand-writtenAiChatProviderunion to get the narrowing back, which is the hand-mirroring ADR-086 §8 removes. - The vocabularies have already drifted: the schema allows a
tool_callrole, while the frontend's previous hand-written type listed onlyuser | assistant | system. - An edge field can be declared with a
typestring outside the field-type vocabulary and is only caught by validation, while a node field'stypecannot be constructed wrong.
Proposed Solution
Add one closed enum per vocabulary in nodespace-types (derived serde with the wire spelling, no user-value variant, ts_rs::TS behind the ts feature) and use it in every struct that carries the field:
AiChatProvider(Native,OpenaiCompatasopenai-compat,Pty). The schema'score_valuesand their labels are built from the enum;AI_CHAT_PROVIDERSstops being a second list.AiChatTurnStatus(Idle,Processing).AiChatSessionStatuswith the values the schema declares when this is implemented (active | archivedtoday; Split ai-chat into an abstract base with ai-chat-native and ai-chat-pty subtypes #3446 renamesarchivedtoended).AiChatMessageRole(User,Assistant,System). ADR-088 fixes the role vocabulary to these three and records that the declaredtool_callrole was never written; droptool_callfrom the schema in the same change.EdgeField.field_typebecomesSchemaFieldType, the enumSchemaField.field_typealready uses.
Replace the string comparisons in core, the daemon and the agent with the enums. Regenerate TypeScript and delete the hand-written AiChatProvider union in packages/desktop-app/src/lib/types/ai-chat-node.ts.
For a stored value outside a closed vocabulary (a row written around the service layer), keep each type's existing read behaviour for a malformed field (the conversion falls back to the field's default, as it does for a query's fields) and cover it with a test. Writes are already rejected by schema validation.
Architecture Impact
Implements existing decisions; reverses none.
decisions/086-core-node-types-defined-once-typed-by-category.md(nodespace-docs) §1 and §4: wire types are defined once innodespace-types, and closed vocabularies are enums. §8: TypeScript is generated from them.decisions/088-ai-chat-subtypes-and-message-nodes.md:roleisuser | assistant | system, and the declaredtool_callmessage shape was never built.components/node-types.mdgets the enum names on the AI-chat sheet in the same change. No ADR change.
Acceptance Criteria
- Follows
development/node-type-sequence.md(nodespace-docs) -
provider,turn_status,session_statusand messageroleare enums innodespace-types, used by the wire structs and by core's AI-chat model; no struct carries them asString -
EdgeField.field_typeisSchemaFieldType; an edge field with a type outside the vocabulary fails to deserialize (test) - Each enum's variants equal its schema field's
core_values, checked in both directions by a test, as forTaskStatusandProjectStatus; the provider schema values and labels come from the enum - No Rust code compares these fields against a string literal (
"idle","processing","pty","native", …) outside tests - The schema's message
roleno longer liststool_call - A stored value outside the vocabulary reads as the field's default without failing the node (test per field)
-
bun run gen:typesemits a TypeScript union for each enum; the hand-writtenAiChatProviderunion is deleted and its users import the generated type -
bun run test:changedpasses;bun run quality:fixclean
Technical Specifications
Reference Files
packages/nodespace-types/src/ai_chat.rs-AiChatNode(turn_status,session_status,provider),AiChatMessage(role);AiChatTurnOutcomeis the closed-enum pattern to copypackages/nodespace-types/src/schema.rs-EdgeField.field_type,SchemaFieldTypepackages/nodespace-types/src/query.rs-QueryGeneratedBy, a closed enum with derived serde and a schema defaultpackages/nodespace-types/src/project.rs,task.rs-ProjectStatus/TaskStatusand theirCORElists (the extensible-enum pattern; these fields are closed, so noUservariant)packages/nodespace-types/src/bin/gen_ts.rs- add each new enum todeclarationspackages/core/src/models/ai_chat_node.rs- core's AI-chat model andAI_CHAT_PROVIDERSpackages/core/src/models/core_schemas.rs- theai-chatschema's enum fields;test_project_status_variants_match_core_values_bidirectionallyis the pin test to mirrorpackages/core/src/behaviors/mod.rs,packages/daemon/src/services/node_service.rs,packages/daemon/src/services/ai_chat_title.rs- string comparisons on turn and session statuspackages/desktop-app/src/lib/types/ai-chat-node.ts- hand-writtenAiChatProvider;stores/settings.svelte.ts,services/ai-chat-default-model.tsandcomponents/viewers/ai-chat-model-selector.svelteimport it
Implementation Details
- Closed enums derive
Serialize/Deserializewith#[serde(rename_all = "...")](kebab-casefor provider,lowercaseorsnake_casefor the rest), sots-rsderives the union from the same attributes and no hand-written serde or#[ts(type)]override is needed. - The proto messages keep these fields as strings; convert at the daemon and Tauri boundaries, as the project and task status paths do.
- The frontend writes
turnStatus: 'processing'to start a turn; after regeneration that literal is checked against the generated union.
Related Issues
- Split ai-chat into an abstract base with ai-chat-native and ai-chat-pty subtypes #3446 (splits
ai-chatinto subtypes and renamessession_status'sarchivedtoended; whichever lands second applies the enums to the other's shape) - Store AI chat messages as ai-chat-message child nodes with real edges #3448 (moves messages to
ai-chat-messagenodes;AiChatMessageRoletypes that node'srole) - Give collection, skill and database-settings typed wire types and updates, and close project and query's gaps #3453 (the flat types' typed wire structs;
ProjectStatuslanded from it in Type project.status as a ProjectStatus enum with a user-value variant #3476) - Generate the frontend's wire types from nodespace-types and gate them for drift #3440 (the TypeScript generator)
Non-Goals
tool.source: Make tool an abstract base with a tool-native subtype and drop source #3450 deletes the field- The tool-call record fields declared under
messages[](args,status,result_summary,duration_ms): ADR-088 records them as never built, and Store AI chat messages as ai-chat-message child nodes with real edges #3448 replaces the message shape - Changing any wire spelling or adding values
Activity
malibio commentedon Oct 2, 2026
Frontend baseline: 304 files, 5579 tests passed
malibio commentedon Oct 2, 2026
Node type sequence, classification: no new type. The change touches
ai-chat-native(core subtype of the abstractai-chatbase, structured) and the schema wire typeEdgeField. No field, relationship or rule is added; stored values and wire spellings are unchanged.Scope found at start: #3446 landed first and already typed
provider,turn_statusandsession_statusas closed enums built into the schema, removedAI_CHAT_PROVIDERS, the hand-written TypeScript provider union and the nestedmessages[]item fields (so the schema no longer lists atool_callrole). What remains here:AiChatMessageRole,EdgeField.field_typeasSchemaFieldType, the default pins, and the out-of-vocabulary read tests.Type the message role and an edge field's type as enums (closes #3479)