NetOSField accessMenu
Signed in user unavailable
Documentation/API & Integrations
Download PDF

Xiber NetOS — API & Integrations Guide

REST API endpoints, MCP tools, and external system integration map.


REST API

SettingValue
Base URLhttp://localhost:8000/api/v1
Production Base URLhttps://netos.xiberian.net/api/v1
Docs (Swagger)https://netos.xiberian.net/docs-api/
OpenAPI Schemahttps://netos.xiberian.net/openapi.json
Content Typeapplication/json

Authentication

The API resolves identity from one of three sources, checked in this order:

  1. API access tokens (recommended for machines and coding agents)
  2. Trusted reverse-proxy identity headers (how browser SSO reaches the API)
  3. Dev-auth header shim (local development only)

There is no native OIDC/JWT validation inside the API today. Browser Microsoft SSO is terminated by oauth2-proxy in front of NetOS; the proxy injects trusted identity headers that the API consumes (see mode 2). The ENTRA_* / JWT_AUDIENCE settings exist but are not yet wired into request validation. Once identity is resolved, NetOS applies its own roles, permissions, scopes, and field-level masking — see Authorization & RBAC for the full model.

1. API access tokens

Use either header format:

Authorization: Bearer ntos_live_...

or:

X-NetOS-API-Key: ntos_live_...

A Bearer value is only treated as an API token when it starts with ntos_. Tokens are bound to an internal NetOS user; the token authenticates the machine, then NetOS applies that user's normal RBAC roles, scopes, and field-level permissions. Frontend visibility is irrelevant for machine access; backend permissions are still authoritative.

2. Trusted reverse-proxy identity headers

When TRUST_PROXY_AUTH_HEADERS=true and the request's client IP falls inside TRUSTED_AUTH_PROXY_CIDRS (default 127.0.0.1/32,172.16.0.0/12), NetOS trusts identity headers set by the fronting proxy. The first present of these wins:

x-auth-request-email
x-ms-client-principal-name
x-auth-request-user
x-forwarded-email
x-forwarded-user
cf-access-authenticated-user-email

Requests from outside the trusted CIDRs have these headers stripped/ignored. A proxy-resolved user must already exist (or be a bootstrap super-admin) to be authorized.

3. Dev-auth header shim (development only)

When ALLOW_DEV_AUTH=true, unauthenticated requests may pass dev headers:

x-user-email: dev@xiber.com
x-user-role: exec

If x-user-role is omitted it defaults to exec (DEV_AUTH_DEFAULT_ROLE); an unknown role returns 403. Do not enable dev auth in production. When neither ALLOW_DEV_AUTH nor a trusted proxy header is present and no API token is supplied, the API returns 401 {"detail": "Authentication required"}.

Identity-header (dev/proxy) example:

curl -sS \
  -H "x-user-email: dev@xiber.com" \
  -H "x-user-role: exec" \
  http://localhost:8000/api/v1/authz/me

Creating Machine Access Tokens

Only users with users_edit can create or revoke API tokens.

POST /api/v1/authz/api-tokens
{
  "user_id": "00000000-0000-0000-0000-000000000000",
  "name": "agent01 quote importer",
  "expires_at": "2026-09-01T00:00:00Z"
}

The response includes the full token once:

{
  "api_token_id": "11111111-1111-1111-1111-111111111111",
  "user_id": "00000000-0000-0000-0000-000000000000",
  "user_email": "agent-netos@xiber.net",
  "name": "agent01 quote importer",
  "token_prefix": "ntos_live_abcd...",
  "expires_at": "2026-09-01T00:00:00+00:00",
  "token": "ntos_live_full_secret_value_returned_once"
}

Store the token immediately in the calling system's secret manager. NetOS stores only a hash and cannot display the full token again.

List tokens:

GET /api/v1/authz/api-tokens

Revoke a token:

DELETE /api/v1/authz/api-tokens/{api_token_id}

Rotate a token:

POST /api/v1/authz/api-tokens/{api_token_id}/rotate

Rotation keeps the same token record but replaces the secret. The previous token stops working immediately, and the new token is returned once.

Machine Access Example

export NETOS_API_TOKEN='ntos_live_...'

curl -sS \
  -H "Authorization: Bearer $NETOS_API_TOKEN" \
  https://netos.xiberian.net/api/v1/authz/me

Prompt For Another Coding Agent

You are integrating with Xiber NetOS.

Base URL:
https://netos.xiberian.net/api/v1

API docs:
https://netos.xiberian.net/docs-api/

OpenAPI schema:
https://netos.xiberian.net/openapi.json

Authenticate every API request with:
Authorization: Bearer <NETOS_API_TOKEN>

The token is bound to an internal NetOS user. Do not try to bypass RBAC. If an endpoint returns 401 or 403, report the missing permission or authorization failure.

Useful endpoints:
- GET /authz/me
- GET /search
- GET /quotes
- POST /quotes/extract
- GET /quotes/drafts
- POST /quotes/drafts
- PATCH /quotes/drafts/{draft_id}
- POST /quotes/drafts/{draft_id}/reject
- POST /quotes/drafts/{draft_id}/commit
- POST /quotes
- POST /quotes/{quote_id}/documents
- POST /quotes/{quote_id}/documents/text
- POST /quotes/{quote_id}/documents/{document_id}/reanalyze
- GET /circuits
- GET /infrastructure
- GET /customers
- GET /rf-links
- GET /electrical-services

Vendor quote model:
- One quote represents one vendor/path/service.
- Term pricing is stored in pricing_options.
- NNI is a valid quote service type and represents an EVPL landing point.
- `target_type` identifies the quote scope. Use `circuit` for transport/path quotes with A and Z endpoints. Use values such as `infrastructure`, `tower`, `rooftop`, `datacenter`, `electrical`, `rf_link`, or `other` for single-site/service quotes that only need a target site/location.
- Quote attachments are stored on the quote record. Uploaded vendor PDFs, images, order forms, and proposals are analyzed in the background when an AI provider is configured; AI summaries and notes are searchable at low search priority.
- Pasted vendor emails or unstructured quote notes can be saved as text quote attachments through `POST /quotes/{quote_id}/documents/text`. NetOS persists the text as a quote document, preserves line breaks for later viewing, queues AI analysis, and includes the text/AI notes in search.
- Claude Desktop and other MCP users should create quote drafts, not committed quotes. NetOS users review drafts in Vendor Quotes -> Draft Inbox, edit the form, then commit or reject them.

Endpoints

Health Check

GET /healthz
{"status": "ok"}

No authentication required.


List Circuits

GET /api/v1/circuits

Query Parameters:

ParamTypeDescription
carrierstringFilter by carrier name (case-insensitive substring)
statusstringFilter by status enum
service_typestringFilter by service type enum
searchstringFull-text search across circuit label, carrier circuit ID, carrier name
term_end_beforedateOnly circuits whose contract term ends before this date

Response: Array of circuit objects with nested carrier, contract, A endpoint, and Z endpoint data. This is one of the few endpoints backed by a Pydantic response_model (CircuitRead), so its shape is enforced by the schema; most other endpoints return hand-built payload dicts (see List Behavior & Response Shapes). The list is ordered by circuit label and capped at 1000 rows; soft-deleted circuits (deleted_at set) are excluded.

Example:

curl -H "x-user-role: exec" \
  "http://localhost:8000/api/v1/circuits?status=active&carrier=Lumen"

Get Circuit Detail

GET /api/v1/circuits/{circuit_id}

Response includes:

  • Circuit attributes (label, carrier circuit ID, service type, bandwidth, status, MRC, NRC)
  • Carrier details (name, NOC phone, portal URL)
  • Contract details (term dates, renewal type, ETF, escalator)
  • A and Z endpoints (name, address, coordinates)
  • Exact monitoring URL and generated monitoring search fallback
  • Provider portal URL
  • Lifecycle events (ordered by date)

Create Circuit

POST /api/v1/circuits

Requires: Write role (exec, finance, network_eng, operations, pm)

Request Body:

{
  "xiber_circuit_label": "XIB-LUM-0042",
  "carrier_circuit_id": "DHEC.123456",
  "carrier_name": "Lumen",
  "service_type": "wave",
  "bandwidth_mbps": 10000,
  "status": "active",
  "monitoring_url": "https://librenms.xiber.local/device/123",
  "mrc_usd": 2500.00,
  "nrc_usd": 5000.00,
  "a_endpoint_name": "Xiber HQ",
  "z_endpoint_name": "Cologix COL1"
}

The manual UI now uses Infrastructure and Customer site selectors for circuit A/Z locations. The REST API still accepts endpoint fields so imports and integrations can create endpoint snapshots. For attribution, creating a circuit is not enough by itself; create a subtended_links relationship to state which infrastructure asset is the upstream donor and which downstream structure or customer endpoint is served over that circuit.

CircuitCreate/CircuitUpdate also accept a_property_id/z_property_id (UUID, nullable) to land that side's endpoint on a Property instead of a free-text site: when set, NetOS looks up the property (404 if it doesn't exist or is deleted), fills that side's Endpoint display name and address from the property, and copies the property's coordinates (Property.geom) when it has them, geocoding from the property's address otherwise. This also sets endpoints.property_id, an exact FK distinct from the legacy free-text xiber_property_id. On PATCH, submitting a_property_id/z_property_id as null explicitly clears that side's property link without touching the rest of the endpoint (name, address, coordinates); omitting the field entirely leaves it unchanged. EndpointRead carries property_id, and linked_site_type reports "property" (with linked_site_id/linked_site_name from that property) whenever endpoint.property_id is set — this exact link always wins over the fuzzy name/address match NetOS otherwise uses to guess an endpoint's site. EndpointType is a native Postgres enum with no property-specific member; a property-linked endpoint's endpoint_type is set to mdu for now.

For DIA and broadband services, NetOS treats the circuit as single-site and clears the Z endpoint.


Customers

GET /api/v1/customers
POST /api/v1/customers
GET /api/v1/customers/{customer_id}
PATCH /api/v1/customers/{customer_id}
DELETE /api/v1/customers/{customer_id}

Customer records represent commercial and MFC customer sites. They can be selected by circuits, RF Links, and subtended links. Customer detail responses include associated circuits, RF Links, serving paths, inherited donor cost, customer revenue, attributed cost, and gross margin.

GET /api/v1/customers accepts two Property-related query filters: property_id=<uuid> returns only customers linked to that property, and unassigned=true returns commercial customers with no property_id (the target of the customers_missing_property mission). Every customer payload (list and detail) carries property_id (null when unlinked) and, when linked, a property summary object {"property_id", "name", "nickname"}; CustomerWrite.property_id is accepted on create/update (404 if the property doesn't exist, 422 if the customer is not commercial), but PATCHing property_id this way does not itself score property.link_tenant — the web UI always uses POST /api/v1/properties/{id}/customers for scored linking.

Tenant model

A customer with property_id set is a tenant: it carries only a service detail (service type, capacity, MRR, cost) and has no network components of its own — it inherits RF links, circuits, PtMP sectors, and subtended links from its property. Every customer payload carries is_tenant (bool, property_id is not None). A tenant's own serving_paths is normally empty (the tenant model expects network components to land on the property, not the tenant), and the detail payload adds inherited_network: {property_id, property_name, infrastructure: [...], rf_links: [...], circuits: [...]} built from the property's own linked infrastructure/RF links/circuits (the same builders GET /properties/{id} uses), reused so the shapes are identical; null for a non-tenant. financial_summary for a tenant is computed the same way as any customer — since its serving_paths are empty, direct_path_cost_mrc_usd/inherited_donor_cost_mrc_usd naturally come back 0.00 (it isn't inheriting cost from paths it doesn't have), while revenue_mrr_usd still reflects the tenant's own contracted_mrr_usd service detail.

New attachments of a tenant directly onto network components are rejected with 422 naming the property (e.g. "Acme Corp is a tenant of Milkyway; land the link on the property instead") — enforced on POST/PATCH /rf-links (a_customer_id/z_customer_id/legacy customer_id), POST/PATCH /infrastructure/{id}/subtended-links (customer_id), and POST/PATCH /ptmp-nodes (customer_id). The guard only fires for a *new* attachment (a value differing from what the row already had) — existing rows created before this feature are left alone; re-home them with POST /properties/{id}/absorb-tenant-links (below). Circuit.customer_id is unaffected — it is a soft "this circuit serves this customer" pointer that isn't itself a revenue-attribution edge (that's SubtendedLink.customer_id/.property_id), so tenants keep it for their service detail without triggering the guard.

Properties

GET    /api/v1/properties
POST   /api/v1/properties
GET    /api/v1/properties/{property_id}
PATCH  /api/v1/properties/{property_id}
DELETE /api/v1/properties/{property_id}
POST   /api/v1/properties/{property_id}/customers
DELETE /api/v1/properties/{property_id}/customers/{customer_id}
POST   /api/v1/properties/{property_id}/infrastructure
DELETE /api/v1/properties/{property_id}/infrastructure/{infrastructure_id}
GET    /api/v1/properties/{property_id}/candidates
POST   /api/v1/properties/{property_id}/absorb-tenant-links
POST   /api/v1/properties/{property_id}/documents
GET    /api/v1/properties/{property_id}/documents/{filename}
GET    /api/v1/properties/{property_id}/documents/{filename}/thumbnail
PATCH  /api/v1/properties/{property_id}/documents/{document_id}
POST   /api/v1/properties/{property_id}/documents/{document_id}/reanalyze
DELETE /api/v1/properties/{property_id}/documents/{document_id}

