License Runtime Protocol v2¶
This guide describes the v2 runtime exchange between Main Server and the license service, its operator routes, and the runtime gates it controls. It documents the implemented source contract; remote compilation and production deployment checks remain separate. License issuance and the capsule version selector are covered in License and Licensed Brand Administration.
Version selection and readiness¶
A capsule without protocol_version is interpreted as version 1. Integer 1
uses the existing runtime and wire protocol unchanged. Integer 2 selects the v2
client. Duplicate version members, null/boolean/string/array/object values,
floating-point values such as 1.0 or 2.0, and integers other than 1 or 2
are rejected during capsule loading. A v1 runtime with the optional upgrade
storage interface probes v2 automatically while its legacy v1 path remains
available. Once an upgrade is prepared and the server has persisted its v2
candidate, it must resume v2 after a restart; it does not fall back to v1 if
activation is uncertain.
V2 requires the optional stcore::ILicenseLeaseApi side interface. Automatic
v1-to-v2 migration also requires the optional
stcore::ILicenseUpgradeApi side interface, implemented by the current Main
Server. The public ILicenseApi ABI remains version 4 and is unchanged. A v2
runtime fails startup if its host does not implement the lease interface. Older
v1 binaries do not probe v2 and continue to use the existing license path.
The optional side interface has these methods:
| Method | Signature | Effect |
|---|---|---|
requireLicenseLease |
void requireLicenseLease() noexcept |
Requires an active lease for runtime-protected operations. |
grantLicenseLease |
void grantLicenseLease(int64 remaining_milliseconds) noexcept |
Grants runtime permission for the supplied remaining duration, from 1 through 3600000 milliseconds. |
invalidateLicenseLease |
void invalidateLicenseLease() noexcept |
Immediately removes local runtime permission. |
These methods change runtime-only permission; they add no fields, methods, or
virtual functions to the existing ILicenseApi ABI layout.
The upgrade interface reads, stages, and commits an encrypted capsule. The
server stores a prepared candidate next to the current license file as
.v2.pending, preserves the original as .v1.backup, and uses atomic synced
file operations when committing. The capsule keeps the same license UID and key
pair; only its document version changes to protocol_version: 2.
| Method | Signature | Effect |
|---|---|---|
pendingLicenseUpgrade |
bool pendingLicenseUpgrade(std::string& capsule) noexcept |
Reads a pending encrypted candidate. Returns true with an empty string when none exists; false means the read failed. |
stageLicenseUpgrade |
bool stageLicenseUpgrade(const std::string& expected_capsule, const std::string& replacement_capsule) noexcept |
Saves the v2 candidate only if the current capsule still matches the expected v1 capsule, and preserves the original backup. |
commitLicenseUpgrade |
bool commitLicenseUpgrade() noexcept |
Atomically replaces the current capsule after an authenticated v2 lease. |
An independent optional stcore::ILicenseTelemetryApi side interface provides
getLicenseTelemetryJson(), which returns a JSON string snapshot. It does not
change ILicenseApi or ILicenseLeaseApi, and it is not required for a v2
lease. When available, the Main Server may attach this snapshot to encrypted
acquire and renew reports. Older v2 clients can omit it; envelope structure,
cryptographic processing, and v1 behavior are unchanged.
The license service reports v2 readiness separately from protocol support.
The license service automatically runs its pending Knex migrations during
startup, before initializing the runtime authority store or subscribing to NATS.
It uses Knex 0.95.14 db.migrate.latest with the alfa-license/migrations
directory and a separate alfa_license_migrations history table
(id, name, batch, migration_time) plus Knex's
alfa_license_migrations_lock lock table. Only migrations absent from this
ledger are run; a successful migration is recorded so a later restart does not
repeat it. MySQL migrations use disableTransactions: true because DDL is not
transactional there.
Before calling Knex, the runner obtains the MySQL session lock
alfa-license:startup-migrations with a 60-second wait on a checked-out connection.
It holds that connection through migration discovery and execution, then releases
its own lock. This serializes the initial history-table creation and pending-step
snapshot across replicas; Knex's normal history and lock tables are still used.
The named lock is scoped to the MySQL server, so different environments on that
server also serialize their startup migrations. If acquisition is uncertain or
release fails, the connection is closed before it is returned to the pool.
The runtime-v2 migrations add license_runtime_v2,
license_runtime_v2_challenges, and license_runtime_v2_upgrades. The automatic
upgrade migration creates the separate upgrade table; it does not alter existing
columns in licenses, brands, or license_runtime_v2. V2 issuance is refused
with HTTP 503 before key generation when the required schema is unavailable.
Readiness also requires the existing licenses and brands tables to use
InnoDB. Initialization also checks required columns, ascii_bin collation for
license_uid in all three new tables, primary keys on license_uid for the
authority and upgrade tables, the challenge table's primary key on session_id,
and the unique key on (license_uid, run_id, hello_id). These checks use read-only
information_schema metadata and do not alter existing tables. The
license/runtime/capabilities route reports runtime_v2_ready: false unless
all prerequisites pass. With partial DDL, missing keys/columns, wrong
collation, or an unsupported table engine, issuance capabilities remain [1],
v2 issuance/administration/grants are unavailable, and already protected UIDs
remain excluded from the legacy protocol. Resolve the schema prerequisites
explicitly and restart the service. Startup migration does not alter the
existing licenses or brands tables.
Telemetry persistence uses the separate tracked migration
202610080001_license_runtime_v2_telemetry.js and the table
license_runtime_v2_telemetry. It is optional for runtime-v2 readiness. If the
optional table is unavailable, reads return server.status: "unknown". Failed
writes retain previously persisted data, whose presence timestamp continues to
age. Neither case changes license issuance or lease decisions. Startup tolerates
only an explicitly marked optional telemetry migration failure and still verifies
the required authority schema; other migration failures keep v2 unavailable.
Writes run after the authority commit in a bounded background queue (up to 1024
license IDs and four active writes), without awaiting them in the license reply
or locking the authority row. The latest pending event per license replaces older
pending work. Missing, delayed or failed persistence can leave monitoring unknown
or stale without extending or revoking a lease.
An unsuccessful optional migration is not recorded as complete and is retried
on the next startup.
New v2 issuance creates the license and its authority record atomically. A v1
license can also migrate automatically when a current Main Server connects to a
license service that supports the upgrade protocol. No Console action or new
license is required. The service retains v1 while the upgrade is only prepared;
the authenticated v2 acquire then atomically changes the stored capsule to v2,
creates the bound v2 authority and lease, and removes the preparation record.
The runtime authority read reports protocol_version: 1 until this commit and
2 afterward. V1 licenses on older Main Server binaries remain on v1.
The preparation record is stored separately in license_runtime_v2_upgrades,
not in the active authority table. This allows old license-service replicas to
continue v1 responses during preparation. Deploy the upgraded license service
to every replica before relying on automatic migration; after a license has
committed to v2, all replicas must enforce its v2 authority state. An existing
v2 capsule issued before the authority schema was available is not automatically
enrolled or reissued. Use the explicit operator enrollment procedure below only
after confirming the capsule is v2 and stopping/upgrading any older binary that
could grant legacy permission. The enrollment endpoint cannot inspect/decrypt
the capsule; its protocol_version: 2 field is an operator assertion, while the
fingerprint only confirms the stored license identity key. An old binary can
keep using cached license state even after a new server begins requiring v2; an
authority row cannot erase that local cache.
Protected UIDs are rejected on the old
lic.events subject, including brand-configuration messages, so they cannot
fall back to the unsigned legacy path. The existing v1 callback remains
unchanged for unprotected legacy licenses.
Automatic v1-to-v2 migration¶
The current Main Server detects a v1 capsule at startup and probes the v2 TLS
endpoint without stopping its existing v1 connection. It sends the existing
signed v2 hello envelope with the additive plaintext payload field
auto_upgrade: 1. The service verifies the signature against the license's
existing public key, checks that the license has trial, active, or development
status and has not expired, then records a pending migration and machine ID.
The signed challenge echoes
auto_upgrade: 1; the encrypted acquire body must echo it as well.
During migration, the stored capsule UID must exactly match the registered UID.
Its private signing key must be Ed25519 and derive the same public key (compared
as DER SubjectPublicKeyInfo) as the registered key that authenticated the request.
The redundant database private-key column is not compared as PEM text: equivalent
PEM formatting or a stale redundant column does not change the authenticated
public identity. Capsule UID or cryptographic key mismatches still reject the
transition; no key is substituted, regenerated or rewritten to repair a mismatch. No key,
license UID, protocol envelope, subject, or encryption format is replaced.
Before the first acquire, the Main Server atomically writes the encrypted v2
candidate to .v2.pending and saves the original capsule as .v1.backup. Only
then does it require a v2 lease, so risk-increasing operations are gated until
the authenticated v2 lease is applied. A lost acquire reply can therefore cause
a temporary trading interruption while the server retries or resumes v2; the
server does not switch back to v1 to guess whether the service committed.
A successful acquire both creates the initial
machine binding and activates v2 in one license-service database transaction.
This automatic binding applies only to a previously unbound v1 license. An
existing binding to another machine is never overwritten; it receives the
normal machine-binding denial. Manually issued v2 licenses retain the existing
operator-approved binding flow.
Before the candidate is staged, a missing v2 service, TLS failure, inactive
license, or unsupported auto-upgrade request leaves v1 running and the server
retries the probe every 30 seconds. Once the candidate is staged, there is no
v1 fallback: an uncertain acquire may already have committed the server-side
transition. The pending capsule is loaded on restart and the server resumes v2;
after a verified lease, it atomically replaces the license file and retains the
original backup. These names are suffixes of the configured license path: for
example, license.lic.v2.pending and license.lic.v1.backup. Persistence also
uses .upgrade.lock, temporary files and a private .v2.committed receipt for
idempotent recovery. Created capsule files have mode 0600; ordinary startup
without a pending upgrade does not require creating a lock file. Do not publish
these files with release artifacts. After local commit, the server logs
License automatically upgraded to v2; existing license identity preserved
(when Info logging is enabled). If local commit fails after the lease succeeds,
the active v2 process retries the local commit. Resolve persistent local
storage errors before replacing or deleting pending files.
This flow cannot distinguish the rightful host if an old v1 capsule and its private key were copied before migration. The first valid signed upgrade from a previously unbound license can establish its machine binding. A machine already bound in the v2 authority is protected from automatic rebinding.
Diagnosing an automatic upgrade that does not complete¶
A TLS connection confirms the broker transport, not license approval. Current
clients identify the automatic path with
License automatic v1-to-v2 upgrade connected over TLS; awaiting signed challenge.
Native v2 startup retains
License v2 TLS connection established; awaiting authenticated lease.
| Diagnostic | Check |
|---|---|
hello: no reply within 5 seconds |
License-service startup/readiness, completion of tracked migrations, and NATS publish/subscribe permissions. |
no verified reply; malformed response or signature verification failed |
Service identity must match the peer key pinned in the capsule; do not disable signature validation. |
no matching reply |
Delayed responses or another process using the same license reply subject. |
V2_LICENSE_REQUIRED during automatic migration |
The responding replica lacks the automatic-upgrade implementation. For native v2, check authority enrollment. |
did not confirm automatic upgrade support |
The signed challenge omitted the upgrade capability; update the responding license-service replica. |
cannot persist its candidate |
License directory write access or a conflicting pending/backup/receipt file. |
acquire denied: CHALLENGE_EXPIRED or CHALLENGE_USED |
The challenge expired, is missing, or was already consumed by a different acquire; the client starts a fresh handshake. |
renew denied: SESSION_NOT_OWNER or INVALID_SEQUENCE |
Authority session/epoch or request order differs; inspect competing instances and service session state. |
The diagnostics do not print JWTs, license keys, capsules or message bodies. A missing reply alone does not establish which prerequisite failed.
Operator HTTP routes¶
These routes are exposed through the license service's existing license
namespace and require superadmin. Successful responses return the object
shown below directly, without a data wrapper.
Read v2 capabilities — GET /v1/license/license/runtime/capabilities¶
This route takes no query parameters or request body. It returns:
| Field | Type | Description |
|---|---|---|
runtime_protocol_versions |
integer array | Runtime protocol versions supported by this license service. |
runtime_v2_ready |
boolean | Whether the v2 authority, challenge, and automatic-upgrade tables satisfy the required schema checks. |
brand_policy_versions |
integer array | Brand admission policy versions supported by the service; [1] when the optional policy schema is ready, otherwise []. |
renew_interval_seconds |
integer | Client renewal interval; 30. |
lease_seconds |
integer | Maximum lease duration; 3600. The grant is also capped by license expiry. |
enrollment |
string | administrator; this is the binding flow for manually issued v2 licenses. Eligible v1 licenses use the automatic migration flow described above. |
schema_version |
integer | Runtime authority schema version; currently 1. |
{
"runtime_protocol_versions": [1, 2],
"runtime_v2_ready": true,
"brand_policy_versions": [1],
"renew_interval_seconds": 30,
"lease_seconds": 3600,
"enrollment": "administrator",
"schema_version": 1
}
The capability route remains readable while the tables are absent and returns
runtime_v2_ready: false. Routes that read or change runtime authority return
503 LICENSE_V2_NOT_READY until initialization succeeds. The separate issuance
capabilities route then advertises only [1]; it advertises [1, 2] after runtime
schema readiness. Brand-policy readiness is advertised separately: the
brand_policy_versions array contains [1] when the optional policy schema is
ready, or [] when it is unavailable.
Read runtime authority — GET /v1/license/license/runtime¶
Query parameters:
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | Yes | License record ID; minimum 1. |
The route returns:
| Field | Type | Description |
|---|---|---|
license_id |
integer | License record ID. |
protocol_version |
integer | 2 after a v2 authority is active; 1 before activation, including while automatic migration is prepared. This reports service state, not a direct capsule inspection. |
key_fingerprint |
string | Lowercase SHA-256 hex fingerprint of the license public key's DER SubjectPublicKeyInfo. |
bound_machine_id |
string or null | Bound 32-character lowercase machine ID. Automatic migration sets it on the first successful authenticated acquire; manually issued v2 licenses use operator binding. |
pending_machine_id |
string or null | Machine ID from the most recent authenticated v2 hello, including an automatic migration in preparation. During preparation, this is not yet a binding. |
pending_run_id |
string or null | Run ID associated with the latest authenticated hello. |
last_seen_at |
integer | Last authenticated report time as Unix seconds; 0 if the license has not reported. |
session |
object or null | Current/most recent authority session, or null before the first successful acquire. |
server |
object | Additive server-presence and optional telemetry view, described below. |
When session is present, it contains:
| Field | Type | Description |
|---|---|---|
run_id |
string | 32-character lowercase hex process run ID. |
session_id |
string | 32-character lowercase hex protocol session ID. |
epoch |
integer | Monotonically increasing authority epoch. |
expires_at |
integer | Server lease expiry as Unix seconds. |
active |
boolean | Whether the stored expiry is later than the license service database clock. |
Complete example:
{
"license_id": 17,
"protocol_version": 2,
"key_fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"bound_machine_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pending_machine_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pending_run_id": "11111111111111111111111111111111",
"last_seen_at": 1791300000,
"session": {
"run_id": "11111111111111111111111111111111",
"session_id": "22222222222222222222222222222222",
"epoch": 3,
"expires_at": 1791303600,
"active": true
},
"server": {
"status": "online",
"last_seen_at": 1791300000,
"offline_after_seconds": 90,
"received_at": 1791300000,
"telemetry": {
"version": 1,
"sampled_at": 1791300000,
"hostname": "trade-node-01",
"interfaces": [
{
"name": "eth0",
"ips": ["192.0.2.10", "2001:db8::10"],
"mac": "02:00:00:00:00:01"
}
],
"cpu": {
"cores": 8,
"model": "Example virtual CPU",
"usage_percent": 23.4,
"load1": 0.48,
"load5": 0.42,
"load15": 0.39
},
"memory": {
"total_bytes": 17179869184,
"available_bytes": 8589934592,
"used_bytes": 8589934592,
"process_rss_bytes": 1073741824
},
"uptime_seconds": 86400,
"hour": {
"started_at": 1791298800,
"cpu_peak_percent": 61.5,
"memory_peak_used_bytes": 9126805504,
"load1_peak": 1.2,
"samples": 40
},
"counters": {
"sampled_at": 1791300000,
"accounts_count": 25,
"open_trades_count": 17,
"pending_orders_count": 3
}
}
}
}
The server object is additive, so older clients can ignore it. A response from
an older license-service backend may omit the object; consumers should treat
missing presence or telemetry as unknown. Its fields are:
| Field | Type | Description |
|---|---|---|
status |
string | online, offline, or unknown, based on the license-service database clock and authenticated owner activity. |
last_seen_at |
integer or null | Database time in Unix seconds of the most recent persisted fresh authenticated owner acquire/renew/release; hello, clone reports, and replayed requests do not refresh it. |
offline_after_seconds |
integer | Staleness threshold; 90. |
received_at |
integer or null | Database time in Unix seconds when the most recent valid telemetry sample from an authenticated owner report was accepted. |
telemetry |
object or null | Most recently accepted telemetry snapshot, or null when none is available. |
Presence is online while a committed authenticated owner acquire/renew
was received less than 90 seconds ago; it is offline when that time is at
least 90 seconds old or an owner release has been persisted. It is unknown
when there is no qualifying presence record or the background record has not
caught up with the current owner sequence. Presence is independent of lease
validity: a fresh authenticated owner renewal can update presence even when its
license response remains LEASE_EXPIRED. It never grants permission.
An omitted or malformed telemetry value does not refresh received_at; the
prior snapshot is retained for the same owner. A new owner without a valid
snapshot clears the previous owner's metrics. Valid owner activity still updates
presence. The runtime route does not itself affect lease validity.
The paginated license-list route can opt in to a compact per-license presence
summary and deduplicated interface IPs with include_server=true; see
License and Licensed Brand Administration.
When neither an active authority row nor a prepared automatic upgrade exists,
the runtime route returns protocol_version: 1, null binding/pending/session
fields, and last_seen_at: 0. A prepared automatic upgrade also reports
protocol_version: 1, but can include pending_machine_id, pending_run_id,
and a nonzero last_seen_at; it has no binding or session yet. A v2 capsule
issued before the authority schema was available can also have no authority or
upgrade record; operators must verify capsule version separately before using
enrollment.
Bind a runtime — PUT /v1/license/license/runtime¶
The request body is a plain object:
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | Yes | License record ID; minimum 1. |
machine_id |
string | Yes | Exactly 32 lowercase hexadecimal characters from the target machine's /etc/machine-id. |
{
"id": 17,
"machine_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
The response is the refreshed runtime-authority object shown for the GET route.
The binding is not changed while a different machine has an unexpired lease;
that request returns 409 LICENSE_IN_USE. Binding the same machine is safe to
repeat. A missing v2 authority row returns 409 V2_LICENSE_REQUIRED.
Enroll an already issued v2 capsule — POST /v1/license/license/runtime/enroll¶
Use this route only for an existing capsule that is known to contain
protocol_version: 2 but was issued before its authority row existed. This is an
explicit policy change: it adds v2 enforcement to that license identity and
operator-binds it to the supplied machine ID. It does not reissue or modify the
capsule. Stop or upgrade any old binary using that capsule before enrollment;
older binaries may ignore the version field and keep cached legacy permission.
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | Yes | License record ID; minimum 1. |
protocol_version |
integer | Yes | Must be 2; confirms the operator's intended enrollment version. |
machine_id |
string | Yes | Exactly 32 lowercase hexadecimal characters for the approved machine. |
key_fingerprint |
string | Yes | 64 lowercase hex characters; must match the fingerprint returned by the GET route. |
{
"id": 17,
"protocol_version": 2,
"machine_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"key_fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
The response is the refreshed runtime-authority object from the GET route. The authority row and initial binding are created atomically. Repeating enrollment for the same identity and binding is idempotent; it cannot change a binding while another machine has an active lease.
Runtime administration errors are returned through the license service's
standard Moleculer error response and identified by numeric code and type:
| HTTP code | Error type | Meaning |
|---|---|---|
404 |
LICENSE_NOT_FOUND |
The license ID does not exist. |
409 |
V2_LICENSE_REQUIRED |
Binding requires an existing runtime-v2 authority record. |
409 |
LICENSE_IN_USE |
An unexpired lease prevents changing the approved machine. |
409 |
LICENSE_IDENTITY_CHANGED |
The supplied public-key fingerprint does not match the stored license identity during enrollment. |
500 |
LICENSE_V2_ADMIN_FAILED |
An unexpected runtime-authority administration failure. |
422 |
INVALID_MACHINE_ID |
Machine ID is not exactly 32 lowercase hex characters. |
422 |
INVALID_LICENSE_PROTOCOL_VERSION |
Enrollment version is not integer 2. |
422 |
INVALID_KEY_FINGERPRINT |
Fingerprint is not 64 lowercase hex characters. |
503 |
LICENSE_V2_NOT_READY |
Required runtime authority schema is unavailable. |
Framework parameter-validation errors use the service's standard validation response. These administrative routes do not expose private keys.
NATS request/reply protocol¶
The v2 client sends requests through the v2 DNS host lic.scaletrade.com on the
existing common NATS port and with the existing credentials. It publishes to
lic.v2.request and sets its NATS reply subject to
lic.v2.response.<license-uid>. The client subscribes only to that exact
per-license subject. The subject is stable across process restarts; protocol
fields (license, run_id, and request_id) and the signature still determine
whether a message is accepted.
The client configures a synchronous subscription queue limited to 16 messages and 2 MiB total pending data. Each request has one five-second deadline. During that deadline, it ignores malformed messages, oversized messages, mismatched license/run/request identifiers, and invalid signatures without extending the deadline. NATS subject wildcards route messages; they do not authenticate a license or isolate users that share credentials and the same NATS account.
For comparison, v1 continues using its original IP endpoints, credentials,
lic.events request subject, lic.events.<license-uid> replies, and payload
format. Existing v2 clients that still reply through a validated NATS _INBOX
need the service's response permission until those clients have been upgraded.
The license service's NATS connection remains plaintext. Only the Main Server
v2 client connects to the v2 host over TLS.
The v2-capable license service validates the reply destination before sending a
response. It accepts the exact lic.v2.response.<response-license-uid> subject
for the response license, and retains strict validation of NATS _INBOX
subjects for earlier v2 clients. Upgrade the license service before deploying
the client that uses the exact per-license subject; otherwise requests may time
out. Keep the validated _INBOX path during a mixed rollout, then remove its
response permission after all earlier v2 clients are upgraded. During overlap,
the shared client user must also retain its existing _INBOX.> subscription
permission for earlier v2 clients; updated clients do not need that permission.
Subject permissions¶
Use subject allow-lists for each NATS user. The following is a minimal example
for these license flows; merge the listed subjects with any other subjects the
deployed service already uses. The license service also publishes
lic.events.init, which is covered by lic.events.*. The legacy lic.state
value is an event inside a lic.events.<license-uid> payload, not a separate
NATS subject. Keep allow_responses while any deployed v2 service path relies on
validated _INBOX reply subjects.
authorization {
users: [
{
user: "client"
password: "<existing-main-server-password>"
permissions: {
publish: { allow: ["lic.events", "lic.v2.request"] }
subscribe: { allow: ["lic.events.*", "lic.v2.response.*"] }
}
}
{
user: "server"
password: "<existing-license-service-password>"
permissions: {
publish: { allow: ["lic.events.*", "lic.v2.response.*"] }
subscribe: { allow: ["lic.events", "lic.v2.request"] }
allow_responses: true
}
}
]
}
NATS allow lists deny every subject not listed. The service may require
additional permissions for other configured integrations; preserve those when
adding these restrictions. The lic.v2.response.* wildcard permits routing to
all matching license reply subjects for that user. It is not per-license
authorization. Cryptographic signatures and the encrypted session envelope
provide protocol authentication and confidentiality for session payloads;
hello, challenge, and signed error envelopes are signed but not encrypted.
Every v2 envelope is a JSON object with exactly these twelve fields. Unknown,
missing, or duplicate fields are rejected. The serialized message is limited to
1 MiB, including the Base64 ciphertext and signature. Requests still contain the
complete brand/symbol report and fail closed if they exceed that limit. Every
successful grant retains the complete mandatory license state and brand admission
policy. Optional hashes and assets updates can span consecutive grants when
including all metadata would exceed the envelope limit. The receiver merges
these updates into its existing per-brand and per-symbol caches; an omitted item
is not a deletion.
The service partitions optional updates in stable order (hashes, then assets),
selecting batch (request.seq - 1) % batch_count. With stable metadata/report
ordering, successive acquire/renew responses deliver every batch; the initial
acquire starts at batch zero. Cached retries return the same signed response.
Small responses retain their original full arrays. Partitioning uses exact
UTF-8 and Base64 size accounting before encryption; it does not retry encryption
of different content with the same nonce. No envelope fields, algorithms, keys,
subjects or payload-size limits change.
If the mandatory state or one optional item cannot fit in a bounded response,
the operation fails closed instead of truncating it. Acquire returns the signed
V2_RESPONSE_TOO_LARGE error after rollback; internal renewal failures retain
the current unexpired lease without extending it. Validate realistic datasets
on a stand before rollout.
| Field | Type | Constraint |
|---|---|---|
v |
integer | Exactly 2. |
type |
string | hello, challenge, acquire, renew, release, result, or error. |
license |
string | 1–128 characters from A-Z, a-z, 0-9, _, -. |
run_id |
string | Exactly 32 lowercase hexadecimal characters; one Main Server process run. |
request_id |
string | Exactly 32 lowercase hexadecimal characters; unique request/idempotency identifier. |
session_id |
string | Empty for an initial hello and its plain error response; otherwise 32 lowercase hexadecimal characters. |
epoch |
integer | Nonnegative safe integer, at most 9007199254740991. |
seq |
integer | Nonnegative safe integer, at most 9007199254740991. |
nonce |
base64 string | Canonical standard base64; empty for plain handshake/error messages, otherwise exactly 12 decoded bytes. |
body |
base64 string | Canonical standard base64 containing plaintext handshake/error JSON or encrypted JSON bytes. |
tag |
base64 string | Canonical standard base64; empty for plain messages, otherwise exactly 16 decoded AES-GCM tag bytes. |
signature |
base64 string | Canonical standard base64 with exactly 64 decoded Ed25519 signature bytes. |
A complete shape-only hello example is below. The zero-filled public-key and
signature values are fictitious placeholders and are not cryptographically
valid; the body decodes to the payload shown after it.
{
"v": 2,
"type": "hello",
"license": "example-license-uid",
"run_id": "11111111111111111111111111111111",
"request_id": "33333333333333333333333333333333",
"session_id": "",
"epoch": 0,
"seq": 0,
"nonce": "",
"body": "eyJtYWMiOiIwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZiIsImNsaWVudF9wdWJsaWMiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBPSIsImNsaWVudF9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE9In0=",
"tag": "",
"signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
Decoded hello body:
{
"mac": "0123456789abcdef0123456789abcdef",
"client_public": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"client_nonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
For automatic migration, the Main Server adds auto_upgrade: 1 to this
plaintext body. The service includes the same field in the signed challenge
body, and the client checks it before staging the candidate:
{
"mac": "0123456789abcdef0123456789abcdef",
"auto_upgrade": 1,
"client_public": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"client_nonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
The service answers with a signed plain challenge envelope. The following
shape-only example uses all-zero base64 placeholders; its signature and key
values are not cryptographically valid.
{
"v": 2,
"type": "challenge",
"license": "example-license-uid",
"run_id": "11111111111111111111111111111111",
"request_id": "33333333333333333333333333333333",
"session_id": "22222222222222222222222222222222",
"epoch": 0,
"seq": 0,
"nonce": "",
"body": "eyJjbGllbnRfcHVibGljIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT0iLCJjbGllbnRfbm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBPSIsInNlcnZlcl9wdWJsaWMiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBPSIsInNlcnZlcl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE9IiwiY29va2llIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBPT0iLCJjaGFsbGVuZ2Vfc2Vjb25kcyI6NjB9",
"tag": "",
"signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
A signed plain error has the same complete envelope layout, with empty
nonce and tag. For example, a license without an authority record may receive
V2_LICENSE_REQUIRED; the zero signature below is a shape placeholder only.
{
"v": 2,
"type": "error",
"license": "example-license-uid",
"run_id": "11111111111111111111111111111111",
"request_id": "33333333333333333333333333333333",
"session_id": "",
"epoch": 0,
"seq": 0,
"nonce": "",
"body": "eyJvayI6ZmFsc2UsImNvZGUiOiJWMl9MSUNFTlNFX1JFUVVJUkVEIn0=",
"tag": "",
"signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
The error body decodes to {"ok":false,"code":"V2_LICENSE_REQUIRED"}. The
challenge body decodes to client_public and client_nonce (echoed from
hello), server_public and server_nonce (the service ephemeral values), an opaque
cookie, and challenge_seconds: 60. The cookie is server-sealed state and is
returned unchanged inside the encrypted acquire payload. A challenge can be
consumed only once and expires after 60 seconds. The cookie does not contain an
ephemeral private key. The database stores session keys in a sealed
session_keys column; it clears that material when caching the immutable
acquire response, including a denied acquire. The sealing key is derived from
the existing license identity key with HKDF. This does not provide full forward
secrecy against historical database backups containing the sealed session key.
A session result uses the same twelve-field envelope with a 12-byte nonce,
16-byte tag, ciphertext body, and service signature. This shape-only example
uses placeholder ciphertext and an invalid all-zero signature; a real result is
authenticated and encrypted, so these bytes cannot be decoded as a grant.
{
"v": 2,
"type": "result",
"license": "example-license-uid",
"run_id": "11111111111111111111111111111111",
"request_id": "33333333333333333333333333333333",
"session_id": "22222222222222222222222222222222",
"epoch": 1,
"seq": 1,
"nonce": "AAAAAAAAAAAAAAAA",
"body": "AAAAAAAAAAAAAAAAAAAAAA==",
"tag": "AAAAAAAAAAAAAAAAAAAAAA==",
"signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
The first encrypted client request is acquire at epoch: 0, seq: 1. Its
plaintext contains cookie, mac, symbols, and brands; a policy-v1 client
also includes brand_policy_version: 1. For example, this decoded plaintext
opts into policy version 1:
following decoded plaintext is illustrative; in the actual envelope it is
AES-GCM encrypted:
{
"cookie": "[fictitious opaque challenge placeholder]",
"mac": "0123456789abcdef0123456789abcdef",
"symbols": ["EURUSD"],
"brand_policy_version": 1,
"brands": [
{
"brand": "example-brand",
"terminal_host": "trade.example.test",
"enable": 1,
"status": 0,
"dark_logo": "[fictitious asset reference]",
"light_logo": "[fictitious asset reference]",
"favicon": "[fictitious asset reference]",
"settings": {}
}
]
}
During automatic migration, the encrypted acquire and subsequent renew
reports in that migration session also contain "auto_upgrade": 1. The service
requires it in the first acquire together with the signed challenge echo.
Later renew uses the same report fields without cookie; release uses an
empty JSON object. The service returns encrypted result messages for acquired,
renewed, released, and policy-denied sessions. A signed plain error envelope is
used before a session is available.
Cryptographic profile¶
The v2 exchange retains the existing AES-encrypted license capsule. It uses the crosswise Ed25519 identity keys already associated with that license:
- The Main Server signs client envelopes using the capsule's
private_key. The license service verifies them with the license record's public key. - The license service signs server envelopes with its configured service
identity. The capsule's
public_keylets the Main Server verify those replies. - Private keys are not returned by runtime administration responses. V2 does not create a new permanent per-instance key file or a second service key pair.
The two peers generate ephemeral X25519 key pairs for each handshake. They derive independent client-to-service and service-to-client material with HKDF-SHA256. The KDF salt is SHA-256 of the compact JSON transcript array:
["alfa-license/v2/kdf", "<license>", "<run_id>", "<hello_request_id>", "<session_id>", "<client_public>", "<server_public>", "<client_nonce>", "<server_nonce>"]
The HKDF info values are alfa-license/v2/client-to-service and
alfa-license/v2/service-to-client. Each direction derives 36 bytes: a 32-byte
AES key and a 4-byte nonce prefix. Ephemeral public values and 32-byte nonces are
base64-encoded.
The AES-256-GCM nonce is the direction's 4-byte prefix followed by the 64-bit big-endian sequence number. The AAD is the compact JSON array:
["alfa-license/v2", "client-or-service", 2, "type", "license", "run_id", "request_id", "session_id", 1, 1, "base64-nonce"]
Ed25519 signs a compact JSON array with the same header and then body and
tag:
["alfa-license/v2", "client-or-service", 2, "type", "license", "run_id", "request_id", "session_id", 1, 1, "base64-nonce", "base64-body", "base64-tag"]
client-or-service is the literal direction string client for requests and
service for responses. The exact scalar types and values are the JSON envelope
fields; JSON object member order is not used for signing because both peers build
the canonical arrays in this fixed order. This authenticates both the header and
ciphertext/tag and prevents cross-direction replay. Plain handshake and error
messages use the same Ed25519 signature scheme without AES-GCM.
Handshake, lease, and retry behavior¶
hellocarries the machine ID, ephemeral client public key, and client nonce. The service verifies the signature against the license identity and returns a signedchallengewith a 60-second lifetime.- The client and service derive directional session keys.
acquirereturns a durable lease only if the authority row is bound to the reported machine, the license status is trial/active/development, and the license has not expired. - A successful lease lasts at most 3600 seconds and never beyond license expiry. The client renews every 30 seconds. It computes its deadline from the original request send time and ends permission two seconds before the service expiry; retries and delayed replies do not reset that monotonic clock.
- Renewals and releases use strictly increasing sequence numbers within the current session and epoch. The service persists each accepted request ID, request hash, sequence, and response in the shared database transaction. Repeating the same request returns the saved response; reusing an ID with different content is rejected. Challenges are single-use.
- The service serializes ownership changes with database row locks. A fresh
authenticated acquire on the approved machine replaces the previous session
immediately, even while its recorded lease is unexpired. The new session has
a higher epoch; old-session renew/release requests receive
SESSION_NOT_OWNERand cannot modify the new owner. A different machine receivesINSTANCE_NOT_BOUNDand requires administrator-approved binding transfer. A disabled or expired license cannot acquire a replacement. A denied renewal invalidates that Main Server's local permission. Exact acquire retries return their original cached response and do not reclaim ownership or extend expiry.
After validating and applying the first authenticated lease, the Main Server logs this Info message to stdout through its normal logger (when Info logging is enabled):
License v2 approved; authenticated lease applied successfully
It logs the message again after a successful reacquisition following session reset or lease expiry. Routine 30-second renewals do not repeat it. Establishing TLS alone, denied responses, and release acknowledgments do not produce this approval message.
Encrypted lease denials are logged using fixed diagnostic messages for
BINDING_REQUIRED, LICENSE_NOT_ACTIVE, LICENSE_IN_USE (older service
behavior), and LEASE_EXPIRED. INSTANCE_NOT_BOUND logs a machine-change
message requesting administrator-approved transfer. Unknown denial codes use
the generic binding/status message. Raw response bodies and key material are
never included in these diagnostics. For LICENSE_NOT_ACTIVE, compare license
status and expiry with the MySQL database time used by the NATS processor, not
only the Console browser clock.
If renewal or NATS connectivity fails, the Main Server retains only the previously authenticated lease until its original local deadline. There is no outage grace after expiry. The last valid grant allows at most one hour without successful renewal; retries never start another hour. A fresh process still needs an authenticated grant and cannot start offline using the previous process's lease. After a crash, the approved machine can acquire a new session without waiting for release or prior expiry. The local lock file prevents ordinary simultaneous starts on one host; the shared MySQL-backed license-service database is the durable authority across service replicas and hosts. The NATS client reconnects without a retry limit and configures its reconnect publish buffer to zero; protocol-level retries retain immutable request IDs. A stale or expired initial grant causes the client to abandon that challenge and start a fresh handshake. During graceful shutdown, it stops the worker and invalidates local lease permission without sending or waiting for owner release. An exchange already in progress can finish its bounded timeout before the worker joins. Older v2 clients may still send release; the service continues accepting it only from the current owner. The local lock is not a hardware identity check and does not prevent cloned hosts from competing through the shared lease.
Reports and grant payload¶
The encrypted acquire and renew request body contains:
| Field | Type | Constraints |
|---|---|---|
mac |
string | 32 lowercase hex characters from /etc/machine-id; this is a machine ID, not a network-interface MAC address. |
cookie |
string | Required only in the first acquire; opaque challenge state. |
symbols |
string array | At most 20,000 names; each name is 1–255 characters. |
brands |
object array | At most 1,000 reported brand records. |
brand_policy_version |
number | Optional opt-in; the only supported value is integer 1. Omitted by legacy v2 clients. |
telemetry |
object | Optional schema-version-1 snapshot; at most 32 KiB of JSON. Older v2 clients may omit it. |
Each reported brand object contains brand (string, 1–255 characters),
terminal_host (string, up to 2,048 characters), enable (integer 0 or 1),
status (integer), dark_logo (string), light_logo (string), favicon
(string), and settings (object). Brand synchronization updates reported host
and enablement while preserving its administrative status and retaining omitted
registry records.
Optional runtime telemetry¶
When ILicenseTelemetryApi is available, the client can include a telemetry
object in the encrypted acquire and renew reports. It is optional for all v2
clients, including older v2 runtimes that do not implement the side interface.
The telemetry JSON has schema version 1 and a maximum size of 32 KiB. The
normal report cadence is 30 seconds; an immutable protocol retry reuses its
cached request and response instead of resampling. Runtime counters are cached
for 300 seconds.
The complete telemetry object shape is:
{
"version": 1,
"sampled_at": 1791300000,
"hostname": "trade-node-01",
"interfaces": [
{
"name": "eth0",
"ips": ["192.0.2.10", "2001:db8::10"],
"mac": "02:00:00:00:00:01"
}
],
"cpu": {
"cores": 8,
"model": "Example virtual CPU",
"usage_percent": 23.4,
"load1": 0.48,
"load5": 0.42,
"load15": 0.39
},
"memory": {
"total_bytes": 17179869184,
"available_bytes": 8589934592,
"used_bytes": 8589934592,
"process_rss_bytes": 1073741824
},
"uptime_seconds": 86400,
"hour": {
"started_at": 1791298800,
"cpu_peak_percent": 61.5,
"memory_peak_used_bytes": 9126805504,
"load1_peak": 1.2,
"samples": 40
},
"counters": {
"sampled_at": 1791300000,
"accounts_count": 25,
"open_trades_count": 17,
"pending_orders_count": 3
}
}
| Object / field | Type and meaning |
|---|---|
version |
Integer schema version; currently 1. |
sampled_at |
Integer Unix seconds when this snapshot was sampled. |
hostname |
Host's reported hostname string. |
interfaces |
Array of up to 32 interfaces. Each has name (string), ips (array of up to 16 valid local non-loopback IPv4/IPv6 strings), and mac (physical interface MAC string in colon-separated hexadecimal form, or an empty string when unavailable). |
cpu.cores |
Integer logical CPU count. |
cpu.model |
CPU model string. |
cpu.usage_percent |
Number or null; busy percentage calculated from an interval between /proc/stat samples. |
cpu.load1, cpu.load5, cpu.load15 |
Load-average numbers or null when unavailable. |
memory.total_bytes, memory.available_bytes, memory.used_bytes |
Integer memory sizes in bytes. |
memory.process_rss_bytes |
Process resident-set size as an integer number of bytes, or null. |
uptime_seconds |
Host uptime in integer seconds, or null. |
hour.started_at |
Integer Unix seconds at the start of the current UTC hour. |
hour.cpu_peak_percent |
Highest sampled CPU percentage during this hour, or null. |
hour.memory_peak_used_bytes |
Highest sampled used-memory value during this hour in integer bytes, or null. |
hour.load1_peak |
Highest sampled one-minute load average during this hour, or null. |
hour.samples |
Unsigned integer number of samples in the current hour bucket. These are sampled maxima, not continuous absolute peaks. |
counters.sampled_at |
Integer Unix seconds when the in-memory runtime counters were sampled. |
counters.accounts_count |
Nonnegative integer count of cached accounts, including disabled and demo accounts. |
counters.open_trades_count |
Nonnegative integer count from the in-memory open-trade index for TS_OPEN_NORMAL, TS_OPEN_REQUEST, TS_OPEN_RESTORED, and TS_PARTIAL_CLOSE_REQUEST. |
counters.pending_orders_count |
Nonnegative integer count from the in-memory pending-command index, including cached pending, cancel, and update requests. |
These counters are lookups in runtime managers' in-memory indexes/caches, not
SQL queries over trade or account history. They do not include total historical
trade counts or trade details. Reported addresses are local interface addresses;
the client does not infer a public NAT/external IP. An interface MAC is not the
/etc/machine-id used for license binding.
A successful decrypted result has this complete shape. Every result is carried
inside a complete v2 JSON envelope; this is the decoded application payload:
{
"ok": true,
"epoch": 1,
"issued_at": 1791300000,
"expires_at": 1791303600,
"lease_seconds": 3600,
"renew_interval_seconds": 30,
"license": {
"status": 1,
"initDate": 1790000000,
"expireDate": 1794000000,
"brands": 5,
"brand_policy_version": 1,
"modules": {
"prop_trade": true,
"copy_trade": false,
"backup": true,
"plugin": true,
"ai_agent": false
},
"brands_list": [
{
"brand": "example-brand",
"status": 0,
"api_allowed": true,
"terminal_allowed": true,
"approved_host": "trade.example",
"admission_reason": "admitted"
}
]
},
"hashes": [
{ "brand": "example-brand", "hash": "[fictitious content hash]" }
],
"assets": [
{
"symbol": "EURUSD",
"full_symbol_name": "EUR/USD",
"description": "Euro against US dollar",
"industry": "Financial services",
"sector": "Currencies",
"category": "Major",
"market": "FX",
"icon": "[fictitious icon reference]",
"exchange": "[fictitious venue]",
"analyst_rating": 0,
"ma_rating": 0,
"tech_rating": 0,
"os_rating": 0,
"market_cap": 0,
"shareholders": 0
}
],
"version_interface": "[fictitious interface version]",
"server_version": "[fictitious server version]"
}
The decrypted success-result top-level fields are:
| Field | Type | Description |
|---|---|---|
ok |
boolean | true for a successful grant. |
epoch |
integer | Current lease ownership epoch. |
issued_at |
integer | Grant issue time in Unix seconds from the license-service database clock. |
expires_at |
integer | Grant expiry time in Unix seconds, capped at license expiry. |
lease_seconds |
integer | Granted duration, at most 3600. |
renew_interval_seconds |
integer | Renewal interval; 30. |
license |
object | License state described below. |
hashes |
array | Incremental brand content hash updates; may contain one bounded batch. |
assets |
array | Incremental asset metadata updates; may contain one bounded batch. |
version_interface |
string | Interface version; empty string if not supplied. |
server_version |
string | Main Server compatibility version; empty string if not supplied. |
The service always includes these fields in license:
| Field | Type | Description |
|---|---|---|
status |
integer | License status: 0 trial, 1 active, or 2 development. |
initDate |
integer | Initialization time as Unix seconds. |
expireDate |
integer | Expiration time as Unix seconds. |
brands |
integer | Configured brand allowance. |
brand_policy_version |
integer | Present as 1 when the request opted into brand policy version 1. |
modules |
object | Configured module flags; supported fields are listed below and appear only when configured. |
brands_list |
array | Registered brand names and administrative statuses. Policy-v1 fields are included when the request opted in. |
Supported modules properties are optional booleans: prop_trade, copy_trade,
backup, plugin, and ai_agent. Every brands_list entry has brand
(string) and status (signed 32-bit integer). In a policy-v1 response, each
entry also has api_allowed (boolean), terminal_allowed (boolean),
approved_host (string or null), and admission_reason (string). The possible
admission reasons are legacy, admitted, limit_reached, not_reported,
deleted, terminated, stopped, and disabled. These fields describe brand admission;
they do not replace the Main Server's local enable or status checks. The
response hashes array is
always present; each entry has exactly brand and hash string fields. The
response assets array is always present. Each asset has these required fields:
| Field | Type |
|---|---|
symbol, full_symbol_name, description, industry, sector, category, market, icon, exchange |
string |
analyst_rating, ma_rating, tech_rating, os_rating, market_cap |
finite number |
shareholders |
signed 32-bit integer |
Missing or invalid cached text is normalized to an empty string and invalid or
missing numeric attributes to 0. version_interface and server_version are
strings and default to the empty string. The full success payload is encrypted
inside the wire envelope.
Brand policy negotiation and admission¶
Brand policy version 1 is an optional capability inside the encrypted v2
acquire and renew request body. A client opts in by sending
"brand_policy_version": 1. The successful result then includes
license.brand_policy_version: 1 and the policy fields on each license.brands_list
entry described above. A client that omits the marker retains legacy brand
admission behavior until it opts in. Once a license has entered policy-v1
enforcement, omission on a later renewal or reconnect does not disable that
enforcement or restore legacy admission. The value must be the number 1, not
a string; an unsupported present value is rejected as INVALID_V2_PAYLOAD.
brand_policy_versions is optional discovery metadata for clients that want
to check support before opting in; it is not a precondition for sending the
marker. The server recognizes the marker when the optional policy schema is
available. If that schema is unavailable, the service retains its existing
behavior. Runtime-protocol readiness and brand-policy readiness are advertised
independently.
The license's brands value is the configured allowance. Under policy version
1, 0 means unlimited; otherwise it is the maximum for new API brand
admission. Existing registered brands are grandfathered at first enrollment and
continue to work even when the count is above the allowance. Present brands with
administrative status 0 (active) or 4 (pending) may be admitted within the
allowance; statuses 1 (terminated), 2 (stopped), and 3 (deleted) remain
blocked. A locally disabled brand has api_allowed: false and
admission_reason: "disabled", but still consumes allowance; historical
registry rows absent from the current report do not.
API admission and terminal use are separate. Terminal use requires an approved
canonical host and an active brand. On first enrollment, an already active brand
with a valid reported host can be bootstrapped to that host once. Subsequent host
changes require operator approval. Approving a pending brand atomically changes
its administrative status to active; stopped, terminated, and deleted brands
are never silently reactivated. Revoking host approval removes terminal
permission but does not revoke API admission. The Main Server continues to
enforce local enable and status checks. Operators can inspect and update policy
through License and Licensed Brand Administration.
Asset rows without a string symbol are omitted from assets; rows with a
symbol are returned using the field normalization described above.
A denied encrypted result contains ok: false and code. Encrypted policy denials are
BINDING_REQUIRED, INSTANCE_NOT_BOUND, LICENSE_NOT_ACTIVE,
and LEASE_EXPIRED. Signed plain error envelopes can report
V2_LICENSE_REQUIRED, LICENSE_IDENTITY_CHANGED, CHALLENGE_EXPIRED,
CHALLENGE_USED, SESSION_NOT_OWNER, INVALID_SEQUENCE, and
REQUEST_ID_REUSED. Internal acquire processing failures also return a signed
plain error after the failed transaction has rolled back:
| Code | Failed acquire stage |
|---|---|
V2_GRANT_FAILED |
Building the lease and brand/asset state. |
V2_CAPSULE_FAILED |
Decrypting, validating, or re-encrypting the existing capsule during migration. |
V2_ACTIVATION_FAILED |
Persisting the migrated capsule or activating its authority record. |
V2_STORAGE_FAILED |
Loading/saving challenge state or persisting the granted session. |
V2_RESPONSE_TOO_LARGE |
Mandatory lease state or an individual metadata item cannot fit in the 1 MiB envelope. |
These responses contain only ok: false and code in the signed error body,
for example {"ok": false, "code": "V2_CAPSULE_FAILED"}. The service logs a
sanitized stage and allowlisted cause as
[LICENSE_V2] processing_failed stage=capsule_validate cause=INVALID_LICENSE_CAPSULE.
No SQL text, driver message, bindings, keys, capsule, or decrypted reports are
logged.
Capsule validation causes distinguish CAPSULE_INVALID_PAYLOAD,
CAPSULE_UID_MISMATCH, CAPSULE_INVALID_SIGNING_KEY and
CAPSULE_SIGNING_KEY_MISMATCH. These describe rejected validation checks;
they do not include identity/key contents. Authenticated acquire validation failures can additionally emit
[LICENSE_V2] processing_failed stage=acquire_decrypt cause=INTERNAL_ERROR.
The stage is one of challenge_unseal, session_keys_unseal, acquire_decrypt,
acquire_reports, acquire_cookie, or response_encrypt; these diagnostics
preserve the original exception/rejection behavior and do not bypass any
cryptographic or payload checks. An error response is generated only after rollback; it does not commit
migration or a lease. Transient internal renew failures retain their previous
no-response behavior so they do not revoke an existing offline lease.
Malformed or unauthenticated protocol requests are not converted into grants.
Runtime acquire no longer emits LICENSE_IN_USE; the code
remains applicable to administrative binding changes with an active lease.
For example, an encrypted denial payload after decryption may
be:
{
"ok": false,
"code": "INSTANCE_NOT_BOUND"
}
The machine binding is an operator control based on /etc/machine-id, not a
hardware anti-clone guarantee. A copied machine image can contain both the same
machine ID and license capsule. Same-machine session replacement means cloned
hosts with identical identities can each receive a grant in succession. Only the
latest session can renew, but the previous client can retain its already granted
offline permission until its original deadline unless it receives a denial.
There is no guarantee of immediate revocation of a disconnected copy. The
license-service database clock issues lease timestamps, and the
client validates timestamps against its wall clock before enforcing a deadline
with its monotonic clock; synchronize the database host and Main Server system
clocks to avoid rejecting otherwise valid grants. Exclusivity also assumes a
stable synchronized database clock: a large forward clock step can expire the
stored authority before an existing client's monotonic lease deadline. Avoid
database clock steps while leases are active; arbitrary clock jumps are not
guaranteed safe.
Runtime access behavior¶
The lease is required before new sessions and risk-increasing requests are
admitted. A blocked HTTP action returns status 503 with this complete body:
{
"error": "LICENSE_NOT_ACTIVE",
"message": "An active license lease is required for this operation"
}
The same response applies if a license expires between authentication and
creation of a new access token. Current client session actions covered include
AuthUser, AuthCustomer, AuthManager, SSOAuthUser, and AffiliateLogin.
Risk-increasing trade actions include opening market/pending orders and modifying
open/pending orders. The gate is applied to those commands as a whole; it does
not inspect whether a particular modification would reduce or increase risk.
Existing close and partial-close requests, cancellation handlers, and automatic
risk processing remain available under their ordinary authorization and
account/group checks.
| Interface | Behavior without an active v2 lease |
|---|---|
| Client REST | New sign-in/token actions and risk-increasing trade actions return HTTP 503 LICENSE_NOT_ACTIVE. |
| TCP | Newly accepted TCP clients are refused while the lease is inactive; guarded session/trade actions return the same license error. Guarded trade commands include opens and manager open/update/restore operations. |
| FIX | A new Logon is rejected with reason LICENSE_NOT_ACTIVE. |
| WebSocket | Authorization emits { "event": "authorization", "success": false, "error": "LICENSE_NOT_ACTIVE" }. |
GetTradingReadiness |
trading_ready is false; the status fields remain available for diagnosis. |
| Close/cancel and automatic risk | Existing close/partial-close and cancellation paths remain available; automated risk processing continues. |
For protocol-v2 runtimes, trading_ready now also requires an active lease in
addition to the server initialization and accepted license statuses. This field
is an admission/readiness signal, not a promise that market data, routing, or a
specific trade will succeed.
TLS and deployment¶
Main Server v2 uses the separate hostname lic.scaletrade.com with the existing
common NATS port and credentials. Its three DNS A records are:
| Address | Role |
|---|---|
23.88.60.116 |
NATS broker address advertised by DNS. |
94.130.26.55 |
NATS broker address advertised by DNS. |
116.203.207.112 |
NATS broker address advertised by DNS. |
Configure every broker reached through these records with a public-CA
certificate whose SAN contains the DNS name lic.scaletrade.com. Install the
matching private key on that broker and serve the complete certificate chain in
leaf-first order. The client validates the certificate chain and DNS identity;
do not distribute a broker private key to Main Server or the license service.
The v2 host is selected through the new selector 4 of the existing
GetProtect / getProtectedValue configuration access methods; the XOR-masked host values are obfuscation, not
confidentiality. The existing v1 selector and original IP endpoints are
unchanged. V2 has no plaintext or IP-endpoint fallback. Deploy matching rebuilt
Main Server and libstcore binaries that provide selector 4; an older Main
Server does not provide this selector. The SDK ILicenseApi ABI layout remains
unchanged.
DNS resolution can return and shuffle the configured A records, but it does not perform health checks. Whether the client proceeds to another address after a TLS failure on a reachable broker has not been confirmed. Test the TLS certificate and connection path against each resolved broker address in a remote stand before rollout; do not assume DNS alone provides health-aware failover.
Keep the v1 IP endpoints on the existing shared listener while migrating. The listener must allow both TLS for v2 and legacy plaintext for v1:
allow_non_tls: true
tls {
cert_file: "/path/to/server.crt"
key_file: "/path/to/server.key"
}
The Main Server v2 client uses TLS to lic.scaletrade.com; the license-service
NATS connection remains plaintext as currently configured. V1 Main Servers
retain their original IP endpoints and plaintext behavior. Do not disable
plaintext on the shared listener until every legacy client has been upgraded and
separately verified.
Compatibility and migration checklist¶
Build the v2-capable Main Server and libstcore remotely from matching source
versions. The optional lease interface requires the updated Main Server for v2;
the existing ABI-4 interface remains unchanged for v1. Compilation is not performed
locally under the server project's build policy.
The license-service checkout provides an explicit schema setup command:
node scripts/setup-license-runtime-v2.js stage --apply
Run it from the license-service root for the intended environment: dev,
stage, or prod. The second argument must be --apply; omitted or invalid
arguments print usage and exit with code 2 without accessing SQL. The command
uses the same Knex migration runner and ledger as service startup; it is an
optional manual setup/check path, not a prerequisite for an ordinary Docker
start. It reads that environment's existing configuration and credentials,
applies only missing migrations, then validates all prerequisites. It does not
alter the existing licenses or brands tables, enroll licenses, issue
capsules, or change keys. Setup or validation failures return exit code 1; a
successful check returns 0. Deployment preserves current config credentials,
signing identities, and v1 NATS endpoints; v2 host selection is described in
the TLS section below.
Knex uses its standard migration lock. MigrationLocked and
ER_TABLE_EXISTS_ERROR conditions are retried up to 60 times, one second apart.
After that the migration attempt fails; the service does not delete or force
unlock another process's or a stale lock. Check that no migration process is
still running before an operator resolves a stuck lock. Do not remove lock or
history rows blindly.
MySQL DDL can leave partial tables if interrupted. Because the migration is not
recorded until its up step succeeds, the next startup retries that migration;
the migration skips already-created tables and records the step after a successful
run. Full structural readiness is checked separately afterward; a history entry
does not guarantee that an earlier partial table has the required structure.
If startup still reports incomplete schema, inspect its columns, indexes,
collations and engines in information_schema, repair
the schema explicitly, then restart the service. The migration runner does not
automatically drop/recreate partial tables or alter existing licenses or
brands. If migration execution fails, v2 remains unready. The service still
probes the authority store even after failed migration and continues rejecting
protected UIDs on the legacy protocol; v1 continues when the existing legacy
schema is usable.
- Back up the license-service database, then start the service normally so
pending migrations run automatically (or use the optional setup command).
Confirm all three new runtime tables and the existing
licensesandbrandstables all use InnoDB; verify required columns,ascii_binlicense_uidcollations, primary keys, and the challenge uniqueness key. The capability endpoint must reportruntime_v2_ready: true. Resolve any partial schema or engine issue explicitly, then restart the service before expecting readiness. - Upgrade every license-service replica that can serve the target license UID
before protecting it with a v2 authority row. Confirm old license-service
workers are drained. Confirm the upgraded service rejects every message for
a protected UID on legacy
lic.events, including brand configuration. - Install the v2-capable license service and configure the already approved
Ed25519 service identity key. Confirm the issuance capabilities route reports
[1]while runtime schema is unavailable and[1, 2]only after it is ready. Confirm v1 create/check behavior and NATS connectivity remain unchanged. - Configure all three brokers advertised for
lic.scaletrade.comwith a matching public-CA certificate chain and key, and enable TLS on the existing common NATS port while retaining legacy plaintext. Verify DNS-name SAN validation and a TLS connection to each broker, as well as unchanged v1 plaintext connections to the original IP endpoints, before issuing v2 in the stand. Do not rely on unverified TLS-failure failover between DNS answers. - Issue a v2 license only after runtime schema readiness. Confirm the new
authority row exists and the capsule contains
protocol_version: 2. Existing v1 licenses remain v1 until a current Main Server and upgraded license service complete the automatic migration described above. - For a newly issued v2 license, start the target runtime and verify the
authenticated hello/challenge/acquire exchange, pending machine ID, and
initial
BINDING_REQUIREDdenial. An operator confirms the machine ID and binds it through the superadmin route; confirm the next lease acquisition succeeds. For automatic migration, verify the signedauto_upgrade: 1handshake, candidate persistence, first lease, and finalprotocol_version: 2without a Console binding action. - Verify the capability GET, authority GET, bind PUT, and (only when needed) explicit enrollment route against expected input errors and response shapes. Do not enroll a v1 capsule. For enrollment, check the actual capsule version, public-key fingerprint, and that the old runtime is stopped/upgraded first.
- Start two runtime copies for one license using separate test hosts, then
verify a changed machine receives
INSTANCE_NOT_BOUND. Restart the approved machine without release and confirm immediate acquisition with a higher epoch. Test concurrent same-machine acquisitions across replicas: both can receive grants in succession, but only the latest owner can renew. A delayed old renew or release must returnSESSION_NOT_OWNERwithout changing the new authority. - Retry an identical request, replay a challenge, reuse a request ID with changed content, and send a bad signature/tag/sequence. Confirm exact retries return the stored response and altered/replayed messages are rejected. Validate the largest realistic complete brands/symbols/asset dataset and confirm requests or grants above the 1 MiB envelope limit fail closed.
- Interrupt NATS before and after lease acquisition. Confirm the client retains only its original unexpired lease, does not reset the monotonic deadline on retries and blocks after expiry with no additional grace. A new process on the approved machine must acquire without waiting for old expiry or release, but cannot start offline. Verify the database and client clocks remain synchronized; exercise an out-of-tolerance time offset in the isolated stand and confirm the grant is rejected safely.
- Check readiness and public access gates: v2 without a lease fails readiness; new sign-in and risk increases return the documented errors; close/partial close, cancels, and automatic risk processing remain available; verify FIX Logon and WebSocket authorization responses.
- Keep a v1 license and old Main Server running against the mixed listener. Verify v1 capsule parsing, NATS subject, and behavior stay unchanged. The old binary does not probe or migrate its license. For a planned manual enrollment, stop the old process before binding the v2 runtime; the server cannot remotely revoke cached permission in an old binary. Confirm every license-service replica is upgraded before allowing automatic migrations and that no old replica remains after a UID commits to v2.
These are required stand checks, not claims that a local or production deployment
has passed them. Cross-language crypto vectors, compiled runtime behavior,
certificate validation on the deployed NATS version, multi-replica database
behavior, and production outage/recovery checks remain subject to remote
verification. The server repository provides remote-only CTest targets
license_lease_policy and license_v2_crypto, including public Node fixture
vectors; compilation and those tests were not run locally.