Change management in Topologix
Monitoring & Observability
A change is a planned deploy, configuration or infrastructure change. You name the components it touches; Topologix takes a snapshot of their blast radius (+1 or +2 hops over the dependency graph and the hardware they run on), holds notifications for that blast radius only while the window is open (everything else keeps alerting; held alerts are still evaluated and recorded), re-sends the held alarms that are still failing after the change, then collects evidence — alerts, health transitions, log volume and error fingerprints (Graylog, Elasticsearch / OpenSearch, OTLP logs) and RED metrics against the hour before and the same hour a week earlier — into a stored report with an impact verdict, e-mailed to the recipients. Pipelines drive the same lifecycle through the Changes API, which is reachable only through your on-prem proxy. By default every change first needs approval by a quorum of the organisation's designated approvers (people, signed in); on approval Topologix can open a ticket in Jira, Asana, ServiceNow or your own webhook, and it calls your pipeline back at every step.
Requirements
- A Topologix account in an organisation. Everyone in the organisation sees its changes; the owner, an organisation admin or a super admin changes them.
- The components must be in the dependency graph (traces, OTLP, NetFlow, discovered infrastructure). Keys the graph has not seen yet are kept and listed as not silenced.
- For the API: an organisation API token (Organisation → API tokens — owners and admins) and a proxy agent ≥ 1.206.0 owned by someone in the organisation, with Expose the Changes + Catalogue API on this proxy (port 4320) switched on. Callbacks through the proxy need proxy ≥ 1.226.0; the catalogue inventory needs proxy ≥ 1.262.0 (older proxies answer 404 for it).
- For the report, approval and decision e-mails: SMTP configured on the server (without it the report says so and the e-mails are skipped; everything is still stored and shown on the change page).
- For approvals: at least as many approvers as the quorum (Organisation → Members → Approver) — or set approvals to Not required. A new organisation starts at every change needs 2 approvals with nobody designated, so changes wait until they expire.
Register a change in the UI
- Op
- Gi
- Pi
- Ch
- Ch
- <b
How it works
register → snapshot → silence → evidence → report
The lifecycle
A change is draft / scheduled → active (the window) → evaluating (after finish, until the evidence horizon) → reported; or cancelled. Under the organisation's approval setting it first passes pending_mapping / pending_approval → approved (or ends rejected / expired) — see Approval workflow for CI/CD.
- Snapshot. Saving stores the blast radius as a versioned snapshot (a new version per edit while scheduled); the report compares against exactly this set.
- Silence. From the window start, a suppression holds notifications for the components, the services they call and that call them (up to the hops), their runs-on hardware and the single-service metric sources that stand for those services. Nothing outside it is held.
- Held release. Five minutes after finish the suppression ends; every held alarm whose subject is still failing (re-checked) is re-sent once, prefixed
[held during change "<title>"]. Alarms that recovered during the change are not re-sent. Switch it off per change with Release held alerts at finish. - Evidence. After the horizon, the window start → finish + horizon is compared with the hour before and the same span a week earlier; flags need both sides measured (a "—" is not measured, never zero).
- Verdict. The first rule that matches: outage (an error on a component still open at the end, a component down ≥ 5 min), significant (a component moved against every baseline, an alert that fired and recovered, an error on an affected neighbour — not a component — still open at the end, …), minor, none (measured, nothing moved) or unknown (nothing measured). The report names the rule and what matched.
- What the verdict does not count. A subject that entered error 3 or more times inside the window is unstable (flapping) — it is not an open error, it makes the change minor. A subject already in error when the window started is pre-existing — listed in the caveats, never counted (unless it also flaps). A latency alarm at threshold 0 fires on every sample — a rule defect (Kubernetes cluster scope excepted, where 0 is by design): skipped, minor when it was open at the end or flapped, and the caveat says to raise the source's latency alarm threshold. The report lists all three under Not counted by the verdict.
- Names, not keys. Alerts, transitions and the verdict's reasons print subjects by name —
<service> · latency · <source>,<node> · <rule>,<source> (source health); the CSV keeps the rawsubjectand addssubject_label. - AI assessment. After the report is stored, the AI model reads the same evidence (log samples already redacted; nothing else is sent) and gives its own tier, a confidence, a short summary, findings tagged impact / noise / pre-existing / rule defect and recommendations. It sits beside the rules' verdict on the change page, in the e-mail and in
report.html— it never changes the verdict; when the two disagree a Rules … · AI … line says so, and the changes list shows both chips. It runs when the organisation's AI assessment of change reports setting is on (Organisation → Settings, on by default, owner only) and a provider serves the organisation's Change assessment role (Organisation → AI models; automatically: its own cloud AI provider, else the platform Claude key where allowed, else its self-hosted model) — with none, nothing is sent and the page says “No AI provider is assigned to change assessment”. A model error, timeout or the hourly cap (30 per organisation) shows AI assessment unavailable (reason); the report and its e-mail are never held back by more than about two minutes. - Report. Stored as versioned HTML / text / CSV, shown on the change page, exported, e-mailed. Re-evaluate writes a new version.
What infrastructure a change silences
The blast radius walks down from every affected service to what it runs on — pods, Kubernetes nodes, VMs, hosts, datastores, devices. Most of that is shared: a worker VM, a host or a datastore carries many other services. Silencing it would hold their alarms too, so by default a change silences only its dedicated infrastructure.
- Dedicated (the default): a VM, host, pod, node, datastore or device is silenced only when every service above it — walked up its runs-on links — is part of the change. Anything else is shared: it keeps alerting and is listed once under Not silenced (“Shared infrastructure — 24 VMs, 17 hosts, …”).
- All: everything under the affected services is silenced, shared hosts and datastores included (the behaviour before server 1.324). Services only: no infrastructure is silenced. Infrastructure you name as a component is always silenced.
- Where to set it. The organisation default lives in Organisation → Settings → Change approvals (
silenceInfrain the change settings); a change overrides it in its editor or withsilenceInfraonPOST/PUT /api/changes(null= back to the default). - Counting. Pods count per workload (one Deployment, StatefulSet or DaemonSet = one item, all its replicas silenced). Each kind has a cap — workloads 200, VMs 200, hosts 100, Kubernetes nodes 100, datastores 50, devices 100 — closest first; what a cap leaves out is named under Not silenced. Over 600 silenced items after the caps the change is refused.
- The scope line reads Silence scope: 12 services · 2 databases · +54 infrastructure (3 held, 51 shared) — held = silenced, shared = left alerting because other services run on it; the per-kind breakdown is in its tooltip.
The dependency map
The change page (and the editor's preview) draws the change as Infrastructure → Topology's Focus mode does: every component as a focus card, its callers and callees one or two hops out (+0 / +1 / +2, Callers / Callees / Both), the runs-on chain, and a tint on what the change silences.
- Layers. Chips beside the hops choose which kinds of card are drawn: Services · Databases · Schemas · Workloads · K8s nodes · VMs · Hosts · Datastores · Devices. The default is services, databases and schemas — the readable picture of a large change; turn on a layer to see the infrastructure (a hidden card's runs-on links are bridged, so a service still connects to the host under its hidden VM). The choice is remembered per person. Layers are display only: they never change what the change silences. The components are always drawn.
- Shared infrastructure is drawn plain (not tinted) with a shared badge — other services run on it, so the change leaves it alerting.
- Same-named databases. A database or schema card names its instance — wallet_reset_db · on Prod (10.4.16.71) — so a primary and its replica never look like two identical cards.
- Large changes. The snapshot is stored compact; one that would still exceed 512 KB is trimmed with the call edges last, and each trim step is listed in the warnings. When the infrastructure cards or the call edges were trimmed, the page draws them from the current graph and the caption says so (“infrastructure and call edges from the current graph”); the blast radius and the silence scope stay exactly as registered.
Contracts on the blast radius
The change page's Contracts card (between the dependency map and the blast table) lists every service → service call edge of the blast radius with its contract: the routes of the callee this caller actually uses — method, route, calls, error share, p95 and a sample path — from the interface inventory (Catalogue → service → Interfaces). The header says how many edges have one (“3 of 4 call edges have a contract · 11 routes”); an edge no trace ever showed says no traces seen for this edge instead of an empty contract, and a route that is also reached from outside carries an external ingress marker. Health probes and routes not seen recently (7 days by default) are left out.
- The same data adds · N routes to each peer line of the map's card popover and a contract line to each service row of the blast table.
- It is read when the page opens (never blocking it) and again after a recompute. Pipelines read it with
GET /api/changes/{id}/contracts— the input of the trace-derived smoke tests that come next.
Limits
Chosen so a runaway pipeline or a hub service cannot silence the estate.
| What | Limit |
|---|---|
| Components per change | 100 |
| Hops | 1 or 2 |
| Window | 5 minutes – 7 days (a change still running at its end is finished automatically) |
| Evidence horizon | 15 minutes – 24 hours (default 60) |
| Scope | ≥ 100 services needs confirmLarge; > 300 services, or > 600 silenced infrastructure items after the per-kind caps, is refused |
| Snapshot | 512 KB stored (compact); trimmed past it, call edges last |
| Live events per change | 2,000 |
| Recipients | 20 |
| Held release | once, ≥ 5 minutes after finish, ≤ 50 alarms |
| API tokens per organisation | 20 live |
| Relay | 60 requests / minute per token · 64 KiB body · 30 s upstream timeout |
| Catalogue inventory | pages ≤ 2 000 entities (default 500) · 200 neighbours per hop list · 2 hops · snapshot memoised 60 s · 12 MiB page |
| Retention | ended changes 365 days (Change management retention) |
Approval workflow for CI/CD
A pipeline (or a person) registers a change; Topologix matches its components to the catalogue, waits for a quorum of approvers, opens a ticket, calls the pipeline back, watches the deployment and the surveillance window after it, and mails the report to a completion list. Approvals are always made by people, signed in — an API token can register, poll, start and complete a change, never approve one.
The lifecycle
Statuses in order. rejected, expired, cancelled and reported are final.
| Status | What it means | How it moves on |
|---|---|---|
pending_mapping | A pipeline named components the catalogue does not know (the UI refuses them with candidates instead). | A person maps each name on the change page → pending_approval (or straight on when approval is not required). |
pending_approval | Waiting for the quorum. Its deadline is now + approval expiry (24 h by default). | N distinct approvals → approved — a critical change also needs one critical approver's approval, and a service without a tier must be classified by an approver first (see Criticality and critical approvers); the first rejection (with a reason) → rejected; the deadline → expired. Editing the change clears the decisions so far and asks again. |
approved | Cleared to start. A ticket is opened now (when a destination is set). | POST …/start, or at once with autoStart (a fixed UI window starts at its start time) → active; not started within the start timeout (24 h) → expired. |
active | The deployment window: notifications of the blast radius are held. A pipeline window is re-stamped now … now + maxDurationMinutes at the start, however long the approval took. | POST …/complete (or finish / abort; the window end finishes it automatically) → evaluating. |
evaluating | Surveillance: the evidence horizon after the deployment (the change's surveillanceMinutes, else the organisation's default, 60). | At the horizon → reported. |
reported | The evidence report and its impact verdict are stored. | The completion e-mail goes to the change's recipients ∪ the organisation's completion list; the ticket is updated (closed in ServiceNow, completed in Asana). |
Lifecycle
┌─ unrecognised names ─┐
POST /api/changes ──► pending_mapping ──(a person maps them)──┐
│ ▼
├─ gated ───────────────────────────────────► pending_approval ──(any rejection)──► rejected
│ │ └────(deadline)────────► expired
│ (quorum of approvals)
│ ▼
│ approved ──(not started in time)──► expired
│ │ (POST …/start, or autoStart)
└─ not gated (scheduled / startNow) ──────────────────┤
▼
active (the window: alerts of the
│ blast radius are held)
(POST …/complete · finish · abort · window end)
▼
evaluating (surveillance: the evidence
│ horizon, 15 min – 24 h)
▼
reported (report + completion e-mail)
Cancel: from pending_mapping, pending_approval, approved (and scheduled).Organisation settings
Organisation → Settings → Change approvals (the owner, an organisation admin or a super admin). The card warns when approval is required but fewer approvers than the quorum are designated.
| Setting | Default | Range / values |
|---|---|---|
Approval required (approvalRequired) | all | none — never; api — changes registered with an API token (pipelines); all — every change, from the UI too. A personal change (no organisation) is never gated; a draft is gated when it is scheduled or started. |
Quorum (quorum) | 2 | 1–25 distinct approvals; a quorum above the number of approvers is refused when you change it. |
Approval expiry (approvalExpiryMinutes) | 1,440 (24 h) | 15 min – 7 days; a fixed window's end caps it. |
Start timeout (startTimeoutMinutes) | 1,440 (24 h) | 15 min – 7 days after approval. |
Surveillance (surveillanceMinutes) | 60 | 15 min – 24 h; a pipeline overrides it per change with surveillanceMinutes. |
Completion recipients (completionRecipients) | none | Up to 25 addresses; every report goes to them as well as to the change's own recipients. |
Private callback addresses (allowPrivateCallbacks) | off | Lets callbacks and tickets sent by the server reach RFC 1918 / CGNAT / unique-local addresses (a self-hosted install's LAN). Loopback, link-local and cloud metadata addresses are always refused. |
| Critical approvers (Members → Critical) | none | Approvers flagged Critical; a change touching a critical service needs one of them besides the quorum. The card says Critical approvers: … and warns while approval is required but none is designated (a critical change would wait until it expires). |
Change tickets (ticketing) | none | One destination: Jira, Asana, ServiceNow or a webhook — with Test connection. Secrets are sealed at rest and shown as ••••; leave the field blank to keep the stored one. |
Approvers
Organisation → Members: switch Approver on for each person who may approve (up to 50, active members only). Only listed approvers can decide — not the owner nor a super admin unless they are listed — and never the person who registered the change (for a pipeline: the API token's owner). Each person decides once; any single rejection rejects the change and needs a reason. While approval is required, the list cannot shrink below the quorum: lower the quorum first.
Criticality and critical approvers
Every change gets a criticality — critical, important or standard — the highest catalogue tier of its service components (services and instances; the tier set on the Catalogue service page, or on an organisation's manual service). It shows as a chip next to the status in the list and on the change page; the list's Critical filter (GET /api/changes?criticality=critical) shows only those. The editor's preview says it before you save: Criticality: critical (payments-api).
| Components | Criticality | Approval |
|---|---|---|
| Every service component has a tier | The highest one (source components) | A critical change needs the quorum and at least one approval from a critical approver. Important and standard: the quorum alone. |
| Some service components have no tier | The highest known tier, or none (source pending) | Approve is refused (409, blockedOn: classification) until an approver classifies them; a rejection is always taken. |
| No service component (hardware, databases, externals only) | The highest tier among the services within its hops (source blast) | Informational only — never blocks, never asks for a critical approver. |
| Nothing tiered at all | standard (source none) | The quorum. |
- Classifying. While a component waits, the approval panel lists it with a tier picker for each approver who did not register the change (
POST /api/changes/{id}/classify); everyone else reads Waiting for classification of: X, Y. The tier is saved to the catalogue — on the owners whose catalogue carries the service, on the organisation's manual service, or (a service no catalogue has seen yet) under the change's owner — so later changes reuse it. A pipeline cannot classify:/statusshowsclassification.unknownandapproval.blockedOnwhile it waits. - Critical approvers. Organisation → Members: an approver gets a Critical switch next to Approver (
PUT …/change-approvers {userIds, criticalUserIds}). The panel says Needs a critical approver: A, B until one of them approves, and the approver list marks them. The requester never counts, even when they are a critical approver. - Mail. A critical change's approval request says it needs a critical approver; when a classification raises a change to critical, the critical approvers get a dedicated e-mail (unless one already approved).
- Removing the last critical approver is allowed: the settings card warns that a critical change would then wait until it expires.
- The criticality is fixed once the change leaves
pending_approval. Tickets carry it (ServiceNowimpact1 / 2 / 3).
Approval e-mails and the approve link
When a change enters pending_approval each approver (except the requester) gets Approval needed: <title> with the components, the window, the requester and two buttons: Approve or reject (/?view=changes&change=<id>&approve=1) and View details. The link only opens the change page with its approval panel focused — sign-in is required and nothing is approved by the click.
- Signed out, the link survives the login: a password login replays it after sign-in; Microsoft Entra ID carries it as a validated relative
returnToinside the signed login state and lands on the change instead of the home page. - The requester and the change's recipients get Approved (by whom), Rejected (by whom, with the reason) or Expired (the approval or the start deadline passed). A Mapping needed e-mail goes to the approvers and the organisation's owners / admins when a pipeline names unknown components.
- One message per recipient; an approver never sees who else was asked. Without SMTP the e-mails are skipped (a log line), never an error. Reserved test addresses (
.test,.example,.invalid,.localhost,example.com) are never handed to the mail server.
Component matching and mapping
Each component of a registration is resolved in this order, and the answer's match says how (exact, alias, manual or key): 1. exactly one catalogue hit for the name (case-insensitive); 2. an organisation alias — a name someone mapped before with remember; 3. an organisation manual service by name or key; else it is unresolved with up to 10 candidates. {kind, key} is taken as given (unseen keys are kept and listed as not silenced).
- A pipeline registration with unresolved names gets 201
pending_mapping(the matched part is kept); the UI gets a 400 with the candidates instead./statuslists the names and the link to map them. - On the change page each name shows candidate chips, a search, and Create service svc:<name> (an organisation manual service, labelled added manually in the catalogue and merged automatically when discovery later reports the same key). Remember this name saves an organisation alias so the next pipeline run matches by itself. Mapping needs write access to the change or being an approver of its organisation; a token can never map.
- When nothing is left, the blast radius is computed and snapshotted and the change goes on to approval (or starts, when approval is not required).
- Organisation → Settings → Component mappings lists the aliases (name → target, who created it, last used) and the manual services (merged or not), with edit and delete for owners / admins. These are organisation-wide and never touch the topology's own service aliases.
The pipeline API
Through the proxy relay with an organisation API token (see The Changes API). Registration fields beyond the basic change:
| Field | Meaning |
|---|---|
pipeline | {pipeline, run, runUrl, commit, version, environment, repo} — each ≤ 200 characters; shown on the change page, in the ticket and in /status. |
callbackUrl | http(s), ≤ 1,000 characters, no credentials: signed callbacks at every step. The 201 returns callbackSecret (cs_…) once; it is stored sealed. |
autoStart | Start the moment the quorum is reached (else POST …/start before the start timeout). |
maxDurationMinutes | The deployment window, 5 min – 7 days (default 60), re-stamped from the real start. |
surveillanceMinutes | 15 min – 24 h; the horizon after completion (alias of evidenceMinutes; default the organisation's). |
GET /api/changes/{id}/status(changes:read) —status,approval {state, approvals, required, expiresAt, decidedAt, reason},mappingwhilepending_mapping,deployment,surveillance {minutes, evaluateAt, reportedAt, impact, aiImpact},ticket,pipeline,links. Poll it every 30 s or so; fail the job on rejected / expired.POST /api/changes/{id}/start—approved→active(409 while still pending).POST /api/changes/{id}/complete{outcome?, notes?}— the outcome defaults to success; the change enters surveillance.- Callbacks:
change.mapped,.approved,.rejected,.expired,.started,.completed,.reported(+.test), each signedX-Topologix-Signature: sha256=HMAC(raw body, callbackSecret)— see Callbacks for the receiver and the retry rules.
Tickets
With a destination set, Topologix opens one ticket per change when it is approved (a change that never needed approval: when it starts) and adds an update at started, completed (or aborted), reported, and when an approved change is cancelled or expires before starting. The ticket's key and link are shown on the change page, in the report and in /status; the Pipeline card lists each ticket step (delivered / retrying / failed, with the reason). Steps go out from the Topologix server (public addresses, or private ones with allowPrivateCallbacks) on the same retry schedule as callbacks: 2xx done; 408 / 429 / 5xx / no answer retried; other 4xx final (bad credentials, an unknown project, a refused field).
| Destination | Opens | Updates | At the report |
|---|---|---|---|
| Jira Cloud (email + API token, REST v3) or Data Center (personal access token, REST v2) | An issue in the project, of the issue type (Task), with labels; description = the change, components, approvals, link. | A comment per step. | A comment with the verdict. Jira issues are never transitioned — your workflow decides. |
| Asana (personal access token) | A task in the project (workspace must match). | A story (comment) per step. | A comment, and the task is completed (Complete the task on report, on by default). |
| ServiceNow (Basic auth, an integration user) | A change_request (type normal / standard, correlation_id = the change id — a retry finds the record instead of duplicating it — start / end dates, optional assignment group). | work_notes + the state (table below). | Closed with close_code + close_notes (Close on report, on by default). |
| Webhook (your own bridge) | change.ticket.created; answer with {id, key, url} to store your ticket. | change.ticket.updated with update {event, text, close}. | update.close = {reason: reported, outcome, impact}. |
- The ServiceNow state model decides: a normal change still waiting for CAB authorisation in ServiceNow refuses a jump to Implement / Closed. Topologix then writes the work note alone and records state not changed on the step — it never fails the change over it.
ServiceNow state mapping
Each update PATCHes work_notes and, where the step has one, the state of the out-of-the-box change model:
| Topologix step | ServiceNow state | Also |
|---|---|---|
| started | -1 Implement | |
| completed (or aborted) | 0 Review | |
| reported | 3 Closed (when Close on report) | close_code unsuccessful when the outcome was rolled_back or the impact outage / significant, else successful; close_notes with the outcome, the impact and the report link. |
| cancelled after approval, or expired before starting | 4 Canceled | |
| state refused by the instance (400 / 403) | unchanged | The work note alone; state not changed recorded on the step. |
The ticket webhook
For any other system: point the webhook at a small bridge of your own. It is signed with the organisation's webhook secret (≥ 16 characters) exactly like pipeline callbacks — verify the HMAC over the raw body before parsing.
change.ticket.* contract
POST <your ticket webhook URL>
Content-Type: application/json
User-Agent: Topologix-Tickets/1
X-Topologix-Event: change.ticket.created | change.ticket.updated | change.ticket.test
X-Topologix-Delivery: dlv_… # the same on every retry of this delivery — drop duplicates
X-Topologix-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed by the organisation's webhook secret>
{ "event": "change.ticket.updated",
"deliveryId": "dlv_…",
"occurredAt": "2026-10-06T09:12:44.120Z",
"change": { …the GET /api/changes/{id}/status body… },
"ticket": { "kind": "webhook", "id": "OPS-42", "key": "OPS-42", "url": "https://tickets.example.com/OPS-42" }, # updates only
"update": { "event": "change.reported", # updates only
"text": "Surveillance report: impact none, outcome success. https://…",
"close": { "reason": "reported", "outcome": "success", "impact": "none" } } } # or {reason: cancelled}, or null
# Answer 2xx. To change.ticket.created you MAY answer with the ticket you opened —
# {"id": "OPS-42", "key": "OPS-42", "url": "https://…"} or {"ticket": {…}}
# — and Topologix stores it (shown on the change page, in the report and in /status); otherwise
# the ref is the change id with no link. 408 / 429 / 5xx / no answer → retried (1 m … 4 h, 8 attempts);
# any other 4xx is final.Signed-in API (settings, approvers, decisions, mappings)
The routes behind the Organisation settings card and the change page — a signed-in session (cookie + CSRF), never an API token: the relay refuses …/approve, …/reject, …/mappings, …/classify and …/callback-test with 403, and the public server never accepts a token.
OpenAPI 3.0 (YAML) — session routes
openapi: 3.0.3
info:
title: Topologix change approvals (signed-in session — never an API token)
version: "1"
servers:
- url: https://topologix.example.com # the Topologix server itself; cookie session + CSRF
components:
schemas:
ChangeSettings:
type: object
properties:
approvalRequired: { type: string, enum: [none, api, all], default: all, description: "none = never; api = changes registered with an API token (pipelines); all = every change, UI included" }
quorum: { type: integer, minimum: 1, maximum: 25, default: 2, description: "approvals needed; refused above the approver count when it changes" }
approvalExpiryMinutes: { type: integer, minimum: 15, maximum: 10080, default: 1440, description: "pending_approval / pending_mapping → expired after this" }
startTimeoutMinutes: { type: integer, minimum: 15, maximum: 10080, default: 1440, description: "approved but not started → expired after this" }
surveillanceMinutes: { type: integer, minimum: 15, maximum: 1440, default: 60, description: "the evidence horizon a pipeline change gets when it names none" }
completionRecipients: { type: array, maxItems: 25, items: { type: string, format: email }, description: "every report also goes here" }
allowPrivateCallbacks: { type: boolean, default: false, description: "server-side callbacks / tickets may reach RFC 1918 / CGNAT / ULA addresses" }
silenceInfra: { type: string, enum: [none, dedicated, all], default: dedicated, description: "the infrastructure the organisation's changes silence unless a change overrides it (see ChangeInput.silenceInfra in the relay API)" }
ticketing:
nullable: true
description: "ONE destination per organisation; null or {kind: none} clears it. The secret field comes back as •••• (+ hasSecret); send it blank or •••• to keep the stored one (same kind only)."
oneOf:
- { $ref: "#/components/schemas/JiraTicketing" }
- { $ref: "#/components/schemas/AsanaTicketing" }
- { $ref: "#/components/schemas/ServiceNowTicketing" }
- { $ref: "#/components/schemas/WebhookTicketing" }
JiraTicketing:
type: object
required: [kind, baseUrl, apiToken, projectKey]
properties:
kind: { type: string, enum: [jira] }
baseUrl: { type: string, format: uri, example: "https://acme.atlassian.net" }
deployment: { type: string, enum: [cloud, datacenter], default: cloud, description: "cloud = email + API token, REST v3 (ADF); datacenter = personal access token, REST v2" }
email: { type: string, format: email, description: "required for cloud — the account the API token belongs to" }
apiToken: { type: string, writeOnly: true }
projectKey: { type: string, example: OPS }
issueType: { type: string, default: Task }
labels: { type: array, maxItems: 10, items: { type: string } }
AsanaTicketing:
type: object
required: [kind, token, workspaceGid, projectGid]
properties:
kind: { type: string, enum: [asana] }
apiUrl: { type: string, default: "https://app.asana.com" }
token: { type: string, writeOnly: true, description: "a personal access token" }
workspaceGid: { type: string, pattern: "^[0-9]+$" }
projectGid: { type: string, pattern: "^[0-9]+$" }
completeOnReport: { type: boolean, default: true }
ServiceNowTicketing:
type: object
required: [kind, instanceUrl, username, password]
properties:
kind: { type: string, enum: [servicenow] }
instanceUrl: { type: string, example: "https://acme.service-now.com" }
username: { type: string }
password: { type: string, writeOnly: true }
changeType: { type: string, enum: [normal, standard], default: normal }
assignmentGroup: { type: string, nullable: true }
closeOnReport: { type: boolean, default: true }
WebhookTicketing:
type: object
required: [kind, url, secret]
properties:
kind: { type: string, enum: [webhook] }
url: { type: string, format: uri }
secret: { type: string, minLength: 16, writeOnly: true, description: "signs every ticket webhook (X-Topologix-Signature)" }
TicketTest:
type: object
properties:
kind: { type: string }
ok: { type: boolean }
message: { type: string }
steps: { type: array, items: { type: object, properties: { name: { type: string }, ok: { type: boolean }, status: { type: integer, nullable: true }, detail: { type: string, nullable: true } } } }
paths:
/api/organizations/{id}/change-settings:
get:
summary: The organisation's approval settings (owner, admin, super admin)
responses:
"200": { description: "{settings: ChangeSettings (+ ticketingKind, updatedAt, ticketingUnreadable when the stored destination cannot be opened), approvers [{userId, email, name, critical}], criticalApprovers [{userId, name}], limits, warning (the gate is on but too few approvers), criticalWarning (the gate is on but no critical approver is designated — a critical change would wait until it expires)}" }
"403": { description: "not a member, or a plain member" }
"404": { description: "unknown organisation" }
put:
summary: Change some of them (fields left out keep their value) — audited org_change.settings / org_change.ticketing
requestBody: { content: { application/json: { schema: { $ref: "#/components/schemas/ChangeSettings" } } } }
responses:
"200": { description: "as GET" }
"400": { description: "{error, field} — validation (a quorum above the approver count, a bad address, a ticketing field); nothing is written" }
/api/organizations/{id}/change-settings/ticketing-test:
post:
summary: Probe the stored destination, or a draft {ticketing, allowPrivateCallbacks} merged over it — nothing is saved (audited org_change.ticketing_test)
description: "Jira: GET myself + the project. Asana: users/me + the project (its workspace must match). ServiceNow: GET change_request?sysparm_limit=1. Webhook: a signed change.ticket.test POST."
responses: { "200": { description: "TicketTest", content: { application/json: { schema: { $ref: "#/components/schemas/TicketTest" } } } }, "400": { description: "no destination / validation" } }
/api/organizations/{id}/change-approvers:
put:
summary: "The whole approver list {userIds, criticalUserIds?} — active members only (≤ 50); while approval is required a removal may not drop it below the quorum. criticalUserIds ⊆ userIds marks the critical approvers; left out, the stored flags of those still listed are kept. Removing the last critical approver is allowed (criticalWarning says what it means). Audited with criticalAdded / criticalRemoved."
requestBody:
content:
application/json:
schema:
type: object
required: [userIds]
properties:
userIds: { type: array, maxItems: 50, items: { type: string } }
criticalUserIds: { type: array, items: { type: string }, description: "server ≥ 1.333.0 — each must also be in userIds" }
responses: { "200": { description: "as GET change-settings" }, "400": { description: "{error, field: userIds | criticalUserIds}" } }
/api/organizations/{id}/component-aliases:
get: { summary: "The organisation's remembered names (alias → {kind, key}, created by, last used) — members read, owner / admin write" }
/api/organizations/{id}/component-aliases/{alias}:
put: { summary: "Set {kind, key} for a name", responses: { "200": { description: "the alias" } } }
delete: { summary: "Forget a name", responses: { "200": { description: "ok" } } }
/api/organizations/{id}/manual-services:
get: { summary: "Manual catalogue services (svc:<slug>, name, merged when discovery found them)" }
post: { summary: "Create {name} → svc:<slug>", responses: { "200": { description: "the service" } } }
/api/organizations/{id}/manual-services/{key}:
delete: { summary: "Remove a manual service", responses: { "200": { description: "ok" } } }
/api/changes/{id}/approvals:
get: { summary: "{status, gated, required, approvals [{userId, name, decision, comment, at}], approvers [{userId, name, requester, critical}], canApprove, isRequester, isApprover, mine, expiresAt, criticality, criticalitySource, componentTiers, criticalRequired, criticalApproved, criticalApprovers [{userId, name, requester}], blockedOn (classification | critical_approver | null), blockedReason (‘Waiting for classification of: …’ / ‘Needs a critical approver: …’), unknownTierComponents [{key, name, kind}], canClassify}" }
/api/changes/{id}/approve:
post:
summary: "Approve {comment?} — a listed approver, never the requester, one decision per person; the quorum recount → approved"
responses: { "200": { description: "{status, approvals, required, criticality, criticalitySource, criticalRequired, criticalApproved, blockedOn} — a critical change stays pending_approval until the quorum AND one critical approver's approval are in" }, "403": { description: "not an approver / the requester / an API token" }, "409": { description: "not pending_approval, or already decided — or {error, blockedOn: classification, unknownTierComponents} while a service component has no tier (classify it first; a rejection is always taken)" } }
/api/changes/{id}/reject:
post:
summary: "Reject {reason} (required) — the first rejection rejects the change"
responses: { "200": { description: "{status: rejected}" }, "400": { description: "no reason" } }
/api/changes/{id}/classify:
post:
summary: "Classify service components that have no catalogue tier (server ≥ 1.334.0) — a listed approver of the organisation, never the requester, never an API token; pending_approval only. The tier is saved to the catalogue (the owners whose catalogue carries the service, an organisation manual service, or — a key no catalogue has seen — the change's owner) and reused by every later change. Audited change.classify; a classification event on the change; raising it to critical mails the critical approvers."
requestBody:
content:
application/json:
schema:
type: object
required: [components]
properties:
components:
type: array
items: { type: object, required: [key, tier], properties: { key: { type: string, example: "svc:payments-api" }, tier: { type: string, enum: [critical, important, standard] } } }
responses:
"200": { description: "{classified [{key, name, tier, where: catalogue | manual | unseen, owners}], criticality, criticalitySource, componentTiers, unknownTierComponents, change}" }
"400": { description: "{error, field: components | tier} — not an unclassified service component of this change (an already-tiered one is refused: the catalogue holds it), a bad tier, a duplicate" }
"403": { description: "not an approver, the requester, or an API token" }
"404": { description: "not found" }
"409": { description: "not pending_approval / moved meanwhile" }
/api/changes/{id}/mappings:
post:
summary: "Map the unresolved names of a pending_mapping change — write access or an approver of its organisation"
requestBody:
content:
application/json:
schema:
type: object
properties:
entries:
type: array
maxItems: 50
items:
oneOf:
- { type: object, required: [input, kind, key], properties: { input: { type: string }, kind: { type: string }, key: { type: string }, remember: { type: boolean, description: "save an organisation alias: the name matches by itself next time" } } }
- { type: object, required: [input, createService], properties: { input: { type: string }, createService: { type: object, properties: { name: { type: string } } }, remember: { type: boolean } } }
confirmLarge: { type: boolean }
responses: { "200": { description: "{change, remaining, status} — remaining 0 → pending_approval (or scheduled / active with the gate off)" }, "409": { description: "not pending_mapping / ≥ 100 services without confirmLarge" }, "422": { description: "scope over 300" } }
/api/changes/{id}/callback-test:
post: { summary: "Send a signed change.test to the change's callbackUrl now (write access)", responses: { "200": { description: "{delivery}" } } }Security notes
What protects the workflow.
- Tokens only through the proxy relay. The public server answers every
Bearer tpx_…with 401 without looking it up; the proxy checks token hashes and relays with the token's sha256 — the token never leaves your network. - Approvals are human-only. Approve, reject, mapping and classification need a signed-in session; the approve link in an e-mail only opens the page. The requester can never approve their own change; the quorum is recounted under the change's lock.
- Callbacks go out through the proxy the change was registered through (proxy ≥ 1.226.0), else from the server. Server-side callbacks and tickets pass an SSRF guard: every DNS answer is judged and the socket is pinned to the checked address; loopback, link-local / cloud metadata, multicast and unspecified addresses are always refused; RFC 1918 / CGNAT / unique-local only with
allowPrivateCallbacks; no redirects; 10 s; responses capped. - Secrets at rest. The per-change callback secret and the ticket destination (incl. its token / password) are sealed with the organisation's data key and never returned (
••••). Settings changes, ticket tests, approver changes, aliases and manual services are audited.
The Changes and Catalogue APIs (through your proxy)
Why proxy-only: an API token that the public server accepted would be a credential usable from anywhere on the internet. Instead, the server never accepts an API token — it answers 401 "API tokens are only accepted through your on-prem proxy" without even looking it up. Your pipeline calls the proxy on the LAN; the proxy checks the token against the hashes it receives on its config poll, and relays the call over its own authenticated channel with the sha256 of the token — the token itself never leaves your network. The server verifies again, as the token's owner, in the token's organisation.
Tokens
Organisation → API tokens (owners and admins): New API token → a name, the scopes, an optional expiry. The token (tpx_ + 8 + 43 characters) is shown once with a copy button and a pipeline snippet — store it as a masked CI/CD secret. The list shows each token by its prefix, scopes, created / last used (updated at most once a minute) and expiry; Revoke stops it within a config poll.
changes:read—GET(list, one change, reports).changes:readalso coversGET /api/changes/{id}/status(the poll).changes:write—POST/PUT(register, edit, start, finish / complete, abort, cancel, evaluate). Approve, reject, map and the callback test are never available to a token.catalogue:read—GET /api/catalog/inventory(see Catalogue inventory). GET only; it is not in a token's default scopes — tick it for a token that should read the inventory (a changes-only token gets 403 there, and a catalogue-only token gets 403 on/api/changes).- A change registered with a token is owned by the token's owner and records the token and the proxy (
viaTokenId/viaProxyId); the report's "Registered by" line names them. A token stops working when its owner leaves the organisation. - Tokens are scrubbed from logs and uploads everywhere by the built-in Topologix API token redaction rule (part of the secret floor).
Switch the relay on
Settings → Proxies → edit the proxy → Expose the Changes + Catalogue API on this proxy (port 4320). Within one config poll (≤ 15 s) the proxy opens TCP 4320 and receives the organisation's token hashes. The proxy's Activity panel shows a Changes + Catalogue API relay · port 4320 row (forwarded, rejected, rate-limited, upstream errors).
- Firewall: allow TCP 4320 from your build agents only — LAN, never the internet. Every request is refused in this order before anything else happens: not
/api/changes…or/api/catalog/inventory…(proxy ≥ 1.262.0) → 404; no or unknown token → 401 (before the body is read); over 60 / min for that token → 429; body over 64 KiB → 413. - Port and bind address: Settings → Proxies → Proxy settings → Changes + Catalogue API relay (proxy ≥ 1.235.0; default 4320 on all interfaces).
- HTTPS on the relay:
apiRelay.tlsstays in the proxy'sconfig.json(the certificate and key never leave the machine) — set it with a certificate your agents trust. OtherapiRelaykeys left in config.json are imported once after the upgrade; then remove them.
proxy config.json (optional — only for HTTPS; the server switch is enough for plain HTTP on 4320)
{
"apiRelay": {
"tls": { "certPath": "/etc/topologix/relay.crt", "keyPath": "/etc/topologix/relay.key" }
}
}Catalogue inventory
GET /api/catalog/inventory (scope catalogue:read, proxy ≥ 1.262.0) lists every entity the Topology graph knows — services, instances, databases, schemas, externals, VMs, hosts, pods, Kubernetes nodes, datastores, devices, volumes and business processes — each with its kind, name, key and its dependencies in both directions: uses (what it calls or runs on; a business process: its members) and usedBy (the reverse), each as hop1 and hop2 lists. It answers what the token's owner sees on the Topology: the owners of their visible integrations, database and schema names only of integrations they can see, and business processes as the token's owner sees them (owned or shared with them — two tokens of one organisation can see different processes).
viaon a hop-1 neighbour:calls(a call edge from traces, OTLP or NetFlow),runs-on(a placement — a service on a VM / pod / node / host, a pod on a node, a node that is a VM, a VM on a host or datastore, a host that is a device, a database on its instance, a schema in its database, a volume on a host),member(a business process and its services / assets).stale: truemarks a stale link. Hop-2 neighbours carry key, kind and name only.- Rules: the shortest depth wins (a key is never in both hop lists of one side, the entity itself never); an external is reached but not walked through on the
usesside; stale call edges count only withincludeStale=1, stale placements always (as the blast radius does). Each list holds ≤ 200 neighbours and each walk examines ≤ 2 000 edges — beyond either, the entity saystruncated: trueandmeta.truncatedEntitiescounts them. - Keys:
svc:<name>(services and data-source instances),ext:<host>,db:<instance key>/<database>,schema:…,bp:<id>, and the infrastructure keys of the Topology. For one entity,GET /api/catalog/inventory/{key}with the keyencodeURIComponent-ed — always for a key with/or spaces, such asdb:svc:orders pg/orders→db%3Asvc%3Aorders%20pg%2Forders. - Paging:
limit(1 … 2 000, default 500) andoffset, filtered bykind(comma-separated) andq. Every page is cut from one snapshot memoised for 60 s —snapshotAtnames it; when it changes between two pages, start again at offset 0.hops=1empties the hop-2 lists for a smaller page; a page over 12 MiB answers 413 — lowerlimitorhops. - Read-only: a
POST/PUTto it is refused with 400. A signed-in session reads the same route on the Topologix server.
every service and instance with its +1 / +2 dependencies (bash + jq)
curl -s -H "Authorization: Bearer $TOPOLOGIX_TOKEN" \
"http://proxy.lan:4320/api/catalog/inventory?kind=service,instance&hops=2&limit=500" \
| jq '.entities[] | {key, name, uses: [.uses.hop1[].key], usedBy: [.usedBy.hop1[].key]}'
# one entity by key (encode it)
curl -s -H "Authorization: Bearer $TOPOLOGIX_TOKEN" \
"http://proxy.lan:4320/api/catalog/inventory/$(jq -rn --arg k 'svc:checkout' '$k|@uri')" | jq .entityOpenAPI
The relay serves the same Changes API as the UI uses, under the token's owner, and the catalogue inventory (/api/catalog/inventory). Components are {kind, key} or {query} (resolved through search: exactly one case-insensitive name match, else 400 with the candidates). Responses are JSON except report.html (the report e-mail, Content-Security-Policy: default-src 'none') and report.csv (an attachment).
OpenAPI 3.0 (YAML)
openapi: 3.0.3
info:
title: Topologix Changes and Catalogue APIs (through the on-prem proxy relay)
version: "1"
servers:
- url: https://proxy.lan:4320 # your proxy; http:// unless the proxy has apiRelay.tls
security:
- apiToken: []
components:
securitySchemes:
apiToken:
type: http
scheme: bearer
bearerFormat: "tpx_<8>_<43>" # an organisation API token (Organisation → API tokens)
schemas:
Component:
type: object
description: "{kind, key} — or {query}: a name resolved through search (exactly one match)"
properties:
kind: { type: string, enum: [service, instance, database, schema, vm, host, datastore, pod, node, device, external, integration] }
key: { type: string, example: "svc:checkout-api" }
query: { type: string, example: "checkout-api" }
ChangeInput:
type: object
required: [title, components]
properties:
title: { type: string, maxLength: 200 }
kind: { type: string, enum: [deploy, config, infra, other], default: other }
description: { type: string }
ticketUrl: { type: string, format: uri }
components: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/Component" } }
hops: { type: integer, enum: [1, 2], default: 1 }
startNow: { type: boolean, description: "start the window now (windowEnd defaults to now + 1 h)" }
windowStart: { type: string, format: date-time }
windowEnd: { type: string, format: date-time, description: "5 min – 7 days after the start" }
evidenceMinutes: { type: integer, minimum: 15, maximum: 1440, description: "the surveillance horizon; default the organisation's (60)" }
surveillanceMinutes: { type: integer, minimum: 15, maximum: 1440, description: "alias of evidenceMinutes (evidenceMinutes wins)" }
maxDurationMinutes: { type: integer, minimum: 5, maximum: 10080, description: "a pipeline window: now … now + d, re-stamped when the change starts (after approval)" }
autoStart: { type: boolean, description: "start the moment the change is approved (startNow under approval)" }
pipeline:
type: object
description: "who registered it — each value ≤ 200 characters; shown on the change page and in /status"
properties:
pipeline: { type: string, example: "deploy-checkout" }
run: { type: string, example: "981" }
runUrl: { type: string, format: uri }
commit: { type: string }
version: { type: string }
environment: { type: string, example: production }
repo: { type: string, example: "acme/checkout" }
callbackUrl: { type: string, format: uri, maxLength: 1000, description: "http(s): signed callbacks at every step (see Callback) — POSTed by the proxy the change was registered through" }
recipients: { type: array, maxItems: 20, items: { type: string, format: email } }
releaseHeld: { type: boolean, default: true }
confirmLarge: { type: boolean, description: "required when the scope has ≥ 100 services" }
silenceInfra:
type: string
enum: [none, dedicated, all]
nullable: true
description: |
Which infrastructure under the affected services the change silences (server ≥ 1.324.0).
dedicated = a VM / host / pod / k8s node / datastore / device only when EVERY service on it
is part of the change (shared infrastructure keeps alerting); all = everything under them;
none = services only (infrastructure you name as a component is always silenced).
Omitted / null = the organisation's default (ChangeSettings.silenceInfra, itself dedicated
by default). Display filters such as the map's layers never change it.
ScopeCounts:
type: object
description: "What a change silences (and what it leaves loud). Pods count per WORKLOAD (one Deployment = one item)."
properties:
services: { type: integer }
items: { type: integer, description: "silenced infrastructure items after the per-kind caps (pods per workload) — over 600 is refused" }
workloads: { type: integer }
vms: { type: integer }
hosts: { type: integer }
nodes: { type: integer }
datastores: { type: integer }
devices: { type: integer }
pods: { type: integer, description: "silenced pods (all replicas of the silenced workloads)" }
shared: { type: integer, description: "blast infrastructure NOT silenced because other services run on it (dedicated mode)" }
capped: { type: integer, description: "dedicated items left out by a per-kind cap (workloads 200, VMs 200, hosts 100, k8s nodes 100, datastores 50, devices 100) or the blast radius's own ceiling — named under notSilenced" }
externals: { type: integer }
sources: { type: integer }
Blast:
type: object
description: |
A change's blast-radius snapshot (topologix/change-blast/1) as the API answers it. Stored compact
(server ≥ 1.325.0 — keys and reason phrases once, infrastructure columnar) and always answered
in this full shape. Over 512 KB stored, it is trimmed in this order, each step named in
warnings: the infrastructure items' reason chains → the map cards' details → pods folded into
workloads on the map → the infrastructure cards (mapInfra: live) → the service reason chains,
then the lists (with a warning; counts and scope stay whole) → the call edges, last
(edgesDropped). The scope is never trimmed.
properties:
scope: { type: object, description: "serviceKeys, assetKeys, hvEntityKeys (= assetKeys), sourceIds, integrationIds — what the suppression holds" }
counts: { $ref: "#/components/schemas/ScopeCounts" }
silenceInfra: { type: string, enum: [none, dedicated, all] }
notSilenced: { type: array, items: { type: object, properties: { kind: { type: string, enum: [monitors, multi-service-source, external, unseen, shared-infra, capped, infra-off] }, name: { type: string }, why: { type: string }, items: { type: array, items: { type: object } }, count: { type: integer } } } }
edges: { type: array, description: "call edges among the affected services", items: { type: object } }
nodes: { type: array, description: "the dependency map's cards as registered", items: { type: object } }
lineage: { type: array, description: "runs-on links among the cards", items: { type: object } }
refused: { type: string, nullable: true }
large: { type: boolean }
warnings: { type: array, items: { type: string } }
derivedFromLive: { type: boolean, description: "read only — the map was drawn from the current graph (a snapshot without one)" }
infraFromLive: { type: boolean, description: "read only — the infrastructure cards were trimmed (mapInfra: live) and come from the current graph" }
edgesFromLive: { type: boolean, description: "read only — the stored snapshot had no call edges (trimmed); they were rebuilt from the current call graph" }
Change:
type: object
properties:
id: { type: string, example: "chg_3f0c…" }
status: { type: string, enum: [draft, scheduled, pending_mapping, pending_approval, approved, rejected, expired, active, evaluating, reported, cancelled] }
outcome: { type: string, enum: [success, partial, rolled_back, unknown], nullable: true }
impact: { type: string, enum: [outage, significant, minor, none, unknown], nullable: true }
windowStart: { type: string, format: date-time }
windowEnd: { type: string, format: date-time }
viaTokenId: { type: string, nullable: true }
viaProxyId: { type: string, nullable: true }
silenceInfra: { type: string, enum: [none, dedicated, all], nullable: true, description: "the change's override; null = the organisation's default" }
criticality: { type: string, enum: [critical, important, standard], nullable: true, description: "the highest catalogue tier of its service components (null while none of them has a tier)" }
criticalitySource: { type: string, enum: [components, blast, none, pending], nullable: true, description: "components = every service component is tiered; pending = some have no tier yet (approval waits for an approver to classify them); blast = no service component, taken from the services within the hops (informational); none = nothing tiered → standard" }
componentTiers: { type: object, nullable: true, additionalProperties: { type: string, nullable: true }, description: "svc: key → its tier (null = unclassified)" }
unknownTierComponents: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string }, kind: { type: string } } }, description: "service components without a tier while the change waits" }
canClassify: { type: boolean, description: "the caller may classify them now (always false for an API token)" }
Status:
type: object
description: "GET /api/changes/{id}/status — and every callback's change"
properties:
id: { type: string }
title: { type: string }
status: { type: string, enum: [draft, scheduled, pending_mapping, pending_approval, approved, rejected, expired, active, evaluating, reported, cancelled] }
approval:
type: object
properties:
state: { type: string, enum: [not_required, mapping, pending, approved, rejected, expired] }
approvals: { type: integer, nullable: true }
required: { type: integer, nullable: true, description: "the organisation's quorum" }
expiresAt: { type: string, format: date-time, nullable: true, description: "the approval deadline while pending; the start deadline once approved" }
decidedAt: { type: string, format: date-time, nullable: true }
reason: { type: string, nullable: true, description: "the rejection reason" }
criticalRequired: { type: boolean, description: "a critical change (from its components): besides the quorum, a critical approver has to approve it" }
criticalApproved: { type: boolean, nullable: true }
blockedOn: { type: string, enum: [classification, critical_approver], nullable: true, description: "why a pending change cannot be approved yet beyond the quorum" }
criticality: { type: string, enum: [critical, important, standard], nullable: true }
criticalitySource: { type: string, enum: [components, blast, none, pending], nullable: true }
classification:
type: object
description: "only while service components wait for an approver to classify them"
properties:
unknown: { type: array, items: { type: string }, description: "their names" }
url: { type: string, description: "the change page, where an approver classifies them" }
mapping:
type: object
description: "only while pending_mapping"
properties:
unresolved: { type: array, items: { type: string } }
url: { type: string, description: "the change page, where a person maps them" }
deployment: { type: object, properties: { startedAt: { type: string, nullable: true }, finishedAt: { type: string, nullable: true }, windowStart: { type: string }, windowEnd: { type: string }, outcome: { type: string, nullable: true }, autoStart: { type: boolean } } }
surveillance: { type: object, properties: { minutes: { type: integer }, evaluateAt: { type: string, nullable: true }, reportedAt: { type: string, nullable: true }, impact: { type: string, nullable: true }, aiImpact: { type: string, nullable: true } } }
ticket: { type: object, nullable: true }
pipeline: { type: object, nullable: true }
links: { type: object, properties: { change: { type: string }, approve: { type: string } } }
Callback:
type: object
description: |
POST <callbackUrl>, Content-Type: application/json, with
X-Topologix-Event: change.mapped | change.approved | change.rejected | change.expired |
change.started | change.completed | change.reported | change.test
X-Topologix-Delivery: dlv_… (the same on every retry — use it to drop duplicates)
X-Topologix-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed by callbackSecret>
Answer 2xx. 408 / 429 / 5xx / no answer → retried after 1 m, 5 m, 15 m, 1 h, 4 h, 4 h, 4 h
(8 attempts); any other 4xx (and a 3xx — redirects are never followed) ends it.
properties:
event: { type: string }
deliveryId: { type: string }
occurredAt: { type: string, format: date-time }
change: { $ref: "#/components/schemas/Status" }
Error:
type: object
properties: { error: { type: string } }
InventoryNeighbour:
type: object
required: [key, kind, name]
properties:
key: { type: string, example: "svc:payments" }
kind: { type: string }
name: { type: string }
via: { type: string, enum: [calls, runs-on, member], description: "hop 1 only — calls (a call edge), runs-on (a placement: a service on a VM / pod / host, a VM on a host / datastore, a database on its instance …), member (a business process and its members)" }
stale: { type: boolean, enum: [true], description: "hop 1 only, present when that link is stale (a stale call edge under includeStale=1, or a stale placement)" }
InventoryEntity:
type: object
required: [key, kind, name, stale, uses, usedBy]
properties:
key: { type: string, example: "svc:checkout" }
kind: { type: string, enum: [service, instance, database, schema, external, vm, host, pod, node, datastore, device, volume, business-process] }
name: { type: string }
stale: { type: boolean }
integrationId: { type: string }
engine: { type: string, description: "instance / database / schema (postgres, mysql, …)" }
meta: { type: object, description: "services with ownership metadata", properties: { tier: { type: string, nullable: true }, owner: { type: string, nullable: true }, team: { type: string, nullable: true }, environment: { type: string, nullable: true } } }
uses:
type: object
description: "what it calls or runs on (a process: its members) — hop 1 and hop 2, ≤ 200 each; a key is never in both lists, the entity itself never"
properties:
hop1: { type: array, items: { $ref: "#/components/schemas/InventoryNeighbour" } }
hop2: { type: array, items: { $ref: "#/components/schemas/InventoryNeighbour" } }
usedBy:
type: object
description: "the reverse: its callers, what runs on it, the processes it is in"
properties:
hop1: { type: array, items: { $ref: "#/components/schemas/InventoryNeighbour" } }
hop2: { type: array, items: { $ref: "#/components/schemas/InventoryNeighbour" } }
truncated: { type: boolean, enum: [true], description: "a list hit 200 entries or the walk 2 000 edges" }
InventoryMeta:
type: object
properties:
hops: { type: integer, enum: [1, 2] }
includeStale: { type: boolean }
staleAfterDays: { type: integer, nullable: true }
lastSweepAt: { type: string, format: date-time, nullable: true }
truncatedEntities: { type: integer }
kinds: { type: object, additionalProperties: { type: integer }, description: "entities per kind in the whole snapshot" }
InventoryPage:
type: object
properties:
format: { type: string, enum: ["topologix/catalogue-inventory/1"] }
snapshotAt: { type: string, format: date-time, description: "the memoised snapshot (60 s) the page was cut from — when it changes between pages, start again at offset 0" }
total: { type: integer, description: "entities matching kind / q" }
offset: { type: integer }
limit: { type: integer }
entities: { type: array, items: { $ref: "#/components/schemas/InventoryEntity" } }
meta: { $ref: "#/components/schemas/InventoryMeta" }
paths:
/api/changes:
get:
summary: List changes (newest window first, ≤ 200)
parameters:
- { name: status, in: query, schema: { type: string } }
- { name: q, in: query, schema: { type: string } }
- { name: mine, in: query, schema: { type: string, enum: ["1"] } }
- { name: criticality, in: query, schema: { type: string, example: "critical,important" }, description: "comma-separated criticalities (critical, important, standard); another value → 400 {field: criticality}" }
responses:
"200": { description: "{changes: Change[]}" }
post:
summary: Register a change (and start it with startNow)
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ChangeInput" } } } }
responses:
"201": { description: "{change: Change, blast, match {matched, unresolved}, callbackSecret (cs_…, ONLY when callbackUrl was sent — returned once, store it as a secret), approval {required, approvers, expiresAt, criticality, criticalitySource, criticalRequired, criticalApprovers (a count), unknownTierComponents} when it waits for approval (warning names a missing critical approver too), mapping {unresolved, hint} when names were not recognised (status pending_mapping)}" }
"400": { description: "validation (pipeline fields, callbackUrl, …)" }
"409": { description: "needsConfirm: ≥ 100 services in scope — resend with confirmLarge" }
"422": { description: "refused: over 300 services, or over 600 silenced infrastructure items after the per-kind caps — {error, refused, counts, silenceInfra}" }
/api/changes/preview-blast:
post:
summary: "What a registration would silence — never stored, never trimmed (the editor's live preview)"
requestBody:
content:
application/json:
schema:
type: object
properties:
components: { type: array, items: { $ref: "#/components/schemas/Component" } }
lines: { type: string, description: "names / keys, one per line (resolved like {query})" }
hops: { type: integer, enum: [1, 2], default: 1 }
title: { type: string }
silenceInfra: { type: string, enum: [none, dedicated, all], nullable: true }
responses:
"200":
description: "What a save would answer, up front: refused (a save → 422) and large (a save → 409 without confirmLarge)"
content:
application/json:
schema:
type: object
properties:
blast: { $ref: "#/components/schemas/Blast" }
resolved: { type: array, items: { $ref: "#/components/schemas/Component" } }
unresolved: { type: array, description: "a line that is not exactly one thing, with up to 10 candidates — e.g. the same database on a primary and its replica", items: { type: object } }
match: { type: object }
refused: { type: string, nullable: true }
large: { type: boolean }
counts: { $ref: "#/components/schemas/ScopeCounts" }
silenceInfra: { type: string, enum: [none, dedicated, all], description: "the mode the scope was derived with (the override, else the organisation's)" }
criticality: { type: string, enum: [critical, important, standard], nullable: true, description: "what the change's criticality would be" }
criticalitySource: { type: string, enum: [components, blast, none, pending] }
componentTiers: { type: object, additionalProperties: { type: string, nullable: true } }
unknownTierComponents: { type: array, items: { type: object }, description: "service components without a tier — an approver classifies them after registration" }
"400": { description: "validation (components, silenceInfra)" }
/api/changes/{id}:
get:
summary: One change (+ its live suppression)
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: include, in: query, schema: { type: string, example: "blast,events,evidence,report" } }
responses:
"200": { description: "{change: Change, suppression, …included parts}; blast is a Blast — completed from the current graph when the stored snapshot was trimmed (infraFromLive / edgesFromLive)" }
"404": { description: "not found (or not visible to the token's owner)" }
/api/changes/{id}/status:
get:
summary: The compact status a pipeline polls (changes:read)
parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
responses: { "200": { description: "Status", content: { application/json: { schema: { $ref: "#/components/schemas/Status" } } } }, "404": { description: "not found" } }
/api/changes/{id}/start:
post: { summary: Start a scheduled or approved change now, responses: { "200": { description: "{change}" }, "409": { description: "still waiting for approval / mapping" } } }
/api/changes/{id}/complete:
post:
summary: "Finish's alias for pipelines — the outcome defaults to success; the change enters surveillance (evaluating)"
requestBody: { content: { application/json: { schema: { type: object, properties: { outcome: { type: string, enum: [success, partial, rolled_back, unknown], default: success }, notes: { type: string } } } } } }
responses: { "200": { description: "{change} (status evaluating)" }, "409": { description: "not running" } }
/api/changes/{id}/finish:
post:
summary: Finish (the evidence horizon starts; held alerts still failing are released after 5 min)
requestBody: { content: { application/json: { schema: { type: object, properties: { outcome: { type: string, enum: [success, partial, rolled_back, unknown] }, notes: { type: string } } } } } }
responses: { "200": { description: "{change} (status evaluating)" } }
/api/changes/{id}/abort:
post: { summary: Abort a running change, responses: { "200": { description: "{change}" } } }
/api/changes/{id}/cancel:
post: { summary: Cancel a scheduled change, responses: { "200": { description: "{change}" } } }
/api/changes/{id}/evaluate:
post:
summary: Re-evaluate (a new report version)
requestBody: { content: { application/json: { schema: { type: object, properties: { evidenceMinutes: { type: integer, minimum: 15, maximum: 1440 } } } } } }
responses: { "200": { description: "{change} (status evaluating)" } }
/api/changes/{id}/report.json:
get:
parameters: [{ name: version, in: query, schema: { type: integer } }]
responses: { "200": { description: "{change, version, report, evidence}" }, "404": { description: "no report yet" } }
/api/changes/{id}/report.html:
get: { responses: { "200": { description: "the report e-mail (text/html, CSP default-src 'none')" } } }
/api/changes/{id}/report.csv:
get: { responses: { "200": { description: "RED + alerts CSV (attachment change-<id>-v<n>.csv)" } } }
/api/changes/{id}/contracts:
get:
description: "Every service → service call edge of the blast snapshot with its interface contract (the callee's routes this caller uses, from traces). contract null = no trace showed the call."
parameters: [{ name: stale, in: query, schema: { type: integer, enum: [0, 1] }, description: "1 = include routes not seen recently" }]
responses: { "200": { description: "{changeId, computedAt, edges: [{fromKey, toKey, routes, contract: topologix/interface-contract/1 | null}], withContract, without}" }, "404": { description: "no blast snapshot" } }
/api/catalog/inventory:
get:
summary: "The catalogue inventory — every entity with its +1 / +2 dependencies both ways (catalogue:read; proxy ≥ 1.262.0)"
parameters:
- { name: kind, in: query, schema: { type: string, example: "service,instance" }, description: "comma-separated kinds; an unknown kind → 400 naming the kinds" }
- { name: hops, in: query, schema: { type: integer, enum: [1, 2], default: 2 }, description: "1 leaves the hop2 lists empty (a smaller page)" }
- { name: includeStale, in: query, schema: { type: integer, enum: [0, 1], default: 0 }, description: "1 counts stale call edges too (placements count either way)" }
- { name: q, in: query, schema: { type: string }, description: "case-insensitive substring of the name or key" }
- { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 2000, default: 500 } }
responses:
"200": { description: "InventoryPage", content: { application/json: { schema: { $ref: "#/components/schemas/InventoryPage" } } } }
"400": { description: "a bad kind / hops / includeStale / offset / limit — or a POST / PUT (the inventory is read-only)" }
"403": { description: "the token lacks catalogue:read" }
"413": { description: "page too large — lower limit or hops (a page is capped at 12 MiB)" }
/api/catalog/inventory/{key}:
get:
summary: "One entity by its key — encodeURIComponent it (svc%3Acheckout; db%3Asvc%3Aorders%20pg%2Forders)"
parameters:
- { name: key, in: path, required: true, schema: { type: string } }
- { name: hops, in: query, schema: { type: integer, enum: [1, 2], default: 2 } }
- { name: includeStale, in: query, schema: { type: integer, enum: [0, 1], default: 0 } }
responses:
"200": { description: "{format, snapshotAt, entity: InventoryEntity, meta: InventoryMeta}" }
"404": { description: "Entity not found in your scope" }
# Not available to an API token (403 at the relay): …/approve, …/reject, …/mappings, …/classify,
# …/callback-test — approvals, mappings and classifications are made by people, signed in.Approval, status and completion (pipelines)
Under the organisation's approval setting (Organisation → Change approvals; all by default) a registered change waits in pending_approval until the quorum of designated approvers approves it — people, signed in; an API token can never approve. A name the catalogue does not know puts it in pending_mapping first: a person maps it on the change page. The pipeline either polls GET /api/changes/{id}/status or receives callbacks, then deploys and reports with POST /api/changes/{id}/complete — the change enters surveillance (the evidence horizon) and the report follows.
maxDurationMinutesmakes a pipeline window: it is re-stamped now … now + d when the change starts, so the silence matches the real deployment however long the approval took.autoStart: truestarts it the moment it is approved; otherwisePOST …/start(before the start deadline — 24 h by default).surveillanceMinutes(alias ofevidenceMinutes) sets the horizon; without either, the organisation's default applies.pipeline{pipeline, run, runUrl, commit, version, environment, repo} is shown on the change page and returned by/status./statusanswersstatus+approval.state(mapping, pending, approved, rejected with the reason, expired), the approvals so far of the quorum, the deadlines, the deployment and surveillance times, the impact once reported, and — whilepending_mapping— the unrecognised names with the link to map them. Fail the pipeline on rejected or expired.
Callbacks
Send callbackUrl (http(s), ≤ 1000 characters) at registration and the 201 carries a callbackSecret (cs_ + 43 characters) once — Topologix stores it sealed and never shows it again. Every step then POSTs a JSON callback: change.mapped, change.approved, change.rejected, change.expired, change.started, change.completed, change.reported (and change.test from the page's Send test callback). The body is {event, deliveryId, occurredAt, change} with change = the /status shape.
- Verify every call:
X-Topologix-Signature: sha256=<hex HMAC-SHA256 of the raw body keyed by callbackSecret>; compare in constant time before parsing.X-Topologix-Deliverystays the same across retries — use it to drop duplicates.X-Topologix-Eventnames the event. - Routing. A change registered through a proxy is called back by that proxy (it reaches your LAN; proxy ≥ 1.226.0, online). Otherwise the server sends it — public addresses only (resolved once and pinned, no redirects, 10 s), unless the organisation allows private callback addresses (
allowPrivateCallbacksin the change settings: RFC 1918 / CGNAT / unique-local; loopback, link-local and metadata addresses never). - Retries. Answer 2xx. 408, 429, 5xx, a timeout or an unreachable receiver is retried after 1 min, 5 min, 15 min, 1 h, then every 4 h — 8 attempts. Any other 4xx (and a 3xx: redirects are never followed) ends it. The change page's Pipeline card shows the last callback (delivered / retrying / failed, the HTTP status, via the proxy or the server, the error).
Callback receiver (Node)
// A callback receiver (Node ≥ 18, no dependencies). TOPOLOGIX_CALLBACK_SECRET = the change's
// callbackSecret (cs_…), returned once by POST /api/changes.
const http = require('http');
const crypto = require('crypto');
const SECRET = process.env.TOPOLOGIX_CALLBACK_SECRET;
const seen = new Set(); // X-Topologix-Delivery: drop retried duplicates
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // verify the RAW bytes, before any JSON parse
const want = 'sha256=' + crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
const got = String(req.headers['x-topologix-signature'] || '');
if (got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
res.writeHead(401); return res.end('bad signature'); // a 4xx is final — Topologix stops retrying
}
const id = String(req.headers['x-topologix-delivery']);
if (!seen.has(id)) {
seen.add(id);
const msg = JSON.parse(raw.toString('utf8'));
console.log(msg.event, msg.change.id, msg.change.status, msg.change.approval.state);
// change.approved → continue the deployment; change.rejected / change.expired → stop it;
// change.reported → msg.change.surveillance.impact is the verdict.
}
res.writeHead(204); res.end();
});
}).listen(8080);Signature check (bash + openssl)
# Verify a callback in a shell step (body saved to body.json, the header to $SIG):
WANT="sha256=$(openssl dgst -sha256 -hmac "$TOPOLOGIX_CALLBACK_SECRET" -hex < body.json | sed 's/^.* //')"
[ "$SIG" = "$WANT" ] && echo "signature ok" || { echo "bad signature"; exit 1; }GitHub Actions
A self-hosted runner inside the network; TOPOLOGIX_TOKEN is a repository secret. It registers the change (keeping the change id, and the callback secret for a receiver), polls /status until approved — failing on a rejection or an expiry and printing the link when components need mapping — deploys, and completes the change even when the deploy fails, so it is always closed with an outcome.
.github/workflows/deploy.yml
# .github/workflows/deploy.yml — a self-hosted runner on the LAN that can reach the proxy.
# Register → wait for approval → deploy → complete. Needs jq.
jobs:
deploy:
runs-on: [self-hosted, lan]
env:
TOPOLOGIX: https://proxy.lan:4320
TOPOLOGIX_TOKEN: ${{ secrets.TOPOLOGIX_TOKEN }}
steps:
- name: Register the change
run: |
RESP=$(curl -sf -X POST "$TOPOLOGIX/api/changes" \
-H "Authorization: Bearer $TOPOLOGIX_TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Deploy checkout-api '"$GITHUB_SHA"'","kind":"deploy",
"components":[{"query":"checkout-api"}],"maxDurationMinutes":45,"surveillanceMinutes":60,
"pipeline":{"pipeline":"'"$GITHUB_WORKFLOW"'","run":"'"$GITHUB_RUN_ID"'","commit":"'"$GITHUB_SHA"'",
"runUrl":"'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"'",
"repo":"'"$GITHUB_REPOSITORY"'","environment":"production"},
"callbackUrl":"https://ci-hooks.lan/topologix"}')
CHANGE_ID=$(echo "$RESP" | jq -r .change.id)
echo "CHANGE_ID=$CHANGE_ID" >> "$GITHUB_ENV"
# The callback secret is returned ONCE: hand it to your callback receiver (here: a masked output).
SECRET=$(echo "$RESP" | jq -r '.callbackSecret // empty'); [ -n "$SECRET" ] && echo "::add-mask::$SECRET"
- name: Wait for approval
timeout-minutes: 240
run: |
while true; do
S=$(curl -sf "$TOPOLOGIX/api/changes/$CHANGE_ID/status" -H "Authorization: Bearer $TOPOLOGIX_TOKEN")
case "$(echo "$S" | jq -r .status)" in
approved|active) echo "approved ($(echo "$S" | jq -r '.approval.approvals') of $(echo "$S" | jq -r '.approval.required'))"; break ;;
rejected) echo "::error::rejected: $(echo "$S" | jq -r .approval.reason)"; exit 1 ;;
expired|cancelled) echo "::error::$(echo "$S" | jq -r .status) before it was approved"; exit 1 ;;
pending_mapping) echo "unrecognised components $(echo "$S" | jq -c .mapping.unresolved) — map them: $(echo "$S" | jq -r .mapping.url)" ;;
*) echo "waiting for approval ($(echo "$S" | jq -r '.approval.approvals // 0') of $(echo "$S" | jq -r '.approval.required // "?"'))" ;;
esac
sleep 30
done
# Start the window (skip when the change was registered with "autoStart": true).
curl -sf -X POST "$TOPOLOGIX/api/changes/$CHANGE_ID/start" -H "Authorization: Bearer $TOPOLOGIX_TOKEN" > /dev/null
- name: Deploy
run: ./deploy.sh
- name: Complete the change
if: always()
run: |
OUTCOME=$([ "${{ job.status }}" = "success" ] && echo success || echo rolled_back)
curl -sf -X POST "$TOPOLOGIX/api/changes/$CHANGE_ID/complete" \
-H "Authorization: Bearer $TOPOLOGIX_TOKEN" -H "Content-Type: application/json" \
-d '{"outcome":"'"$OUTCOME"'"}'GitLab CI
A runner that can reach the proxy; TOPOLOGIX_TOKEN as a masked, protected variable. With autoStart the change starts when approved, so the job waits for active.
.gitlab-ci.yml
# .gitlab-ci.yml — a runner inside the network that can reach the proxy. Needs curl + jq.
variables:
TOPOLOGIX: "https://proxy.lan:4320" # TOPOLOGIX_TOKEN: a masked, protected CI/CD variable
deploy:
stage: deploy
timeout: 6h
script:
- |
RESP=$(curl -sf -X POST "$TOPOLOGIX/api/changes" \
-H "Authorization: Bearer $TOPOLOGIX_TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Deploy '"$CI_PROJECT_NAME"' '"$CI_COMMIT_SHORT_SHA"'","kind":"deploy",
"components":[{"query":"checkout-api"},{"query":"payments-api"}],"maxDurationMinutes":60,"autoStart":true,
"pipeline":{"pipeline":"'"$CI_PROJECT_PATH"'","run":"'"$CI_PIPELINE_ID"'","runUrl":"'"$CI_PIPELINE_URL"'",
"commit":"'"$CI_COMMIT_SHA"'","environment":"'"$CI_ENVIRONMENT_NAME"'"}}')
CHANGE_ID=$(echo "$RESP" | jq -r .change.id)
echo "CHANGE_ID=$CHANGE_ID" > change.env
- |
# autoStart: the change starts the moment it is approved — wait until it is active.
while true; do
S=$(curl -sf "$TOPOLOGIX/api/changes/$CHANGE_ID/status" -H "Authorization: Bearer $TOPOLOGIX_TOKEN")
ST=$(echo "$S" | jq -r .status)
case "$ST" in
active) break ;;
rejected) echo "Rejected: $(echo "$S" | jq -r .approval.reason)"; exit 1 ;;
expired|cancelled) echo "The change was $ST before it was approved"; exit 1 ;;
pending_mapping) echo "Map $(echo "$S" | jq -c .mapping.unresolved) at $(echo "$S" | jq -r .mapping.url)" ;;
*) echo "$ST — $(echo "$S" | jq -r '.approval.approvals // 0') of $(echo "$S" | jq -r '.approval.required // "?"') approvals" ;;
esac
sleep 30
done
- ./deploy.sh
after_script:
- |
source change.env 2>/dev/null || exit 0
OUTCOME=$([ "$CI_JOB_STATUS" = "success" ] && echo success || echo rolled_back)
curl -sf -X POST "$TOPOLOGIX/api/changes/$CHANGE_ID/complete" \
-H "Authorization: Bearer $TOPOLOGIX_TOKEN" -H "Content-Type: application/json" \
-d '{"outcome":"'"$OUTCOME"'"}'
artifacts:
reports: { dotenv: change.env }Good to know
- A change never silences what it cannot name: canvas HTTP / TCP monitors have no service identity and keep alerting; the change page lists them under Not silenced.
- Held alerts are evaluated and recorded as usual — the alert history shows them as Held · change <title>; only the sending waits.
- The public server answers every
Bearer tpx_…with 401 and never looks it up; there is nothing to guess against from outside. - Production rollout: create a token, switch the relay on for the proxy your build agents can reach, open TCP 4320 from the agents only, designate approvers (or set approvals to Not required), optionally set a ticket destination and a completion list, add the register / status / complete calls to the pipeline; upgrade the proxy to ≥ 1.226.0 for callbacks.
- Tickets are sent from the Topologix server, not through the proxy: a Jira Data Center or ServiceNow reachable only on your LAN needs a self-hosted Topologix with
allowPrivateCallbackson (routing tickets through the proxy is planned).
Related guides
See it on your own infrastructure
Book a demo and we will walk you through Topologix with your systems in mind: on our cloud, or self-hosted in your own network.