A Property groups commercial customers ("tenants") that share a building, plus optionally the infrastructure assets located there. Rollups (tenant counts, penetration, MRR) are computed on read from linked children — there is no persisted aggregate to go stale.

`GET /api/v1/properties` — permission properties_view. Query params: q (matches name/nickname/address_line_1), state, city, status (active/prospect/inactive), sort (name | penetration | mrr | customers | tenants, default name), direction (asc/desc, default asc). Capped at 1000 rows; rows with a null sort value always sort last. Each row includes tenant_count, customers_total/customers_active/customers_prospect/customers_churned, penetration_pct (customers_active ÷ tenant_count, null if tenant_count is unset), total_mrr_usd, avg_mrr_per_active_customer_usd, avg_mrr_per_tenant_usd, and infrastructure_count. customers_active counts status active; customers_prospect counts prospect and installing; customers_churned counts inactive. total_mrr_usd is the sum of contracted MRR across active tenants only. MRR fields come back null for callers lacking customer_financial_view (or field.customer_revenue.view).

`GET /api/v1/properties/{property_id}` — permission properties_view. Detail payload adds tenants[] (customer summary rows including masked contracted_mrr_usd), infrastructure[] (linked asset summaries), documents[] (the property's own photos/documents — see below; distinct from recent_tenant_documents[]), recent_tenant_documents[] (up to 24 image documents from linked tenants, newest first), legacy_property_names[] (distinct non-blank free-text property_name values its tenants used before being linked), rf_links[], circuits[], ptmp_nodes[], and subtended_links[]. rf_links[] lists every non-deleted RF link whose a_property_id or z_property_id is this property: {"rf_link_id", "name", "link_type", "status", "bandwidth_mbps", "mrc_usd", "this_end": "a"|"z", "other_end": {"kind": "infrastructure"|"customer"|"property"|null, "id", "name"}} (mrc_usd is mrc_usd + tower_rent_mrc_usd). circuits[] lists every non-deleted circuit whose A or Z Endpoint.property_id is this property: {"circuit_id", "xiber_circuit_label", "carrier", "service_type", "status", "mrc_usd", "this_end": "a"|"z", "other_end_name"}. ptmp_nodes[] lists PtMP sectors hosted directly at the property (PtmpNode.property_id set) in the same shape as GET /ptmp-nodes (below), including tenant_count. subtended_links[] lists SubtendedLinks landed on the property (SubtendedLink.property_id set): {"subtended_link_id", "parent_infrastructure_id", "parent_infrastructure_name", "via": {"type": "circuit"|"rf_link", "id", "label"}|null, "relationship_type", "status", "revenue_mrr_usd", "cost_mrc_usd", "redundancy_role"}. rf_links[].mrc_usd, circuits[].mrc_usd, ptmp_nodes[].capex_usd/mrc_usd, and subtended_links[].revenue_mrr_usd/cost_mrc_usd are all masked by the same rule as this payload's other financial fields (customer_financial_view/field.customer_revenue.view), not the circuit's own circuit_cost_view permission. Both list_properties and the detail payload's rollup also carry rf_link_count/circuit_count — the count of distinct RF links/circuits touching the property on either side.

The rollup also carries a commercial view of the property: landed_cost_mrc_usd (the sum of every landing circuit's mrc_usd plus every landing RF link's mrc_usd + tower_rent_mrc_usd — the same rows counted by circuit_count/rf_link_count), margin_usd (total_mrr_usd - landed_cost_mrc_usd), and margin_pct (margin_usd ÷ total_mrr_usd × 100, rounded to 1 decimal; null when total_mrr_usd is 0). All three mask to null under the same customer_financial_view/field.customer_revenue.view rule as total_mrr_usd.

Property documents — a property has its own photos/documents, resolved through app.services.site_targets.resolve_site_target's "property" branch alongside infrastructure/customer documents (site media phase B). The upload/serve/reanalyze/update/delete routes mirror /api/v1/customers/{customer_id}/documents exactly (same MIME allowlist/size cap, same response shape keyed by property_id instead of customer_id), permission properties_view to read, properties_edit to write. Audit actions: upload_property_document, update_property_document, reanalyze_property_document, delete_property_document. Uploads are scored the same as customer/infrastructure photos (photo.site/document.site, or photo.sharepoint_ingest when ingested from a linked SharePoint folder).

`POST /api/v1/properties` — permission properties_edit. Body is the same field set as the detail payload (name/nickname/type/status/tenant_count/suite_count/floors/owner/manager/contacts/access_rules/notes/address/lat/lon); country defaults to "US". Returns 422 "Name is required" if name is blank, and 409 "An active property with this name and city already exists" if an active property already has the same name (case-insensitive) in the same city. Address fields trigger geocode-on-save the same way customer/infrastructure records do. Response is the detail payload plus a contribution object when the write scored points.

`PATCH /api/v1/properties/{property_id}` — permission properties_edit. Partial update (only submitted fields change); writes an update_property audit row and re-geocodes when address fields are submitted without explicit lat/lon.

`DELETE /api/v1/properties/{property_id}?force=<bool>` — permission properties_edit. Without force, a property with any linked customers or infrastructure returns:

{
  "detail": {
    "detail": "Property has linked customers or infrastructure; pass force=true to unlink and delete anyway",
    "customers": 19,
    "infrastructure": 1
  }
}

With force=true, every linked customer and infrastructure asset is unlinked first (each unlink writes its own audit row), then the property is soft-deleted. Returns {"deleted": "<property_id>"}.

`POST /api/v1/properties/{property_id}/customers` — requires both properties_edit and customers_edit (403 "Missing permission: customers_edit" if the caller has the former but not the latter). Body:

{"customer_ids": ["<uuid>", "..."], "source": "manual"}

All-or-nothing validation: 404 listing any customer id that doesn't exist; 422 "Only commercial customers can be linked to a property: ..." if any requested customer is deleted or not customer_type == "commercial" — nothing is written if either check fails. Customers already linked to this property are reported back rather than re-linked. Response is the property detail payload plus:

{
  "linked": ["<customer_id>", "..."],
  "already_linked": ["<customer_id>", "..."],
  "contributions": [{"...": "contribution_payload(...) per newly linked customer"}]
}

Each newly linked customer writes a link_customer_property audit row on the customer (scoring property.link_tenant, 3 points, capped at 30/user/day, paid once ever per customer+property pair) plus one unscored link_property_customers summary row on the property.

`DELETE /api/v1/properties/{property_id}/customers/{customer_id}` — permission properties_edit. 404 "Customer is not linked to this property" if the customer isn't currently linked here. Writes an unscored unlink_customer_property audit row and returns the refreshed property detail.

`POST /api/v1/properties/{property_id}/infrastructure` — permission properties_edit. Body {"infrastructure_ids": ["<uuid>", "..."]}. 404 for missing ids, 422 for soft-deleted assets. Already-linked assets are skipped silently. Writes an unscored link_infrastructure_property audit row per newly linked asset.

`DELETE /api/v1/properties/{property_id}/infrastructure/{infrastructure_id}` — permission properties_edit. 404 "Infrastructure asset is not linked to this property" if not currently linked here.

`GET /api/v1/properties/{property_id}/candidates?q=<text>` — permission properties_view. Suggests commercial customers to link, ranked same-address first, then same-postal-code, then free-text search matches against name/address; excludes customers already linked to this property. Each row includes match ("address", "postal_code", or "search") and, if the customer is linked elsewhere, that other property's property_id/property_name.

`POST /api/v1/properties/{property_id}/absorb-tenant-links` — admin re-home tool, requires both properties_edit and customers_edit (403 "Missing permission: customers_edit" if the caller has only the former). Body:

{"dry_run": true}

For every tenant of this property (Customer.property_id == property_id), finds RF links where the tenant is an a_customer_id/z_customer_id/legacy customer_id endpoint, SubtendedLinks with the tenant's customer_id, and PtMP nodes hosted at the tenant (PtmpNode.customer_id). Response:

{
  "rf_links": [{"rf_link_id": "<uuid>", "name": "...", "tenant_name": "..."}],
  "subtended_links": [{"subtended_link_id": "<uuid>", "parent_infrastructure_name": "...", "tenant_name": "..."}],
  "ptmp_nodes": [{"ptmp_node_id": "<uuid>", "name": "...", "tenant_name": "..."}],
  "dry_run": true
}

With dry_run: false, each object found above is moved onto the property in the same request (one commit): the RF link's tenant-side a_customer_id/z_customer_id becomes a_property_id/z_property_id (its a_location_name/z_location_name recomputed the same way a direct property endpoint would render), the legacy customer_id is cleared; the SubtendedLink's customer_id becomes property_id (its revenue_mrr_usd/cost_mrc_usd overrides, allocation settings, and redundancy_role are untouched — only the target column moves); the PtmpNode's customer_id becomes property_id. Each move writes its own audit row (rehome_rf_link_to_property, rehome_subtended_link_to_property, rehome_ptmp_node_to_property, unscored), plus one summary absorb_tenant_links audit row on the property listing every moved id. Idempotent — once everything has moved, a second call (dry-run or not) finds nothing left to move.

Property grouping

POST   /api/v1/property-grouping/runs
GET    /api/v1/property-grouping/runs
GET    /api/v1/property-grouping/runs/latest
GET    /api/v1/property-grouping/proposals
GET    /api/v1/property-grouping/proposals/{proposal_id}
PATCH  /api/v1/property-grouping/proposals/{proposal_id}
POST   /api/v1/property-grouping/proposals/merge
POST   /api/v1/property-grouping/proposals/{proposal_id}/suggest-address
POST   /api/v1/property-grouping/proposals/{proposal_id}/apply
POST   /api/v1/property-grouping/proposals/apply-all-confirmed
POST   /api/v1/property-grouping/proposals/{proposal_id}/reopen
GET    /api/v1/property-grouping/suggest

This is the reviewed address scrub behind /properties/review (see the user manual's Properties section and the admin manual's Property grouping scrub runbook for the full mechanics — canonical address keys, clustering rules, warning codes, the proposal status machine, and what Apply writes). Every route requires properties_edit except GET /suggest, which only requires customers_edit. Proposals group commercial customers only; nothing is ever linked except by POST .../apply or POST .../apply-all-confirmed.

`POST /api/v1/property-grouping/runs` — body {"min_members": 2, "radius_m": 60, "include_linked": false} (all optional; radius_m is 1–500). Scans commercial, non-deleted customers (unlinked only, unless include_linked is true), clusters them by canonical address key with a guarded proximity merge, and syncs the result into property_group_proposals/_members (creating new proposals, refreshing existing proposed/stale ones, flagging confirmed/skipped ones whose membership changed — applied/merged proposals are untouched). Returns the run row:

{
  "run_id": "<uuid>", "status": "completed",
  "params": {"min_members": 2, "radius_m": 60, "include_linked": false},
  "counts": {
    "customers_scanned": 319, "unaddressable": 4, "singletons_dropped": 210, "clusters": 12,
    "proposals_created": 12, "proposals_updated": 0, "proposals_stale": 0, "proposals_unchanged": 0, "members_added": 61
  },
  "started_by": "user@example.com", "started_at": "...", "finished_at": "...", "error": null
}

A run that raises is still committed with status: "failed" and error set, and the endpoint returns 500.

`GET /api/v1/property-grouping/runs/latest` — the most recent run (null if none has ever run). `GET /api/v1/property-grouping/runs?limit=20` — recent runs, newest first.

`GET /api/v1/property-grouping/proposals` — query params: status (comma-separated; defaults to every status except merged — i.e. proposed,confirmed,skipped,applied,stale), needs_review (bool), customer_id (only proposals where this customer is an active member), q (matches name/suggested_name/address_line_1/city). Response:

{
  "items": [{ "...": "one proposal payload per item, shape below" }],
  "counts": {"proposed": 8, "confirmed": 1, "skipped": 2, "applied": 1, "stale": 0, "merged": 1}
}

Items are sorted needs_review first, then by status (proposed, confirmed, skipped, applied, stale, merged), then by descending active member count. counts always covers all six statuses regardless of the status filter applied to items.

`GET /api/v1/property-grouping/proposals/{proposal_id}` — one proposal payload:

{
  "proposal_id": "<uuid>", "group_key": "4201 millersville rd|in", "absorbed_group_keys": [],
  "status": "proposed", "confidence": "exact", "needs_review": false,
  "suggested_name": "4201 Millersville Rd", "name": null, "display_name": "4201 Millersville Rd",
  "nickname": null, "address_line_1": "4201 Millersville Rd", "address_line_2": null,
  "city": "Indianapolis", "state": "IN", "postal_code": "46205",
  "centroid_lat": 39.842, "centroid_lon": -86.055, "tenant_count": null, "notes": null,
  "existing_property_id": null, "applied_property_id": null, "merged_into_proposal_id": null,
  "warnings": [{"code": "city_mismatch", "values": {"Carmel": 5, "Indianapolis": 2}}],
  "first_run_id": "<uuid>", "last_run_id": "<uuid>",
  "confirmed_by": null, "confirmed_at": null, "skipped_by": null, "skipped_at": null, "skip_reason": null,
  "applied_by": null, "applied_at": null, "created_at": "...",
  "members": [
    {
      "member_id": "<uuid>", "customer_id": "<uuid>", "name": "Acme Co", "status": "active",
      "contracted_mrr_usd": "150.00", "address_line_1": "4201 Millersville Road", "address_line_2": "Ste 100",
      "city": "Indianapolis", "state": "IN", "postal_code": "46205", "latitude": 39.842, "longitude": -86.055,
      "match_kind": "exact", "distance_m": 0.0, "warnings": [],
      "removed_at": null, "removed_by": null, "added_by": null, "added_run_id": "<uuid>",
      "current": {"deleted": false, "property_id": null}
    }
  ],
  "member_count": 19, "active_member_count": 19, "total_mrr_usd": "2850.00"
}

contracted_mrr_usd/total_mrr_usd come back null when the caller lacks field.customer_revenue.view. members[].current is a fresh re-read of that customer's live state (deleted, current property_id) so a stale card can be spotted before Apply is attempted.

`PATCH /api/v1/property-grouping/proposals/{proposal_id}` — partial update; any field omitted is left unchanged. Accepted fields:

{
  "name": "Milkyway", "nickname": null, "address_line_1": "...", "address_line_2": null,
  "city": "...", "state": "IN", "postal_code": "46205", "tenant_count": 24, "notes": "...",
  "status": "confirmed", "skip_reason": null,
  "existing_property_id": null,
  "add_customer_ids": ["<uuid>"], "remove_customer_ids": ["<uuid>"]
}

status only accepts proposed, confirmed, or skipped (there is no direct transition to applied or merged through this route — use apply/merge below). An empty string on any text field clears it to null. add_customer_ids re-adds/adds members (rejecting deleted, non-commercial, already-linked-elsewhere, or already-active-in-another-proposal customers with 422 {"detail": "Some customers could not be added", "conflicts": [{"customer_id", "name", "reason"}]} and writing nothing); remove_customer_ids soft-removes members (removed_at/removed_by set) — a removed member is never re-added by a later scan run. Returns 409 {"detail": "Proposal is already applied"} (or "merged") if the proposal is no longer editable. Writes one update_property_group_proposal audit row (unscored). Response is the refreshed proposal payload.

`POST /api/v1/property-grouping/proposals/merge` — body {"survivor_id": "<uuid>", "absorbed_ids": ["<uuid>", "..."]}. Moves every absorbed proposal's active members onto the survivor (skipping any customer already active on the survivor), marks each absorbed proposal merged with merged_into_proposal_id set to the survivor, and recomputes the survivor's centroid, warnings (always including mixed_confidence, plus recomputed city_mismatch/postal_mismatch), and confidence (mixed). 409 if the survivor or any absorbed proposal is already applied/merged. Writes a merge_property_group_proposal audit row per absorbed proposal plus one on the survivor. Returns the survivor's refreshed proposal payload.

`POST /api/v1/property-grouping/proposals/{proposal_id}/suggest-address` — no body. Makes one Nominatim geocoding lookup against the proposal's current address fields and returns {"suggestions": [...]} (the same shape AddressSuggestionInput uses elsewhere); this is not persisted — the reviewer applies a suggestion into the proposal's address fields with a normal PATCH.

`POST /api/v1/property-grouping/proposals/{proposal_id}/apply` — body {"confirm": false}. If the proposal is already confirmed, confirm is ignored; if it's proposed, pass confirm: true to confirm-and-apply in one call, otherwise this returns 409 "Proposal must be confirmed before it can be applied (status=proposed)". 409 "Proposal is already applied" / "merged" if terminal. 422 "A proposal needs at least two members to become a property" if fewer than two active members remain and it isn't targeting an existing property, or "Proposal has no active members" if none remain at all. If any active member is no longer eligible, returns 409:

{
  "detail": {
    "detail": "2 members are no longer eligible",
    "conflicts": [
      {"customer_id": "<uuid>", "name": "Acme Co", "reason": "already_linked"},
      {"customer_id": "<uuid>", "name": "Old Co", "reason": "deleted"}
    ]
  }
}

(reason is one of not_found, deleted, not_commercial, already_linked.) Nothing is written when this 409 fires — remove the flagged members and retry. On success, creates (or reuses existing_property_id) the Property, links every valid member via the same writer POST /properties/{id}/customers uses (source: "property_grouping", unscored per-customer link rows), marks the proposal applied, and pays one flat property.group_apply contribution. Response:

{
  "proposal": { "...": "applied proposal payload" },
  "property": { "...": "property detail payload" },
  "linked_customer_ids": ["<uuid>", "..."],
  "contribution": { "...": "contribution_payload(...) or null" }
}

`POST /api/v1/property-grouping/proposals/apply-all-confirmed` — no body. Applies every confirmed proposal, each inside its own database savepoint so one proposal's failure never rolls back another's success, then commits once. Returns:

{
  "applied": [{ "...": "one apply-response object per successful proposal, as above" }],
  "failed": [{"proposal_id": "<uuid>", "detail": "2 members are no longer eligible", "conflicts": [{"customer_id", "name", "reason"}]}]
}

`POST /api/v1/property-grouping/proposals/{proposal_id}/reopen` — no body. Only valid on an applied proposal (409 otherwise), and only when the applied property has already been fully unlinked or soft-deleted (409 "Cannot reopen: the applied property still has linked customers" otherwise) — this is the undo path, and it deliberately can't strand a live property. On success, returns the proposal reset to proposed (applied_by/applied_at/applied_property_id cleared).

`GET /api/v1/property-grouping/suggest?address_line_1=&city=&state=&postal_code=&exclude_customer_id=` — permission customers_edit (not properties_edit); this is the hint the customer add/edit form calls as the address is typed. Returns:

{
  "canonical_key": "4201 millersville rd|in",
  "properties": [{"property_id": "<uuid>", "name": "Milkyway", "customer_count": 19, "distance_m": null, "match": "exact_key"}],
  "customers": [{"customer_id": "<uuid>", "name": "Acme Co", "property_id": "<uuid or null>"}],
  "proposal": {"proposal_id": "<uuid>", "display_name": "Milkyway", "status": "applied", "member_count": 19}
}

All four fields come back empty/null (canonical_key: null) if the given address doesn't parse to a canonical key (blank, no house number, PO Box). properties/customers are populated by an exact canonical-key match against other commercial customers' addresses; proposal is the matching property_group_proposal, if any (resolved through merges the same way a scan run resolves keys).

GET /api/v1/rf-links
POST /api/v1/rf-links
PATCH /api/v1/rf-links/{rf_link_id}
DELETE /api/v1/rf-links/{rf_link_id}
POST /api/v1/rf-links/{rf_link_id}/documents

RF Links are built from existing infrastructure, customer, and property sites. Links between two infrastructure assets are backhaul. Links from infrastructure to a customer site are customer endlinks. When a customer endlink is saved, NetOS synchronizes the matching subtended customer endpoint relationship so attribution can show RF transport.

Each side (a_*/z_*) may reference at most one of a_infrastructure_id/a_customer_id/a_property_id (and the z_* equivalents) — POST/PATCH returns 422 "Select only one A/Z endpoint: facility, customer, or property" if more than one is set on the same side. A property endpoint is treated like a customer endpoint for network_role ("customer_endpoint", never backhaul) and for attribution_status, but it creates no SubtendedLink — SubtendedLink.customer_id is required, and a property is neither a customer nor an infrastructure asset, so a property-terminated link (customer↔property or infrastructure↔property) never gets a subtended-link attribution row. The response payload carries a_property_id/z_property_id and, when set, a_property/z_property objects ({"property_id", "name", "nickname"}); a_location_name/z_location_name for a property endpoint render as the property's name plus (nickname) when it has one, the same convention circuits and the map use.

RF System Catalog

GET /api/v1/rf-systems
POST /api/v1/rf-systems
PATCH /api/v1/rf-systems/{rf_system_id}
DELETE /api/v1/rf-systems/{rf_system_id}

RF systems define selectable defaults such as name, description, frequency used, capacity, cost, PTP/PtMP type, equipment role, and PtMP compatibility. PtMP source nodes must reference RF systems with role ptmp_base. PtMP customer RF Links must reference a subscriber/client RF system with role ptmp_subscriber; if the subscriber has compatible base system IDs, the selected PtMP source node's base system must be included.

Electrical Services

GET /api/v1/electrical-services
POST /api/v1/electrical-services
PATCH /api/v1/electrical-services/{electrical_service_id}
DELETE /api/v1/electrical-services/{electrical_service_id}
POST /api/v1/electrical-services/{electrical_service_id}/documents

Electrical services attach to exactly one Infrastructure or Customer site. They store provider, service type, account and meter numbers, voltage, amperage, phase, delivery location, service address, average monthly cost, start date, monitoring URL, notes, and supporting documents.

POST /api/v1/infrastructure/{infrastructure_id}/subtended-links
PATCH /api/v1/infrastructure/{infrastructure_id}/subtended-links/{link_id}
DELETE /api/v1/infrastructure/{infrastructure_id}/subtended-links/{link_id}

Subtended links explicitly define the financial hierarchy. Use them to attach customer endpoints, downstream structures, existing circuits, or RF Links to a parent infrastructure asset. This is what drives the attribution tree, dashboard attribution cards, customer inherited-cost drilldowns, and infrastructure financial summaries.

A subtended link's downstream target may also be a property — property_id on the write payload, alongside downstream_infrastructure_id/circuit_id/rf_link_id/customer_id/customer_name/endpoint_name (the underlying check constraint just requires at least one target field set; it isn't a strict one-of, except customer_id and property_id, which are mutually exclusive — a link is either attributed to one customer or to one property, never both). A property-targeted link contributes implicit revenue equal to the sum of contracted_mrr_usd across that property's active, non-deleted tenant customers (see app.services.properties.property_active_mrr) — the same redundancy_role == "secondary" → 0 rule and explicit revenue_mrr_usd override precedence apply as for a direct customer link. Creating/updating a subtended link with a tenant customer's id in customer_id returns 422 (see Customers → Tenant model, above) — land it on the property instead.

