Connector Config Conventions
Every field on a connector's connector.yaml and its Go Config struct
lives at the boundary between operator input (UI form value), storage
(JSONB / TEXT blob), and runtime execution (typed Go struct). Getting
that boundary right without introducing silent unit bugs requires a small
set of rules that this page spells out.
These rules are enforced by TestConnectorConfigConsistency at
apps/backend/modules/connectors/infrastructure/protocols/config_consistency_test.go.
A CI failure on that test means one of the rules below was broken.
The four rules
1. Duration fields are time.Duration in Go
Any config field that represents a duration MUST be typed as
time.Duration in the Go Config struct. No int seconds, no int64
milliseconds, no float scalars.
Why: time.Duration carries the unit information in the type
system. An int field labeled "timeout seconds" is a comment, not an
enforcement — the next reader can misread it as ms and get a 1000×
mistake. The runtime layer must trust the type.
// Correct
type Config struct {
ConnectTimeout time.Duration `json:"connectTimeout"`
FlushInterval time.Duration `json:"flushInterval"`
}
// Wrong — recreates the ambiguity the type system exists to remove
type Config struct {
ConnectTimeoutSeconds int `json:"connectTimeoutSeconds"`
FlushIntervalMs int `json:"flushIntervalMs"`
}
2. YAML duration fields carry a unit: tag
Any YAML field that a Go time.Duration reads MUST have unit: s or
unit: ms set in connector.yaml. This is the wire-shape contract:
operators enter a bare integer in the unit declared here, and
connector.PrepareConfig converts it to nanoseconds before
json.Unmarshal into the Go struct.
Why: without the tag, a wire value of 1000 reaches
json.Unmarshal unchanged. Go's default time.Duration JSON decoder
treats an integer as nanoseconds — so 1000 becomes 1 microsecond
instead of 1 second (or 1 ms). The unit: tag is what tells
PrepareConfig to multiply by time.Second or time.Millisecond.
# Correct
connectTimeout:
type: integer
unit: s
default: 30
label: "Connection Timeout (seconds)"
flushInterval:
type: integer
unit: ms
default: 1000
label: "Flush Interval (ms)"
# Wrong — a wire value of 30 will materialize as 30 nanoseconds
connectTimeout:
type: integer
default: 30
label: "Connection Timeout (seconds)"
3. Field names carry NO unit suffix when a matching unit: tag exists
When a YAML field has a unit: tag, its name MUST NOT end in that
unit's suffix.
unit: s→ field name must NOT end inSecondsunit: ms→ field name must NOT end inMsorMilliseconds
Why: the unit: tag IS the wire-shape source of truth. A
connectTimeoutSeconds field with unit: s is either redundant (name
matches tag) or misleading (name and tag drift). Either way, the suffix
is a comment pretending to be a contract.
Fields without a unit: tag may legitimately carry a unit suffix —
the name is doing the work the tag would. This is the pattern for
domain-native integer fields (DefaultUpdateRateMs int on OPC-DA
matches the underlying COM API's DWORD-in-ms parameter) or PLC register
values where the "duration" is a scalar the hardware interprets.
Correct Wrong
────────────────────────────── ───────────────────────
connectTimeout + unit: s connectTimeoutSeconds + unit: s
flushInterval + unit: ms flushIntervalMs + unit: ms
connectTimeoutSeconds (no unit tag — name IS the unit indicator)
DefaultUpdateRateMs (no unit tag — int type, COM API convention)
4. UI labels reflect the wire unit
The label: in YAML MUST make the wire unit visible to the operator.
A unit: s field's label mentions seconds ("Seconds", "sec", " (s)").
A unit: ms field's label mentions ms ("ms", "milliseconds").
Why: the operator sees the label, not the tag. Without an explicit unit in the label they'll guess — and the wire is a bare integer, so guessing wrong ships silently.
# Correct
connectTimeout:
unit: s
label: "Connection Timeout (seconds)"
flushInterval:
unit: ms
label: "Flush Interval (ms)"
# Wrong — operator can't tell if 30 means seconds or ms
connectTimeout:
unit: s
label: "Connection Timeout"
The round-trip
The four rules together make one coherent flow:
1. UI → operator enters `30` in a field labeled "(seconds)"
2. Wire → frontend sends {"connectTimeout": 30}
3. DB → stored as-is: {"connectTimeout": 30}
4. YAML → connector.PrepareConfig reads `unit: s`,
multiplies: config["connectTimeout"] = 30_000_000_000
5. Go → json.Unmarshal into `ConnectTimeout time.Duration`
= 30 * time.Second
Every layer's representation matches its natural scale — operator sees
seconds, DB stores seconds, Go stores nanoseconds. No layer has to know
the others' scales. The unit: tag is the sole conversion point.
What's NOT covered
- Engine (pipeline / node) configs. The engine has its own
Durationwrapper atmodules/engine/domain/node.gowith different wire semantics — bare number defaults to milliseconds, not seconds. This is a known inconsistency; seeinternal-docs/architecture/timeout-hierarchy-design.md. A future unification will fold the engine into the same discipline. - AWS SDK pass-through fields. SQS
waitTimeSecondsanddelaySecondsare AWS API-defined; we mirror the SDK naming so operator values pass through directly. Allowlisted in the test. - External wire protocols. ODBC's Config is shaped by the .NET
adapter's
appsettings.jsoncontract — we don't own the schema alone, so the field names track upstream. Fully carved out in the test.
When you're adding a new field
Quick checklist:
- Is it a duration? →
time.Durationin Go,unit: sorunit: msin YAML. - Is the field name free of a unit suffix?
- Does the YAML label mention the unit?
- Does
TestConnectorConfigConsistencystill pass?
If the answer to any of the first three is "no," the fourth will fail and tell you which rule broke.