Skip to content

Commands

A command is a registered, governed function that brings external computation under Provisa's governance, audit, and lineage system. Where the federation engine handles SQL natively, a command is the seam for computation it cannot express: an enrichment microservice, a Python model, a shell script, a native database stored procedure. Register it once; every client surface — GraphQL, pgwire SQL, REST, Arrow Flight, gRPC, Bolt/Cypher — can invoke it with identical governance (REQ-885, REQ-1156). [tool-verified: function_dispatch.py module docstring + REQ-885 in requirements.md]

The key distinction: a command is a governed RPC, not ad-hoc ETL. Its inputs and outputs are declared, typed, validated, traced, and wired into lineage. An ungoverned curl call or subprocess is none of those things.

Implementation kinds

Five impl_kind values are supported [tool-verified: _EXECUTORS dict in function_dispatch.py:420-426]:

impl_kind Transport
source_procedure Native stored procedure on a registered source
script Local subprocess fed JSON on stdin, reads JSON from stdout
http HTTP/S endpoint; JSON request body, JSON response
grpc gRPC unary; proto-less JSON bridge
python In-process Python callable (module:attr)

Addressing (the catalog name and function_name) is decoupled from binding (transport and location). Swap the binding and the command's governance, lineage, and caller contracts stay unchanged. [tool-verified: Function model in models.py:710-750]

Argument kinds

Each argument declares an arg_kind [tool-verified: FunctionArgument.arg_kind in models.py:691-700]:

arg_kind Behavior
column_value Scalar; passed directly in the request payload
table_ref Lazy; Provisa passes the relation reference as-is; the service fetches the data
result_set Eager; Provisa materializes the referenced relation and sends its rows

http and grpc commands must declare at least one table_ref or result_set argument. An external command receiving only scalar arguments would be invoked once per row, which defeats batching. The dispatcher rejects this configuration at call time (422). [tool-verified: _reject_rowwise_external in function_dispatch.py:322-344]

A command that returns a set (declared via output_columns and return_schema) is a table-valued function. Use it in a FROM clause or a JOIN. [inferred from models.py:744-748 and command_localize.py:52-63]

The dataset contract (REQ-1159)

Each table_ref or result_set argument may declare an input column contract: an ordered, IR-typed list of columns in FunctionArgument.columns. The command itself declares an output column contract in Function.output_columns. [tool-verified: DatasetColumn model in models.py:675-683, Function.output_columns in models.py:748]

Both contracts are validated fail-loud at every invocation:

  • Input (result_set only): after materialization, Provisa validates the rows against the declared columns. Extra fields, missing fields, and wrong types all raise HTTP 422. [tool-verified: _validate_against called in _prepare_args at function_dispatch.py:243-248]
  • Output: rows returned by the command are validated against output_columns before they reach the caller. [tool-verified: function_dispatch.py:488-490]
  • Narrow projection: when an input contract is declared, the materialization query projects only those columns (SELECT "id", "region" FROM ...) rather than SELECT *. [tool-verified: _materialize_relation at function_dispatch.py:155-177, col_names passed to projection at line 171]

The IR type vocabulary

Contract column types use the canonical IR type system (REQ-846), not GraphQL scalars or source-native spellings. The valid names are [tool-verified: _IR_TO_SA keys in ir_types.py:45-63]:

smallint integer bigint text boolean float double numeric date timestamp time uuid bytea json

Common aliases resolve automatically (varchartext, int4integer, jsonbjson, etc.). [tool-verified: _ALIASES dict in ir_types.py:67-90]

return_schema is the GraphQL projection of output_columns, not the source of truth. Declare output_columns for validation and lineage; add return_schema for GraphQL type generation. [tool-verified: models.py:744-748, comment "return_schema is its GraphQL projection"]

Authoring a command

Config file