Attribution is intentionally single-path by default: a customer endpoint or downstream structure may have only one active (date-overlapping) relationship at a time. If you POST a subtended link whose target is already attributed elsewhere, NetOS returns 409 Conflict with a detail string such as:

{"detail": "Customer 'Acme Tower A' is already attributed to Downtown POP. End the existing relationship before assigning it here. Or mark this link as redundant."}

To create a legitimate backup/secondary path instead of ending the existing one, send redundancy_role:

{
  "customer_id": "…",
  "circuit_id": "…",
  "redundancy_role": "secondary"
}
  • On subtended links, redundancy_role accepts "primary", "secondary", "standalone", or null. A "secondary" customer link at a *different* parent facility is allowed through the conflict check; the customer keeps its primary attribution elsewhere.
  • A link marked "secondary" contributes no attributed customer revenue (it is a backup path), but transport cost is still counted as real spend.
  • The " Or mark this link as redundant." hint is appended only for cross-facility customer conflicts. Downstream-structure and same-facility duplicates are never relaxed this way, and a downstream link that would form a hierarchy loop returns 409 "…circular infrastructure hierarchy".

For how backup paths are attributed in financial reporting (cost flows to the backup facility while revenue stays credited at the primary), see Infrastructure Attribution → Redundant / Backup Links.

The same redundancy_role concept applies to circuit creation (POST /circuits accepts redundancy_role) and to customer RF Links (POST /rf-links accepts redundancy_role, limited to "secondary" or null), which is how integrators register a backup RF endlink for a customer.


Bulk Update Circuits

PATCH /api/v1/circuits/bulk

Requires: Write role (exec, finance, network_eng, operations, pm)

Request Body:

{
  "circuit_ids": ["8d8e4333-8182-4443-8e0d-f2307276db52"],
  "updates": {
    "status": "active",
    "carrier_name": "Lumen",
    "service_type": "dia",
    "mrc_usd": 1850.00,
    "nrc_usd": 0,
    "contract_term_end_date": "2027-12-31",
    "notes": "Updated through bulk action"
  }
}

Only fields included in updates are changed. Each affected circuit receives an audit entry.

Response:

{"updated": 1, "requested": 1}

Bulk Delete Circuits

POST /api/v1/circuits/bulk-delete

Soft-deletes circuits by setting deleted_at. Records remain available for audit and historical reporting.

Request Body:

{
  "circuit_ids": ["8d8e4333-8182-4443-8e0d-f2307276db52"]
}

Response:

{"deleted": 1, "requested": 1}

Map Data

GET /api/v1/circuits/map

Query Parameters:

ParamTypeDescription
carrierstringFilter by carrier
service_typestringFilter by service type enum
statusstringFilter by status enum
statestringFilter by endpoint state (US state)

RF link A/Z coordinates and names coalesce, per side: the infrastructure asset's geometry/name, then the customer's, then a linked property's (Property.geom/name), then the RF link's own stored a_/z_latitude/a_/z_longitude/a_/z_location_name. Circuit endpoints already resolve through Endpoint.geom/display_name, which a property-linked endpoint fills in directly, so no separate property fallback is needed there.

Response:

