ModelOp — Declarative CRUD Inside a Workflow¶
kind: ModelOp is tuvl's zero-code persistence step: one YAML block that creates, reads, lists, updates, or deletes a record of a registered model, scoped by the workflow's context: allowlist. It exists so pure CRUD never needs a custom Python node (Golden Rule 13). This document explains the internals, end to end. For the authoring reference (the YAML contract, field by field), see tuvl-agentic-manual.md §4.8.
Table of Contents¶
- Mental Model
- The Operation Set
- Execution Path
- Context Interpolation
- The Allowlist Boundary
- Version Pinning
- ModelOp vs the REST CRUD Routes
- As an AutonomousAgent Tool
- Validation and Failure Modes
- Reading the Code
1. Mental Model¶
A ModelDefinition YAML file already gives a model three things: a SQLModel class in MODEL_REGISTRY, a table, and a set of auto-generated REST routes. ModelOp is the fourth surface: the same repository layer, invoked from inside a workflow step instead of from an HTTP client.
- id: create_candidate
kind: ModelOp
model: Candidate # PascalCase name from MODEL_REGISTRY
operation: create # create | read | list | update | delete
payload: "{{candidate}}" # dict or {{template}} — create/update
record_id: "{{cand_id}}" # PK value — read/update/delete
filters: # equality filters — list
stage: screening
include: "posting,assessments" # comma-separated relation names — read/list
limit: 50 # list cap (default 100)
output: new_candidate # context key (default: <step_id>_result)
routes:
default: next_step
error: error_handler
The step resolves {{ }} templates from the workflow context, calls one repository method, and writes the result back into the context under output. No SQL, no session management, no serialization code in the workflow.
2. The Operation Set¶
The runner (_run_model_op_step in src/tuvl/core/engine/runner.py) supports exactly five operations. Anything else routes error with _last_error set to unknown ModelOp operation.
operation |
Repo call | Inputs used | Result written to output |
|---|---|---|---|
create |
repo.add(payload) |
payload (must resolve to a dict) |
the new record as a plain dict |
read |
repo.get(record_id) or repo.get_expanded(record_id, include) |
record_id, optional include |
record dict, or None when not found |
list |
repo.list(criteria, limit) or repo.list_expanded(...) |
filters, limit, optional include |
list of record dicts (empty list when nothing matches) |
update |
repo.update(record_id, payload) |
record_id, payload (dict) |
updated record dict, or None when not found |
delete |
repo.remove(record_id) |
record_id |
{"deleted": true|false, "id": <record_id>} |
Notes grounded in src/tuvl/core/repositories/base.py:
filtersare equality only, ANDed together. Filter keys that are not columns on the model are silently skipped. String filter values are coerced to the column's native type (UUID, int, bool, Decimal) before comparison.includetriggers relation expansion (get_expanded/list_expanded): each included relation costs exactly one extra IN-clause query — never N+1. Included relation names come from the model's declared relations (MODEL_RELATIONS).updatesets only the attributes present inpayloadthat exist on the record (partial update).createpasses the payload straight to the model constructor, so unknown fields raise and routeerror.- ISO-format string write values are coerced to the column's native type before the constructor sees them (date, datetime, UUID, Decimal —
_coerce_write_value). LLM-extracted JSON can therefore feedcreate/updatepayloads directly; a string that fails to parse passes through unchanged and surfaces as the DB layer's error. - Misses are not errors: a
readof a missing id yieldsNone, alistwith no matches yields[], anupdateof a missing id yieldsNone, adeleteof a missing id yields{"deleted": false, ...}— all on thedefaultsignal. Route on the output value if a miss should branch. - ORM instances are converted to plain dicts (
_orm_to_context_dict) before entering the context, so downstream steps andResponsemappings see JSON-shaped data.
3. Execution Path¶
HTTP request → workflow route (metadata.required_scope / required_group checked here)
│
▼
_run_engine / _sse_generator (src/tuvl/core/api/manager.py)
│ builds context["_db"] = WorkflowUoW(session, allowed_models, is_write, version_map)
▼
WorkflowEngine step loop → _run_model_op_step
│ repo = uow[model_name.lower()] ← allowlist + version pin enforced here
▼
BaseRepository.add/get/list/update/remove ← flush + refresh only, no commit
▼
workflow completes → session.commit() + uow.commit_secondary()
Key properties:
- One transaction per run. Repository methods only
flush(); the primary session commit happens once, in_run_engine(or_sse_generatorfor streaming), after the whole workflow succeeds. Any uncaught exception rolls back everything — a workflow's ModelOp writes are all-or-nothing. - Suspension commits. A
HumanInTheLoopsuspension commits the session (to persist the instance row), so ModelOp writes made before the suspend point are durable even though the workflow has not finished. - Secondary datasources. A model may declare
spec.datasourcepointing at a non-primary Postgres.WorkflowUoW._session_forlazily opens one session per secondary datasource (with the same tenant-isolation binding as the primary) andcommit_secondary()/rollback_secondary()mirror the primary's outcome. Commits across datasources are sequential, not two-phase: a failure between the primary commit and a secondary commit can leave them divergent. - Repos are cached per run. The first access to a model builds its
BaseRepository; later steps in the same workflow reuse it (and its session).
4. Context Interpolation¶
payload and filters go through WorkflowEngine._resolve_tpl_obj, which resolves templates recursively through dicts and lists with one important rule:
- A string that is a single
{{ key }}reference returns the raw Python object — including via dot-paths like{{step_1.data.id}}. This is what makespayload: "{{candidate}}"pass the whole candidate dict torepo.add()instead of its string repr. Unresolvable references returnNone. - Any mixed string (
"score: {{score}}") stringifies its substitutions; absent keys render as"". No dot-paths in mixed strings.
payload:
candidate_id: "{{new_candidate.id}}" # sole reference → raw value (UUID survives)
summary: "Screened by {{agent_name}}" # mixed string → stringified
raw: "{{llm_result.parsed}}" # sole reference → nested dict passes through
record_id uses the simpler _tpl helper: flat {{key}} substitution only (no dot-paths), absent keys become "" — which then misses and yields None/deleted: false rather than erroring.
If filters resolves to a non-dict it is ignored (treated as {}); if payload resolves to a non-dict on create/update, the step routes error.
5. The Allowlist Boundary¶
The workflow's context: field is a security boundary, not documentation. _extract_allowed_models in src/tuvl/core/api/manager.py accepts three forms:
context: Candidate # string
context: [Candidate, Job] # list
context: # dict — the only form that can pin versions
models:
- name: Candidate
version: v2
These names become WorkflowUoW._allowed (case-insensitive). Every repository access — from a ModelOp step or a custom Functional node using context["_db"] — passes through WorkflowUoW.__getitem__, which raises PermissionError for any model not in the list. The ModelOp runner wraps that into a RuntimeError (cannot access model '<name>'), which fails the workflow and rolls back — it does not take the error route. The same hard failure applies when _db is missing from the context entirely.
There is no per-step scope check inside the workflow. The Biscuit scope/group gate (metadata.required_scope, metadata.required_group) runs once, as a FastAPI dependency on the workflow's trigger route (_build_route_deps). Past that gate, what a run may touch is defined solely by the context: allowlist — declare it minimally.
6. Version Pinning¶
Multiple ModelDefinition files may declare the same model name with different metadata.schema_version values. The loader (src/tuvl/core/models/loader.py) records all of them in MODEL_VERSION_REGISTRY, but only enabled definitions produce a class in MODEL_REGISTRY — and each generated class is stamped with __schema_version__. A version's effective enabled value is the YAML flag (enabled: true is the default) unless an admin toggle (PATCH /admin/models/{name}/{version}/toggle) overrides it — the DB flag wins and is applied at the next boot (apply_db_enabled_overrides in src/tuvl/core/models/model_version.py).
Two enforcement points protect against version mix-ups:
- Load time — cross-version collision. Two enabled versions of the same model sharing a physical table but declaring different field shapes raise
RuntimeErrorat registration ("Cross-version model collision"). Resolve by separatespec.tablenames, disabling one version, or aligning the fields. - Run time — the pin check. When a workflow pins a version (
context.models: [{name: Candidate, version: v2}]),WorkflowUoW.__getitem__compares the pin against the live class's__schema_version__on first access. A mismatch — including the case where the pinned version's definition is disabled — raisesRuntimeErrorand refuses to execute against the wrong schema.
The pin map is also injected into the execution context as _context_model_versions so custom Functional nodes can honor it. Versions are toggled at runtime via POST /admin/models/{name}/{version}/toggle (state persists in the tuvl_system_model_versions table); a toggle takes effect for MODEL_REGISTRY on reload.
Unpinned workflows simply get whatever version is currently enabled in MODEL_REGISTRY — pin when that ambiguity matters.
7. ModelOp vs the REST CRUD Routes¶
Every registered model also gets five auto-generated routes under /models/{model}/ (src/tuvl/core/api/crud_router.py). Both surfaces call the same BaseRepository; the differences are around it:
kind: ModelOp (in a workflow) |
REST /models/{model}/ |
|
|---|---|---|
| Auth | workflow's metadata.required_scope / required_group, checked once at the trigger route |
per-route Biscuit scopes: <model>:read / :write / :delete (overridable via spec.access.*_scope in the model YAML) |
| Access boundary | context: allowlist (PermissionError) |
scope check only — any model, given the scope |
| Transaction | one commit for the whole workflow run; rollback on failure undoes all steps | commit per request, immediately after the operation |
| Record id | any PK value from context (templated string) | uuid.UUID path parameter |
| Filters | filters: map in YAML |
?filter[field]=value query params |
| Pagination | limit only |
limit + offset |
| Validation | payload passed raw to the model constructor | Pydantic create/read/update schemas from SCHEMA_REGISTRY |
| Version pinning | context.models[].version |
none — always the enabled version |
| Miss behavior | None / deleted: false on default signal |
HTTP 404 |
Use REST when an external client owns the call; use ModelOp when persistence is one step of declared business logic — validation, agents, routing, and response shaping around it.
8. As an AutonomousAgent Tool¶
A ModelOp step can be declared as a tool for a kind: AutonomousAgent step (agent.tools[].ref naming the step id). See autonomous-agent.md for the loop itself; the ModelOp-specific mechanics:
- Parameter schema. When the tool entry omits
parameters,_derive_parametersinsrc/tuvl/core/engine/agent_tools.pyderives one. For ModelOp it is a single free-form object property namedpayload("Record fields for the operation."). Author the step'spayload: "{{payload}}"so the LLM-provided object flows in via the sole-reference rule. Declare explicitparametersfor a precise field contract. - Dispatch.
_dispatch_toolruns the referenced step against a shallow copy of the context with the LLM's arguments merged in. The copy shares the same_dbhandle, so the allowlist and the run's single transaction still apply — an agent cannot reach a model the workflow did not declare. - Results and errors. The tool result fed back to the model is the public-context delta the step produced (its
outputkey). Anerrorsignal returns{"error": <_last_error>}to the model — the loop continues, letting the agent react to, say, a unique-constraint violation. - Context writes. The delta is merged back into the real workflow context only when the tool entry sets
writes_context: true. - Agent-invoked writes commit or roll back with the surrounding workflow run, exactly like directly-executed ModelOp steps.
9. Validation and Failure Modes¶
tuvl validate (src/tuvl/cli/commands/validate.py) checks ModelOp steps statically: a model: value that matches no known ModelDefinition produces a warning, and the generic route check flags routes: targets that name no step. The engine reads model from the step root — there is no nested model_op: block.
At runtime, failures split into two classes:
| Condition | Behavior |
|---|---|
| success (including read/list/update/delete misses) | signal default, result under output |
unknown operation |
signal error, _last_error set |
payload not a dict (create/update) |
signal error, _last_error set |
| repository/DB exception (constraint violation, bad column type, ...) | signal error, _last_error set |
_db missing from context |
RuntimeError — workflow fails, transaction rolls back |
model not in context: allowlist |
RuntimeError (wrapping PermissionError) — workflow fails, rolls back |
| version pin mismatch / pinned version disabled | RuntimeError from WorkflowUoW — workflow fails, rolls back |
error-signal failures are routable (routes: {error: handle_it}) and leave _last_error in the context for the handler; an unmapped error route ends the run on that signal. The RuntimeError class is deliberate: those are authoring mistakes, not runtime conditions, and they surface as HTTP 500 with a rolled-back transaction.
Every ModelOp execution is traced and logged like any other step — see observability.md; failures log Model-op step failed with workflow, step, and error fields.
10. Reading the Code¶
| Module | What lives there |
|---|---|
src/tuvl/core/engine/runner.py |
_run_model_op_step (the five operations, signals), _resolve_tpl_obj / _tpl (interpolation), _orm_to_context_dict, _dispatch_tool (agent-tool path) |
src/tuvl/core/repositories/uow.py |
WorkflowUoW — allowlist gate, version-pin check, per-datasource session routing, commit_secondary / rollback_secondary |
src/tuvl/core/repositories/base.py |
BaseRepository — add / get / list / update / remove, relation expansion, filter coercion |
src/tuvl/core/api/manager.py |
UoW construction from context: (_extract_allowed_models, _extract_model_version_map), commit/rollback in _run_engine and _sse_generator, _build_route_deps (workflow-level auth) |
src/tuvl/core/models/loader.py |
MODEL_REGISTRY, __schema_version__ stamping, cross-version collision detection |
src/tuvl/core/models/model_version.py |
tuvl_system_model_versions persistence, enabled toggling |
src/tuvl/core/api/crud_router.py |
the parallel REST CRUD surface |
src/tuvl/core/engine/agent_tools.py |
_derive_parameters — the payload object schema for ModelOp tools |
src/tuvl/cli/commands/validate.py |
static checks for ModelOp steps |
tests/core/test_model_op_step.py |
execution-path coverage: all five operations, allowlist, templates, error routes |