TypeScript Tool State Meta-Models Plan
Goal
Create a TypeScript package (galaxy-tool-util) using Effect Schema that can:
- Accept serialized
ToolParameterBundleModelJSON (as produced by Python’smodel_dump()) - Dynamically generate validation schemas for each state representation
- Validate tool state dicts against those schemas
- Produce JSON Schema from those schemas
- (Future) Cache tool objects, produce OpenAPI fragments, etc.
The correctness oracle is parameter_specification.yml from the Galaxy repo — the same YAML that drives the Python test suite.
Key Design Decision: Effect Schema over Zod
Effect Schema chosen because custom validation filters can declare their JSON Schema representation via jsonSchema annotation — Zod’s .refine() is opaque to JSON Schema generation. This matters because Galaxy API exposes these schemas and validators like in_range, regex, length need to appear in JSON Schema output.
Project Structure
galaxy-tool-util/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts # public API
schema/ # Effect Schema generation (this plan)
index.ts
state-representations.ts # StateRepresentation type + registry
parameters/ # per-parameter-type schema generators
index.ts # registry: parameter_type -> generator
base.ts # shared types, DynamicSchemaInfo
gx-integer.ts
gx-float.ts
gx-text.ts
gx-boolean.ts
gx-select.ts
gx-data.ts
gx-data-collection.ts
gx-color.ts
gx-hidden.ts
gx-directory-uri.ts
gx-drill-down.ts
gx-data-column.ts
gx-group-tag.ts
gx-genomebuild.ts
gx-baseurl.ts
gx-rules.ts
cwl-integer.ts
cwl-float.ts
cwl-string.ts
cwl-boolean.ts
cwl-file.ts
cwl-directory.ts
cwl-null.ts
cwl-union.ts
containers/ # conditional, repeat, section
conditional.ts
repeat.ts
section.ts
validators/ # Galaxy XML validators -> Effect filters
index.ts
regex.ts
in-range.ts
length.ts
expression.ts
empty-field.ts
no-options.ts
model-factory.ts # createFieldModel() — assembles schemas
connected-value.ts # ConnectedValue union helper
bundle/ # ToolParameterBundleModel parsing (future)
index.ts
cache/ # tool object caching (future)
index.ts
json-schema/ # JSON Schema generation (future)
index.ts
test/
parameter-specification.test.ts # main spec-driven test suite
fixtures/
parameter_specification.yml # copied from Galaxy repo
parameter_models/ # serialized bundles per tool (generated by Python)
gx_int.json
gx_boolean.json
...
Input Data
The TypeScript side consumes two artifacts from the Python side:
parameter_specification.yml— the test oracle (valid/invalid payloads per tool per state representation)- Serialized
ToolParameterBundleModelper tool — JSON files produced by running:
This dumps# in Galaxy repo PYTHONPATH=lib python test/unit/tool_util/test_parameter_specification.pyparameter_models.yml(or we write a small script to dump per-tool JSON files).
The TS test harness loads a tool’s serialized bundle, passes it to createFieldModel(bundle, stateRep), and validates each test case against the resulting Effect Schema.
Enumerated Scope
State Representations (12)
| State Representation | Key Behavior |
|---|---|
request | String IDs, batching allowed, defaults fill absent |
relaxed_request | Like request but nulls -> defaults |
request_internal | Int IDs, batching allowed |
request_internal_dereferenced | Int IDs, no URL sources |
landing_request | String IDs, ALL optional |
landing_request_internal | Int IDs, ALL optional |
job_internal | Int IDs, ALL required, no batching |
job_runtime | CWL-style file metadata, ALL required |
test_case_xml | File paths, string splitting |
test_case_json | File paths, no string splitting |
workflow_step | Data params always None |
workflow_step_linked | Allows ConnectedValue |
Parameter Types (28 distinct parameter_type values across ~80 tool entries)
Galaxy scalar: gx_integer, gx_float, gx_text, gx_boolean, gx_color, gx_hidden, gx_directory_uri, gx_genomebuild, gx_baseurl
Galaxy choice: gx_select (single + multiple), gx_drill_down, gx_data_column, gx_group_tag
Galaxy data: gx_data (single + multiple), gx_data_collection (many collection_type variants)
Galaxy containers: gx_conditional (boolean + select test), gx_repeat, gx_section
Galaxy special: gx_rules (rule builder with mappings — complex nested dict structure)
CWL: cwl_integer, cwl_float, cwl_string, cwl_boolean, cwl_file, cwl_directory, cwl_null, cwl_union
Validator Types
regex, in_range, length, expression, empty_field, no_options (others like metadata validators are dynamic/runtime-only — skip for now)
Per-Parameter Semantic Quirks
These are non-obvious behaviors that differ from naive assumptions:
gx_floatacceptsStrictInt | StrictFloat(not just float) —5is valid,"5"is notgx_colorhas embedded validator logic beyond a simple regex — lowercase hex only, exactly 7 chars, with per-state-rep validator dispatch (validate_color_str,validate_color_str_if_value,validate_color_str_or_connected_value)gx_hiddenforcesoptional=trueforworkflow_step(not_linked) even when the parameter is non-optionalgx_textrequest_requires_valueis unconditionallyFalse(unlikegx_integerwhere it depends onoptionalandvalue)gx_hidden_datais NOT a distinct parameter_type — it’sgx_datawithoptional=true. Tools using it just work whengx_datais implemented.safe_field_nameescaping: Parameters starting with_getXprefix in the model field name withalias=original_namefor JSON deserialization. Must replicate in TS.relaxed_requesthas per-parameter-type handling — e.g.gx_textallows null for non-optional params (py_type_relaxed_request), not just global “nulls allowed”
Phase 1: Infrastructure — COMPLETE (2026-03-29)
All steps implemented. Test results: 33 passed, 139 skipped.
What was built
- Step 1.1: Project scaffold —
effect@3.21.0,@effect/schema@0.75.5,vitest@4.1.2,typescript@6.0.2, ESM module, strict tsconfig - Step 1.2:
src/schema/state-representations.ts— 12 state reps, 7 helper predicates (requiresAllFields,allowsAbsent,allowsBatching,usesStringIds,usesIntIds,allowsConnectedValue,allOptional,allowsUrlSources) - Step 1.3:
src/schema/bundle-types.ts— TypeScript interfaces for all 28 parameter types + container types,collectParameterTypes()andcollectValidatorTypes()walkers for skip logic - Step 1.4:
src/schema/model-factory.ts+src/schema/parameters/base.ts+ registries —createFieldModel(bundle, stateRep)assemblesSchema.Structfrom per-parameter generators,safeFieldName()handles_-prefix escaping - Step 1.5:
test/parameter-specification.test.ts— spec runner with:- YAML merge key support (
{ merge: true }) - Skip on unregistered parameter_type, validator_type, or state representation
- Auto-inference of
request_internalfromrequest(whenrequest_internalstate rep is implemented) Schema.decodeUnknownEitherfor validation
- YAML merge key support (
- Step 1.6:
scripts/generate_fixtures.py— generates 77 Galaxy tool bundles (10 CWL skipped, needs cwltool). Copiesparameter_specification.yml.
Learnings / deviations from plan
- Effect Schema
S.optional()returnsPropertySignature, notSchema—assembleStruct()neededS.Struct.Fieldstyping with a cast - YAML
merge: trueoption required —yamllibrary doesn’t resolve<<merge keys by default - Unknown parameter types →
undefinedreturn fromcreateFieldModel()instead of permissiveSchema.Unknown— cleaner for skip logic, the test harness checks registration before calling gx_integeralready implemented as part of Phase 1 (needed to validate the infrastructure works) — coversrequeststate rep forgx_int,gx_int_optional,gx_int_required,gx_int_required_via_empty_string- CWL fixtures need cwltool — not available in system Python, deferred to Round 7
S.Intfrom Effect Schema serves as strict integer — rejects strings, floats, booleans. Works well as PydanticStrictIntequivalent.
Phase 2: First Parameter + First State Representation — COMPLETE (2026-03-29)
Implemented as part of Phase 1 infrastructure validation.
src/schema/parameters/gx-integer.ts— handlesrequeststate repS.Intfor strict integer validationS.NullOr(S.Int)whenoptional: trueisOptionallogic:requiresAllFields(stateRep)→ false,allOptional(stateRep)→ true, else based onp.optional || p.value !== null
- All 4
gx_int*tools passing forrequest: 33 tests green
Phase 3: Iterative Expansion
This phase is a structured loop. Each iteration adds either a parameter type or a state representation (or both) and fixes tests until green. The order is chosen to maximize coverage per iteration.
Critical ordering note: Validators must be implemented alongside the parameter types that use them. The skip logic checks both
parameter_typeregistration AND validator support to prevent premature un-skipping.
Round 1 — Scalars, choice, all validators, request state rep — COMPLETE (2026-03-29)
Test results: 216 passed, 323 skipped (up from 33 passed).
Parameter types implemented (10 total):
gx_integer—S.Int, implicitin_rangefrommin/max,S.NullOrwhen optionalgx_float—S.Number.pipe(S.finite())(accepts int or float), same validator pattern as integergx_boolean—S.Booleanstrict,requestRequiresValuealways falsegx_text—S.String, nullable whenoptional,requestRequiresValuebased on!optional && value === nullgx_color—S.String.pipe(S.pattern(/^#[0-9a-fA-F]{6}$/)), always has defaultgx_hidden—S.String+ validators, nullable when optionalgx_select— literal union fromoptions[],S.Arraywhenmultiple,S.NullOrwhen optional/multiplegx_directory_uri—S.String+gx*://prefix filter + validatorsgx_genomebuild—S.String(dynamic options),S.Arraywhen multiple,S.NullOrwhen optionalgx_baseurl—S.String, nullable when optional
Validators implemented (6 total):
in_range— min/max with exclude_min/exclude_max/negateregex— JSRegExpfrom expression string, negate supportlength— min/max string length, negate supportexpression— subset parser for'literal' in valueandvalue == 'literal'patterns; unrecognized expressions pass throughempty_field— non-empty string check, negate supportno_options— no-op at schema level (runtime option availability check)
Architecture established:
- Validator registry (
validators/registry.ts) withapplyValidators()dispatch — parameter generators call this to fold validators onto their base schema - Validators applied before nullable wrapping (null bypasses validators for optional params)
- Implicit
in_rangesynthesized frommin/maxfields on integer/float params
Post-Round 1 Review Fixes — COMPLETE (2026-03-29)
A subagent review identified P0/P1 issues. All fixed:
- P0:
fromKeyalias on non-optional fields —assembleStructnow wraps withS.propertySignature()beforeS.fromKey()for non-optional aliased fields (was silently broken for_-prefixed required params) - P1-1:
GeneratorContextfor container recursion —ParameterSchemaGeneratorsignature now takes(param, stateRep, ctx). Context providesbuildChildSchema(params, stateRep)callback so container generators can recurse without circular imports onmodel-factory.ts. All 10 generators updated. - P1-2:
BooleanParameterModel.truevalue/falsevalue— changed fromstringtostring | null(fixtures havenull) - P1-3: Container type interfaces —
ConditionalParameterModel,RepeatParameterModel,SectionParameterModelnow extendContainerBaseFieldswithhidden,label,help,argument,is_dynamic - P1-5:
collectValidatorTypestest_parameter — now checks validators on conditional’stest_parameter(was only walking when-branch children)
Tooling added:
- ESLint + typescript-eslint, Prettier
Makefilewithmake lint,make format,make typecheck,make test,make check(all static),make fix
Round 2 — Container parameters — COMPLETE (2026-03-29)
Test results: 275 passed, 347 skipped (up from 216).
Extended GeneratorContext with buildChildSchemaInfos() and assembleStruct() methods to give containers access to raw field infos and struct assembly without circular imports.
Container types implemented (3):
gx_conditional— discriminatedS.Unionof per-branch structs. Each branch hasS.Literal(when.discriminator)on test_parameter + child params. Test_parameter isS.optionalonly in theis_default_whenbranch for non-requiresAllFieldsstates. Handles boolean (true/false) and select (string literals) discriminators. Nested conditionals (conditional_conditional_boolean) work via natural recursion.gx_section— thin wrapper. Child struct viactx.assembleStruct(childInfos). Section optional if all children optional (anyChildRequireddrivesrequestRequiresValue).gx_repeat—S.Array(childStruct)withS.filterfor min/max length. Repeat optional in request only when children DON’T require values OR min is 0/null. Formula:requestRequiresValue = anyChildRequired && min > 0.
Design decisions:
- Conditional uses
S.optional(S.Literal(discriminator))in default branch, not a separate absent-case struct. Works becauseS.Uniontries all branches and only the default branch allows missing test_parameter. onExcessProperty: "error"at decode time (not struct definition) ensures wrong-branch fields are rejected.- Repeat min/max as
S.filternotS.minItems/S.maxItems(more portable across Effect Schema versions).
Round 3 — Choice, data, data_collection, data_column, group_tag — COMPLETE (2026-03-29)
Test results: 498 passed, 548 skipped (up from 275). All remaining skips are state rep coverage.
Parameter types implemented (5), completing ALL 18 Galaxy types:
gx_drill_down— collects valid values from recursive options tree.hierarchy: "exact"= all nodes,hierarchy: "recurse"+ single = leaf-only,hierarchy: "recurse"+ multiple = all nodes. Literal union from values. No implicit default (requestRequiresValue = true).gx_data— HDA direct ({src: "hda", id: <string>}), URL source ({src: "url", url, ext}), Batch ({__class__: "Batch", values: [hdca|dce]}) with optionalmap_over_type. Multiple mode adds HDCA direct + array-of-sources variant. UsesusesStringIds(stateRep)andallowsBatching(stateRep)for state rep conditioning.gx_data_collection— HDCA ref + inline Collection ({class: "Collection", collection_type, elements: Array(Unknown)}). Elements permissive for now (recursive nesting).gx_data_column—S.Int(strict integer column index). Multiple =S.Array(S.Int). Default fromvaluefield.gx_group_tag—S.String, multiple =S.Array(S.String).
Bundle type updates: DrillDownParameterModel.hierarchy, DataColumnParameterModel.value, DataCollectionParameterModel.extensions/value.
Review findings (all addressed):
- URL source gating:
allowsUrlSources()exists in state-representations but is incorrect (says request_internal only, but request also allows URLs). Deferred to state rep expansion. - Shared data source schemas: minor DRY opportunity between gx-data and gx-data-collection (idSchema, hdcaSource). Not worth extracting yet.
- Repeat min/max logic: verified correct against spec comment in
gx_repeat_boolean_min(“can skip the repeat all together”).
Round 4 — Expand state representations — COMPLETE (2026-03-29)
Test results: 2087 passed, 10 skipped (up from 498). All 12 state representations implemented. Remaining 10 skips are CWL parameter types (no fixture bundles).
All 12 state reps implemented in one pass:
request_internal+request_internal_dereferenced+landing_request+landing_request_internal— Scalars just worked via existingcomputeIsOptional. Data changes:allowsUrlSourcesfixed to cover all request-like reps (was wrong — only hadrequest_internal).request_internal_dereferencedexcludes URL sources AND inline collections for data_collection.job_internal— All required (viacomputeIsOptional). Data:{src: "hda"|"dce", id: int}— addeddcedirect source. Data collection:{src: "hdca"|"dce", id: int}. No batch, no URL, no inline collection.job_runtime— All required. Data:{class: "File", basename, location, path, nameroot, nameext, format, size, element_identifier?}. Data collection: recursivecollection_type-aware validation —list→ array elements,paired→{forward, reverse}record, nested types likelist:pairedvalidated recursively. Extra fields (column_definitions,has_single_item) as optional.workflow_step+workflow_step_linked— Data params useS.Unknown.pipe(S.filter(() => false))(rejects all values).workflow_step: data always optional (absent only).workflow_step_linked: central ConnectedValue wrapping inmodel-factory.tsviaS.Union(schema, ConnectedValueSchema). Array types (select_multiple) useconnectedValueHandledflag for item-level ConnectedValue instead of outer wrapping.test_case_xml+test_case_json— Data:S.Union({class: "File", path}, {class: "File", location}). Data collection: recursive schema with typed File elements ({class: "File", identifier, path}), validates nested collection structure.test_case_xmlaccepts comma-separated strings for select_multiple and data_column_multiple.relaxed_request— Only 6 tests, mostly same asrequest. Key difference:gx_textallows null for non-optional params (null coercion viastateRep === "relaxed_request"check).
Key design decisions:
- ConnectedValue wrapping is centralized in
buildSchemaInfos(model-factory.ts) — added after each generator returns. Generators that handle it themselves (array types) setconnectedValueHandled: trueon DynamicSchemaInfo. S.Unknown.pipe(S.filter(() => false))used instead ofS.Neverto avoid TypeScript variance issues withS.Schema.Any.- Repeat min/max constraints skipped for
allOptionalstate reps (landing_request allows single-element arrays even when min=2). isWorkflowStep()andisTestCase()helpers added to state-representations.ts for clean branching.
Round 5 — Special parameters
gx_rules— rule builder with mappings, complex nested dict structure
Round 6 — CWL parameters
cwl_integer,cwl_float,cwl_string,cwl_boolean— simple wrapperscwl_file,cwl_directory— CWL-specific data refscwl_null— validatesLiteral[None]cwl_union— union of other CWL types (recursive)
Development harness for each iteration
- Pick next item from the iteration order
- Run tests:
make test— note which tools/reps are newly unskipped - Implement the parameter type or state representation
- Run tests — iterate until all newly-unskipped tests are green
- Verify no regressions:
make check && make test - Commit
Phase 4: Drop All Skips
Step 4.1: Remove Skip Logic
- Remove the
parameter_typeregistry check that skips unknown types - Remove the state representation skip check
- All spec entries must now pass or explicitly be marked as
expected_failure(if any Python/TS semantic differences exist)
Step 4.2: Full Green
- Run full suite, fix any remaining failures
- These should only be edge cases missed during iterative dev
Step 4.3: CI
- Add GitHub Actions workflow
- Run
npx vitest runon PR - Add fixture regeneration check (ensure committed fixtures match Python output)
Future Work (not in this plan, but project structure supports)
- JSON Schema generation:
JSONSchema.make()from Effect Schema on generated models, with custom annotations for Galaxy-specific metadata - Tool object caching: LRU cache keyed on tool ID + version, returns precomputed schemas for all state reps
- OpenAPI fragments: Generate OpenAPI 3.1 request/response schemas per tool
- Bundle validation: Validate incoming
ToolParameterBundleModelJSON with Effect Schema (currently trusted) - Conversion functions:
decode(),encode(),runtimeify()— visitor pattern over parameter tree - State representation auto-inference: Infer
request_internalfromrequestlike the Python test runner does - Galaxy client integration: Use generated schemas for client-side form validation in the Galaxy UI
Risks
| Risk | Severity | Status | Mitigation |
|---|---|---|---|
| Data parameter complexity (~8 type shapes across state reps) | High | Resolved | gx-data.ts branches on state rep: source refs (request-like), File objects (job_runtime), File path/location (test_case), S.Never (workflow_step). gx-data-collection.ts has recursive collection_type validation for job_runtime. |
Conditional discriminated union with onExcessProperty: "error" | High | Resolved | S.Union of per-branch structs with S.Literal discriminators + S.optional in default branch. Tested with boolean, select, and nested conditionals. |
relaxed_request null coercion is per-parameter-type, not global | Medium | Resolved | Only gx_text needed null coercion — simple stateRep === "relaxed_request" check adds NullOr wrapping. Only 6 test cases for relaxed_request total. |
allowsUrlSources() predicate is wrong | Medium | Resolved | Fixed to include request, relaxed_request, request_internal, landing_request, landing_request_internal. |
expression validator only handles subset of Python expressions | Medium | Open | Currently parses 'X' in value and value == 'X'. Unrecognized expressions pass through. |
cwl_union is recursive (union of CWL types including nested unions) | Low | Open | Small scope, deferred to Round 6. Schema.suspend handles it. |
| Fixture staleness — Python model changes break TS tests silently | Medium | Open | CI step to regenerate fixtures and diff against committed versions. |
Unresolved Questions
- Generate fixture JSON from Galaxy
.venvin this repo or maintain a separate script in the Galaxy repo? - Should the Python fixture generation script live in this repo (with Galaxy as a dev dependency) or in Galaxy itself?
- Pin to a specific Galaxy commit for fixtures, or always regenerate from Galaxy main?
ForResolved: Simplerelaxed_requestnull-to-default coercionstateRep === "relaxed_request"check in gx-text.ts. Only gx_text needed it.- CWL parameters share some spec entries with Galaxy — share code or keep separate implementations?
Should theResolved:__absent__branch for conditionals useSchema.optionalon the test field or omit it entirely from the struct?S.optional(S.Literal(discriminator))on the test field in the default when-branch.gx_ruleshas a complex nested dict structure (rules + mappings) — implement fully or treat as opaqueSchema.Unknowninitially?Resolved: test_case collections now have recursive typed validation (File + nested Collection). job_runtime collections validate collection_type + element structure recursively.gx_datainline collection elementsData source schema duplicationResolved: Not extracted. Each generator has its own state-rep-specific logic that would complicate sharing. Minimal overlap in practice.