{
  "circuits": [
    {
      "id": "uuid",
      "label": "XIB-LUM-0042",
      "carrier": "Lumen",
      "service_type": "wave",
      "bandwidth_mbps": 10000,
      "status": "active",
      "a_endpoint": {"name": "...", "lat": 39.96, "lng": -82.99},
      "z_endpoint": {"name": "...", "lat": 39.95, "lng": -83.00},
      "geometry": {"type": "LineString", "coordinates": [[-82.99, 39.96], [-83.00, 39.95]]}
    }
  ],
  "endpoints": [
    {
      "id": "uuid",
      "name": "Xiber HQ",
      "type": "pop",
      "geometry": {"type": "Point", "coordinates": [-82.99, 39.96]}
    }
  ],
  "infrastructure": [
    {
      "id": "uuid",
      "name": "Cologix COL1",
      "type": "data_center",
      "geometry": {"type": "Point", "coordinates": [-83.00, 39.95]}
    }
  ],
  "properties": [
    {
      "property_id": "uuid",
      "name": "Milkyway",
      "tenant_count": 19,
      "geometry": {"type": "Point", "coordinates": [-86.16, 39.77]}
    }
  ],
  "filters": {
    "carriers": ["Lumen", "Zayo", "Cogent"],
    "service_types": ["wave", "dia", "dark_fiber"],
    "statuses": ["active", "ordered"],
    "states": ["OH", "GA", "IL"]
  }
}

Dashboard Summary

GET /api/v1/dashboard/summary

Response includes:

SectionFields
kpistotal_circuits, active_circuits, infrastructure_count, circuit_mrc, facility_mrc, total_mrc, modeled_mrr, modeled_margin, renewal_window_count
spend_by_carrierArray of {carrier, mrc, circuit_count}
service_mixArray of {service_type, count}
state_mixArray of {state, count}
renewal_pipelineArray of {state, count}
margin_histogramArray of {bucket, count}
at_risk_circuitsArray of circuit summaries
top_expensiveArray of circuit summaries
lowest_marginArray of circuit summaries

attribution_customers rows now carry kind: "customer" | "property". A "property" row's customer_id is null and it additionally carries property_id, name (the property's name), and tenant_count (its active tenant count); revenue_mrr_usd/costs/margin are computed the same way as a customer row, with revenue sourced from the property's active tenants' summed contracted_mrr_usd (see app.services.properties.property_active_mrr) rather than a single customer's. attribution_tree nodes gain node_type: "property" alongside the existing "infrastructure"/"customer" — a property endpoint node additionally carries property_id, tenant_count, and href: "/properties/{id}".


Import Preview

POST /api/v1/imports/circuits/preview
Content-Type: multipart/form-data

Upload a CSV or XLSX file. Returns import job ID, detected column mapping, headers, sample rows, and validation summary.

Example:

curl -H "x-user-role: exec" \
  -F "file=@examples/sample_circuits.csv" \
  http://localhost:8000/api/v1/imports/circuits/preview

Import Commit

POST /api/v1/imports/circuits/{import_job_id}/commit

Commits all valid staged rows. Returns insert/update/invalid counts.

Example:

curl -H "x-user-role: exec" \
  -X POST \
  http://localhost:8000/api/v1/imports/circuits/{import_job_id}/commit

List Service Providers

GET /api/v1/service-providers

Returns carrier records with circuit count, infrastructure count, and total spend.


Get Service Provider

GET /api/v1/service-providers/{carrier_id}

Returns provider detail with related circuits and infrastructure assets.


List Infrastructure Assets

GET /api/v1/infrastructure

Returns infrastructure asset records with facility type, provider, costs, terms, and monitoring_url.


Bulk Update Infrastructure Assets

PATCH /api/v1/infrastructure/bulk

Request Body:

{
  "infrastructure_ids": ["ae30e22f-565d-46f3-be94-9d0e001a59de"],
  "updates": {
    "status": "active",
    "provider_name": "American Tower",
    "site_type": "tower",
    "mrc_usd": 2200.00,
    "price_escalator_pct": 3.0,
    "term_end_date": "2028-12-31",
    "notes": "Updated through bulk action"
  }
}

Only fields included in updates are changed. Each affected asset receives an audit entry.

Response:

{"updated": 1, "requested": 1}

Bulk Delete Infrastructure Assets

POST /api/v1/infrastructure/bulk-delete

Soft-deletes infrastructure assets by setting deleted_at.

Request Body:

{
  "infrastructure_ids": ["ae30e22f-565d-46f3-be94-9d0e001a59de"]
}

Response:

{"deleted": 1, "requested": 1}

Audit Activity

GET /api/v1/audit/activity

Returns recent request/write activity including user, role, IP, event type, action, entity, changed fields, and response status.


Bugs & Requests

GET    /api/v1/feedback?view=mine|assigned|all|triage&status=open|closed|done|declined|all&request_type=&category=&q=
GET    /api/v1/feedback/options
GET    /api/v1/feedback/guess-category?page_url=/customers/…
POST   /api/v1/feedback                      (multipart: payload=<JSON>, files=<screenshots>)
GET    /api/v1/feedback/{feedback_id}
PATCH  /api/v1/feedback/{feedback_id}        (status, priority, category, request_type, title, description, assignee_email)
DELETE /api/v1/feedback/{feedback_id}        (triagers)
POST   /api/v1/feedback/{feedback_id}/comments      ({body, visibility: public|internal})
POST   /api/v1/feedback/{feedback_id}/attachments   (multipart: files, optional comment_id)
GET    /api/v1/feedback/{feedback_id}/attachments/{name}[/thumbnail]
DELETE /api/v1/feedback/{feedback_id}/attachments/{attachment_id}

The create payload fields are:

  • request_type (bug/feature/data/question), title, description, priority;
  • category (optional; guessed from page_url when it's left out) and page_url;
  • context (user_agent, viewport, screen, page_title, timezone);
  • assignee_email (optional).

Each item reports what the viewer may do: can_manage, can_comment, can_add_internal, can_assign, can_edit_text and can_change_status. Other people's requests come back with full: false, as a summary without text, page, files or comments. An assigned request is also a task (source_key feedback:<id>, entity_type feedback), and each follows the other; see docs/admin-operations.md "Bugs & Requests".


Document/Photo Location (site-inherited)

Every infrastructure/customer/property document payload (InfrastructureDocument/CustomerDocument/PropertyDocument, returned from the upload, list/detail, PATCH, and reanalyze routes documented under each domain above) carries a location object alongside the existing gps_latitude/gps_longitude/metadata_json columns (those keep their existing meaning — measured/user-set coordinates — and are unchanged):

"location": {
  "latitude": 39.7684,
  "longitude": -86.1581,
  "source": "exif",
  "place": "Indianapolis, IN",
  "address": null,
  "heading_deg": 271.5,
  "bearing_to_site_deg": 88.0,
  "accuracy": "photo"
}

Product rule: a photo or video with no GPS of its own inherits the location and address of the site (infrastructure asset / customer / property) it belongs to. location is null only when the document has no GPS of its own and the owning site has no geocoded point either.

source precedence (highest wins):

  • "user" — a person set or overrode the location by hand (see the PATCH fields below). Beats the file's own GPS.
  • "exif" / "ingest" — the file's own GPS: "exif" for a direct upload's EXIF GPS, "ingest" for GPS attached during a SharePoint ingest (POST /site-links/{id}/ingest). Recorded under metadata_json.gps.source at upload/ingest time.
  • "site" — inherited from the owning site's own coordinates/address (accuracy: "site"; heading_deg/bearing_to_site_deg are always null for this source — a site doesn't have a camera heading or a distance from itself). place is the site's city/state (or its address line when city/state aren't set); address is the site's address line 1 + city.

accuracy is "photo" for user/exif/ingest (a real point tied to the file) or "site" (the owning site's point, standing in for the file's). place/address are null when neither is available.

Setting a location by hand. The document PATCH routes —

PATCH /api/v1/infrastructure/{infrastructure_id}/documents/{document_id}
PATCH /api/v1/customers/{customer_id}/documents/{document_id}
PATCH /api/v1/properties/{property_id}/documents/{document_id}

— accept two additional optional body fields alongside the existing title/comment/document_type/photo_folder:

  • gps_latitude / gps_longitude (number) — both or neither must be given; sending exactly one is a 422. Setting both writes the columns, sets metadata_json.gps.source = "user" (plus set_by/set_at), and re-runs the same reverse-geocode + site-bearing enrichment a fresh upload gets, so location.place/bearing_to_site_deg reflect the new point.
  • clear_location (bool, default false) — nulls gps_latitude/gps_longitude and removes the user marker, so the document falls back to its site's inherited location. Wins over gps_latitude/gps_longitude if both are sent in the same call.

Filling in GPS by hand does not retroactively pay the upload-time photo-GPS contribution bonus — that only scores at upload (app.services.contributions.engine._score_photo_or_document); the metadata-update rule (_score_document_metadata_update) only scores a newly-added comment/folder, never GPS.


Admin: Photo GPS Backfill

POST /api/v1/admin/photos/backfill-geo?limit=200

Requires settings_edit. One-off backfill for InfrastructureDocument/CustomerDocument photos that already have GPS coordinates (from EXIF at upload time) but no reverse-geocoded place yet — e.g. photos uploaded before GPS enrichment shipped, or before GEOCODING_ENABLED/GEOCODING_REVERSE_URL were configured. limit (default 200) caps how many candidate rows are examined per document type per call; rows that already have metadata_json.gps.place are skipped, so calling it again continues where the previous call left off. Rate-limited to ~1 row/second to respect Nominatim's usage policy. Returns:

{
  "infrastructure_documents": 12,
  "customer_documents": 4,
  "examined": 16,
  "left_without_place": 0
}

left_without_place counts rows that were examined but still have no place afterward (geocoding disabled, or the lookup failed) — non-zero is expected when GEOCODING_ENABLED is false.


Error Contract

Errors follow FastAPI's convention: a JSON body with a detail field and an appropriate status code. Integrators should branch on status code and surface detail verbatim.

StatusMeaningdetail shape
401No/invalid identityString, e.g. "Authentication required", "Invalid API token", "API token expired"
403Authenticated but not permittedString, e.g. "Missing permission: circuits_edit", "Write access required", "Unknown role"
404Record not found (or soft-deleted)String, e.g. "Stored document not found"
409State conflict (attribution, in-use, cycle)String — see Attribution-conflict 409; also raised when deleting an RF system / PtMP node still referenced by RF links
422Validation errorEither FastAPI's structured detail array (Pydantic body validation) or a plain string for hand-rolled checks, e.g. "Label, display name, and site type are required"

Every route is gated by a route-permission dependency, so a valid identity lacking the mapped permission gets 403 {"detail": "Missing permission: <code>"} before the handler runs.


List Behavior & Response Shapes

  • Response conventions. A handful of endpoints (e.g. GET/POST/PATCH /circuits) declare a Pydantic response_model (CircuitRead) and return schema-validated objects. Most other endpoints build response dicts by hand, so field presence tracks the code rather than a published schema — always tolerate extra/missing keys. Financial and other sensitive fields may be returned as null when the caller's RBAC lacks the field-level permission (masking is applied to the payload, not signalled by a separate error).
  • List caps. GET /circuits and GET /customers are capped at 1000 rows (ordered by label/name). GET /infrastructure and GET /rf-links are not paginated and return all non-deleted rows. GET /search caps results at 30 (default 12). There is no cursor/offset pagination yet.
  • Filtering. GET /circuits supports carrier, status, service_type, search, and term_end_before server-side. GET /customers, GET /infrastructure, and GET /rf-links take no query filters — filter client-side (this is what the MCP list_* tools do).
  • Soft delete. Deletes set deleted_at; every list/detail query filters deleted_at IS NULL. Deleted records stay for audit/history but are invisible to reads and return 404 on direct fetch. Bulk deletes (POST /circuits/bulk-delete, POST /infrastructure/bulk-delete) are soft deletes and return {"deleted": n, "requested": m}.
  • Bulk update. PATCH /circuits/bulk and PATCH /infrastructure/bulk apply only the fields present in updates and write an audit entry per affected row.
  • CSV/XLSX imports now cover three entities — circuits, infrastructure, and customers — each with preview → PATCH .../{import_job_id}/rows (in-app staging-row correction) → commit.

MCP Server

Location: apps/mcp/

The MCP server exposes NetOS data to XOS agents (ExecOS, department agents, Claude Desktop users).

Current Tools

The MCP server is intentionally a thin, permission-aware layer over the NetOS REST API. It does not read the database directly. API-token RBAC controls which rows and fields are returned.

ToolDescription
netos_auth_meVerify the configured NetOS API identity, roles, and permissions
search_netosUniversal search across inventory, sites, customers, documents, and providers
list_circuitsList wholesale circuits with optional API filters
get_circuitGet a circuit detail record
list_infrastructureList infrastructure sites, with simple client-side filters
get_infrastructure_contextGet full infrastructure context, relationships, economics allowed by RBAC, and compact document/photo metadata
list_customersList customer sites, with simple client-side filters
get_customer_contextGet customer context including contacts, access notes, service paths, and compact document/photo metadata
list_propertiesList commercial multi-tenant properties, with tenant/customer rollups, penetration %, and RBAC-filtered MRR
get_property_contextGet full property context: profile, rollups, linked tenants, linked infrastructure, legacy property names, and optional tenant photo metadata
get_site_documentsReturn metadata, user notes, and AI summaries for site documents/photos
list_rf_linksList RF links, including PTP/PtMP path metadata
get_rf_linkGet one RF link by UUID via list filtering
list_quotesList vendor quotes using supported API filters
get_quoteGet quote detail, pricing options, and status history when permitted
quote_aggregationSite-level vendor quote aggregation for NNI/port planning signals
create_quote_draftCreate a vendor quote draft for human review without writing to quote history
list_quote_draftsList vendor quote drafts by review status
get_quote_draftGet one vendor quote draft by UUID
update_quote_draftCorrect a draft payload before review/commit
reject_quote_draftReject a duplicate, stale, or invalid draft with a reason
get_carrier_summaryProvider/carrier summary with circuit count, MRC totals, ETF exposure, contacts, portals, circuits, and infrastructure
get_renewal_pipelineCircuit and infrastructure renewal deadlines by state for a lookahead window
get_field_recordField-oriented context by kind: circuit, infrastructure, customer, property, or rf_link

