The CLI's WebDev routes (ign webdev)
Every tag operation the CLI performs on a gateway rides the CLI's own
WebDev route bundle — small Jython doPost.py routes deployed into a
DEDICATED project (ign-cli by default) under
/system/webdev/ign-cli/cli/{route} (the wire protocol — NOT
/data/webdev/*, which does not exist). The bundle is embedded in the
binary at build time and carries a ROUTE_BUNDLE_VERSION handshake
({"action":"version"} → {routeVersion, minCli}) on every route.
Deploy semantics
ign webdev deploy packs the embedded bundle into a project zip and
imports it with overwrite=true — a clean replace, never a merge.
The dedicated project is CLI-OWNED: it is born from the first deploy
zip and wholesale-replaced by every later deploy, which is why deploy
carries no --yes guard (user projects are never touched; you
cannot accidentally deploy over anything you own — use --project
only if you deliberately want a different name). There is no
pre-flight project create (the gateway's first-import quirk); the
import is the deployment.
The version-negotiation refusal matrix
WebDev-dependent commands (the tag family, from Phase 5 on) probe the
canonical tags route's handshake BEFORE doing anything, and refuse
exit 6 with an actionable error — no auto-upgrade magic, ever:
| Probe answer | Slug | Hint |
|---|---|---|
| 405 (route/project absent) | routes_not_deployed | run ign webdev deploy |
| deployed < expected | route_version_mismatch | run ign webdev deploy (old routes on the gateway) |
| deployed > expected | route_version_mismatch | update ign (the binary is older than the routes) |
| 402 | webdev_unlicensed | license the gateway (trial-expired rigs cannot serve /system/webdev) |
| 200 body denial, unmapped code | webdev_route_error | the route's own code + message verbatim |
Two wire facts the matrix is built on (live-proven on 8.3): denials
ride HTTP 200 — WebDev ignores a status key in route returns, so
every refusal is detectable only from the body envelope
{ok, data|error}; and 405 = absent (missing routes and missing
projects both answer 405 — ign doctor's earlier 404 assumption was
wrong and has been re-pinned).
The route-authoring contract (live-bisected 05-06): every
doPost.py must begin with def doPost(...) at BYTE 0. Any
module-level content before it — comments, constants, even a blank
line — makes the route silently unloadable on the real WebDev engine
(the servlet answers 200 with an empty body; nothing is logged). The
route sources keep their header docs and ROUTE_VERSION/MIN_CLI
constants NESTED inside doPost for exactly this reason. A second,
related engine quirk: the exec(...) CALL form can fail to compile
at deep nesting on this Jython build — use the statement form
(exec code in globals) in route scripts.
ign webdev status itself is a READ: it exits 0 whenever the sweep
completes and reports per-route degradation as data (the doctor
precedent) — the refusal matrix above belongs to the tag commands
that DEPEND on the routes, not to the sweep that inspects them.
The configure-shape traps (authoring tag definitions)
tags config create|edit definitions ride system.tag.configure
verbatim — the CLI never reshapes them. Four traps reproduced live
during Phase 5 research (each one silently breaks or misleadingly
errors on a real 8.3 gateway — author definitions against this
table):
| Trap | Wrong | Right |
|---|---|---|
| Discriminator key | "type": "AtomicTag" | "tagType": "AtomicTag" (type is silently ignored) |
| Nesting | slash-names ("name": "P5/T1") — rejected | children NEST under a tags array on Folder/UdtType entries |
| Alarms | a name-keyed dict — silently ignored (returns Good, no alarm attached) | alarms are a LIST of dicts: "alarms": [{"name": "HighLimit", "mode": "AboveValue", "setpointA": 100, "priority": "High"}] |
| Base path | a provider name as configure's first arg | a basePath ([default]P5) + per-tag names — the CLI derives both from the PATH you pass, so this trap cannot bite via the CLI |
A minimal memory tag definition: {"tagType": "AtomicTag", "dataType": "Int4", "value": 123}.
Tag↔historian bindings — the surfaced field set (TAGS-13)
When a tag's config carries history-binding keys, tags config get
human mode appends an additive history: block after the pretty
JSON — one line per present field. The key names are CAPTURE-LOCKED
to the 13-01 live spike record (both trial rigs, read-back + data
proof; provenance: .planning/phases/13-composite-engine-workspace-historian-edit/13-LIVE-CAPTURES.md
§Field-set table). No name below is guessed:
| Config key | Type | history: line | Meaning |
|---|---|---|---|
historyEnabled | Boolean | enabled: true | The binding switch |
historyProvider | String | provider: <name> | The storage provider's name (the doc-corrected key — historicalProvider is a documented typo that does NOT bind) |
sampleMode | Enum | sample mode: TagGroup | The capture enum VERBATIM ("TagGroup" binds via the gateway's DEFAULT historical group — no group key is needed and none is guessed) |
| — (no captured key) | — | historical group: … | Rendered only if a key is ever captured; the spike bound without one |
Additive-only contract: a config WITHOUT history keys renders
byte-identically to the pre-TAGS-13 output (pinned by test), and the
compact/JSON envelope is unchanged (the full config passthrough
already carries the keys there).
The binding recipe (TAGS-14, live-proven on both trial rigs):
binding a tag to a storage provider is a plain tag-config write —
the CLI's existing tags config create|edit passthrough needs NO
new surface. Provision an InternalHistorian first (native REST, no
database: POST /data/api/v1/resources/com.inductiveautomation.historian/historian-provider
with the array body [{name, type: "com.inductiveautomation.historian/historian-provider", collection: "core", enabled: true, config: {profile: {type: "InternalHistorian"}, settings: {}}}]), then send the COMPLETE node
shape plus the three history keys in ONE write:
{"tagType": "AtomicTag", "dataType": "Int4", "value": 0,
"historyEnabled": true, "historyProvider": "my-historian",
"sampleMode": "TagGroup"}
Two wire truths make the "complete node shape" mandatory (both from
the 13-01 captures): (1) tags config writes REPLACE the whole node
— an edit that sends only the history keys silently drops
dataType/defaultValue and nulls value; (2) NO historical-group
key is needed — at sampleMode: "TagGroup" the gateway binds its
DEFAULT historical group. Follow-through: write tag values, then
ign tags history query — the tag-group scan stores at 40–50 s
cadence, so allow a couple of scans before expecting the written
values as DATA rows (live-proven on 8.3.6 AND 8.3.3; the e2e gate
live_tags_history_bindings re-proves the full chain). Provider
cleanup rides the generic resources API: find
GET /data/api/v1/resources/find/{module}/{type}/{name} → signature
→ DELETE /data/api/v1/resources/{module}/{type}/{name}/{signature}.
Bulk export/import (the portability loop)
tags export → tags import moves a tag subtree between providers
(or gateways) with values intact. --format json (both sides'
default) is the gateway's own JSON interchange, parsed and written
pretty — the lossless round-trip. --format xml passes the gateway's
own XML document through RAW-BYTE both directions (export: the
gateway's exportTags format=xml answer decoded verbatim; import:
your bytes ride base64 to the gateway's importTags untouched — the
gateway's parser is the only semantic actor). The collision
conventions are IDENTICAL to project import's (locked in Phase 3):
abort pre-checks and refuses before any write; overwrite is
--yes-guarded with no pre-check. export -o - pipes into
import --file - in any format.
The CSV contract (read this before --format csv)
The gateway cannot export tags to CSV (official Ignition 8.3 docs:
"Ignition does not export tags to a CSV format").
ign tags export --format csv output is CLI-generated from the
gateway's JSON interchange and is LOSSY:
- No alarm configurations — the legacy CSV grammar cannot carry
them; alarm data in the source is dropped (live-proven: even a CSV
imported with the undocumented
AlarmStatescolumn lands nothing). - The legacy column set only — modern properties (tag groups, bindings, UDT parameter overrides, scan-class-adjacent and history-retention columns) have no legacy column and are dropped.
- Numeric enum coercion —
TagType/DataType/ExpressionTyperide legacy numeric codes; folders flatten (the legacyPathcolumn is unusable — any non-empty value crashes the gateway's importer wholesale, so every row carries an empty Path).
The command prints exactly what was dropped on stderr
(warning: csv export dropped field(s): …) and carries the
structured twin in data.loss_report ({format, dropped_keys, coerced, rows}). Generation is warn-and-continue, never gated
(nothing destructive happens — no gate applies); the column-by-column
coverage table (what lands, coerces, drops, or crashes) is documented
in .planning/phases/11-tag-bulk-transfer-xml-csv/11-LIVE-CAPTURES.md
§Probe 5, live-proven on 8.3.3 and 8.3.6.
The loss gate (imports, TAGS-12)
When importing xml/csv, the CLI SCANS the input before anything is
sent and reports what the import would drop or coerce — UDT type
definition transfer caveats (definitions DO import on this command's
provider-root path, routing to [provider]_types_/Name; but exported
udtParentType stays provider-qualified, so cross-provider imports
keep pointing at the source provider, and instance parameter overrides
silently drop when the referenced type is unresolvable in the target
provider on 8.3.3), gateway-export-shaped files carrying only edited
properties, and (for CSV, unconditionally) the no-alarms/legacy-columns
facts. If the scan reports losses, the CLI
prints the loss report as prose on STDERR and refuses exit 2
invalid_input unless --yes — the refusal happens before any
network activity (profile null, zero requests). With --yes the
import proceeds and the structured scan summary rides the success
envelope as data.loss_report ({format, facts, top_level_names}),
so agents can read the same findings without parsing prose. The scan
is advisory — the gateway remains the parsing authority; a clean
scan never blocks an import, and a gateway-side parse error still
surfaces as the gateway's own error.
Collision policy (xml/csv)
The gateway's importTags also accepts an Ignore collision policy
('i') that silently keeps existing tags. ign deliberately
surfaces only abort and overwrite (the same two-value matrix as
tags import's JSON path since 03-02): abort is the safe default
with a real pre-check, overwrite is honest about being destructive
and requires --yes — a silent-ignore policy has no honest CLI
story, so it is not a value.
Live-proven payload shapes (05-06): the gateway's exportTags never
answers a bare array — one path yields a SINGLE subtree object,
several yield the {"tags": [...]} wrapper. tags export
NORMALIZES both to the list-of-subtrees interchange (what the export
file carries and tags import consumes). A provider-shaped export
subtree (empty name) lands its CHILDREN at the import target —
the abort pre-check and the imported count therefore key on the
EFFECTIVE top-level names, and the structural _types_ folder
(present on every provider; the server's own abort policy accepts
configuring it) never counts as a collision.
Provider-ROOT paths are not supported on 8.3 WebDev threads —
system.tag.getConfiguration/exportTags need an RPC context the
WebDev route's threads don't carry (live-proven 8.3.3
b2026012009 on both devops-rig gateways; ign tags export [default]
and ign tags export default both hit it). The deployed route
refuses honestly — exit 6 provider_root_unsupported with the
subtree workaround in the message and hint — where the bracket form
is detected before the call and the bare form (a leading
provider-matching segment) is translated from the gateway's own
No RpcContext throw. Subtree paths ([default]folder) are the
supported form and behave exactly as documented.
Browsing tag exports offline (--from-export)
ign tags browse --from-export <PATH> reads a tag export WITHOUT a
gateway — no profile resolution, no credential, no deployed routes
(the envelope carries profile: null; the positional browse path
and the flag are mutually exclusive). Three layouts are accepted:
- The CLI's own export — the JSON file
ign tags export -owrites (the list-of-subtrees interchange). The provider is the file stem (default.json→[default]…rows). - A legacy whole-tree file — a
<provider>.jsoncarrying the entire provider (the pre-individual-file git-module format). Provider = file stem. - A git-module directory — the layout
ignition-git-module
checks in: a
tags/root (or the directory itself when it directly holds provider folders/.jsonfiles), one folder or legacy<provider>.jsonper provider. In the individual-file format, folders are DIRECTORIES, each leaf tag is one.json(the tag'snamefield is stripped — the filename is the name,%XX-decoded for reserved characters), and_types_/*.jsonat the provider root are the UDT definitions. Dot-entries skip (the module's own rule),.tag-config.jsonis config (not a provider), and theSystemprovider is always excluded.
The rows ride the SAME renderer and flat JSON shape as the live
browse ({path, name, tag_type, has_children, data_type} with
bracketed fullPaths) — --filter applies client-side. Offline
errors (missing path, unparseable JSON) exit 2 invalid_input
before any network. The natural pipe: browse an export from one
gateway's git module, then promote it with
ign tags import --provider <prov> --file <interchange> (or the
README's cross-profile pipe).
Alarms and tag history
The alarms + tagHistory routes close the tag surface (TAGS-07/08).
The alarm lifecycle is live-proven end-to-end (configure a
LIST-form alarm → write past the setpoint → alarms active shows
Active, Unacknowledged → alarms ack → the state flips to
Active, Acknowledged):
alarms activefilters ridesystem.alarm.queryStatuskwargs verbatim; quality/state strings are data, never parsed.- Alarm history needs a JOURNAL — and default rigs have none.
alarms historyon an unprovisioned rig refuses exit 6 (alarm_journal_missing) naming the missing chain — the honest, actionable default-rig path (live-proven). The provisioning chain is: (1) a running database reachable from the gateway (e.g. a sidecar postgres container), (2) aignition/database-connectionresource pointing at it, (3) anignition/alarm-journalprofile referencing that connection, and (4) theignition/general-alarm-settingssingleton pointed at the journal profile. All four steps are native config-resource REST (the same resource family as tag providers) — provisionable headlessly once a database exists; the e2e gate leaves this as a documented stretch (the wiremock pins carry the capability proof). alarms ackis the gateway-scope 3-arg form (String[] ids, note, username) — the explicit--usernameis required, and the unacknowledged REMAINDER comes back as data (acknowledged = requested − remainder, computed client-side honestly).
Tag history (TAGS-08): tags history query works STRUCTURALLY
on any rig (a well-formed t_stamp-keyed dataset, null values
without a historian — exit 0). DATA requires a provisioned
historian: an InternalHistorian needs no database — creatable
via native REST
(POST /data/api/v1/resources/com.inductiveautomation.historian/historian-provider,
profile type InternalHistorian; the e2e gate provisions one
live). The tag↔historian binding is CLOSED (13-01 spike, both
rigs): the Phase-5 "documented limitation" was a key-name typo —
historicalProvider never bound; the captured field set
historyEnabled + historyProvider + sampleMode: "TagGroup"
(discussed in Tag↔historian bindings above) landed, read back,
and returned WRITTEN values from history on 8.3.6 AND 8.3.3. The
e2e gate live_tags_history_bindings re-proves the full chain on
licensed trial rigs.
Phase 5 requirement map
| Requirement | Shipped as |
|---|---|
| WEB-01 (route bundle + deploy) | ign webdev deploy (+ the embedded 13-member bundle) |
| WEB-02 (status sweep) | ign webdev status |
| TAGS-01 (provider CRUD) | ign tags provider list/create/delete |
| TAGS-02 (browse) | ign tags browse |
| TAGS-03 (read) | ign tags read |
| TAGS-04 (write) | ign tags write |
| TAGS-05 (config CRUD) | ign tags config get/create/edit/delete |
| TAGS-06 (UDTs) | ign tags udt types/def |
| TAGS-07 (alarms) | ign tags alarms active/history/ack |
| TAGS-08 (tag history) | ign tags history query |
| TAGS-09 (bulk transfer) | ign tags export/import |
Parity with the 21-tool MCP tag-domain surface is complete (intent-corrected, not bug-replicated — the prior-art defects the research found are corrected in the routes).
scriptExec — the LOCKED security posture
scriptExec (arbitrary Jython execution through the gateway) ships
in the bundle only as a TEMPLATE with a __IGN_CLI_SECRET__ marker
and deploys ONLY with an explicit --with-script-exec:
- the deploy generates a fresh 32-byte hex secret from
/dev/urandom, substitutes it into the template (the placeholder can never ship — it is excluded from the plain manifest), and persists it in the profile config at 0600;--rotate-secretregenerates (any route copy deployed with the old secret starts refusing); - the route fail-closes on every action — version included — unless
the request carries the matching
X-Ignition-CLI-Secretheader: no header →secret_required, wrong secret →secret_mismatch(constant-time compare); - the secret appears in exactly ONE place: the baked zip member on the gateway. Never in command output, JSON envelopes, or logs.
Threat-model honesty: the secret gate is a SHARED-SECRET posture, not
real authentication — anyone who can read the gateway project's
resources (e.g. a Designer user or a project export) can read the
secret. It exists to make scriptExec an opt-in, auditable surface
with a definite off switch (never deploy it, or redeploy without the
flag), not to protect against gateway insiders. The route keeps its
config at require-auth=false deliberately: an auth layer would lock
the CLI's own token-authenticated calls out (API tokens 401 on WebDev
require-auth — live-verified).
The ign script run verb contract
ign script run (07-03) is the route's entire consumer surface, and
its security posture is the deploy flag — nothing more:
- Input forms:
--code PY(inline — a one-liner's best form),--file PATH,--file -(stdin — the agent pipe path). Giving both--codeand--file, or neither, refusesinvalid_input(exit 2) before any resolution work. - The opt-in is structural: a profile with no persisted
webdev_secretcan only mean the route was never deployed through the flag — the verb refuses exit 6script_exec_not_configuredwith zero HTTP requests, its hint namingign webdev deploy --with-script-execverbatim. There is deliberately NO--yesguard here: the deploy flag is the opt-in, and requiring a second confirmation would only tax the non-interactive agents the route exists for. - Result shape:
{stdout, result, elapsedMs}— all keys always (empty string / null / 0 when the answer carried nothing; agents never key-hunt).stdoutis the script's captured output verbatim;resultis a final expression's value or the_resultglobal's statements;elapsedMsis the route-measured wall time. - Timeout honesty: v1.0.0's route has NO server-side execution timeout — a long-running script simply holds the HTTP connection while it runs (the client rides its existing per-request timeout class). Budget long scripts accordingly.
- Redaction guarantee: the secret appears in exactly one place (the baked zip member) — never in the JSON envelope, the human render, logs, or any error path (the refusal and result canaries are contract-pinned at both the action and binary levels).
- Threat-model cross-reference: the shared-secret honesty note
above applies to every
script runinvocation — the gate makes the surface opt-in and auditable; it does not defend against gateway insiders who can read the project's resources.