functions:
  - name: enrich_orders
    description: Enrich orders inline — deterministic score + region label
    domain_id: sales-analytics
    kind: query
    impl_kind: python
    source_id: ""
    function_name: enrich_orders
    returns: ""
    binding:
      callable: demo.py_functions:enrich_orders
    arguments:
      - name: input
        type: String
        arg_kind: result_set
        columns:
          - {name: id, type: integer}   # narrow input contract
          - {name: region, type: text}
    visible_to: [admin]
    output_columns:
      - {name: id, type: integer}
      - {name: score, type: double}
      - {name: region_label, type: text}
    return_schema:
      type: array
      items:
        type: object
        properties:
          id: {type: integer}
          score: {type: number}
          region_label: {type: string}

[tool-verified: sample_config.yaml enrich_orders block]

The gRPC variant (enrich_grpc_set) follows the same pattern but specifies impl_kind: grpc and a binding with target and method keys instead of callable:

  - name: enrich_grpc_set
    impl_kind: grpc
    binding:
      target: ${env:DEMO_GRPC_TARGET:-localhost:50071}
      method: /provisa.demo.Enrich/EnrichRows
    arguments:
      - name: input
        type: String
        arg_kind: result_set
        columns:
          - {name: id, type: integer}
          - {name: region, type: text}
    output_columns:
      - {name: id, type: integer}
      - {name: embedding, type: text}
      - {name: geo, type: text}

[tool-verified: config/provisa.yaml enrich_grpc_set block]

Admin UI

The command form in Settings → Commands includes a per-dataset input-columns editor (one row per declared column, with an IR type selector) and an output-columns editor. Save the form to register or update the command without a config reload. [inferred from CommandFormFields.tsx]

Inline composition (REQ-1159)

Commands may appear inside a larger SQL statement — joined, sub-queried, or projected. You are not limited to SELECT * FROM fn(args).

-- Enrich the orders relation and join the result back inline.
SELECT o.id, o.amount, e.score, e.region_label
FROM   orders o
JOIN   enrich_orders('main.public.orders') e ON o.id = e.id
WHERE  e.score > 0.8;

Before governance, validation, or routing runs, the pipeline detects registered command calls, executes each through the shared governed executor (so the I/O contract and identity model apply exactly as for a direct call), and rewrites the call site to a typed local relation. [tool-verified: _localize_inline_commands in _pipeline.py:145-163 and localize_commands in command_localize.py:178-222]

Substitution is size-adaptive: up to 1,000 rows the result inlines as a typed VALUES list; above that threshold it registers as a named local relation in the engine. [tool-verified: _DEFAULT_VALUES_MAX_ROWS = 1000 in command_localize.py:49, path at lines 211-216]

A localized statement routes normally. Single-source queries stay on the source; only genuinely cross-source queries go to the federation engine. [tool-verified: _pipeline.py:304 comment "REQ-1159: a localized statement carries an inline local relation..."]

Commands and lineage

Because every command declares its input and output columns, column-level lineage closes across the opaque command boundary. The lineage engine applies a taint closure: each declared output column derives from every declared input column. [tool-verified: _splice_commands in graph.py:223-242]

The actionable consequence: the width of your input contract determines the precision of that closure. A narrow input — only the columns the command actually needs — produces a tight, readable lineage cone. Declaring every column in the source relation fans in widely across every output, which is still sound (no lineage is lost) but blurs traceability.

Rule of thumb: pass the minimum projection the command needs, and return only derived columns (not echoed-through inputs unchanged). This keeps the taint cone accurate. [inferred from _splice_commands behavior in graph.py and _materialize_relation narrow-projection in function_dispatch.py:161]

See Lineage for how command nodes appear in the DAG and how to read them.

Egress allowlist

http and grpc commands call external endpoints. Every target host must appear on the deployment's udf_egress_allowlist. Loopback (localhost, 127.0.0.1, ::1) is always permitted. An absent allowlist denies all external egress with HTTP 403 — there is no silent default. [tool-verified: _check_egress in function_dispatch.py:292-311]

Invocation tracing (REQ-886)

Every invocation emits a trace regardless of outcome. The trace includes the command name, transport kind, identity model (DEFINER or INVOKER), input relation references, role id, and output cardinality. The dispatcher emits the trace — no impl_kind can bypass it. [tool-verified: udf_invocation_trace context in dispatch_function:475-492]