Status: day-1 spike schema for issue #1575 group 1. This document is the written-down form of checkbox group 0 (the day-1 protocol schema), which had to land before any verb did, because argument constraints are schema.
Sources: the ButterStack team's design note §2.2 and §2.4 (the ButterStack connector design note (2026-08-29, internal), an internal planning branch) and the security review's must-fix list §6 (the connector security review (2026-08-29, internal)).
Two things this document does not do. It does not describe an endpoint that
exists: the broker side is a later PR, and the only implementation of this
protocol's server half today is the drill harness in test/mock_broker.rb. And
it does not restate the six survival conditions; it records the parts of them
that are schema.
One outbound TLS connection, from the studio's network to one ButterStack hostname, on 443. The connector never listens on anything.
wss://<connect-host>/connect
| Rule | Why |
|---|---|
wss:// only. There is no plaintext option and no "skip verification" option for the broker connection. |
allow_insecure_tls exists for a studio's self-signed LAN TeamCity, and is scoped to that host. The connection that carries commands is always verified. |
| The endpoint must carry no query string, no userinfo, and no fragment. | See §2. Enforced at config load, before a socket is opened. |
| TLS 1.2 minimum. | Matches the production ALB policy (ELBSecurityPolicy-TLS13-1-2-2021-06) and the instance nginx (TLSv1.2 TLSv1.3). |
Heartbeat every 25 s by default; the broker may negotiate 5–120 s in welcome. |
Must stay under the shortest idle timeout on the path: ALB idle_timeout = 300, instance nginx proxy_read_timeout 300, local docker nginx 60 s. |
| Reconnect: exponential backoff with full jitter, floor 500 ms, cap 60 s, forever. The backoff resets after a session that actually came up. | A connector that cannot connect is a log line on the studio side, never an alert. |
security review §6 item 4, verbatim in effect: the broker is a dedicated Rack endpoint
at /connect. Not ActionCable. Not /cable. Not ApplicationCable::Connection.
No change to allowed_request_origins. No disable_request_forgery_protection.
The reasons are code-verified in her review and are worth keeping next to the schema, because the tempting shortcut is real:
app/channels/application_cable/connection.rb:19-25authenticates from the warden session cookie and rejects everything else, so a bearer-token machine client has no path through it.config/environments/production.rb:56restrictsallowed_request_originsto the twoRAILS_HOSTorigins. A non-browser client sends noOrigin, so admitting one through ActionCable would mean widening that list or disabling forgery protection, either of which weakens a live cross-site WebSocket-hijacking control that protects real users' browser sockets.- That connection carries
identified_by :current_user, :current_account,impersonates :user, and setsActsAsTenant.current_tenant. A machine client must inherit none of it.
/connect therefore has its own auth (§2), its own connection object, and no
session, cookie, or CSRF machinery in the path.
bsc_<project public id>_<32 characters, 58-symbol alphabet>
- The
bsc_prefix makes the credential greppable by secret scanners, ours and the studio's. - The id segment makes the broker's hashed lookup a primary-key read rather than a table scan.
- The secret segment is 32 characters drawn from a 58-symbol alphabet
(
[A-Za-z2-7], i.e. A-Z + a-z + 2-7), not standard base32 - that's32 * log2(58) ~ 187bits of entropy, which is why plain SHA-256 is the correct storage: a slow KDF buys nothing against an input with that much entropy.
Superseded (Ryan, 2026-08-30, #1575 group 2b): the id segment was
originally <integration public id>. A Connector is now a first-class
per-project record rather than a per-integration one, so the id segment is
<project public id> instead - a compromise or misconfiguration of one
connector can only ever be scoped to its own project's account, never leak
across projects. This is a cross-project-isolation change to the id segment
only, not a wire-format redesign: the frame shapes in §3 are unaffected, and
connector/internal/config's tokenPattern is id-segment-agnostic
(\Absc_[a-z0-9][a-z0-9\-]{0,62}_[A-Za-z2-7]{32,128}\z), so the daemon needs
no code change to accept a project-scoped token.
Store only SHA-256(secret segment). Compare with
ActiveSupport::SecurityUtils.secure_compare. Display the token exactly once at
issue time; never make it retrievable afterwards, which is what makes the revoke
story in §6 honest.
This is a new pattern for this codebase and the nearest precedent is the wrong
one: Integration#webhook_token is generated with good entropy
(app/models/integration.rb:575-577) but is stored reversibly and looked up by
equality (jenkins_controller.rb:46). A connector token stored that way means
one database read yields every studio's live connector credential. Do not copy
it.
Authorization: Bearer bsc_...
A token supplied as a query parameter on the /connect upgrade is rejected,
and the rejection happens before any per-connection state is allocated. A
connector is not a browser: the only reason WebSocket clients put credentials in
query strings is a browser limitation that does not apply here, and a URL is
logged by every proxy on the path. This is the F1 lesson (issue #935, a live
webhook token in an nginx access log) applied to a new surface before it
exists rather than after.
Both halves are enforced and drilled:
- Client half: an endpoint carrying a query string is refused at config load
(
internal/config), so a copy-pasted?token=URL cannot start the daemon. - Broker half: the upgrade is refused with an HTTP status, never a 101, and
there is no code path that reads a token from a query string
(
test/mock_broker.rb).
The broker refuses, in this order, before allocating any per-connection
state: wrong path, query string present, not an upgrade, missing
Sec-WebSocket-Key, missing bearer token, malformed token, unknown token,
revoked token. An unknown verb must likewise never reveal what is configured
(§4).
/connect upgrades need a dedicated, stricter throttle in addition to the
generic req/ip 300-per-5-minutes at
config/initializers/rack_attack.rb:112-115. That generic budget is shared with
a studio's webhook traffic arriving from the same NAT address, and rack-attack
sees only the upgrade, never the frames, so per-session command budgets and the
per-integration connection cap (v0: 2) belong in the broker, not in rack-attack.
All frames are single JSON objects with a type discriminator. There is no
binary frame type and no streaming.
reason is always a stable machine token, on every status that carries one,
never free text. For status: "error" (a tool call that reached the tool and
failed), reason is the fixed token tool_error: the real error text -- p4
stderr with a server host:port, a Go *url.Error with the TeamCity URL, any
of it -- carries detail about the studio's LAN and never leaves it. That text
is written only to the connector's local audit log (see §7); the broker never
receives it.
{ "type": "welcome", "session_id": "...", "server_time": "...",
"min_supported_version": "0", "heartbeat_interval": 25 }
{ "type": "reject", "reason": "<stable token>" }
// command -- the only frame that can cause the connector to touch a tool.
{ "type": "command", "id": "<uuid>", "verb": "teamcity.build.get",
"args": {"build_id": 9001}, "deadline_ms": 8000, "max_bytes": 32768 }Note what a command does not contain: no host, no port, no URL, no shell
string, no credential. Those live in connector.yml on the studio's disk and
never appear on the wire.
idis a UUID minted by us. The connector rejects a duplicateidinside a 10-minute sliding window withdenied / duplicate_command_id, rather than re-executing it.- A command past
deadline_msanswerstimeout / deadline_exceeded. The connector caps the deadline at 60 s regardless of what the broker asks for. - Every verb has a
max_bytesdefault; the connector truncates and setstruncated: truerather than streaming unbounded. (Same instinct as the 256,000-character pushed-log cap atjenkins_controller.rb:293.) - A
resultis discarded unless the responding session's authenticatedintegration_idis the one that issued the command. Reply routing is derived server-side from the authenticated session, never from a field in a frame.
config/initializers/acts_as_tenant.rb:8 sets require_tenant = false, so a
missed scope returns cross-tenant rows silently instead of raising. Therefore:
- every command execution wraps in an explicit
ActsAsTenant.with_tenant(integration.account); - nothing relies on ambient
Current.account, which is set by a controllerbefore_action(app/controllers/concerns/set_current_request_details.rb:6-9) that does not run on a socket; - the Redis reply channel is derived server-side from the socket's authenticated
integration_id; - a
resultframe is dropped unless it matches the issuing session.
The drill: assert tenant context is nil at the start of a request that follows a
connector frame on the same Puma thread. That drill is Rails-side and is not
covered by this PR (see README.md, "what this does not prove").
The authoritative, executable form of this section is
internal/vocab/vocab.go, which is one file on
purpose: an IT director is asked to read the source, so the source has to be
readable. butterstack-connector -print-vocabulary prints it.
-
The verb must be in the compiled vocabulary. A name that is not in the vocabulary at all →
denied / unknown_verb. A name the schema reserves but this build does not compile in →denied / verb_not_compiled. There is no dynamic registration, no plugin path, no verb name read from config, and there is nosys.exec. -
Every argument is validated against a fixed per-verb schema.
Rule Denial reason An argument the schema does not name stops the command. It is never ignored into the tool call. unknown_argumentEvery argument is a scalar. The schema has no map, object, or free-form kind, so it cannot express a parameter bag. (structural) Integers are type- and range-checked at the frame boundary. A JSON string "42"is not an integer.argument_type,argument_rangeDepot paths must begin //, carry no revision specifier (@ # %) and no traversal segment.argument_patternScoped arguments must fall inside a list that lives only in connector.yml:depot_scope,allowed_build_types,repo_allowlist.out_of_scope_path,out_of_scope_build_type,out_of_scope_repoA depot path's literal prefix (everything before the first wildcard) must already sit inside a scoped prefix, so a wildcard can never climb above the scope. //...is denied even to a P4 user who could read it.out_of_scope_pathContent-class output is off in v0 at the schema level, not merely in config. content_verb_disabled
A well-formed verb with an out-of-scope argument is denied exactly like an unknown verb, and both write a local audit line. Only the second kind of denial actually tests survival condition 2; the first only tests the dispatcher. Both are drilled separately for that reason (security review §6 item 7b).
This is the finding that made this layer day-1 work rather than v1 polish
(security review F4). allowed_jobs constrains which job runs; a params map is
unconstrained, and Jenkins build parameters and TeamCity properties are
interpolated into shell build steps by design, including in our own
Jenkinsfile.unreal and Jenkinsfile.minimobile templates. A caller-supplied
parameter bag would therefore turn the typed allowlist into a code-execution
primitive on the studio's build agents, and falsify the single sentence the
Tier 2 sale rests on (README Appendix B answer 2: "a compromise of our cloud
yields read-access to changelist metadata via a P4 user you scoped, a
bounded blast radius").
So:
jenkins.build.triggertakes{job}and nothing else. When it ships it triggers with the job's own default parameters.teamcity.build.queuetakes{build_type_id}and nothing else; the connector composes a fixed request body,{"buildType":{"id":"<id>"}}. Noproperties, nobranchName, nocomment.- Neither verb is compiled into v0 at all.
- A v1 may add per-job
allowed_paramsinconnector.yml- an allowlist of parameter names, each with a value pattern or enum, enforced connector-side before the call.
This is enforced structurally, not by convention: bannedArgNames in
vocab.go lists the argument names no verb may declare - compiled or reserved -
each with the reason it is banned, and Selfcheck() runs both in the test suite
and at process start. A build whose vocabulary grew one of them refuses to run.
If the connector shells out to the p4 CLI rather than using P4Ruby, every
invocation is an argv array with no shell interpretation (security review F5's smaller
sibling). The ticket is passed through P4PASSWD in a minimal environment
rather than on the command line, so it never appears in the studio host's
process list. p4 describe is always invoked with -s, so diffs are excluded
at the tool boundary as well as in the schema.
| Verb | Class | v0 |
|---|---|---|
sys.ping, sys.version, sys.capabilities |
M | compiled |
teamcity.server.info |
M | compiled |
teamcity.build.get {build_id} |
M | compiled |
p4.describe {change, max_files, include_diff:false} |
P | compiled |
p4.changes {path, max} |
P | compiled |
teamcity.build.queue {build_type_id} |
X | reserved, denied |
jenkins.build.trigger {job} |
X | reserved, denied |
p4.file_contents {depot_path, rev, max_bytes} |
C | reserved, denied |
ghes.commit.get {repo, sha} |
P | reserved, denied |
horde.server.info |
M | reserved, denied |
Classes are the egress classes from the design note: M = metadata, P = paths,
C = content, X = mutation. No X-class and no C-class verb is compiled into
v0, and Selfcheck() fails the build if one ever is.
p4.changes is the one verb here beyond the three the spike was scoped to
(teamcity.server.info, teamcity.build.get, p4.describe). It is in the
design note's own §2.4 list, and it is compiled in because it is the natural
carrier for depot_scope: without a path-bearing argument, the out-of-scope
drill has nothing to deny before a tool call, and that drill is the one the security review
singled out as the only real test of condition 2.
Every credential the connector uses comes from connector.yml, or from a
*_file path that connector.yml names. There is deliberately:
- no environment-variable fallback for any credential;
- no command-line flag that takes a secret;
- no remote configuration - the broker cannot tell the connector where to find a credential.
If the broker could, "your credentials never leave your network" would depend on our good behaviour rather than on the studio's file permissions.
Enforced: connector.yml and every *_file must be mode 0600 or stricter, or
the daemon refuses to start. An unrecognised key in connector.yml is an error,
not a silent ignore. The redacted config rendering used by the startup banner
and the audit log never prints a secret; no code path prints the config
directly.
Our side stores only the SHA-256 digest of the connector token, and
credentials_ciphertext holds nothing for a Connector-transport integration.
- Revoking the connector token on our side closes the socket within one heartbeat. The studio's own credentials are untouched, and reconnects with the revoked token are refused at the upgrade.
- For every tool with a Tier 1 push path, no event path depends on the connector. Stopping it removes pull verbs only.
- A verb-dependent feature checks connector presence and renders the offline state; it records "needs connector" and moves on rather than raising. Nothing retries a verb against an offline connector more than once.
- Horde is the stated exception: it has no push path, so it is connector-only with no degraded mode, and its own setup docs have to say so.
One local audit line per command, whatever the outcome, including every rejected
and denied one: timestamp, session id, command id, verb, SHA-256 of the
canonical arguments, status, denial reason, bytes out, duration, truncation
flag. JSON lines, mode 0600, dated files.
Arguments are hashed rather than recorded verbatim, so the log correlates with
our side ("we sent command X, they ran command X") without becoming a second
copy of whatever the arguments contained. Denial detail strings stay local and
never travel in a result frame: a denial message that echoed the rejected
value back would be a small egress channel of its own. The same rule applies
to a failed tool call: the result frame's reason is the stable token
tool_error only, and the real error text -- which can carry LAN detail like
a p4 server host:port or a TeamCity URL -- is written to the local audit
line's detail field and never leaves the studio.
The log is the studio's evidence, not ours. No verb can read it.
The mock broker (test/mock_broker.rb) is not the broker. The real one is a dedicated Rack endpoint at /connect on the Rails app with hashed-token auth, Redis-routed command and result, and explicit tenant scoping, a later PR. What the mock broker models is the surface the drills need, and it does implement faithfully the four rules the drills exist to prove:
- The upgrade is refused before any per-connection state is allocated when the token is absent, malformed, revoked, or wrong.
- A query-string token is refused, and no code path reads a token from a query string.
- Tokens are stored as the SHA-256 digest of the secret segment and compared in constant time; the plaintext is never held.
- A
resultframe is matched against the issuing session's own outstanding commands; one carrying another session's command id is discarded.