Planned Tools

ToolDescription
get_circuit_plPer-circuit P&L with revenue attribution
get_outage_historyOutage events for a circuit
Write toolsCreate/update with explicit agent confirmation flow and audit trail

Authentication

Production MCP deployments should use a NetOS API token generated in Admin. Configure the MCP process with:

NETOS_API_BASE_URL=http://10.10.67.118:8010
NETOS_API_TOKEN=ntos_live_...

Use the protected internal API endpoint for machine clients. The public browser hostname https://netos.xiberian.net is fronted by Microsoft SSO through oauth2-proxy and will return an HTML sign-in page before NetOS can validate an API token.

Every MCP request is sent to the REST API with:

Authorization: Bearer <NETOS_API_TOKEN>

The token user's RBAC permissions determine what the tools can see. Financial fields, executive summaries, document metadata, and search results follow the same field-level masking rules as the REST API.

For local development only, the MCP server can fall back to trusted dev headers:

NETOS_MCP_AGENT_EMAIL=xos-agent@xiber.com
NETOS_MCP_AGENT_ROLE=agent

This fallback should not be used for production gateway access.

Registration with XOS Gateway

The MCP server should be registered with the Xiber MCP Gateway (gateway.xiberian.net) so it's available to all XOS agents and Claude Desktop users via the SSE bridge. This is not yet configured.


XSurvey Integration (Site Survey Footage)

XSurvey is the system of record for site survey media (terabytes of footage); NetOS stores only survey references — metadata, up to 5 thumbnail URLs, and deep links back into XSurvey's UI. NetOS never ingests media bytes. Surveys are rendered as a "Site Surveys" panel on infrastructure and customer detail pages.

Auth: XSurvey pushes with a NetOS API token bound to the Integration: XSurvey service role (xsurvey_push + read access for its site picker: assets_view, customers_view). Browser deep links and thumbnail loads assume both apps sit behind the shared Entra SSO.

Push Contract (v1)

POST /api/v1/integrations/xsurvey/surveys — permission xsurvey_push. Upsert key: survey_id (XSurvey UUID, stable forever). Envelope:

{
  "schema_version": 1,
  "survey_id": "8c4c9f6a-…",
  "event": "upsert",                     // or "tombstone"
  "netos_entity_type": "infrastructure", // or "customer"
  "netos_entity_id": "b1f0d3c2-…",
  "captured_at": "2026-07-06",
  "technician": "Phil Smith",
  "title": "9000 Keystone Crossing — site walk",
  "summary": "…from the APPROVED report only…",
  "status": "published",                 // or "archived"
  "media_counts": { "photos": 0, "videos": 10, "minutes": 27 },
  "thumbnail_urls": ["https://<xsurvey>/api/v1/frames/<uuid>/image"],  // max 5
  "deeplink_url": "https://<xsurvey>/surveys/<survey_id>",             // required on upsert
  "report_url": "https://<xsurvey>/reports/<uuid>",
  "updated_at": "2026-08-02T14:11:07Z",
  "media_retention": "indefinite-tiered"
}

Response is a receipt: { "receipt_id": "<uuid>", "survey_id": "…", "action": "created" | "updated" | "tombstoned" | "ignored_stale" | "ignored_stale_tombstone", "received_at": "…" }. Receipts are also written to the NetOS audit log (xsurvey_survey_upsert / xsurvey_survey_tombstone).

Semantics:

  • Upserts with an updated_at older than the stored source_updated_at are

ignored (ignored_stale) — retries are always safe.

  • Unknown netos_entity_id → 422 with a clear message. XSurvey's site picker

should use GET /api/v1/infrastructure and GET /api/v1/customers.

  • Tombstones null all content fields and hide the survey from the UI; a

tombstone naming a *different* entity than the stored row (re-link race) is ignored (ignored_stale_tombstone) so the newer linkage survives.

Read-back / Reconciliation

GET /api/v1/integrations/xsurvey/surveys?netos_entity_id=<uuid>&include_tombstoned=false (permission assets_view) returns stored survey references — used by the NetOS UI panel and available for XSurvey-side reconciliation. XSurvey exposes the mirror endpoint (GET /api/v1/surveys?netos_entity_id=&updated_since=) so NetOS can backfill if pushes were missed.

Tarana RN Lookup Integration (Xiber XOS / FW Fleet)

Lets a customer record carry one or more Tarana radio nodes (RNs) looked up from the Xiber XOS "Tarana RN Lookup" API against the Xiber FW Fleet map. NetOS never talks to XOS from the browser — two thin server-side proxy routes forward with our own bearer token, and the "Add RN endpoint" type-ahead calls those, not XOS directly.

Upstream: http://10.10.67.131:9001/api/v1/tarana (LAN only, plain HTTP), Authorization: Bearer $XOS_API_TOKEN, scope tarana:read, 5s timeout. See app/services/xos_tarana.py.

Env vars (apps/api/app/core/config.py, set in infra/docker/.env.public — gitignored, never committed):

VariableDefaultNotes
XOS_API_TOKEN*(empty)*Required for the integration to work. When empty, the proxy/attach routes return a 503 config error and the scheduled refresh skips quietly (no warning, no batch).
XOS_API_BASE_URLhttp://10.10.67.131:9001/api/v1/taranaOnly needs overriding in a non-standard environment.
TARANA_REFRESH_ENABLEDtrueKill switch for the scheduled batch refresh; the admin-triggered POST /api/v1/tarana/refresh-all still works either way.

Our routes:

  • GET /api/v1/tarana/rns?q=&limit=&connected= — permission customers_view. Proxies the FW Fleet search; every word in q must match (serial, name, site, sector, market, band, MAC, connected BN name); empty q lists everything; limit clamped to 1–200 (default 20).
  • GET /api/v1/tarana/rns/{serial}?include_history= — permission customers_view. Proxies the detail lookup; when include_history=true, requests history_limit=20. Serial is validated (^[A-Z0-9]{12,24}$, case-insensitive) *before* calling XOS.
  • POST /api/v1/tarana/refresh-all — permission settings_edit. Admin-triggerable equivalent of the scheduled batch (e.g. to run right after a deploy).
  • GET /api/v1/customers/{customer_id}/tarana-rns — permission customers_view. Lists RNs attached to a customer.
  • POST /api/v1/customers/{customer_id}/tarana-rns {"serial": "..."} — permission customers_edit. Fetches full detail (include=speedtest_history) and saves rn_serial, rn_snapshot (the entire rn object, so new upstream fields show up without a migration), speedtest_history, xos_cached_at, fetched_at on a new customer_tarana_rns row. Rejected with 422 for a tenant customer (property_id set — tenants have no network components of their own, same rule as RF links/PtMP nodes). 409 if the serial is already attached to another (or the same) customer, naming it.
  • POST /api/v1/customers/{customer_id}/tarana-rns/{id}/refresh — permission customers_edit. Re-fetches detail for the saved serial ("Refresh from FW Fleet" button). A 503 (cache still building) records last_refresh_status="error"/last_refresh_error without touching the existing snapshot.
  • DELETE /api/v1/customers/{customer_id}/tarana-rns/{id} — permission customers_edit. Soft-deletes the attachment (frees the serial to be re-attached elsewhere).

Error mapping (app/services/xos_tarana.py): XOS 401/403 → our 502 ("FW Fleet API rejected NetOS's token — check XOS_API_TOKEN"); XOS 404 → our 404 ("RN not found in FW Fleet"); XOS 503 → our 503 ("FW Fleet cache is still building — try again later"); a bad serial format → our 400, checked before ever calling XOS; a request timeout → our 504; any other connection failure → our 502; missing XOS_API_TOKEN → our 503 ("Tarana lookup isn't configured"). None of these ever include the token or an Authorization header value.

Schedule: XOS rebuilds its FW Fleet cache roughly twice a day (~03:17 and ~09:10 UTC). The background scheduler (app/main.py's notification_scheduler, already polling every 30 minutes for the renewal report / activity digest / recognition email jobs) refreshes every attached RN sequentially, deduplicated per UTC day, at three checkpoints (app/services/xos_tarana_refresh.py's TARANA_REFRESH_SLOTS_UTC): ~03:00, ~09:00, and ~15:00 UTC. It stops the whole batch immediately on the first 401/403 (a config problem, not a per-RN one), logging exactly one warning. A 503 for one RN leaves that RN's data as it was and just records the failure, without affecting the rest of the batch.

UI: the customer detail page's Service tab (non-tenant customers only — tenants see a one-line note instead), apps/web/components/tarana-rn-panel.tsx. Each attached RN shows connected/offline (+ stale) status, site/sector/band/SLA, radio + placement details, last speed test / baseline / lifetime peak, last ping/traceroute, a compact speed-test history table, and "FW Fleet data as of {cachedAt}" + "fetched {fetched_at}" (relative time, absolute on hover). Site linking to the matching NetOS infrastructure asset (XOS site names use underscores, e.g. CCI_829072_Whitestown, vs. NetOS's CCI_829072 Whitestown) was scoped out for now per the admin's "skip otherwise and mention" guidance — worth revisiting if it turns out to be a cheap, exact match in practice.

Bulk RN-to-customer match review (/tarana/rn-matches)

Most of the RNs XOS knows about (prod: 127, only 2 attached) were never run through the "Add RN endpoint" type-ahead one at a time. app.services. tarana_rn_matching scores every un-attached RN (one search_rns call, up to 200 — truncated: true if XOS's own total exceeds that, since the search proxy has no offset/page param) against every non-tenant customer and proposes the best match, so an admin can bulk-accept instead.

Signals (each surfaced as a labeled reason chip): an existing PtMP link already on the NetOS node the RN's connected BN resolves to (via the same app.services.tarana_bn_mapping.resolve_bn the real attach-time automation uses — always high confidence outright); name similarity (difflib ratio + token-set overlap on a CamelCase-aware, noise-token-stripped normalization — "CommandLink4337" vs. customer "CommandLink" counts as an exact match since the digits are a catalog suffix the customer record doesn't carry); location (haversine distance from the RN's own lat/lng to the customer's geom, falling back to its property's) — a distance beyond 2 km caps the confidence a strong name match alone could otherwise reach; street-number match (a 3–5 digit number in the RN's name equal to the customer's address); and a weak same-site-served boost. app.services.tarana_rn_matching.classify_candidate documents the exact confidence rules.

  • GET /api/v1/tarana/rn-matches — permission customers_view. Returns

every un-attached RN with its top pick + up to 3 alternates, bucketed high/medium/low/no_suggestion/dismissed, plus cached_at (XOS's own cache timestamp) and counts.

  • POST /api/v1/tarana/rn-matches/accept {"items": [{"rn_serial", "customer_id"}, ...]}

— permission customers_edit. Bulk-attaches each pick through the exact same path as the single-customer attach route (app.api.v1.customers. attach_tarana_rn_row), so the PtMP-link automation, audit trail, and service-line inheritance all run identically. A per-item failure (already attached, tenant customer, XOS error) is reported in that item's own result and never aborts the rest of the batch.

  • POST /api/v1/tarana/rn-matches/dismiss {"rn_serial", "reason": "no_match"|"rejected", "customer_id"?}

/ POST /api/v1/tarana/rn-matches/reopen {"rn_serial", "customer_id"?} — permission customers_edit. "Not a customer" (no_match, no customer_id) hides the whole RN; "reject this suggestion" (rejected, customer_id required) excludes just that one candidate, so a different customer can still be suggested for the same RN. Persisted as ordinary UserActivityLog rows (entity_type="tarana_rn_match") — the same lightweight pattern the "Tarana BN mapping" review category's skip/map actions use — so no dedicated table/migration was needed.

UI: apps/web/app/tarana/rn-matches, components/tarana-rn-matches.tsx — tabs by confidence (High/Medium/Low/No suggestion/Dismissed) with counts, bulk select (defaults to all of High) + "Accept selected", and a results panel showing attached/links-built/BN-mapping-needed/error counts. Linked from the customer's Tarana RN panel ("Match RNs in bulk") and the /network/review page header.

Microsoft Graph Connector (SharePoint, Phase 0/1)

Part of the "site media" feature: SharePoint folder links + photo ingest are built on app/services/msgraph.py's GraphClient. Phase 0 shipped the connector configuration and client (client-credentials/application auth only). Phase 1 added site links and the browse/ingest endpoints. A later change added delegated (per-user) auth: every Graph call now prefers the signed-in user's own access token (forwarded by the SSO proxy) over the connector's application token, so a user can only see/ingest what they themselves have access to in SharePoint — see docs/public-hostname-sso.md's "Delegated Microsoft Graph token" section for the full header/trust flow.

Client: GraphClient has two constructors -- the original GraphClient(tenant_id, client_id, client_secret) (application auth, acquires and caches its own token via client-credentials) and GraphClient.from_user_token(access_token) (delegated auth: every call uses that bearer token as-is). .access_mode reports which one built it ("user" or "app"). It wraps site search, drive/folder listing (with @odata.nextLink pagination), resolving a pasted SharePoint sharing URL to a drive_id/item_id pair, and a capped file download. All calls are synchronous (urllib-based, matching the rest of the codebase); async call sites use asyncio.to_thread(...).

Per-request client resolution: app/services/graph_access.py's graph_client_for_request(session, request, *, purpose) is what every site-links call site (create/PATCH resolve, /graph/resolve, /browse, /ingest) uses to get a GraphClient:

  1. If app.core.graph_identity.get_user_graph_token(request) returns a token

(forwarded X-Forwarded-Access-Token/X-Auth-Request-Access-Token, honored only from a trusted proxy address -- see below), use it via from_user_token. This is tried regardless of the connector's own enabled/configured state.

  1. Otherwise, if the microsoft_graph connector is enabled and its config

has allow_app_fallback: true (default false) plus a complete tenant/client/secret, build an application client.

  1. Otherwise, raise HTTP 428 Precondition Required with detail

"Sign in through the NetOS SSO to access SharePoint as yourself".

428 semantics: chosen instead of 401/403 specifically so the UI can distinguish "you need to sign in through SSO to use SharePoint as yourself" from a generic auth failure and show that exact message. It means: no delegated user token was available for this request, and either no connector is configured, the connector is disabled, or its config doesn't opt into allow_app_fallback. It is not a permissions error against Graph itself -- that would surface later, as a 502 from an actual failed Graph call (graph_error_to_http).

Trust rule for the forwarded token: X-Forwarded-Access-Token is treated exactly like the X-Auth-Request-Email identity header -- honored only when settings.trust_proxy_auth_headers is true and the request's client IP falls inside TRUSTED_AUTH_PROXY_CIDRS (is_trusted_auth_proxy). The token is never logged and never persisted.

Config: Admin → Integrations → Microsoft Graph card (GET/PUT /api/v1/integrations/connectors/microsoft_graph, permissions settings_view / settings_edit). Non-secret config (tenant_id, client_id, allow_app_fallback) is stored in integration_connector_settings.config_json; the client secret is Fernet- encrypted the same way AI provider keys are (app.services.ai_settings). The Test endpoint (POST .../test) takes an optional mode: "app" | "user" (default "app"): "app" runs the existing client-credentials whoami_test(); "user" uses the caller's own forwarded token and GraphClient.me_test() (GET /me?$select=displayName,mail, decodes the token's scp claim) so an admin can verify their own delegated access from the Admin page. GET /integrations/connectors also reports user_token_present: bool for the current request (whether *this* request carried a forwarded token), independent of the connector's saved config.

App registration: In Entra ID, register an app (or reuse the SSO app). For the delegated path, add Delegated permissions Sites.Read.All, Files.Read.All, and offline_access on the app registration oauth2-proxy itself uses, and grant admin consent (this is an infra-level change, not done from this card -- see the SSO doc). For the optional application fallback, add Application permissions Sites.Read.All (or Sites.Selected + a per-site grant) and Files.Read.All, grant admin consent, paste the tenant ID/client ID/client secret into the admin card, and use Test — it acquires a token, calls GET /sites?search=*&$top=1, and decodes the token's roles claim to confirm both permissions were actually granted (a reachable token endpoint is not enough; admin consent is a separate step people forget).

app/api/v1/site_links.py, prefix /api/v1/site-links. A site link points an infrastructure asset, a customer, or (site media phase B) a property — exactly one of the three, resolved via app.services.site_targets.resolve_site_target — at an external resource: a SharePoint folder, a OneDrive folder, a Teams channel, or a plain URL.

EndpointPermissionNotes
`GET /site-links?infrastructure_id=\customer_id=\property_id=`site_links_viewExactly one filter required.
POST /site-linkssite_links_editBody `{infrastructure_id\customer_id\property_id, label?, url, link_type?, notes?}. link_type is inferred from the URL when omitted. For SharePoint/OneDrive URLs, resolves ms_drive_id/ms_item_id via Graph when the connector is configured; a resolution failure still saves the link and returns a warning` field rather than failing the request.
PATCH /site-links/{id}site_links_editlabel, url, notes, sort_order. Changing url re-resolves the drive/item ids.
DELETE /site-links/{id}site_links_editSoft delete.
GET /site-links/graph/resolve?url=site_links_editResolves a pasted URL to {drive_id, item_id, name, web_url, is_folder} for the add-link form's Check button.
GET /site-links/{id}/browse?item_id=site_links_viewLists a SharePoint/OneDrive folder's children (item_id omitted = the link's root folder). Each item reports already_ingested/document_id by checking existing documents with source='sharepoint' and a matching source_ref->>'item_id'.
POST /site-links/{id}/ingestsite_links_edit and site_media_ingestBody {items: [{drive_id, item_id}], photo_folder?, comment?}. Max 25 items per call (422 above that). Downloads each file via Graph, validates content type/size the same way a direct upload does, stores it under the same infrastructure-documents/customer-documents/property-documents tree, and creates a document row with source='sharepoint' and source_ref (drive_id, item_id, etag, web_url, name, site_link_id, ingested_by, access_mode) — queuing the same background AI analysis a direct upload gets. ingested_by is the calling principal's email; access_mode is "user" or "app" per GraphClient.access_mode (see above), an audit trail of whether this file was pulled using the caller's own delegated access or the tenant-wide application fallback. Returns {ingested: [...], skipped: [{item_id, reason}], errors: [{item_id, name, error}]}; folders and already-ingested items are reported as skipped, disallowed/oversized files as errors.

Prerequisite: browse, ingest, and /graph/resolve all obtain their GraphClient via graph_client_for_request (see above) — a delegated user token (forwarded by a trusted proxy) always works regardless of connector state; otherwise the microsoft_graph connector must be enabled with allow_app_fallback: true and a complete tenant/client/secret (GET /api/v1/integrations/connectors/microsoft_graph). Neither available: 428 with detail "Sign in through the NetOS SSO to access SharePoint as yourself" (previously a bare 409 "Microsoft Graph connector is not configured" before the delegated-token change).

Site Findings & Comments (Phase 2)

app/api/v1/findings.py, prefix /api/v1/findings. A finding is a field issue (corrosion, a grounding gap, a locked gate, ...) recorded against a site (exactly one of infrastructure_id/customer_id/property_id, resolved via app.services.site_targets.resolve_site_target), with evidence photos/documents attached from that site's existing document library and a narrative that starts as an AI draft built from those documents' already-computed ai_analysis JSON plus technician notes — drafting never re-sends image bytes to the model. document_kind on an evidence reference is "infrastructure", "customer", or "property" matching the finding's own site.

EndpointPermissionNotes
`GET /findings?infrastructure_id=\customer_id=\property_id=&status=&severity=&q=&limit=&offset=`findings_viewReturns {items: [...], total}. Each item adds site_label, site_kind ("infrastructure"\"customer"\"property"), document_count, latest_comment_at to the finding fields. q matches title/narrative.
POST /findingsfindings_editBody `{infrastructure_id\customer_id\property_id, title, severity?, category?, narrative?, user_notes?, status?, document_ids?: [{document_kind, document_id}], ai_draft?, ai_provider_label?}. Exactly one site id is required (422 otherwise). Status is open unless status: "draft" is explicitly sent. Every document_id must belong to the same site (422 on a document_kind` mismatch, 404 if a document isn't found on that site).
GET /findings/{id}findings_viewFinding plus documents (full document payloads, each with finding_document_id) and comments.
PATCH /findings/{id}findings_editAny of title, severity, category, status, narrative, user_notes, document_ids (replaces the full evidence set), ai_draft, ai_provider_label. Setting status: "resolved" stamps resolved_at/resolved_by; any other status clears both.
DELETE /findings/{id}findings_editSoft delete.
POST /findings/draftfindings_editBody `{infrastructure_id\customer_id, document_ids: [...], user_notes?, title_hint?}. Calls the findings AI use (see Admin Operations) and returns {title, severity, category, narrative, evidence: [{document_id, points: [str]}], recommended_actions: [str], confidence, provider_label, warnings?} **without saving** — the caller reviews/edits, then POST /findings. Falls back to a deterministic draft (concatenates each document's AI summary/safety concerns/recommended follow-up; provider_label: "deterministic", a warnings` entry) when no AI provider is configured or the call fails.
POST /findings/{id}/redraftfindings_editSame as /findings/draft but sourced from an existing finding's current evidence documents and notes (optionally overridden via {user_notes}); also returns without saving.

Comments are generic (not finding-specific) so they can be reused on documents later: app/api/v1/comments.py, prefix /api/v1/comments. entity_type is one of site_finding, infrastructure_document, customer_document, property_document, property.

EndpointPermissionNotes
GET /comments?entity_type=&entity_id=findings_viewOldest first.
POST /commentscomments_editBody {entity_type, entity_id, body, visibility?} (visibility is public or internal, default internal). Author is the calling principal's email. 404 if the entity doesn't exist.
PATCH /comments/{id}comments_edit, and the comment's own author or settings_editbody, visibility.
DELETE /comments/{id}comments_edit, and the comment's own author or settings_editSoft delete.

Site Findings — Sharing & Sonar Tickets (Phase 3)

app/api/v1/findings_actions.py, mounted under the same /api/v1/findings prefix as Phase 2. Lets a finding be shared out of NetOS by email, Teams, or Slack, or turned into a Sonar ticket, and records every attempt (success or failure) as an outbound_actions row so the finding's Activity list is a full audit trail even for failures.

Transport per channel:

ChannelTransportNotes
Emailvia=auto (default): gateway tool msgraph.send_email (or msgraph.create_draft_email for mode=draft), sent as from_user=<the calling principal's email> so it lands in their own Sent Items; falls back to NetOS SMTP (with real attachments) if the gateway is unavailable or the call fails. via=graph forces a direct Microsoft Graph sendMail call (app.services.msgraph.GraphClient.send_mail, requires the microsoft_graph connector + the Mail.Send application permission) — no draft support, no fallback. via=smtp forces NetOS SMTP.The gateway's msgraph.send_email/create_draft_email tools can't carry attachments, so a gateway-routed email embeds photo deep links in the HTML body instead of attaching files; only the SMTP path (fallback or via=smtp) attaches actual image bytes (see collect_attachments, prefers the cached thumbnail over the full image past 2&nbsp;MB, caps total attachments at 15&nbsp;MB and notes any photos left out).
TeamsAlways the gateway tool msgraph.send_channel_message.There is no Graph-direct path: application tokens cannot be granted ChannelMessage.Send.
SlackAlways the gateway tool slack.post_message.
Sonar ticketAlways the gateway tool sonar.create_ticket (mode: "internal").

Endpoints (all under /api/v1/findings/{finding_id}/actions unless noted):

EndpointPermissionNotes
GET /findings/{id}/actionsfindings_viewNewest first.
POST /findings/{id}/actions/emailfindings_shareBody `{to: [email], cc?: [email], subject?, message?, include_photos?=true, mode?: "send"\"draft"="send", via?: "auto"\"graph"\"smtp"="auto"}. 422 if to is empty or any address fails a basic regex check, or if mode="draft" is combined with via other than "auto". On success, finding.status becomes shared if it was open/draft`.
POST /findings/{id}/actions/teamsfindings_shareBody {team_id, channel_id, message?}.
POST /findings/{id}/actions/slackfindings_shareBody {channel, message?}; the gateway's Slack ts (if returned) is stored as external_id.
POST /findings/{id}/actions/sonar-ticketsonar_ticket_createBody {assignee_user_id?, ticket_group_id?, priority?, subject?, description_note?, account_id?, force_new?=false}. priority defaults from severity (critical→CRITICAL, high→HIGH, medium→MEDIUM, low/info→LOW). On success sets finding.sonar_ticket_id and finding.status="ticketed", and external_url when the gateway connector's config has a sonar_base_url set. 409 if the finding already has a sonar_ticket_id and force_new isn't set.
GET /findings/directory/people?q=findings_editMerges the gateway's msgraph.list_users (name/email/title), filtered by q.
GET /findings/directory/teamsfindings_editGateway msgraph.list_teams.
GET /findings/directory/teams/{team_id}/channelsfindings_editGateway msgraph.list_channels.
GET /findings/directory/sonar-users?q=findings_editGateway sonar.search_users.
GET /findings/directory/capabilitiesfindings_edit{gateway, graph, smtp} booleans, so the web share dialog can disable the Outlook-draft option etc.

Every directory endpoint above returns {items: [], warning: "..."} with HTTP 200 (never 500) when the xiber_gateway connector isn't configured; results are cached in-process for 10 minutes per (tool, arguments) pair. Every send failure (gateway/Graph/SMTP) records a failed outbound_actions row and returns HTTP 502 as {"detail": {"detail": "<message>", "action": {...}}} so the caller can show both the error and the recorded activity entry.

Admin config: the xiber_gateway connector's config_json gains an optional sonar_base_url key (e.g. https://xiber.sonar.software), used only to build external_url on a created Sonar ticket ({sonar_base_url}/tickets/{id}) — omit it and external_url is left null. No new connector type is introduced; Teams/Slack/Sonar all reuse the existing xiber_gateway connector, and the optional direct-Graph email path reuses the existing microsoft_graph connector (needs the Mail.Send application permission granted in addition to the SharePoint permissions Phase 0 already documents).

Contributions & Rewards (Phase 1)

