Skip to content

Type the closed enum fields that are still strings as Rust enums #3479

Closed

Description

@malibio

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-written AiChatProvider union to get the narrowing back, which is the hand-mirroring ADR-086 §8 removes.
  • The vocabularies have already drifted: the schema allows a tool_call role, while the frontend's previous hand-written type listed only user | assistant | system.
  • An edge field can be declared with a type string outside the field-type vocabulary and is only caught by validation, while a node field's type cannot 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, OpenaiCompat as openai-compat, Pty). The schema's core_values and their labels are built from the enum; AI_CHAT_PROVIDERS stops being a second list.
  • AiChatTurnStatus (Idle, Processing).
  • AiChatSessionStatus with the values the schema declares when this is implemented (active | archived today; Split ai-chat into an abstract base with ai-chat-native and ai-chat-pty subtypes #3446 renames archived to ended).
  • AiChatMessageRole (User, Assistant, System). ADR-088 fixes the role vocabulary to these three and records that the declared tool_call role was never written; drop tool_call from the schema in the same change.
  • EdgeField.field_type becomes SchemaFieldType, the enum SchemaField.field_type already 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 in nodespace-types, and closed vocabularies are enums. §8: TypeScript is generated from them.
  • decisions/088-ai-chat-subtypes-and-message-nodes.md: role is user | assistant | system, and the declared tool_call message shape was never built.
  • components/node-types.md gets 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_status and message role are enums in nodespace-types, used by the wire structs and by core's AI-chat model; no struct carries them as String
  • EdgeField.field_type is SchemaFieldType; 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 for TaskStatus and ProjectStatus; 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 role no longer lists tool_call
  • A stored value outside the vocabulary reads as the field's default without failing the node (test per field)
  • bun run gen:types emits a TypeScript union for each enum; the hand-written AiChatProvider union is deleted and its users import the generated type
  • bun run test:changed passes; bun run quality:fix clean

Technical Specifications

Reference Files

  • packages/nodespace-types/src/ai_chat.rs - AiChatNode (turn_status, session_status, provider), AiChatMessage (role); AiChatTurnOutcome is the closed-enum pattern to copy
  • packages/nodespace-types/src/schema.rs - EdgeField.field_type, SchemaFieldType
  • packages/nodespace-types/src/query.rs - QueryGeneratedBy, a closed enum with derived serde and a schema default
  • packages/nodespace-types/src/project.rs, task.rs - ProjectStatus / TaskStatus and their CORE lists (the extensible-enum pattern; these fields are closed, so no User variant)
  • packages/nodespace-types/src/bin/gen_ts.rs - add each new enum to declarations
  • packages/core/src/models/ai_chat_node.rs - core's AI-chat model and AI_CHAT_PROVIDERS
  • packages/core/src/models/core_schemas.rs - the ai-chat schema's enum fields; test_project_status_variants_match_core_values_bidirectionally is the pin test to mirror
  • packages/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 status
  • packages/desktop-app/src/lib/types/ai-chat-node.ts - hand-written AiChatProvider; stores/settings.svelte.ts, services/ai-chat-default-model.ts and components/viewers/ai-chat-model-selector.svelte import it

Implementation Details

  • Closed enums derive Serialize / Deserialize with #[serde(rename_all = "...")] (kebab-case for provider, lowercase or snake_case for the rest), so ts-rs derives 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

Non-Goals

Activity

  1. malibio commented on Oct 2, 2026

    @malibio
    CollaboratorAuthor

    Frontend baseline: 304 files, 5579 tests passed

  2. self-assigned this
    on Oct 2, 2026
  3. malibio commented on Oct 2, 2026

    @malibio
    CollaboratorAuthor

    Node type sequence, classification: no new type. The change touches ai-chat-native (core subtype of the abstract ai-chat base, structured) and the schema wire type EdgeField. 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_status and session_status as closed enums built into the schema, removed AI_CHAT_PROVIDERS, the hand-written TypeScript provider union and the nested messages[] item fields (so the schema no longer lists a tool_call role). What remains here: AiChatMessageRole, EdgeField.field_type as SchemaFieldType, the default pins, and the out-of-vocabulary read tests.

  4. added a commit that references this issue on Oct 2, 2026
    c9da68f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions