13 KiB
Workflow Contract Manifest Design
Status
Approved design for the first TypeScript parity slice after all 70 Python JSON-RPC methods gained named success-result schemas.
Problem
The Python JSON-RPC server exposes 70 methods through a complete OpenRPC
document. The Effect RPC package currently models 16 of those methods by hand.
Operation names are also repeated across rpcs.ts, service.ts, the method
registry, and console contracts. A Python method can therefore be added or
changed without any deterministic signal that TypeScript is stale.
The stock OpenRPC TypeScript generator is not suitable for this repository. A
local spike exhausted a 4 GB Node heap and produced invalid dotted members and
any results. Directly replacing the working Effect schemas would also combine
three separate risks: OpenRPC normalization, JSON Schema translation, and
runtime client migration.
Decision
Introduce a checked-in, transport-neutral workflow contract manifest before generating TypeScript.
The first slice will:
- generate OpenRPC from the real composed Python server;
- normalize it into a smaller deterministic manifest;
- validate structural and reference invariants;
- check in the resulting artifact; and
- fail tests when the generated contract and checked-in artifact drift.
It will not modify the TypeScript runtime, generate Effect schemas, add an HTTP endpoint, or expose more browser operations.
Alternatives Considered
Check In Raw OpenRPC
Rejected. Raw OpenRPC contains framework-owned document metadata and generated schema titles that create noise without improving the wire contract. It also forces every consumer to understand the full OpenRPC document shape.
Generate Effect RPC Directly
Deferred. Pydantic JSON Schema and Effect Schema differ around optional versus nullable values, unions, maps, excess properties, and arbitrary JSON. A direct migration would make extraction failures difficult to distinguish from runtime schema failures.
Generate TypeScript Types But Handwrite Effect Schemas
Rejected as the target architecture. It would preserve two TypeScript contract definitions for every operation while leaving runtime validation manual.
Package Boundary
Create src/wf_contract_manifest/ as a focused tooling package. It depends on
the composed server and JSON-RPC transport to obtain OpenRPC, but it is not part
of either package's runtime behavior.
The package will contain these responsibilities:
model.py: manifest structures andManifestError;normalize.py: puremanifest_from_openrpc(document)transformation;generate.py: compose a temporary local workflow server and obtain OpenRPC;io.py: deterministic serialization, writing, and checked-file comparison;__main__.py:python -m wf_contract_manifest write|check.
The checked-in artifact will live at:
contracts/workflow-api.manifest.json
The package is tooling rather than a new transport. It must not know about browser targets, HTTP headers, presentation metadata, or local server URLs.
Manifest Shape
Manifest version 1 uses this top-level structure:
{
"manifest_version": 1,
"source": {
"format": "openrpc",
"openrpc_version": "1.2.6"
},
"operations": [],
"components": {
"schemas": {},
"errors": {}
}
}
Each operation contains:
{
"method": "workflow.runs.start",
"namespace": ["workflow", "runs"],
"action": "start",
"params": [
{
"name": "deployment_id",
"required": true,
"schema": {
"type": "string",
"minLength": 1
}
}
],
"result": {
"schema": {
"$ref": "#/components/schemas/RunResult"
}
},
"errors": [
{
"$ref": "#/components/errors/5000"
}
]
}
The full method string is canonical identity. namespace and action are
derived navigation metadata and must never replace it.
Operations are sorted lexically by method. Parameter order remains the order published by OpenRPC because it is part of the generated method description. Component keys are sorted for deterministic serialization.
Schema Preservation Rules
The manifest preserves JSON Schema values and local $ref graphs. It does not
inline shared schemas or translate them into another schema language.
Normalization recursively removes generated title fields only. It retains:
- descriptions and defaults;
- required arrays;
- optional parameter flags;
- explicit nullability;
anyOf,oneOf,not,if, andthen;- enums, constants, and discriminators;
- numeric, string, and array constraints;
- object properties and
additionalProperties; and - unknown future JSON Schema keywords.
Optionality and nullability remain distinct. A parameter with required: false
may be omitted. A schema union containing { "type": "null" } permits an
explicit null. Neither implies the other.
additionalProperties retains its three states:
- absent: JSON Schema's default behavior;
true: explicitly extensible provider data;false: explicitly closed data.
An empty schema {} remains an unconstrained JSON value. It must not be
rewritten as an object schema.
Validation
Generation fails with ManifestError and a concrete document path when:
- the OpenRPC document has an unsupported top-level structure;
- method names are absent, malformed, or duplicated;
- params, result, or error entries are malformed;
- a success result is a generic object instead of a named contract;
- a local
$refis dangling or points into an unsupported component namespace; - an external
$refis encountered; or - manifest serialization cannot produce the canonical structure.
Unknown JSON Schema keywords are preserved rather than rejected. The first slice validates the manifest envelope and reference graph, not the complete JSON Schema vocabulary that a later Effect translator must support.
The current method count of 70 is a migration baseline assertion, not a hard generator limit. Method 71 should require an intentional fixture/assertion update and manifest regeneration, not generator code changes.
Deterministic Workflow
The supported commands are:
.venv\Scripts\python.exe -m wf_contract_manifest write
.venv\Scripts\python.exe -m wf_contract_manifest check
write generates canonical UTF-8 JSON with stable indentation and a trailing
newline. It updates only contracts/workflow-api.manifest.json.
check regenerates in memory and byte-compares with the checked-in artifact.
On drift, it exits non-zero with a concise instruction to run write. It does
not overwrite files.
Generation uses a temporary local workflow store. No temporary path, server
target, /rpc path, header, credential value, or environment-specific state may
enter the manifest.
Testing Strategy
Pure Normalization Tests
Synthetic OpenRPC fixtures will verify:
- lexical operation and component sorting;
- preserved parameter order;
- optional versus nullable parameters;
- absent, true, and false
additionalProperties; - union and conditional schemas;
- generated-title removal;
- preservation of unknown schema keywords;
- duplicate method rejection;
- dangling and external reference rejection; and
- deterministic serialization.
Real Contract Integration
An integration test will generate from create_rpc_app(...) and assert:
- 70 unique methods;
- 126 schema components at the initial baseline;
- zero generic success results;
- all local references resolve;
- the five known union result components remain unions;
- auth results do not expose a credential
payloadproperty; and - provider-extension schemas retain explicit
additionalProperties: true.
Drift Gate
A final test regenerates the complete manifest and compares it with
contracts/workflow-api.manifest.json. Python contract changes therefore fail
until the shared artifact is intentionally regenerated and reviewed.
TypeScript And Security Boundaries
The manifest describes all server operations. It does not authorize their use. Future TypeScript work must keep these concepts separate:
WorkflowOperationName: generated inventory of all wire operations;OperationName: operations implemented by the Effect client, inferred from its authored metadata registry and checked againstWorkflowOperationName;BrowserAllowedOperationName: independently authored Hono security/product allowlist, constrained toOperationName;OperationMeta: authored labels, explanations, idempotency, equivalent CLI, and semantic interpretation.
The first slice changes none of them. In particular, generating a 70-operation inventory must not automatically expose admin writes through the browser proxy.
The following remain handwritten throughout the migration:
- evidence capture and redaction;
- target policy, timeouts, and byte limits;
- JSON-RPC and domain error mapping;
- labels, CLI equivalents, and result interpretation;
- snake-case to camel-case presentation projections; and
- console, demo, and presentation view models.
Follow-Up Sequence
After the manifest slice:
- Completed: generate TypeScript operation names and raw request/result types;
- Completed: build a fail-closed JSON Schema-to-Effect translator against representative schemas before applying it broadly;
- Completed: migrate the existing 16 RPC definitions by domain while comparing old and generated decoders. The test-only parity harness covers all 32 payload/result sides and retains frozen pre-migration schemas that pin eight old run-result mismatches. Runtime run schemas now follow the canonical manifest: complete interrupts for inspect/start/resume and a full run envelope with canonical frame identifiers for trace;
- Completed: remove duplicated Effect-client operation-name unions and guards after checking the metadata registry against generated inventory. Keep the Hono browser allowlist separate so client expansion cannot broaden browser authorization implicitly; and
- expand client coverage when a product caller needs each operation.
Direct reverse conversion through newer Effect APIs may be reconsidered only
after checking version compatibility with the repository's pinned Effect and
@effect/rpc versions. This design does not require an Effect upgrade.
Representative Effect Translator Boundary
The completed prototype translates boolean schemas, primitive type schemas,
primitive const and enum, numeric and collection constraints, objects,
anyOf, local component references, and structurally guarded recursive
reference graphs. Tests exercise both synthetic contracts and checked
HealthResult / RunResult
manifest components.
The translator returns a typed JsonSchemaTranslationError and fails closed on
unknown keywords, external or dangling references, oneOf, allOf,
conditionals, not, and typed additional properties mixed with fixed fields.
Required names supplied only through additionalProperties are also outside
the representative subset, as are property names that collide with the object
prototype. Those constructs must not be approximated with a broader Effect
schema. Recursive translation is covered synthetically; checked manifest
coverage currently exercises representative non-recursive components and a
real rejected oneOf boundary. The translator is not exported from the package
root. Generated runtime use covers all 16 authored RPCs; it does not change
service dispatch, operation metadata, or browser authorization.
Generated runtime schemas apply an iterative 64-container value-depth check to requests and responses before Effect decoding. Effect's recursive schema decoder does not independently prevent stack exhaustion on adversarially deep values; the translator's structural recursion guard prevents non-productive schema cycles, while the runtime guard bounds values presented to recursive decoders.
The authored-RPC parity harness found no additional translator blockers for the current 16 operations. Health, sources, artifacts, deployments, and run list agree on representative accepted and rejected values. Frozen pre-migration schemas preserve the former differences: reduced interrupts for run inspect/start/resume and a compact trace page without canonical frame identifiers. Runtime decoding deliberately follows the manifest instead of broadening the translator or retaining those incomplete wire shapes.
Success Criteria
- The shared manifest is deterministic and checked in.
checkdetects any Python/OpenRPC drift without modifying files.- All 70 methods and their request/result/error contracts are represented.
- Every local reference resolves.
- Auth payload values remain absent from public result schemas.
- No TypeScript runtime or browser allowlist behavior changes.
- The manifest is sufficient input for the next TypeScript generation slice.