app/api/v1/contributions.py, prefix /api/v1/contributions. See Contributions & Rewards for the design and the "Implementation notes (Phase 1)" section for the scoring engine, rule keys, and guards. Points are awarded automatically from inside record_activity() — there is no endpoint that itself awards points except the rule editor (which changes future scoring, not the ledger) and the import-commit routes (which call the engine directly since bulk imports don't go through record_activity).

EndpointPermissionNotes
GET /contributions/mecontributions_viewOwn reputation: lifetime_points, monthly_points, level, level_name, next_level_threshold, progress_pct, corroborated_count, reversed_count, rank_this_month (null if excluded_from_leaderboard), excluded_from_leaderboard, `team: {team_id, name} \null, badges: [{badge_key, label, description, icon, earned, awarded_at}], and the last 50 events (each with explanation, improvement_hint, record_href`).
PATCH /contributions/mecontributions_viewBody {exclude_from_leaderboard}. Self-service leaderboard opt-out/opt-in ("Show me on the leaderboard") -- any user with contributions_view (every human role) may toggle their own flag; there is no admin override of *another* user's flag on this route (that's PATCH /api/v1/authz/users/{user_id}). Audited as update_own_leaderboard_visibility.
`GET /contributions/leaderboard?period=month\all&category=&team_id=&department=`contributions_viewIndividual rows (top 25): `{user_id, display_name, department, team: {team_id, name} \null, points, events, categories: {category: points}, href} (href is /contributions/users/{user_id}, for the drilldown below). team_id narrows the individual rows to that functional team; department still narrows by the Graph-synced department (kept for reference — the web UI's filter is team_id). departments_available (true only once any user has a department set), most_improved (largest month-over-month points delta among users below level 3), and most_improved_team (same idea, per functional team, no level gate) — names and counts only, never restricted record fields. teams: functional teams ranked by points, [{team_id, name, color, points, member_count, contributing_members, avg_points_per_member, events}] sorted descending by points, plus a trailing {team_id: null, name: "Unassigned", ...} row for users with events but no team, always last regardless of its points. member_count is every user currently assigned to that team (not just contributors this period); contributing_members is how many of those have an event. Integration: * roles, API-token principals, and any user with exclude_from_leaderboard = true are always excluded from both individual and team totals (app.services.contributions.visibility.leaderboard_excluded_user_ids`; see Contributions & Rewards, "Implementation notes (Leaderboard opt-out)").
GET /contributions/users/{user_id}contributions_viewPer-user drilldown summary (click a name on the leaderboard): `{user: {user_id, display_name, email, department, team: {team_id, name} \null}, reputation: {lifetime_points, monthly_points, level, level_label, streak_weeks, corroborated_count, reversed_count}, badges: [...same shape as /me...], totals: {events, awarded_points, withheld_events, by_rule: {rule_key: {events, points}}, by_category: {category: {events, points}}}, activity_by_day: [{date, events, points}]} for the last 90 days. 404 if user_id` doesn't exist. Same permission as the leaderboard it's reached from — these pages show only names, points, and links to pages the *viewer's own* permissions already gate.
GET /contributions/users/{user_id}/events?from=&to=&rule_key=&status=&entity_type=&limit=&offset=contributions_viewEvery ledger row for that user, newest first: `{items: [{event_id, occurred_at, rule_key, rule_label, category, status, withheld_reason, points, breakdown, entity_type, entity_id, target, source_activity: {action, changed_fields} \null}], total, limit, offset}. target is the shared shape described below. 404 if user_id` doesn't exist.
GET /contributions/events?user_email=&rule_key=&status=&entity_type=&entity_id=&from=&to=&limit=&offset=recognition_adminLedger search joined with the source user_activity_logs row (source_activity.before/after/changed_fields) when one exists. Each item also carries the same target object as the per-user drilldown above.
POST /contributions/events/{id}/reverserecognition_adminBody {reason}. Marks the event reversed, writes a mirrored row with points = -points and reversal_of set, and updates the contributor's user_reputation (including reversed_count). Recorded via record_activity as reverse_contribution, which is not itself a scored action.
GET /contributions/rulesrecognition_adminEvery rule key merged from scoring_rules (latest enabled version) over the code defaults — {rule_key, version, label, category, config: {base, bonuses, caps}, enabled}.
PUT /contributions/rules/{rule_key}recognition_adminBody {label?, category?, config: {base, bonuses: [...], caps: {...}}, enabled}. Always inserts a new version row (never edits in place); 422 if rule_key is unknown or config is missing base/bonuses/caps.
GET /contributions/flags?resolved=recognition_adminPhase 1 produces edit_farming and near_duplicate_burst (≥5 withheld near-duplicate photos for one user in a day) flags.
POST /contributions/flags/{id}/resolverecognition_adminBody {resolution}.
POST /contributions/admin/backfill-dhash?limit=recognition_adminComputes image_dhash for up to limit (default 500) existing photos that predate this feature, so near-duplicate detection covers the full library. Safe to call repeatedly; returns {updated, remaining}.

Functional Teams

app/api/v1/teams.py, prefix /api/v1/teams. NetOS-managed groups an admin assigns (Project Management, Field Operations, Break/Fix, Transmission Engineering, Executive, Engineering, Xperts, Billing -- seeded by migration 0072_functional_teams), deliberately separate from User.department (synced from Microsoft Graph). See Contributions & Rewards for the leaderboard ranking definition.

EndpointPermissionNotes
GET /teamsapp_accessEvery non-deleted team: [{team_id, name, slug, description, color, sort_order, is_active, member_count}]. Open to any authenticated user -- the leaderboard filter, the per-user team pill, and the authorization admin's team picker all need the full roster.
POST /teamsusers_editBody {name, description?, color?, sort_order?, is_active?}. slug is derived from name; 422 if the name collides. Audited as create_team.
PATCH /teams/{team_id}users_editBody: any of name, description, color, sort_order, is_active. Audited as update_team, or deactivate_team when the only field set is is_active: false.
DELETE /teams/{team_id}users_editSoft delete only -- sets is_active = false. Members keep their team_id; there is no hard delete. Audited as deactivate_team.

Assigning a user to a team is PATCH /api/v1/authz/users/{user_id} with {"team_id": "<uuid>" | null} (users_edit; audited as update_user_team when that's the only field set) -- user_payload (used by GET/POST/PATCH /api/v1/authz/users*) and GET /api/v1/authz/me both gained a team: {team_id, name} | null field.

The `target` shape (app/services/contributions/targets.py, used by both /contributions/users/{id}/events and the admin /contributions/events search): {kind, label, href, sublabel, thumbnail_url, owner: {kind, id, name} \| null, deleted}. kind is "document" for a photo/document event (href points at the owning site's page with ?view=media&tab=photos& document={document_id}, thumbnail_url set for images, label the document's title or original filename) or the underlying entity's own kind otherwise (infrastructure_asset → /infrastructure/{id}, customer → /customers/{id}, property → /properties/{id}, carrier → /providers/{id}, circuit → /circuits/{id}, rf_link → /rf-links?highlight={id}, ptmp_node/site_link → the owning site's page with ?view=network/?view=media&tab=links, subtended_link → the parent infrastructure asset's page with ?view=network, electrical_service → /electrical-services (the list page doesn't support a highlight param yet), site_finding → /findings/{id}, import_job → /import, property_group_proposal → /properties/review). An entity_comment event resolves one level to whatever the comment itself is on. deleted is true when the referenced row is soft-deleted or missing — href/label are still built in that case rather than omitted, so an admin can see what *used to be* there. Resolution is batched (one query per entity kind involved, never one per event), since both endpoints can return up to 100–500 items per page.

Write endpoints across the app now optionally include a contribution key in their JSON response when the write scored a contribution: document uploads (infrastructure/customer), the SharePoint ingest summary (a per-file-summed aggregate), finding create, site link create, and infrastructure/customer/provider create-or-update. Shape: {event_id, rule_key, category, points, status: "awarded"\|"withheld"\|"mixed", withheld_reason?, explanation, improvement_hint?, level_up, badges: [...]}. Import-commit responses (POST /imports/{type}/{job_id}/commit) gain the same contribution key for the flat 10-point import.commit award.

Verifications & Missions (Phase 2)

app/api/v1/verifications.py, prefix /api/v1/verifications, plus Phase 2 additions to app/api/v1/contributions.py. See Contributions & Rewards "Implementation notes (Phase 2)" for the corroboration, reciprocal-guard, and mission gap-type details.

EndpointPermissionNotes
POST /verificationsverifications_edit + entity view permissionBody {entity_type, entity_id, field_key?, note?, evidence_document_id?}. entity_type one of infrastructure_asset\customer\carrier\infrastructure_document\customer_document\circuit\rf_link. 404 if the entity doesn't exist; 409 "You cannot confirm your own last change" if the caller authored the entity's most recent contribution/audit row; 409 "You already confirmed this within the last 90 days" for a repeat on the same (entity, field_key, verifier). Returns {verification, contribution} — contribution is the same envelope shape as other scored writes (verify.confirm, possibly folded with a record.refresh event when the confirmation targets a stale, field-less record).
GET /verifications?entity_type=&entity_id=contributions_view{items: [...], summary: {last_verified_at, last_verified_by, contributed_by, contributed_at}} — contributed_by/contributed_at come from the most recent awarded contribution_events row on the entity, falling back to the latest user_activity_logs row.
GET /contributions/next-actions?entity_type=&entity_id=contributions_view{entity_type, entity_id, actions: [{field_key, label, points}]} — missing important fields (services/contributions/fields.py::FIELD_IMPORTANCE) with the points they'd earn under the currently loaded record.fill/vendor.contact rule. entity_type one of infrastructure_asset\customer\carrier\circuit\rf_link.
GET /contributions/missionscontributions_viewActive + recently completed missions with per-user progress: {items: [{mission_id, mission_key, label, description, gap_type, target_count, multiplier, category, starts_at, ends_at, status, auto_generated, total_progress, my_progress, my_points, list_href}]}.
POST /contributions/missionsrecognition_adminBody {gap_type, state?, target_count?, multiplier?, duration_days?}. Creates a custom mission from one of the 8 gap types in services/contributions/missions.py::GAP_TYPES; state narrows the candidate set for gap types on an entity with a state column (infrastructure, customer). 422 if the gap type is unknown or no candidates match.
PATCH /contributions/missions/{id}recognition_adminBody {status?, target_count?, ends_at?} — status one of active\completed\archived.
POST /contributions/missions/generaterecognition_adminRuns generate_missions immediately (also runs daily from the scheduler); returns {created: [...]}. Idempotent per gap type — skips any gap type with an existing active mission.
POST /contributions/admin/sync-departmentsrecognition_adminPopulates users.department from Microsoft 365 via the Xiber gateway's msgraph.list_users tool (case-insensitive email match); also runs weekly from the scheduler. Returns {graph_user_count, matched, updated}; 422 if the xiber_gateway connector isn't configured/enabled.
POST /contributions/admin/recognition-email/send?month=YYYY-MMrecognition_adminSends (or resends, force=true internally) the monthly recognition email for month (default: the previous calendar month); also runs monthly from the scheduler when enabled in Admin → Notifications. Returns the same envelope as the weekly digest/renewal report send-now actions.

GET /contributions/leaderboard additionally returns teams: [{department, points, member_count}] (Phase 2) — the same rows the individual leaderboard is built from, aggregated by department, so it stays consistent with whatever period/category/department filters were applied.

Write endpoints scored via record_activity gain a mission_id on their ledger row (and a mission_multiplier line in breakdown/explanation) whenever the entity was a candidate in an active mission's set at generation time and the rule matches that mission's gap type — this is transparent to callers; the contribution envelope's points already reflects the multiplier.

Rewards & Insights (Phase 3)

app/api/v1/contributions.py, same router/prefix as Phases 1–2. See Contributions & Rewards "Implementation notes (Phase 3)" for the reward ledger math, the sweep/fulfill algorithm, and the redemption state machine (eligible → processing → delivered, with held and failed side states). The live Bonusly connector is off by default (payouts_enabled=false) and every test in tests/test_contributions_phase3.py mocks urllib.request.urlopen — the real Bonusly API is never called by this suite.

