frankctl — CLI Reference
Terminal-first client for the Frank Low-Code Pipeline platform. The CLI wraps the same REST API as the dashboard, plus a declarative apply path for git-managed provisioning.
This guide covers every shipped
frankctlverb. Runfrankctl <cmd> --helpfor option details.
Authoring a feed end to end? See From External Source to Ontology for the full recipe (preview → author → sandbox-validate → apply → verify), with a real worked example.
Install
npm install -g @aiaiai-pt/frankctl
frankctl --helpRequires Node.js >= 18. The published package is @aiaiai-pt/frankctl; the binary is frankctl.
From source
cd frank-low-code-pipeline/frank-cli
npm install
npm run build
npm link
frankctl --helpConfiguration
| Variable | Purpose |
|---|---|
FRANKCTL_API_URL | Frank API base URL. Default http://localhost:8000. |
FRANKCTL_KEYCLOAK_URL | Keycloak base URL. |
FRANKCTL_KEYCLOAK_REALM | Keycloak realm. Default frank. |
FRANKCTL_CLIENT_ID | OAuth client id. |
FRANKCTL_USERNAME | Username for headless login. |
FRANKCTL_PASSWORD | Password input for headless login; prefer injected stdin where available. |
FRANKCTL_CLIENT_SECRET | Service-account secret; prefer injected stdin where available. |
FRANKCTL_PROFILE | Active config profile. |
FRANK_DEV_MODE=true + FRANKCTL_TENANT_ID=<uuid> | Forward X-Tenant-ID header instead of an OIDC token. Local dev only. |
frankctl auth login runs browser PKCE against the configured Keycloak realm and stores the token in the active profile's mode-0600 credential file. Headless and service-account secrets are never accepted in argv:
printf '%s\n' "$FRANKCTL_PASSWORD" | \
frankctl auth login --headless --username "$FRANKCTL_USERNAME" --password-stdin
printf '%s\n' "$FRANKCTL_CLIENT_SECRET" | \
frankctl auth login --service-account --client-secret-stdinDo not run these examples through a wrapper that prints stdin or environment values. Omitting the stdin flags makes the command read the corresponding environment variable directly.
Resources
frankctl templates
Discovers and renders curated source-to-ontology verticals. Rendering is local: it makes no API request and emits ordinary frank.platform/v1 multi-document YAML for the existing pipelines apply -f command.
frankctl templates list
frankctl templates show cira
frankctl --json templates show cira
# Live-proven CIRA components (default)
frankctl templates render cira -o cira-proven.yaml
# Every declared CIRA component
frankctl templates render cira --all -o cira-all.yaml
# One or more explicit components and structural target parameters
frankctl templates render cira \
--component cityfy-events \
--set frank_tenant_prefix=00000000 \
--set ontology_tenant_id=cira \
-o cira-events.yaml
frankctl pipelines apply -f cira-proven.yaml --dry-run
frankctl pipelines apply -f cira-proven.yaml --dry-run-server --live-ontology
frankctl pipelines apply -f cira-proven.yaml --waitRepeat --component to select an explicit subset; it cannot be combined with --all. show reports each component's live_proven or declared_pending status and any pre-existing Source names it requires. The two --set values rewrite only the Frank Iceberg tenant prefix and BackingDataset ontology tenant fields. There is no arbitrary template evaluation.
The live-proven default contains 50 declarations across seven components: four Sources, 23 Pipelines, and 23 BackingDatasets covering Cityfy events, Transdev GTFS, CICLOPE emergency data, regional transport, and CIRA air quality. It emits no pending-component warning. --all includes the pending regional stop-time component: one IdentityPolicy, one Pipeline, and one BackingDataset, for 53 declarations across eight components. It emits a pending warning because that three-document slice is declared and dry-runnable but not yet live-proven end to end.
The air-observation declaration explicitly casts its millisecond epoch to the persisted timestamp(6) with time zone precision. This is value-preserving and lets an unchanged full refresh retain the exact Silver snapshot; omitting the cast makes Trino infer timestamp(3) and causes a false schema-change rewrite.
The CIRA pack treats ontology schema as externally owned: rendered output contains no ensure_schema and cannot create or modify ontology types. Source prerequisites are ordinary Sources configured through the existing product; template rendering neither stores nor rotates their credentials. See the repository's pipeline_templates/cira pack for the inventory and exact repeatability contract.
frankctl sources
CRUD for data sources.
frankctl sources list
frankctl sources get <id>
frankctl sources create -f source.yaml
frankctl sources update <id> -f patch.yaml
frankctl sources delete <id> --yes
frankctl sources discover <id>
frankctl sources sync <id>
frankctl sources logs <id> <run-id>
frankctl sources history <id>
# Vault mode only:
frankctl sources credentials set <id> --name <name> --values-file - < values.yaml
frankctl sources credentials rotate <id> --values-file - < rotated-values.yaml
frankctl sources streams list <source-id>
frankctl sources streams set <source-id> -f streams.yaml
frankctl sources streams refresh-schema <source-id> --from-discovery
frankctl sources streams refresh-schema <source-id> -f discovery-result.yamlCredential behavior depends on the server's explicit deployment mode. Current base Compose uses SOURCE_CREDENTIAL_MODE=legacy_inline: Source create/update accepts the write-only credential_values field, persists it inside the redacted source_config, and preserves hidden values on ordinary updates. The sources credentials set|rotate commands above are unavailable in that mode.
The immutable release uses SOURCE_CREDENTIAL_MODE=vault. Only there do those credential commands accept a YAML/JSON object matching the pattern's declared fields exactly and write it to tenant-isolated Vault. Keep temporary value files untracked and mode 0600, or pipe them from the approved secret manager. A Vault-mode Git manifest contains ordinary source_config and the opaque credential_ref; pipelines export preserves that reference without values. In inline mode, export instead strips registered credential forms and emits no reference. Neither mode returns values through the API or CLI.
streams refresh-schema requires either --from-discovery or -f. The discovery form accepts --timeout <sec> and --poll-interval <ms>; the file form expects {streams: [...]} containing a complete discovery snapshot. For matched existing streams it updates only discovered schema and supported sync modes. It preserves namespace/destination routing, sync/write/cursor/enabled configuration, and runtime state. Streams omitted from a non-empty snapshot are removed as stale. See Sources for the namespace contract and full consequences.
Stream-level type-drift ops (issue #469):
frankctl sources streams diff-types <source-id> <stream-id>
frankctl sources streams set-types <source-id> <stream-id> \
--field dt=integer --field pm25=numberSee § Bronze type drift at the bottom of this guide for the playbook.
frankctl pipelines
CRUD for multi-step pipelines plus the declarative apply path (see below).
frankctl pipelines list
frankctl pipelines get <id>
frankctl pipelines validate <id> # sandbox dry-run
frankctl pipelines delete <id> --yes # archive; retains data and history
# Pipeline-owned scheduling and lifecycle
frankctl pipelines schedule get <id>
frankctl pipelines schedule set <id> --type manual
frankctl pipelines schedule set <id> --type eager
frankctl pipelines schedule set <id> --type cron \
--value "0 6 * * *" --timezone Europe/Lisbon
frankctl pipelines schedule set <id> --type interval \
--value 15m --timezone UTC
frankctl pipelines pause <id>
frankctl pipelines resume <id>
frankctl pipelines trigger <id>
# Declarative apply
frankctl pipelines apply -f vertical.yaml
frankctl pipelines export <id> -o yaml > vertical.yaml
# Scaffold a starter vertical from a synced source's physical bronze schema
frankctl pipelines scaffold --source <id-or-name> > vertical.yaml
frankctl pipelines scaffold --source my_source --stream obs -o vertical.yamlPipeline scheduling controls the Dagster transforms generated from the current Pipeline version. It does not control Source extraction; Source schedules are owned separately by Temporal through frankctl schedules ....
eager is the normal continuous mode: roots react to new upstream materializations and downstream steps follow DAG dependencies. cron and interval run independently of Source freshness. Exact intervals are limited to minute values that divide 60, hour values that divide 24, or 1d; use a five-field cron expression for other cadences. Timezones are IANA names and default to UTC.
schedule get, schedule set, pause, and resume read back the owning Dagster location before exiting successfully. Human and --json output expose desired and effective policy, desired and observed fingerprints, root asset identities, and reconciliation state. Durable-but-unconfirmed pending_activation, drifted, or error state exits non-zero instead of reporting a false success. trigger is active-Pipeline only and returns one correlation ID plus a receipt for each root. Do not schedule a generated Transform directly: the API returns 409 and points to the owning Pipeline command. Pausing a Pipeline does not pause standalone Transforms or the shared Dagster sensor.
pipelines scaffold reads a synced source's physical bronze schema — the real nested Iceberg ROW columns, not discovery's flattened logical names — and emits an apply-ready Source + Pipeline + BackingDataset multi-doc YAML. Everything is derived from the real schema:
- the silver
custom_sqlstep is pre-filled with correct Trino ROW references (e.g.o."properties"."idEstacao"), with envelope_*lineage columns dropped; - the
BackingDatasetcarries a derivedproperty_mappings+ensure_schema(ontology property = column name; physical type → ontology type; primary key picked heuristically); - each stream gets
write_dispositionfrom itssync_mode(full_refresh→replace, so scheduled syncs don't re-append); ensure_schema.tenant_scoped: false(reference datasets — tenant-scoped types need anX-Tenant-Idthe sync path doesn't send).
So the only edit before pipelines apply -f --wait is naming the ontology entity (entity_type_id / entity_type_name). The source must have synced to bronze first (the ROW structure only exists after a sync); scaffolding an un-synced source errors with that hint. --stream selects which stream when a source has more than one enabled.
frankctl backing-datasets
CRUD for backing datasets — Iceberg tables that bind to an ontology entity type. Aliased as frankctl bd.
frankctl backing-datasets list
frankctl backing-datasets get <id>
frankctl backing-datasets create -f bd.yaml [--allow-deprecate]
frankctl backing-datasets update <id> -f patch.yaml [--allow-deprecate]
frankctl backing-datasets sync <id> [--force] [--no-wait] # push silver → ontology, poll exact run
frankctl backing-datasets entities <id> [--limit N] # read entities back out of the ontology
frankctl backing-datasets delete <id> --yesentities closes the authoring loop: after a sync it reads the actual entities back from the ontology (values, not just status), scoped by the BD's ontology_tenant_id — so verification never leaves the CLI.
When binding a BD to a pipeline, omit iceberg_namespace/iceberg_table: set pipeline: (or pipeline_id:) and the silver target is derived from the pipeline's terminal step.
Against a sync-contract-v2 API, sync triggers an ontology sync and polls the returned run ID until that exact run reaches synced, skipped, or error (exit 1 on error). Its output includes the replay reason, row count, exact snapshot ID, force flag, and the safe first 12 characters of both attempted and applied effective-SyncSpec fingerprints. Human output prefers the decimal-string snapshot_id_exact, avoiding JavaScript precision loss for 64-bit Iceberg IDs. Use --force to full-scan and REST-upsert all rows even when both the Iceberg snapshot and effective mapping spec are unchanged. --no-wait --json returns the run ID, full attempted fingerprint, and force flag from the accepted trigger without polling. Waited --json output preserves the flat BackingDataset fields and adds sync_trigger plus sync_run evidence; failed runs still exit 1. JSON preserves the legacy numeric snapshot_id and also exposes snapshot_id_exact for precision-safe consumers.
The CLI also supports an older API for an ordinary sync. If the trigger omits the exact run ID or fingerprint evidence, waited mode polls the BackingDataset status and labels replay evidence unavailable; --no-wait reports only the legacy trigger fields it actually received. This fallback does not claim an exact run. --force never uses it: the CLI first reads GET /api/v1/backing-datasets/capabilities and exits before the sync POST unless version 2, exact-run polling, and force replay are explicitly advertised. It also requires the accepted trigger to confirm force_replay: true.
A BD with sync_mode: on_materialization is pending when either its Iceberg snapshot or its effective mapping spec changes. An identical snapshot and spec creates a persisted skipped run and performs zero ontology entity writes. Mapping updates are rejected with 409 while a sync is running. Retry after that exact run is terminal; an exact declarative re-apply with no ensure_schema work remains a read-only no-op.
--allow-deprecate permits the ensure_schema: block to narrow the ontology entity type by deprecating fields. Without the flag, narrowing returns 409 (additive evolution only).
Publishing files to an ontology file field
A property mapping can carry a file: block, which turns a column into an ontology file field. type stays the physical Iceberg type.
From a URL column — the column holds HTTP(S) URLs the ontology fetches:
property_mappings:
- column: code
property: code
type: string
is_primary_key: true
- column: image_url
property: image
type: string
file:
mode: url
identity_column: image_id # required — a URL is not an identity
fingerprint_column: image_etag # or: immutable: true
filename_column: image_name # optional
content_type_column: image_mime # optional
on_error: fail # or: omit (optional target fields only)Frank sends the complete URL — presigned query strings included — and the ontology fetches, stores, and returns a managed key that Frank attaches to the entity.
From an inline binary column — the column holds the bytes, and Frank uploads them:
property_mappings:
- column: code
property: code
type: string
is_primary_key: true
- column: attachment_bytes
property: attachment
type: binary
file:
mode: upload
input: blob
identity_column: attachment_id # required — bytes are not an identity
filename_column: attachment_name # optional
content_type_column: attachment_mime # optional
on_error: failNo fingerprint_column is needed: Frank computes SHA-256 from the bytes it reads, and that hash drives reuse and replacement. Declare one only when you already carry a trusted content hash.
When a run publishes files, sync adds asset counts to its output:
files_requested 3
files_uploaded 2
files_reused 1
files_attached 3
files_failed 0
files_omitted 0
files_outcome_unknown 0
files_orphan_candidate 1
files_bytes_sent 20480Two need operator attention. files_outcome_unknown counts assets whose upload outcome was lost in transit: the ontology may hold bytes under a key Frank never saw, so those are quarantined and will not retry on their own. files_orphan_candidate counts superseded ontology keys — Frank never deletes ontology storage, so they stay visible for reconciliation.
files_bytes_sent counts only bytes Frank itself sent, so it stays 0 for URL-mode publication.
Republication is driven by identity_column and the fingerprint, never by the URL: a rotated presigned URL for an unchanged asset uploads nothing, and neither does a rerun over an unchanged blob.
See the ontology guide for the full contract.
frankctl ai compose-pipeline
AI-assisted pipeline authoring. Takes a spec describing user intent + source tables + (optionally) the target shape, fires the frank_compose_pipeline Martha workflow, and returns either the raw JSON proposal or a YAML envelope that pipes straight into pipelines apply -f.
# JSON proposal (default) — useful for inspection / piping into other tools
frankctl ai compose-pipeline -f intent.yaml > proposal.json
# YAML envelope — pipes directly into declarative apply
frankctl ai compose-pipeline -f intent.yaml --output yaml > vertical.yaml
frankctl pipelines apply -f vertical.yamlSpec file shape (JSON or YAML):
user_intent: "Land OWM air-quality data into the air_quality_observed entity type"
pipeline_name: air_quality_observed # required for --output yaml
source_tables: [bronze.cm_ave.air_pollution]
target_description: "Promote raw OWM payload into a typed dataset"
target_schema: # optional
- { name: external_id, type: string }
- { name: dt, type: integer }
- { name: pm2p5, type: number }
target_sdm_id: fiware:Environment/AirQualityObserved # optional; flips target_type to "sdm"--output yaml emits a single kind: Pipeline doc. Source and BackingDataset docs are not auto-generated — the compose endpoint takes already-existing source tables, and the BD shape (iceberg_namespace / property_mappings) isn't yet part of the AI's proposal. Operators add Source and BD docs by hand or via pipelines export after applying.
frankctl ai suggest source-config
Paste in raw upstream config (.env, vendor docs, API doc snippets) and get back an inferred pattern_id, redacted source_config, and a suggested name. The other half of the authoring on-ramp — compose-pipeline handles the Pipeline; this handles the Source.
# From a file (JSON/YAML with {raw_text, available_pattern_ids?})
frankctl ai suggest source-config -f spec.yaml
# Inline (short snippets only)
frankctl ai suggest source-config --text "feed_url: https://example.com/rss"
# Via stdin (paste-anywhere workflow)
pbpaste | frankctl ai suggest source-config
# Pipe straight into a Source YAML the operator can `sources create -f`
frankctl ai suggest source-config -f spec.yaml --output yaml > source.yamlSpec file shape:
raw_text: |
REDIS_HOST=cache.example.com
REDIS_PORT=6379
REDIS_PASSWORD=secret
available_pattern_ids: [rest_api, kafka, postgres, redis] # optional hintResponse (JSON, default):
{
"pattern_id": "redis",
"confidence": 0.9,
"config": {"host": "cache.example.com", "port": 6379},
"rationale": "Redis env vars detected",
"detected_fields": ["host", "port"],
"suggested_name": "redis_cache",
"suggested_description": "Redis cache instance",
"available": true
}--output yaml emits the kind: Source envelope using suggested_name, pattern_id, and config. When the AI can't infer a pattern_id (confidence < threshold), --output yaml falls back to JSON and exits 5 with the rationale on stderr — the operator picks the pattern manually.
frankctl transforms, frankctl runs, frankctl patterns, frankctl schedules, frankctl datasets, frankctl ai
Domain-specific verbs. Run frankctl <noun> --help for the full list.
Diagnosing a transform that won't run
When a transform isn't materializing on upstream change, three commands tell you why without touching the database.
frankctl transforms get <id> — runnability at a glance
get surfaces the fields the scheduler actually gates on:
lifecycle_stage ready
last_run_outcome running # "running" with no real run in flight = stuck
can_run_now false # false here means triggers will 400can_run_now=false with last_run_outcome=running is the classic zombie: the cached outcome says a run is in flight when none is, so POST /schedule/trigger returns 400 forever and the source-update sensor can never fire it. Heal it with frankctl admin reconcile-runs (below).
frankctl transforms gate-status — the change-gate (ADL-016)
Eager transforms fire only when their upstream_version (a hash of their input tables' Iceberg snapshot ids) advances. This lists it per transform:
frankctl transforms gate-status
# ID NAME UPSTREAM_VERSION FIRES_ON_CHANGE
# t1 fires 97d2ec9566272863 yes
# t2 stuck NO (cold/unresolved)upstream_version is empty (the sensor skips) when either the source has never synced (cold start) or an input table FQN doesn't resolve (e.g. a pre-#513 bronze.<ns>.<table> identifier). A whole vertical showing NO usually means the latter.
frankctl admin reconcile-runs — heal stuck runs & zombies
Syncs stuck backend runs to their real terminal state and clears zombie last_run_outcome=RUNNING markers (transforms with no run actually in flight), restoring can_run_now.
# Preview only — lists what would be healed, changes nothing:
frankctl admin reconcile-runs --dry-run
# ID CURRENT WOULD_HEAL_TO
# t1 running failed
# Heal (stale runs + zombies):
frankctl admin reconcile-runs
# Also re-check every active run, not just stale ones:
frankctl admin reconcile-runs --forceThe periodic reconciliation sweep performs the same zombie heal automatically; this command is the on-demand operator path.
Declarative apply (pipelines apply -f)
frankctl pipelines apply -f <file> provisions a complete Frank vertical (Source + optional IdentityPolicy + Pipeline + BackingDataset) from a single multi-doc YAML file. The file is the unit of truth: re-running apply against the same file is idempotent.
File format
Each document carries a kubectl-style envelope:
apiVersion: frank.platform/v1
kind: Source | IdentityPolicy | Pipeline | BackingDataset
metadata:
name: <string> # lookup key on apply
labels: {...} # optional, free-form
spec:
# …the existing JSON request body for the resource…spec is exactly what you'd POST to the corresponding REST endpoint — the envelope is a YAML-only convention. The same JSON wire format still works for clients that don't use YAML.
Worked example
air_quality_observed.yaml:
apiVersion: frank.platform/v1
kind: Source
metadata: { name: owm_air_pollution }
spec:
name: owm_air_pollution
pattern_id: rest_api
source_config:
base_url: https://api.openweathermap.org/data/2.5
endpoint: /air_pollution
query_params: { lat: "41.38", lon: "-8.20" }
schedule_type: cron
schedule_value: "0 * * * *"
streams:
- name: air_pollution
sync_mode: full_refresh
write_disposition: replace
is_enabled: true
---
apiVersion: frank.platform/v1
kind: Pipeline
metadata: { name: air_quality_observed }
spec:
name: air_quality_observed
description: OWM air quality → silver
# source_ids resolves Source names → UUIDs at apply time
source_ids: [ owm_air_pollution ]
schedule_config:
type: cron
value: "0 * * * *"
---
apiVersion: frank.platform/v1
kind: BackingDataset
metadata: { name: air_quality_observed }
spec:
# `pipeline` references the Pipeline doc above by name; the CLI
# resolves to pipeline_id before POSTing.
pipeline: air_quality_observed
iceberg_namespace: silver_air_quality
iceberg_table: air_quality
entity_type_id: air_quality_observed
entity_type_name: Air Quality Observed
ontology_tenant_id: ts_demo
primary_key_column: external_id
property_mappings:
- { column: external_id, property: external_id, is_primary_key: true, type: string }
- { column: date_observed, property: date_observed, type: string }
- { column: pm2p5, property: pm2p5, type: number }
- { column: no2, property: no2, type: number }
# ensure_schema: Frank manages the ontology entity-type schema for this BD.
# Field keys must match ^[a-z_][a-z0-9_]*$ — snake_case only.
ensure_schema:
display_name: Air Quality Observed
fields:
- { field_key: external_id, field_type: { type: string }, required: true, indexed: true }
- { field_key: date_observed, field_type: { type: datetime }, required: true }
- { field_key: pm2p5, field_type: { type: number }, required: false }
- { field_key: no2, field_type: { type: number }, required: false }An identity-backed field-mapping step adds an IdentityPolicy declaration. A same-file reference contains only its name; Frank reconciles the recipe first, then pins the Pipeline mapping to the exact returned policy ID and version:
apiVersion: frank.platform/v1
kind: IdentityPolicy
metadata: { name: cira-stop-time-external-id }
spec:
name: cira-stop-time-external-id
strategy: passthrough
source_fields: [_record_id]
normalizers: []
emit_format: "cira:bdtransportes:stop_time:{resolved_key}"
collision_policy: error
---
apiVersion: frank.platform/v1
kind: Pipeline
metadata: { name: cira_transport_stop_times }
spec:
name: cira_transport_stop_times
source_ids: [cira-bdtransportes-horarios]
steps:
- name: stop_time_rows
kind: field_mapping
params:
field_mappings:
- target_column: external_id
mapping_kind: identity
identity_policy: { name: cira-stop-time-external-id }
field_order: 0Use {name, tier, version} for a pre-existing policy that is not declared in the file. Floating external names and tier/version qualifiers on same-file references are rejected. IdentityPolicy declarations create tenant policies; read-only system policies can only be referenced externally and exactly.
Relationship mappings add an execution prerequisite on the BackingDataset metadata. The dependency must match the relationship target and ontology tenant:
metadata:
name: air_quality_observed
dependsOn:
- entityTypeId: air_quality_station
ontologyTenantId: ts_demo
spec:
ontology_tenant_id: ts_demo
sync_mode: manual
property_mappings:
- column: station_key
property: ref_station
is_relationship: true
target_type: air_quality_station
target_key: nameApply:
frankctl pipelines apply -f air_quality_observed.yaml --wait
# applying Source/owm_air_pollution...
# applying Pipeline/air_quality_observed...
# applying BackingDataset/air_quality_observed...
# applied 3 doc(s)Apply semantics
Dependency order. Docs are sorted client-side: Source → IdentityPolicy → Pipeline → BackingDataset. File order within a kind is preserved for provisioning. For execution, each relationship mapping must have a matching
metadata.dependsOnentry and must explicitly setspec.sync_mode: manual. The dependency graph is currently enforced by the CLI invocation, not persisted as server policy, so omitted mode andon_materializationare rejected before any API request.--waitdirectly triggers the manual BackingDatasets in topological order and requires an external dependency to exist in the same ontology tenant with statussynced. Missing declarations, dependency cycles, and self-referential relationships are also rejected.Name resolution. Cross-doc references use
metadata.name. APipeline.spec.source_idsentry that's a name (not a UUID) is looked up against (a) Sources just POSTed in this apply, (b) Sources already in the calling tenant. An identity mapping uses name only when its IdentityPolicy is declared in the same file. A pre-existing policy requires an exact{name, tier, version}reference. External policy references are tenant-aware and all resolve before the first POST; a miss cannot leave an earlier Source partially applied.Idempotency. Each POST carries
?if-not-exists=true. The server handles existing rows per the matrix below:Existing row state Response Missing at the lookup key 201 (create) Exists, mutable fields all match 200, untouched (no PATCH fires) Exists, mutable fields differ 200, PATCHed in place Exists, immutable fields differ 409 with {error: "immutable_diff", existing_id, fields: [{name, existing, requested}]}Lookup keys:
(tenant_id, name)for Source/Pipeline,(tenant_id, entity_type_id, ontology_tenant_id)for BackingDataset (NULLS NOT DISTINCT). Re-runningapply -fagainst an unchanged file is a true no-op — no row writes, no schedule churn, operational state (cursors, file ledgers, snapshot ids, run counters, last_sync_at) is preserved.Source.spec.streamsfollows the same desired-state rule. Declared streams are matched by name, created or patched in place, and retain cursor/chunk/ counter/run state. Omitted streams are preserved asunmanageddrift and produce a warning; only explicitis_enabled: falsedisables one. On reapply, omitted fields on a declared existing stream are also preserved; model defaults are used only when creating a missing stream. For a migration, declare each known obsolete enabled stream as disabled so scheduled and direct Source syncs also converge—not just this command's--waitexecution.IdentityPolicy uses a version-aware desired-state contract:
Latest tenant/name policy Action Result missing createcreate version 1 recipe equal nooppreserve the exact row/version recipe changed and never bound patchupdate that version in place recipe changed after first binding new-versionappend the next version First successful binding freezes a policy monotonically. Policy reads report tenant-live
used_inand transform counts, including for shared system policies. A system policy's shared cached count is not overwritten by one tenant's usage; itsis_frozenstate remains global and monotonic. Returning to zero never makes a frozen recipe mutable or deletable.Authored YAML always keeps identity references symbolic; raw policy UUID, version, or hash pins are rejected. Internally,
frankctlsends the exact ID/version plus the complete recipe hash returned by reconciliation. The server rechecks that hash before any binding replacement. Pipeline activation locks the Pipeline, then every existing affected Transform in deterministic UUID order with refreshed mapping/source snapshots, then all old and new policy rows in canonical UUID/version pin order. Direct Transform PUT/DELETE locks the Transform first and then policies in that same order. This prevents policy/Transform lock inversion and ensures removed bindings participate in the finalused_inrefresh, avoiding deadlocks and stale counters. If another apply changed an unfrozen recipe in the gap between resource requests, Pipeline apply returns structured409 identity_policy_recipe_changed. Re-run the complete file so the policy is reconciled again; do not retry a captured Pipeline request.Upgrade migration
s43_identity_policy_frozenalso recovers historical first-use state from retained PipelineVersion identity mappings. It examines onlymapping_kind: identity, recognizes canonical, uppercase, hyphenless, braced, andurn:uuid:UUIDs plus normalized positive version spellings, and never casts malformed JSON values. Invalid historical values stay unmatched rather than aborting the additive migration.Stops on first failure; the file is not atomic. A 409 (typically
immutable_diff) aborts the apply, but docs that succeeded remain in the database. Fix the failing declaration (or deliberately use--allow-recreate, below) and re-run; unchanged resources reconcile as no-ops.Immutable fields per kind (a change here triggers
immutable_diff):- Source:
pattern_id - IdentityPolicy: recipe changes patch an unfrozen version or create a new frozen-policy version as described above; they do not use
immutable_diff - Pipeline: none beyond
tenant_id(everything else PATCHes; step DAG changes go through/versions) - BackingDataset:
iceberg_namespace,iceberg_table,entity_type_name,schema_library_ref,schema_version,sync_mode,transform_id,pipeline_id
- Source:
--allow-recreateopt-in escape hatch: on animmutable_diff409, DELETE the existing row via theexisting_idin the response body and re-POST. Operational state is lost on the DELETE — the recreated row starts from a clean cursor, empty file ledger, no run history, status DRAFT/PENDING. Default off; use only when an immutable field genuinely needs to change (e.g. swappingpattern_idon a Source).--allow-deprecateis passed through to BackingDataset POSTs to permitensure_schema:narrowing. Default off.--dry-runprints the apply plan (sorted, with metadata names and the names/count of declared Source streams) without POSTing. It does not pretend to know whether a stream is a create or patch because that requires server state.--dry-run-serversends the complete document set to the server's DB/catalog preflight without persisting it. Existing rows, immutable diffs, catalog entries, and cross-document references are checked. IdentityPolicy results reportcreate,noop,patch, ornew-versionplus the exactplanned_version; Source results include a per-streamcreate,patch,noop, orunmanagedplan and the declarative fields that differ. The result describes current state, not a reservation:applyrechecks concurrent changes and can still return a recipe-conflict 409.--dry-run-server --live-ontologyadds a GET-only compatibility check for every locally valid BackingDataset. It verifies the configured ontology tenant/type plus mapped properties, relationship targets, keys, and declared types. Credentials remain server-side.--live-ontologywithout--dry-run-serveris rejected; ordinary server dry-runs never contact the ontology.--waitexecutes applied resources behind strict global barriers: all declared Sources must reach exact terminal success before Pipeline transforms start, and all transforms must succeed before any BackingDataset sync is triggered. Evidence is correlated to the exact Source workflow/SyncRun, Dagster/TransformRun, and ontology SyncRun IDs. When a Source document includesspec.streams,--waitpasses only its explicitly enabled declared stream names to the Source sync. Preserved unmanaged legacy streams are not executed accidentally. A Source document that omitsstreamsretains the legacy behavior of syncing every persisted enabled stream. Transform success additionally requires an output Iceberg snapshot and row count. Source Bronzemainrefs are observations taken immediately after the exact Source run completes; they are labelled as observations, not as workflow-to-snapshot provenance.
For machine-readable execution evidence, combine --wait --json. The command emits one JSON object after execution finishes:
{
"applied": [{"kind": "Pipeline", "name": "example", "id": "..."}],
"wait": {
"ok": true,
"sources": [],
"transforms": [
{
"dagster_run_id": "...",
"transform_run_id": "...",
"snapshot_id": "123",
"row_count": 50,
"status": "completed"
}
],
"backing_datasets": [
{
"workflow_id": "...",
"ontology_sync_run_id": "...",
"correlated_transform_run_id": "...",
"snapshot_id": "123",
"row_count": 50,
"bulk_jobs": [
{
"job_id": "...",
"operation": "insert",
"submitted_count": 50,
"completed_count": 50
}
],
"status": "synced"
}
]
}
}Snapshot IDs are signed 64-bit identifiers serialized as decimal strings. Treat them as opaque IDs; converting them to JavaScript numbers can round the value and invalidate execution evidence. For ontology SyncRuns, --wait prefers the API's snapshot_id_exact, accepts a string-valued snapshot_id only as an old-API fallback, and rejects numeric-only evidence rather than stringifying a value that JavaScript may already have rounded.
For REST bulk syncs, bulk_jobs comes from the exact persisted ontology SyncRun, not an ephemeral console message or a broad "latest run" query. Only job ID, operation, submitted count, and completed count are stored; request payloads and credentials are excluded. A non-bulk or historical run returns an empty list.
When a BackingDataset is linked to a Pipeline executed by the same command, --wait also requires the ontology SyncRun snapshot to match the exact TransformRun snapshot. An already-running ontology workflow is rejected when that provenance is missing or its snapshot differs.
status: skipped means the ontology was already converged at the same snapshot and no rows were replayed. A successful --wait proves correlated execution and sync completion; use frankctl bd entities (or the target API) for separate entity readback proof.
Adoption workflow (export → review → commit → re-apply)
For pipelines that started life in the wizard UI and need to move into git, the round-trip is:
# 1. Export Sources, identity recipes, mappings, BDs, and safe credential metadata.
frankctl pipelines export <pipeline-id> -o yaml > vertical.yaml
# 2. Review locally. frankctl does NOT expand ${ENV_VAR} in Source config.
$EDITOR vertical.yaml
# 3. Commit the reviewed declaration; it contains no credential values.
git add vertical.yaml
git commit -m "adopt air-quality vertical"
# 4. Re-apply the complete declaration.
frankctl pipelines apply -f vertical.yamlAfter step 4 the complete non-secret vertical is the Git source of truth. Wizard edits and apply edits coexist as long as nobody changes the files out of band: the next apply PATCHes mutable drift back to the declaration. In vault mode, rotate values separately with sources credentials rotate; the stable reference keeps the manifest and Source operational state unchanged. In current base legacy_inline mode, update credentials through the product's write-only Source create/update input; the dedicated credential verbs fail.
Applying the CIRA Transdev/AveiroBus historical GTFS snapshot
The Transdev Source example is intentionally non-applyable because its two S3 credential placeholders are null. Create the Source from a non-secret working copy with those keys removed. On current base legacy_inline, bootstrap through the product's write-only Source create/update input. The sources credentials set step below is only for a vault deployment. Never add values to the manifest:
cp dev_docs/examples/sources/cira-transdev-aveirobus-gtfs/source.non-apply.example.yaml \
.Codex/cira-transdev-aveirobus-gtfs.yaml
$EDITOR .Codex/cira-transdev-aveirobus-gtfs.yaml
frankctl pipelines apply \
-f .Codex/cira-transdev-aveirobus-gtfs.yaml \
--dry-run-server
frankctl pipelines apply \
-f .Codex/cira-transdev-aveirobus-gtfs.yaml
# Vault mode only:
frankctl sources credentials set <source-id> \
--name cira-transdev-gtfs --values-file - < credential-values.yaml
frankctl sources discover <source-id>
frankctl sources sync <source-id> \
--streams agency,calendar,calendar_dates,fare_attributes,fare_rules,feed_info,routes,shapes,stops,stop_times,trips \
--force-full-refresh --timeout 1800This is a pinned historical snapshot, not a current publisher feed. The Source uses full_refresh plus atomic replace publication for all eleven declared streams. Empty fare_attributes and fare_rules files remain declared stream identities but may not materialize Iceberg tables and create no ontology instances.
Preflight and run the nine Pipeline/BackingDataset pairs through the existing CIRA ontology types:
frankctl pipelines apply \
-f pipeline_templates/cira/09_transdev_aveirobus_gtfs.yaml \
--dry-run-server --live-ontology
frankctl --json pipelines apply \
-f pipeline_templates/cira/09_transdev_aveirobus_gtfs.yaml \
--wait --timeout 1800The service projection deliberately unions identities from calendar and calendar_dates. GTFS permits exception-only services; this snapshot therefore publishes 11 gtfs_service entities, including one service absent from the 10 weekly calendar rows, so all 1,624 calendar-date relationships resolve. The calendar transforms also normalize DLT-inferred numeric YYYYMMDD fields through an integer cast before parsing, avoiding scientific-notation date failures. The bundle has no ensure_schema and never creates or changes ontology schema.
Applying the CIRA Ciclope vertical
The repository's Ciclope Source example is intentionally not applyable. This first command must exit with unsupported kind 'SourceExample' before any API call:
frankctl pipelines apply \
-f dev_docs/examples/sources/cira-ciclope/source.non-apply.example.yaml \
--dry-runInspect the deployed profile, then materialize a non-secret working copy:
frankctl --json patterns get ciclope_soap
cp dev_docs/examples/sources/cira-ciclope/source.non-apply.example.yaml \
.Codex/cira-ciclope.yaml
$EDITOR .Codex/cira-ciclope.yamlIn the private copy, change apiVersion to frank.platform/v1, change kind to Source, and remove the null username and password keys. Do not add SOAP actions, tickets, raw bodies, or WSDL configuration; ciclope_soap is a closed read-only profile. On current base legacy_inline, bootstrap through the product's write-only Source create/update input. In a vault deployment, use sources credentials set; do not place values in the file or argv.
Declare spec.sync_mode: monolithic and spec.max_items_per_chunk: null on this Source. The historical ends use run_started_at, so their row counts grow as the workflow cutoff advances; the current CICLOPE connector cannot resume safely inside one of those snapshots. Provider-time incremental overlap and natural-key merge are tracked in #613.
Preflight and apply the private Source, capture its returned ID, then prove all nine declared streams through discovery and one explicit Source sync:
frankctl pipelines apply -f .Codex/cira-ciclope.yaml --dry-run-server
frankctl pipelines apply -f .Codex/cira-ciclope.yaml
# Vault mode only:
frankctl sources credentials set <source-id> \
--name cira-ciclope --values-file - < credential-values.yaml
frankctl sources discover <source-id>
frankctl sources sync <source-id> \
--streams towers,cameras,meteo_sensors,camera_status,current_afd_alarms,true_afd_alarms,occurrence_entries,realtime_meteo,meteo_history \
--timeout 1800
frankctl sources get <source-id>
frankctl sources history <source-id>
frankctl datasets list --layer bronze
frankctl datasets preview <dataset-id> --limit 20The tracked bundle contains five Pipelines and five BackingDatasets. Its live preflight performs GET-only compatibility checks against the existing ontology types. The subsequent --wait run materializes Silver and publishes entity values; the bundle has no ensure_schema, so it does not create or change ontology schema:
frankctl pipelines apply \
-f pipeline_templates/cira/10_ciclope_emergency.yaml \
--dry-run-server --live-ontology
frankctl --json pipelines apply \
-f pipeline_templates/cira/10_ciclope_emergency.yaml \
--wait --timeout 1800
frankctl backing-datasets entities <emergency-resource-bd-id> --limit 20
frankctl backing-datasets entities <emergency-incident-bd-id> --limit 20Readback should show tower Points only on emergency_resource. The emergency_incident entities come only from true_afd_alarms and contain code, incident_type, and reported_at, with no inferred location. Use frankctl sources logs <source-id> <run-id> when a Source run reports a transport or provider failure.
pipelines export <id>
Round-trips an existing Pipeline, its Sources, referenced tenant IdentityPolicies, and linked BackingDatasets back to multi-doc YAML. Export always strips every registered credential form from source_config. In vault mode it preserves the applicable opaque credential_ref; in legacy_inline there is no reference to emit. The reviewed output is therefore safe to keep in Git:
frankctl pipelines export <pipeline-id> -o yaml > vertical.yamlServer-set fields (id, created_at, runtime counters, status) are stripped. Source stream declarations are included, but stream IDs, dataset IDs, cursors, iterator chunk state, counters, timestamps, and discovery-learned schema/capabilities are stripped. Complete field-mapping steps preserve their typed mapping kind, source/target fields, transforms, typed literal/context configuration, and field order. Persisted identity UUID/version pins are made portable: tenant pins export as an IdentityPolicy document plus a same-file {name} reference; system pins remain exact {name, tier, version} external references. Credential values and Vault paths are never included in either mode.
ensure_schema: is not included on export — the operator adds the block only if they intentionally want Frank to manage the ontology schema going forward. Reapplying an exported CIRA-style declaration without that block cannot create or edit ontology types.
Direct YAML on any apply-able route
The YAML envelope works on the bare REST routes too — the same shape the CLI emits:
curl -X POST https://api.frank.example/api/v1/sources \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/yaml" \
--data-binary @source.yamlRequest: Content-Type: application/yaml on POST/PUT/PATCH to /api/v1/{sources, identity-policies, pipelines, backing-datasets} is accepted and unwrapped before the route handler sees it.
Response: Accept: application/yaml or ?format=yaml on GET against the same routes returns a YAML-wrapped envelope. JSON is still the default for both directions — the envelope is opt-in.
YAML parsers may resolve an unquoted ISO date or timestamp to a native temporal value. Frank recursively converts those values to ISO-8601 strings before the existing JSON request model validates them, including values nested in objects and arrays. Existing strings, numbers, booleans, and nulls retain their types. YAML-only values that cannot be represented in JSON, such as !!set, fail with a sanitized HTTP 400 that does not echo the submitted spec or credentials.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error |
| 2 | Usage / config error |
| 3 | Authentication error |
| 4 | API / server error |
| 5 | Validation error (e.g. sandbox failed) |
| 6 | Config-file error |
See also
- Provision your first vertical (5-min tutorial)
- Pattern catalog reference
ensure_schema:reference- Apply error cookbook
frankctl ai compose-pipeline— AI-assisted manifest authoring
Bronze type drift
A sync that fails with Cannot change column type: <field>: <bronze-type> -> <new-type> means Iceberg's union_by_name rejected the latest batch because the extracted value's type doesn't match what the bronze column was first written with. This usually happens when:
- A source synced for a while, established the bronze column type (e.g.
long), and - The extract pipeline later started emitting a different type for the same field (e.g.
double— see the defensiveint → floatpromotion inbackend/services/extraction/coercers.py).
Two cures, your pick depending on what you want bronze to look like going forward.
Cure A — declare the field type so the coercer matches bronze
Pin the field's JSON-Schema type on the stream so the extract-time coercer fires on the way in (backend/services/extraction/coercers.build_field_coercers). Pinning dt: integer keeps dt as int64 end-to-end and the write matches the existing long column.
# 1. See where bronze and the stream disagree.
frankctl sources streams diff-types <source-id> <stream-id>
# bronze: bronze.tenant_..._<source>.<stream> (exists=true)
# FIELD BRONZE DECLARED STATUS
# dt long (none) bronze_only ← will fail next sync
# value double (none) bronze_only
# 2. Declare types matching bronze.
frankctl sources streams set-types <source-id> <stream-id> \
--field dt=integer
# 3. Verify.
frankctl sources streams diff-types <source-id> <stream-id>
# dt long integer coerce ← now safeThe PATCH is partial-merge: it only touches the properties you pass, so an existing 50-field schema from discovery stays intact when you pin one field.
Cure B — widen the bronze column (deferred, see #469 slice B)
When you actually want bronze to take the wider type going forward (e.g. an integer field truly needs to become a float), the right fix is a bronze-side widen via PyIceberg update_schema().promote(...) for legal Iceberg promotions or a CTAS-and-atomic-swap for the others. Not shipped yet — tracked in issue #469.
Cure C — preventive (deferred, see #469 slice C)
The defensive int → float promotion in coercers.py is an extraction-side default that prevents an unrelated PyArrow inference bug. Teaching it to skip the promotion when bronze already has a narrower type pinned would prevent this class of incident at the source. Also tracked in #469.