Skip to main content
Version: 2.6.3

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 in Seconds
  • unit: ms → field name must NOT end in Ms or Milliseconds

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 Duration wrapper at modules/engine/domain/node.go with different wire semantics — bare number defaults to milliseconds, not seconds. This is a known inconsistency; see internal-docs/architecture/timeout-hierarchy-design.md. A future unification will fold the engine into the same discipline.
  • AWS SDK pass-through fields. SQS waitTimeSeconds and delaySeconds are 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.json contract — 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.Duration in Go, unit: s or unit: ms in YAML.
  • Is the field name free of a unit suffix?
  • Does the YAML label mention the unit?
  • Does TestConnectorConfigConsistency still pass?

If the answer to any of the first three is "no," the fourth will fail and tell you which rule broke.