EndpointPermissionNotes
GET /contributions/rewards/mecontributions_view{available_points, next_milestone_points, progress_pct, redemptions: [...], monthly_cap, payouts_enabled, fulfillment_mode} for the caller. Each redemption: {redemption_id, milestone_points, reward_units, status, external_id, external_url, attempts, last_error, held_reason, month_key, created_at, processing_at, delivered_at}.
GET /contributions/rewards/settingsrecognition_admin{milestone_points, reward_units_per_milestone, per_user_monthly_cap, company_monthly_budget_units, payouts_enabled, fulfillment_mode, giver_email, hashtag, updated_by, updated_at}.
PUT /contributions/rewards/settingsrecognition_adminSame body. All numeric fields ≥0/≥1; 422 if payouts_enabled=true and fulfillment_mode != "batch" unless the bonusly connector is enabled and was tested successfully within the last 30 days.
GET /contributions/rewards/redemptions?status=&month=&user_email=recognition_admin{items: [...]}, same shape as /rewards/me's redemptions plus user_id/user_email.
GET /contributions/rewards/budgetrecognition_admin{month_key, company_monthly_budget_units, units_committed, units_delivered, units_remaining} for the current calendar month.
POST /contributions/rewards/sweeprecognition_adminRuns the sweep (eligible-balance → redemption conversion) then fulfillment immediately; returns {sweep: {created, held, unheld}, fulfill: {stale_failed, delivered, failed, held_budget, retried, skipped}}. Also runs nightly from the scheduler regardless of payouts_enabled (the sweep itself is useful even before payouts are turned on).
GET /contributions/rewards/export.csv?status=eligiblerecognition_adminStreams text/csv: idempotency_key, user_email, reward_units, milestone_points, month_key, reason for redemptions in status (default eligible) — the batch-fulfillment path when automatic Bonusly delivery isn't available.
POST /contributions/rewards/redemptions/{id}/mark-deliveredrecognition_adminBody {external_id?, note?}. Sets delivered, records the external reference, and credits the month's budget (units_committed/units_delivered) — the manual completion step for the batch/CSV flow. 422 if already delivered.
POST /contributions/rewards/redemptions/{id}/holdrecognition_adminBody {reason}. 422 if already delivered/failed.
POST /contributions/rewards/redemptions/{id}/releaserecognition_adminheld → eligible. 422 if not currently held.
POST /contributions/rewards/redemptions/{id}/retryrecognition_adminfailed → eligible, resets attempts/last_error/processing_at (clears backoff). 422 if not currently failed.
GET /contributions/insightsrecognition_adminThe §9 success measures — completeness, accuracy, freshness, participation (each an object of named _pct/count fields — see the design doc's implementation notes for exact field names) plus submission_count_this_month (shown last, never a headline metric) and computed_at. Cached in-process for 10 minutes; pass ?force=true to bypass the cache.

None of the five redemption-mutation endpoints above (nor PUT /rewards/settings) are scored as contributions — like every other admin action, they're simply absent from engine.SCORED_ACTIONS's allowlist, so no separate "never scored" list needs maintaining.

Connector: bonusly joins microsoft_graph/xiber_gateway under GET/PUT/POST .../test /api/v1/integrations/connectors/{connector_type} (app/api/v1/integrations_admin.py). Config: giver_email (optional) and last_test_ok_at (written automatically by a successful Test, read by the /rewards/settings PUT validator above); secret: the Bonusly personal access token. POST .../connectors/bonusly/test calls GET /users/me and, when a giver email is configured, GET /users?email= to report that account's giving_balance in the result message.

Historical Contribution Replay

app/services/contributions/replay.py, endpoint on the same app/api/v1/contributions.py router. See Contributions & Rewards "Implementation notes (Historical replay)" for the time-injection/extra-reconstruction/ reward-exclusion mechanics.

EndpointPermissionNotes
POST /contributions/admin/replay-historyrecognition_adminBody {since: datetime, until?: datetime, reward_eligible?: bool=false, dry_run?: bool=true, limit?: int} (until defaults to now). Scores pre-existing user_activity_logs audit rows (record creates/updates, photo/document uploads, findings, links, comments — the same engine.SCORED_ACTIONS allowlist live scoring uses) with each event's occurred_at set to the *original* activity's timestamp, skipping rows already scored (activity_id present in contribution_events.source_activity_id) and rows from Integration:*/inactive/missing users. Returns {rows_considered, scored, awarded, withheld, skipped: {reason: count}, per_user: [{email, display_name, awarded_points, awarded_events, withheld_events, by_rule: {rule_key: points}}], dry_run, since, until} (per_user sorted by awarded_points descending). Defaults to a dry run (nothing written — every row is scored inside a SAVEPOINT that's rolled back after capturing the would-be outcome) and to reward_eligible=false, which tags every event evidence.replayed=true so it's excluded from the reward ledger (rewards.recompute_reward_account) while still counting toward reputation/levels/the leaderboard; pass reward_eligible: true to back-pay rewards for a window too. A real run recomputes reputation and the reward account for every affected user and leaves one summary user_activity_logs row (action=replay_contribution_history, never itself a replay candidate).

confirm_record/verify.confirm history and import.commit scoring are not covered by this replay (see the design doc's Known limitations) — everything else SCORED_ACTIONS allowlists is.

Bugs & Requests across Xiber's apps

NetOS's Bugs & Requests (/api/v1/feedback) is the one tracker for every Xiber app (Stephen, 2026-10-09). Each request has an app (netos, xdesign, …, the server constant APPS in app/services/feedback.py). Requests link to each other across apps. Another app files and replies through its own NetOS API token, for the person signed in to it. Migration 0118_feedback_apps_and_links. The xdesign side of the contract is in xdesign's docs/integrations/netos-bugs-requests.md.

An app's access works like XSurvey's:

  • It has a service user with the role Integration: <app>. For xdesign that's feedback_view, feedback_edit, feedback_file_on_behalf and feedback_app_xdesign.
  • It authenticates with an API token, sent as Authorization: Bearer ntos_live_… or X-NetOS-API-Key.
  • It talks server to server, over the LAN (http://10.10.67.118:8010), never through SSO.
  • feedback_app_<app> lets it read that app's requests in full (not internal notes) and anything linked to one, reply, link and add files. It can't triage. It files into its own app, and with feedback_file_netos (xdesign has it, 2026-10-16) also into netos, but only for a named person (reported_by_email required, else 422). It reads and replies on the NetOS requests it filed; the request carries external_ref.via_app and shows "Name (via xdesign)" (via_label). NetOS's auto-fix runner proposes them like any NetOS request, and the app's replies steer the proposal.
  • Auto-fix for an app's own requests (2026-10-16): an app may run its own auto-fix runner against /api/v1/request-autofix/runner/*. Its service user holds feedback_autofix_run plus feedback_autofix_run_<app> (human-only grants), and feedback_view, feedback_edit, feedback_app_<app>. Every runner endpoint (policy, queue, held, claim, release, heartbeat, preflight, report) is scoped to that app's requests (404 for another app's job); GET /runner/policy returns app and the app's own sensitive_paths (XDESIGN_SENSITIVE_PATHS in app/services/request_autofix.py). The runner may post internal notes and move its app's requests along (not decline). Approval stays in NetOS: the same Build & ship panel, /requests/N/ship, feedback_autofix_approve and Slack DMs (which name the app).
EndpointNotes
POST /feedback (multipart payload + files)app (422 if unknown), reported_by_email / reported_by_name (the person; 403 without feedback_file_on_behalf), external_ref (object ≤ 4 KB; a url that isn't http(s) is dropped), `links: [{to_id \to_number, kind}], plus the usual fields. category is presets (categories_by_app`) or free text
GET /feedback?app=xdesign[,netos]Rows carry app, app_label, reported_by_*, link_count (and external_ref when readable). view=mine also matches reported_by_email
GET /feedback/{id}, /by-number/{n}Adds `links: [{link_id, kind, direction (out\in), label, feedback_id, number, key, title, status, status_label, app, app_label}]`. Labels read from that request's side: blocks / blocked by, duplicates / duplicated by, parent of / part of, relates to
POST /feedback/{id}/links `{to_id \to_number, kind}`relates / blocks / duplicates / parent (from is the parent of to). 409 if it exists (relates either way round), 422 for a self-link, 403 without access to both
DELETE /feedback/{id}/links/{link_id}Whoever has access to both, or a triager
POST /feedback/{id}/commentsAlso takes reported_by_email / reported_by_name. Stored as on_behalf_* and shown as "Name (via app)"
GET /feedback/optionsAdds apps, categories_by_app, link_kinds

When an app files for someone, that person is the reporter (reporter_email):

  • they read the request in full in NetOS;
  • their replies set Reply waiting and reopen a closed request;
  • status-change and reply emails go to them, not the service user.

Monitoring devices (LibreNMS + Prometheus)

NetOS keeps one device library (librenms_devices) filled from two monitoring systems, and links its devices to customers, sites (infrastructure), properties and RF links:

  • LibreNMS (LIBRENMS_API_URL + read-only LIBRENMS_API_TOKEN): the

whole inventory, every 6 hours. source = 'librenms', device_id is LibreNMS's id. Matched by location, name, street address and, for RF links, the far end's name and radio model (app/services/librenms.py).

  • Prometheus (PROMETHEUS_URL, no auth; 10.10.43.178:9090 in

production): the Ciena 3928 / 5144 boxes behind the Ciena NETCONF exporter (ciena_up, ciena_chassis_info, alarms, ports) and any scrape target labelled with a hostname (the snmp_exporter's). A device at an IP an enabled LibreNMS device already has stays LibreNMS's (one copy, no doubled links or CAPEX). The rest are source = 'prometheus', ids from prometheus_device_id_seq (1,000,000,000 up), source_key = Prometheus instance (the IP), Prometheus's site label, region, model, alarm and port counts in details. Prometheus has no coordinates, so they're matched by name: the site label and hostname against record names (prometheus.named_matches). A device gets its one clearly best record (score 80+, ahead of the rest) and close runners-up as suggestions. Synced every 30 minutes (new devices are matched at once) and in every full refresh. Target labels carry the SNMP community; it is never stored.

  • Alertmanager (ALERTMANAGER_URL) and Loki (LOKI_URL) feed the

live health view only (nothing stored).

EndpointPermissionNotes
GET /librenms/statussigned inconfigured (either source), devices, sources.{librenms,prometheus}.{configured,devices}, link counts by state, last refresh
POST /librenms/refreshsettings_editSyncs every configured source, re-matches, recomputes site equipment CAPEX. 409 while another sync holds the lock
GET /librenms/links?entity_type=&entity_id=signed inA record's devices (linked and suggested). Device payload: source, url (LibreNMS page; null for Prometheus devices), site, alarms, ports
GET /librenms/devices/{device_id}/healthsigned inLive, by the device's IP: metrics (Prometheus: up, alarms, ports, optics RX/TX dBm, CPU, memory, temperature, power supplies; null when Prometheus doesn't monitor the IP), alerts (Alertmanager, worst first), logs (Loki: 24 h counts by severity, login_events_24h, latest 20 lines from the last 7 days; login events are never listed), errors (systems that didn't answer)
GET /librenms/prometheus/devicessigned inEvery Prometheus device with the records it's linked to or suggested for (Monitoring review → Prometheus devices)

External System Integration Map

SystemDirectionPurposeStatus
XSurveyPush (inbound)Site survey references: metadata, thumbnails, deep links (media stays in XSurvey)Live
SonarPullCustomer accounts, MRR, revenue attribution per circuitPlanned
Monday.comBidirectionalRenewal tasks, variance tasks, circuit order workflowPlanned
WisdmPullTower and network site geometry for map overlaysPlanned
LibreNMS / Prometheus / LokiPullDevice library linked to customers, sites, properties and RF links (Monitoring panels, equipment CAPEX); live device health, Alertmanager alerts and Loki syslog per deviceLive
DocuSealPushGenerate termination notices and contract execution envelopesPlanned
Microsoft 365 / Entra IDPullSSO, user identity, calendar/Teams referencesPlanned
Microsoft Graph (SharePoint)PullActs as the signed-in user via a delegated token forwarded by the SSO proxy (application-token fallback configurable in Admin → Integrations, off by default)Live
BonuslyPushPosts reward-milestone bonuses from a dedicated giver account (Contributions & Rewards, Phase 3); off by default (payouts_enabled=false) until an admin configures and tests the connectorConfigurable, off by default
XOS (MCP Gateway)ExposeAgent-readable circuit tools for automated reporting and decisionsSkeleton
Lumen Control CenterPullCircuit status sync from carrier portalNot started
Zayo TranzactPullCircuit status sync from carrier portalNot started
Cogent PortalPullCircuit status sync from carrier portalNot started

Planned Webhooks (Outbound)

EventTriggerDestination
circuit.installedCircuit status → activeMonday.com, Slack
circuit.decommissionedCircuit status → decommissionedMonday.com, Slack
renewal.state_changedRenewal state transitionMonday.com, Email, Slack
invoice.variance_detectedInvoiced MRC ≠ contracted MRCMonday.com (task for finance)

*Webhooks are not yet implemented.*

XDesign designs (NetOS reads XDesign, migration 0140)

NetOS reads designs and bills of materials from XDesign's read-only integration API. NetOS never writes to XDesign. XDesign's side of the contract is ~/xdesign/docs/integrations/netos-design-read.md.

  • Address: XDESIGN_API_BASE, default http://172.22.0.1:8791/api/integrations/v1. This is the gateway of the netos_default Docker network, served by XDesign's xdesign-integration unit, which serves only the integration routes.
  • Auth: XDESIGN_API_TOKEN, the XDesign service token "netos" with scopes project:read and netdesign:read. On the host it lives in ~/.netos-xdesign-creds (mode 600); deploy copies it into .env.public. Staging runs with it empty and XDESIGN_SYNC_ENABLED=false.
  • NetOS API: /api/v1/design-references. It links an XDesign OSP project or network design to a property, site or customer; reads revisions; keeps an approved revision's BOM and costs as read (a later change is recorded as drift); and pins a budget baseline (an unpriced approval needs explicit acceptance). Permissions: design_references_view and design_references_edit. Prices need field.capex.view or project_budget_view.
  • Scheduler: each pass reads XDesign's approved-revisions feed and re-syncs linked designs with a new approval. Every linked design is re-synced at least daily.
  • Customer IP addressing (request #43): each network-design sync also reads one revision's design record with GET /netdesign/designs/{id}/revisions/{number}: the current as-built, else the latest final, else the latest revision (labelled proposed). NetOS keeps only the per-customer addressing it extracts (site.customers[] joined to addressing.blocks["dia:<i>"] / ["tarana-dia:<i>"]; gateway = host 1, customer = host 2; DNS only if the design carries dns or dnsPrimary/dnsSecondary; NetOS customer id from netosCustomerId) in that revision's summary.customer_addressing. If the read fails, the previous addressing is kept. GET /api/v1/customers/{id}/ip-addressing (customers_view) returns it matched to the customer: NetOS id in the design, then a design linked to the customer, then the customer's circuit IDs against the design's CIDs, then a normalised name on designs linked to the customer, its property or its building (flagged). Read only; no NetBox fallback.