Phoenix Internal API (PAI)

PAI is the privileged internal API plane. It is not publicly reachable and is protected by:

Restricted: Access is limited to Phoenix Global Admins and Phoenix-owned services only.

Base URL

/internal/v1

Swagger

Internal Swagger UI (requires a PAI key, or a session JWT when PAI_SESSION_TOKENS_ENABLED is on; IP allowlist enforced only when configured):

Authentication

Provide a PAI key via one of:

Key Generation

POST /api/v1/admin/pai/keys

Generated Global Admin only, no email step. Returns the api key (phx_pai_...) and companion token (phx_pait_...) once — both shown on screen; the companion token is also emailed best-effort. Manage with GET /api/v1/admin/pai/keys (list, no secrets) and POST /api/v1/admin/pai/keys/{key_id}/revoke (revoke). To rotate, generate a new pair and revoke the old key. POST /api/v1/admin/pai/keys accepts optional per-key restrictions:

{
  "name": "Internal Analytics",
  "ip_allowlist": ["10.0.0.0/8", "192.168.1.10"],
  "domain_allowlist": ["internal.example.com", "*.svc.example.com"]
}

For backwards compatibility, mixed entries submitted in ip_allowlist are split into IP/CIDR and domain restrictions by the backend. Domain restrictions are matched against X-Forwarded-Host, Host, Origin, and Referer; use them as an additional guard with the global IP allowlist, not as a replacement for network controls.

Email delivery is best-effort and is not required before PAI key issuance (GA-only generation succeeds even when SMTP is unavailable; the companion token is shown on screen and, when SMTP is configured, also emailed). MFA verification is planned but not yet available.

IP allowlist enforcement mode (PAI_IP_ALLOWLIST_MODE, added 2026-08-23)

PAI_IP_ALLOWLIST_MODE decides how every PAI IP allowlist is applied. It is an environment variable — it cannot be changed at runtime or from the admin UI.

ValueBehaviour
enforce (default)A client IP outside the allowlist is rejected with 403. Unchanged historical behaviour.
monitorThe identical check runs, every miss is recorded and logged at WARNING, and the request is allowed through. Use it to trial an allowlist against real traffic before it starts rejecting callers.

Any unrecognised value resolves to enforce: a typo must never silently switch an access control off.

The mode applies to all three IP layers — the system-level PAI_IP_ALLOWLIST, a key's own ip_allowlist, and the per-key list carried inside a session JWT. It covers the IP axis only: domain allowlists, companion tokens, scope checks and rate limits are unaffected, so a request that fails any of those is still rejected in monitor mode.

An empty allowlist remains a no-op in both modes — “not configured” has always meant “not applied”, and monitor does not invent misses for a layer that was never restricted. While monitor is active a configured allowlist is not a control, so the process-start “PAI is unrestricted” warning still fires.

GET /api/v1/admin/pai/ip-allowlist

Global-admin only. Read-only report of how the allowlist is currently applied, backing the IP Allowlist panel in the admin dashboard. Returns no key material.

{
  "mode": "monitor",
  "raw_mode": "monitor",
  "effective": "monitoring",
  "system_allowlist": ["10.0.0.0/8"],
  "trusted_proxy_hops": 2,
  "require_paired_token": false,
  "key_mode": "db",
  "caller_ip": "203.0.113.5",
  "caller_allowed": false,
  "observations": [
    {
      "observed_at": "2026-08-23T18:05:11.412000+00:00",
      "layer": "system",
      "client_ip": "203.0.113.5",
      "allowlist": ["10.0.0.0/8"],
      "path": "/internal/v1/cve/CVE-2024-0001",
      "method": "GET"
    }
  ],
  "observation_total": 1,
  "observation_retained": 1,
  "observation_limit": 100
}

effective is not_configured when the allowlist is empty, otherwise enforcing or monitoring. caller_allowed is null when nothing is configured, since “allowed” would imply a check ran. layer is one of system, key, or session_key.

Observations are an in-memory, per-worker ring buffer capped at observation_limit — a rollout aid, not an audit log. A multi-worker deployment shows only the worker that served the request, and a restart clears it. The WARNING log line is the durable record.

DELETE /api/v1/admin/pai/ip-allowlist/observations

Global-admin only. Clears the retained observation buffer and returns {"cleared": true, "dropped": <n>}. Application logs are unaffected. The action is written to the admin audit log.

Paired companion token (x-pai-token, added 2026-07-21)

Database-mode PAI keys (pai_key_mode=db) can carry an optional companion token — an independent secret (phx_pait_...) minted alongside the api key by POST /api/v1/admin/pai/keys (returned inline as pai_token in that response, and also emailed to the requesting admin). Send it as a second header alongside the key:

The companion is required — a missing or mismatched value returns 401 — when either:

This is a backward-compatible, per-key rollout: existing db-mode keys issued before this feature (no stored companion hash) keep working with the api key alone unless pai_require_paired_token is turned on globally. Env-mode PAI keys (pai_key_mode=env, the single shared PAI_KEY) never require a companion token — there is no per-key record to hang a hash off of.

Session tokens (Authorization: Bearer <jwt>, added 2026-07-21)

Once minted via POST /internal/v1/pai/session (below), a short-lived session JWT can be presented as Authorization: Bearer <token> in place of the api-key + companion-token pair on subsequent internal API calls, until it expires or the key's revocation epoch advances.

Session Tokens (added 2026-07-21)

POST /internal/v1/pai/session

Exchanges a PAI api-key + companion-token pair for a short-lived session JWT, so subsequent calls can send Authorization: Bearer <token> instead of resending the raw key pair on every request.

Flag: pai_session_tokens_enabled (default false, env PAI_SESSION_TOKENS_ENABLED). Returns 404 when off.

Auth: Same as any PAI call — the router-level require_pai_access dependency runs first, so the IP allowlist, api key, and (if required for this key) companion token are already validated before this handler runs. If that validation fails, the usual 401/403 responses apply and the request never reaches the session-minting logic below.

Request

No request body. Send the pair as headers:

POST /internal/v1/pai/session
X-API-Key: phx_pai_...
x-pai-token: phx_pait_...

(Omit x-pai-token if the key doesn't require a companion — see Paired companion token above.)

Response 200

{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 900
}

expires_in is seconds until expiry, from pai_session_ttl_seconds (default 900, i.e. 15 minutes; env PAI_SESSION_TTL_SECONDS).

Using the session token

Present it on subsequent internal API calls in place of the key pair:

Authorization: Bearer <access_token>

The minted JWT embeds the same per-key IP/domain allowlist as the underlying key, so the session path is never broader than what the raw api-key + companion pair could already reach. It is also invalidated early if the key's revocation epoch advances (e.g. the key is revoked/rotated) — verify_session_token checks the JWT's embedded epoch against the key's current epoch on every call and rejects a stale one.

Notes

Core Endpoints

Endpoint Purpose
POST /internal/v1/pai/sessionMint a short-lived session JWT (see Session Tokens above)
GET /internal/v1/cve/{cve_id}Full CVE details + raw NVD record
GET /internal/v1/cve/{cve_id}/github-pocsFull (uncapped) GitHub PoC list + PS-HP-aligned popularity summary
GET /internal/v1/phoenix-score/{cve_id}Full PS-HP output with components
GET /internal/v1/high-profileFull high-profile list (no redaction)
GET /internal/v1/enterprise-watchlistFull watchlist entries
POST /internal/v1/packages/intelInternal package-version CVE + MPI malware intelligence
POST /internal/v1/calculate-scoreCustom PS-HP calculation
POST /internal/v1/calculate-pss-scoreCustom PS-OSS (PSS) calculation
POST /internal/v1/calculate-phs-scoreCustom PS-PHS calculation
POST /internal/v1/calculate-pvs-scoreCustom PS-PVS calculation
POST /internal/v1/calculate-adqe-scoreCustom ADQE calculation
POST /internal/v1/calculate-eol-riskCustom EOL SLR risk calculation
GET /internal/v1/scoring-weightsPS-HP/PS-OSS weights
GET /internal/v1/threat-actorsThreat actor intelligence
GET /internal/v1/eol-intelligenceFull EOL intelligence
GET /internal/v1/analysis/cweFull CWE analysis (mirror of /api/v1/analysis/cwe)
GET /internal/v1/analysis/cwe-top25Full CWE Top 25 analysis
GET /internal/v1/analysis/kev-cweFull KEV CWE analysis
GET /internal/v1/analysis/owasp-top10Full OWASP Top 10 analysis
GET /internal/v1/analysis/reference-threat-frequenciesFull, live reference threat-frequency distributions
GET /internal/v1/analysis/cwe-intelligenceFull, unredacted merged CWE-intelligence table (mirror of /api/v1/analysis/cwe-intelligence); gated by enable_cwe_intelligence_endpoint, 404 when off
GET /internal/v1/cvesUnrestricted CVE search — public filters plus sort, published_after/published_before, has_analysis; limit up to 1000; unredacted
GET /internal/v1/cves/by-productUnrestricted vendor/product CVE enumeration (limit up to 2000); response includes an additive cpe_uris field (sorted, deduplicated CPE 2.3 URIs from NVD)
GET /internal/v1/cves/trendingTrending CVEs (news/social mention volume)
GET /internal/v1/cves/kevCISA KEV catalog, no tier field filtering (limit up to 10000)
GET /internal/v1/cves/homepage-intelHomepage intelligence bundle
POST /internal/v1/cves/batchUnrestricted batch CVE lookup (mirror of POST /api/v1/cves/batch); up to 5000 ids per call, unredacted; gated by enable_cve_batch_lookup
GET /internal/v1/intel/entitiesList the 8 queryable intelligence entity names (Unified Intelligence Query Surface mirror)
GET /internal/v1/intel/{entity}Search one entity, full fidelity, no data_shield shaping
GET /internal/v1/intel/{entity}/{entity_id}Get one entity by ID, full fidelity, no data_shield shaping

The /internal/v1/cves* group (added 2026-07-08) mirrors the public /api/v1/cves read surface, unredacted and with higher limits, gated only by PAI auth (require_pai_access + pai_enabled).

POST /internal/v1/cves/batch (added 2026-08-20) mirrors the public POST /api/v1/cves/batch (Purple R1): same id-normalisation (uppercase, dedupe, sort, silently drop malformed ids — all-malformed is a 422, not an empty 200) and the same explicit not_found[] list, but returns records unredacted (no tier projection) and accepts up to a flat 5000 ids per call instead of the public route's per-tier cap. Gated on the same enable_cve_batch_lookup flag as the public route (default false, returns 404 when off) so both transports move together.

not_found semantics. An id lands in not_found when the corpus has no NVD record for it — determined by the returned record carrying no NVD-derived content (no description, published, modified or cvss). This matters because the underlying get_enriched_cve() never signals a miss: it returns a populated-looking envelope (cve_id plus an enrichment block) for any id, including one that does not exist. Treat items as authoritative and not_found as "not in Blue's corpus", rather than assuming every requested id comes back in items.

One known imprecision, stated so it is not mistaken for corruption: a CVE that genuinely exists in NVD but has no description, no dates and no CVSS — a freshly reserved or rejected entry — is reported in not_found. No fabricated record is ever returned in items.

Package Intelligence

POST /internal/v1/packages/intel

Returns the internal package-version intelligence payload for a package coordinate. This is the PAI twin of the premium public package intelligence endpoint.

Gated by enable_package_intel_api (default false). When the flag is off the endpoint returns 404.

Request

{
  "ecosystem": "Maven",
  "name": "com.h2database:h2",
  "version": "1.3.176"
}

Response fields

FieldNotes
ecosystem, name, version, purlNormalised package identity.
cvesVersion-filtered OSV CVE/advisory records.
vulnerabilitiesAlias of cves.
malwareMPI and compromised-package intelligence.
malware.scan_id, malware.signals, malware.iocs, malware.rawInternal-only MPI evidence for Phoenix-owned services.
ps_oss_scoreDerived package risk score.
source_attributionData sources used in the response.

Advisory Endpoints

Gated by ENABLE_ADV_FETCH (default false). All return 404 when the flag is off.

Endpoint Purpose
GET /internal/v1/cves/{cve_id}/advisory-patchesAdvisory patch suggestions for a CVE
GET /internal/v1/advisories/{advisory_id}CVEs + full advisory records for an advisory ID (422 on malformed ID)
GET /internal/v1/advisories/patch-month/{patch_month}Advisory entries for a patch month (YYYY-MM; 422 on bad format, 400 when > 500 records)

CWE / OWASP Top 10 / Threat-Frequency Analysis Endpoints

Added 2026-07-10. PAI mirrors of the five public /api/v1/analysis/* endpoints (see PUBLIC_API.html). These routes are not themselves gated by ENABLE_CWE_OWASP_THREAT_TIERED_API — that flag only governs whether the public routes shape their payload. The PAI mirrors always return the full, unshaped payload (AccessMode.FULL), regardless of the flag's state, because PAI access is itself the Enterprise gate (require_pai_access) and there is no end-user tier context on a PAI call.

Endpoint Purpose
GET /internal/v1/analysis/cweFull CWE analysis (mirror of /api/v1/analysis/cwe)
GET /internal/v1/analysis/cwe-top25Full CWE Top 25 analysis
GET /internal/v1/analysis/kev-cweFull KEV CWE analysis
GET /internal/v1/analysis/owasp-top10Full OWASP Top 10 analysis
GET /internal/v1/analysis/reference-threat-frequenciesFull, live reference threat-frequency distributions — note: this route always reads the true, current reference_threat_frequencies.json, unlike the public route, whose underlying data source is itself flag-gated (legacy snapshot when the flag is off).

No additional tier parameter or header is required beyond the PAI key itself. Missing/unavailable underlying data returns 404 (this router's existing convention — see GET /internal/v1/eol-intelligence), not the public routes' 503.

Merged CWE-Intelligence Mirror (added 2026-08-23)

GET /internal/v1/analysis/cwe-intelligence is the PAI mirror of the public merged CWE-intelligence table (backend/app/routers/analysis.py). Same outer-join merge over /cwe, /kev-cwe, /owasp-top10 and CWE_MASTER, keyed by bare CWE id.

Unlike the CWE/OWASP/threat-frequency mirrors above (not flag-gated — always full, gated only by require_pai_access), this route is flag-gated: enable_cwe_intelligence_endpoint (default false, absent from site_config.json), same flag as the public route. Returns 404 on this PAI plane when off, same as the public route (both are new routes with no legacy unflagged behavior to preserve).

Always AccessMode.FULL, passed explicitly at this one call site rather than baked into the shaper: raw nvd_count and raw kev_count are present on every row. This is the only plane where either raw count is ever returned — the public route's own tier ceiling (Enterprise → PERCENT_ONLY) never reaches FULL; see PUBLIC_API.html for why the public route uses a dedicated resolver instead of the shared resolve_access_mode.

Same response envelope as the public route: schema_version: "cwe-intel-v1", per-source source_provenance list ({"name", "generated_at"}, named to avoid colliding with each row's own differently-shaped sources: List[str] field), cwes (dict keyed by bare CWE id), total_unique_cwes (== len(cwes)), dropped_keys (count of raw source keys that failed CWE-id normalisation and were therefore excluded from the merge — 0 in the steady state). Same caching contract: ETag is a sha256 over the shaped (AccessMode.FULL) bytes — computed the same way as the public route's ETag over its own lower-tier bytes, so the two planes never collide on the same digest for different content — plus Cache-Control: private, max-age=0, must-revalidate and If-None-Match304 support.

Unified Intelligence Query Surface Mirror

Added 2026-07-25. PAI mirror of the public Unified Intelligence Query Surface (backend/app/routers/intel_query_pai.py), over the same resolver registry (backend/app/services/intel_query/registry.py) as every other adapter for this feature. Gated by the same flag, enable_intel_query_surface (default false) — returns 404 when off, in addition to the standing require_pai_access dependency on the whole /internal/v1/intel/* router.

EndpointPurpose
GET /internal/v1/intel/entitiesList the 8 queryable entity names
GET /internal/v1/intel/{entity}Search one entity (q, vendor, product, purl, limit 1–500 default 100, offset)
GET /internal/v1/intel/{entity}/{entity_id}Get one entity by ID

Final whole-branch review fix-round (2026-07-25): the limit ceiling was lowered from 1000 to 500 to match Enterprise's real max_page_size (tier_limits.py) — PAI access is itself the Enterprise gate, so a caller could previously ask for double what even an Enterprise public caller's own tier cap allows. The resolver call is also now offloaded to a worker thread (anyio.to_thread.run_sync), matching the public route's own fix (see PUBLIC_API.html's "Known rollout gates" section) — some resolvers (CveResolver in particular) run unindexed O(N) scans over in-memory data and must never block the event loop.

Full fidelity — no data_shield pass — for a phx_pai_ (pai_internal) key. Unlike the public route, which runs exactly one filter_response_for_tier pass keyed to the caller's resolved tier, this router runs no field-level shaping for an internal PAI key. Every field a resolver emits is returned as-is. provenance.redaction_profile is stamped "pai" (not a tier name) so a deterministic consumer can tell which shaping pass — none — produced the response. As on every other PAI endpoint, PAI's advantage is field-level fidelity, never internal business data: LLM model names and per-call costs are absent because no resolver emits them, not because they were redacted.

A phx_gintel_ (api_global_intel) BUNDLE key IS shaped, at the tier its licence grants (fix C1, 2026-08-14). The "PAI access is itself the Enterprise gate" premise above holds for the internal key family (pai_internal maps to ENTERPRISE); it was falsified for this router when Plan B admitted a customer key sold at two levels. A global_intel:pro key was receiving the ENTERPRISE-classified deterministic.signals / chains that the identical query at Pro on /api/v1/intel/* strips. A bundle key's response — including the 403 body — now goes through exactly one filter_response_for_tier pass at enterprise / pro, floored at registered when the key carries no valid stamp, and provenance.redaction_profile names that tier instead of "pai". Same licence, same fields, whichever door. Its limit is likewise clamped to its own tier's max_page_size rather than the Enterprise ceiling of 500.

Bundle-key admission is flag-gated (same fix). With enable_global_intel_license off, the accepted-scope tuple collapses to PAI_DEFAULT_SCOPES (pai_internal only) and a phx_gintel_ key gets 401 here too — so the documented rollback kill switch genuinely closes this door instead of leaving already-issued bundle keys admitted on the one plane where the tier demotion had no effect. An unreadable flag store means no admission.

allow_clean (malware verdict access gate) — PAI does NOT bypass it. Read this before "fixing" it back to a full bypass.

Whether a caller can resolve an unconfirmed/under-review (SUSPECT-band) malware package at all is a completely separate mechanism from the field-shaping described above (see PUBLIC_API.html's malware verdict access gate section). This router never passes allow_clean=True for the malware entity — it relies entirely on resolve_entity/search_entity's own default (False), identical to how an anonymous/non-admin caller is treated on the public route. A PAI caller sees confirmed-malicious verdicts exactly the way a non-admin public caller does; clearing the PAI dependency does not, by itself, grant visibility into scanned-but-not-confirmed packages.

This was an explicit, human-reviewed security decision made during implementation, reversing an earlier draft that forced allow_clean=True unconditionally for PAI on the theory that backend/app/routers/mpi_artifacts.py (a PAI-only, no-admin-stacking read router with no verdict gate at all) was sufficient precedent that PAI-alone is this platform's ceiling of trust for MPI data. That reasoning was rejected: loosening who can see unconfirmed/under-review SUSPECT-band malware verdicts is an authorization-policy change requiring explicit approval before weakening any access gate (per AGENTS.md Hard Rules), and backend/app/routers/malware_intel_internal.py stacks an extra require_global_admin check on top of require_pai_access specifically for other sensitive malware operations (force-rescan, raw repo_analysis blob) — direct evidence that this codebase's convention does not treat PAI alone as admin-equivalent for sensitive malware data.

If a future task needs PAI to see SUSPECT-band packages, it requires a distinct, higher-trust PAI signal (e.g. a specific key scope or attribute) — not simply restoring the unconditional allow_clean=True this router originally shipped with.

Advisory block opt-in — ?include=advisory (Plan D, added 2026-07-26)

GET /internal/v1/intel/malware/{entity_id}?include=advisory (and the equivalent on GET /internal/v1/intel/malware?include=advisory search) populates the envelope's advisory block with MPI LLM reasoning — {"analyst": {"verdict", "confidence_class", "reasoning"}, "judge": {"verdict", "reasoning"}, "reproducible": false}, built by the shared build_reasoning_view() (app/services/mpi_reasoning.py). See PUBLIC_API.html's "Advisory block opt-in" section for the full field-by-field detail and the confidence-class vocabulary it uses.

PAI honors this unconditionally whenever the caller passes it — no additional tier check, unlike the public REST mirror, which additionally requires the caller's resolved tier to be enterprise. This is consistent with this route's "full fidelity, no data_shield pass" behavior for an internal PAI key described above: PAI access is already this platform's highest trust boundary, so there is nothing narrower to check.

For a phx_gintel_ bundle key the gate is still not applied, but the shield is (fix C1, 2026-08-14). The call still returns 200 and still needs no bundle — advisory is not an expanded token and never triggers the licence gate — but advisory is ENTERPRISE in data_shield, so it survives only for a global_intel:enterprise key. Below that it comes back null, exactly as it does on /api/v1/intel/* at the same tier.

This does not widen the allow_clean verdict-visibility gate described immediately above. include_advisory is only ever consulted by the malware resolver after an allow_clean-gated row load has already succeeded — a PAI caller requesting ?include=advisory for an unconfirmed/under-review (SUSPECT-band) package still gets the same 404 any other non-privileged caller gets.

Terminal-verdict narrowing exists (REST-only) and PAI is exempt from it (2026-07-25 fix). A separate check inside MalwareResolver.get() additionally nulls advisory — independent of the allow_clean-gated row load above — for a D-14-eligible-but-non-terminal- verdict row (freshest scan still SUSPECT/AWAITING_REVIEW/LLM_ANALYSIS/ INCONCLUSIVE) unless the caller is exempt; the row itself still resolves (200), only advisory comes back null. On REST this applies to every caller except a global admin. This router passes a distinct bypass_advisory_verdict_gate=True (never allow_clean=True, which would incorrectly reopen the gate described above) alongside include_advisory=True, so PAI keeps the fully unconditional advisory behavior documented above regardless of verdict state. A first implementation of the terminal-verdict check keyed its exemption off allow_clean alone, which silently narrowed PAI too since this router never sets allow_clean; this was caught and fixed before it shipped as a behavior gap.

Requires both enable_intel_query_surface and enable_mpi_reasoning_enterprise to be on; advisory stays null if either is off.

Expanded intelligence composition — ?include= (Plan B, added 2026-08-13)

GET /internal/v1/intel/library_version/{purl}?include=vulnerabilities,malware,exploitation,campaign,licensing returns the library, its vulnerabilities with per-CVE detail, its malware intelligence, and its exploitation / campaign / licence vectors in one call, under a new top-level expanded envelope block. GET /internal/v1/intel/library/{purl} (unversioned) accepts the same tokens and additionally returns a cross-version rollup (any_version_compromised, compromised_versions[], safe_versions[], worst verdict).

PAI first, then everywhere (Plan D, 2026-08-14). PAI shipped first because it skipped data_shield entirely and stamped redaction_profile="pai", so the composition shape could be proven before being locked behind a tier matrix. Plan D added the same ?include= surface to public REST (/api/v1/intel/*), the MCP intel_get tool and phoenix-cli, all four sharing the registry's single token gate. See PUBLIC_API for the tier matrix — which since fix C1 applies to a phx_gintel_ caller on this router too, and is not applied only to an internal phx_pai_ key.

SCHEMA_VERSION moves 1.0.01.1.0. The bump is MINOR because expanded is opt-in: a request with no include returns a byte-identical response — the expanded key is absent, not null, and provenance keeps exactly its four original keys.

Authentication. Both phx_pai_ (pai_internal) and phx_gintel_ (api_global_intel) keys authenticate on this router, through the same validate_pai_request path: same system IP allowlist, same per-key IP/domain allowlists, same companion-token requirement, same rate limiter. A Global Intel key is a licence, not a way around PAI's network controls.

The bundle scope is admitted on /internal/v1/intel/* only. phx_gintel_ is a customer key — issued against an org's global_intel product entitlement — while /internal/v1/* as a whole is the privileged internal plane (MPI forensic artifact reads via mpi_artifacts.py, which is PAI-only with no admin stacking at all; org/team tenancy administration; research triggers). Admitting the scope globally in validate_pai_request would hand all of that to any Global Intel customer. So PAI_DEFAULT_SCOPES stays (PAI_INTERNAL,) — unchanged for every other PAI endpoint — and this router opts in via require_pai_or_global_intel / PAI_INTEL_QUERY_SCOPES. A phx_gintel_ key presented to any other /internal/v1/* endpoint gets 401, exactly as before.
include tokenGlobal Intel ProGlobal Intel Enterprise
vulnerabilitiesyes, capped 100yes, capped 500
licensing spdx_id, category, license_risk_score, license_policysame
exploitationexploitation_tier + full flags/block_counts+ tte.{tte_hours,speed_pressure,ecosystem_cohort}
malwareverdict + malware_signals + mitre+ chains + iocs + phx_neural_score
campaigncompromised + per-campaign name/date/severity+ campaign_threat_actor, tags, repeat_offender (+ reason), incident_count_365d, days_since_last_incident, decay_phase
advisoryunchanged — see the section aboveunchanged — see the section above

include accepts CSV (?include=a,b) or repetition (?include=a&include=b). include=all expands server-side to every token the caller's tier permits and never errors on an over-privileged token. Token validation and the token→tier matrix live in backend/app/services/intel_query/registry.py — one gate for four transports, not one per adapter.

Several envelope field names are deliberately not the source's own. data_shield's DEFAULT_FIELD_CLASSIFICATIONS is a flat map keyed on bare field names and filter_response recurses by bare key, so a nested key here shares one classification with every dataset on the platform. Two distinct hazards force the renames, and both are live.

Envelope nameSource nameHazard
malware.malware_signalssignals signals is ENTERPRISE for the MPI dossier; adopting it would ship a Pro tier that pays for signals it cannot read, and relaxing it would leak dossier signals to Pro everywhere.
malware.malware_signals[].malware_signal_idsignal_id Same. Without this one, a Pro caller receives [{"category", "severity"}] — signal rows with no identity, which is worse than withholding the array because it looks like a feature.
malware.mitre.mitre_technique_idstechnique_ids Same.
licensing (block + include token)license license is currently unclassified, and under data_shield_fail_closed unclassified means dropped. description_sources[].license ("CC-BY-4.0") is dropped platform-wide today on the cve dataset; classifying the bare name would newly expose it there.
licensing.license_policy / licensing.license_risk_score policy / risk_score Same class of hazard; renamed pre-emptively because the names are generic.
exploitation.block_countscounts enrichment.circl_history.counts on the cve dataset — same inverse hazard as license.
exploitation.tte.tte_hourshours Generic; renamed pre-emptively.
campaign.campaigns[].campaign_threat_actorthreat_actor projections.from_full_cve emits threat_actor on the cve dataset.
provenance.intel_licenselicense As licensing above.

The second hazard is the less obvious one and is worth stating plainly: adding a classification is not free either. Under fail-closed, an unclassified name is dropped for every tier, so classifying a name that another data_shield-filtered producer also emits is a live behaviour change to that unrelated, already-shipped endpoint. tests/test_data_shield_intel_coverage.py (D-T2c) checks this mechanically against every module reachable from a filter_response_for_tier call site. None of these renames is cosmetic.

ConditionBehaviour
No includeByte-identical to the pre-Plan-B response.
Unknown token400, detail naming the unknown token(s) and listing every valid token.
enable_intel_query_surface off404 — checked before include parsing and before licence resolution, so neither a 400 nor a 403 can leak the feature's existence.
enable_global_intel_license off, or no bundle on the key403 {"detail": "Global Intel license required for expanded intelligence"}and the base envelope is still in the response body. Losing the bundle narrows the answer; it does not delete it.
Daily intel_expanded quota exhausted429 {"detail": "Usage limit exceeded"} (Plan C, added 2026-08-14). Checked after the licence gate and before composition runs, so a rate-limited call costs a dict lookup rather than a full fan-out.

Metering and quota (Plan C, added 2026-08-14). Expanded composition is metered separately from a plain lookup because it is materially more expensive — it fans out to MPI, OSV/library-intel and up to 500 per-CVE exploitation lookups.

CounterIncrements on
intel_query_lookupsevery billable call to /api/v1/intel/* or /internal/v1/intel/*, composed or not
intel_expanded_lookupsadditionally, every call whose include names at least one licence-gated token on a transport that can serve it

?include=advisory alone is not an expanded call: it needs no bundle, does not increment intel_expanded_lookups, and is not subject to the intel_expanded quota. Only the tokens in EXPANDED_TOKENS (plus all) trigger either — the classifier consults registry.wants_expanded(), the same gate the licence check uses, so the two cannot drift.

All three composing transports are billed identically (Plan D, 2026-08-14; corrected 2026-08-14 by fix I2). The rule is "a transport bills intel_expanded_lookups exactly when it can actually serve the composition". For the two HTTP transports that is usage_metering.COMPOSING_PATH_PREFIXES = ("/api/v1/intel/", "/internal/v1/intel/"). MCP is the third: it has no billable path of its own (classify_request decides /api/v1/mcp in an earlier branch), so IntelToolHandler records the same two counters itself, from the same usage_metering.intel_usage_keys() definition the path classifier uses. Until that fix MCP enforced the intel_expanded quota against a counter only REST and PAI ever wrote — composition served, billing zero, the 2,000/day Pro cap unreachable through that door. An expanded call now costs the same fan-out and the same counter through any of the three.

MCP bills intel_query_lookups on intel_get/intel_search and adds intel_expanded_lookups only when a licence was actually resolved and the composition served; api_calls_total/api_calls_mcp continue to come from usage_tracking_middleware, which does not skip /api/v1/mcp, so nothing is double-counted.

Metering has a hard prerequisite, and the surface now refuses without it. Both counters are columns added by db/migrations/123-global-intel-usage-counters.sql. With 123 unapplied the read path (SELECT *) silently returns 0 and the write path fails wholesale into a swallowed exception — the whole surface unmetered and unquota'd, with only a WARNING. app/services/usage_schema.py now checks the columns directly: logged loudly at startup, and every licence-gated expanded call returns 503 (naming the migration) rather than serving the platform's most expensive read unbilled. Deploy order is 122 → 123 → code.

One consequence on the public route, and the shape of it matters: an anonymous caller sending a licence-gated include is refused by the route, not by _enforce_anonymous_limits, so the 403 still carries the base envelope. Plan C had put intel_expanded in ANONYMOUS_ENFORCEABLE_FEATURES so the middleware would refuse it before any resolver work; once the public router actually composed, that became a regression — the middleware has no envelope, licence or tier context, so all it could return was a bodiless {"detail": …}, deleting data the caller was entitled to. anonymous_feature_key now falls the anonymous enforcement back to search, so the call is still metered and still 429-able while the route owns the refusal. ?include=advisory alone is unaffected: it needs no bundle and was never an expanded call.

Daily intel_expanded caps: Pro 2,000, Enterprise unlimited; free/registered are 0, though an unlicensed caller is refused by the 403 licence gate before the quota is ever consulted. Source of record is the tier_features DB table (db/init/07-user-tiers.sql), mirrored by the fallback map in backend/app/services/tier_limits.py; tests/services/test_tier_limits.py pins the two together.

/entities and /vocabularies are static vocabulary listings and are not metered. /internal/* is skipped entirely by usage_tracking_middleware, so this router records its own usage — there is no second writer and nothing to double-count. If the metering backend is unreachable the quota check fails open (logged at WARNING): the licence gate is the authorization control, and a storage blip must not deny a paying customer access they hold.

allow_clean is NOT widened by a Global Intel licence. This is the security invariant of the whole feature. Composition reuses MalwareResolver in-process with allow_clean at its default False, so a phx_gintel_ Enterprise key gets no expanded.malware block for a SUSPECT-band (scanned-but-not-confirmed) package — not a redacted one, not an empty one. A product entitlement is not the separately-decided authorization to see pre-confirmation triage state; see the allow_clean section above for that decision's full record.
expanded.malware.verdict is never inferred, and absence of evidence is never reported as cleanliness. A verdict may only come from a source the caller is authorized to see: MalwareResolver (allow_clean-gated) for any verdict including CLEAN; or the library_intelligence.json D-14 compromise flag for MALICIOUS only, and only on the unversioned library entity where resolve_purl_to_versioned can legitimately miss a purl_base that compromised_package_intel knows about (that flag is already public to non-admins and cannot encode SUSPECT-band state). Anything else — clean, gated, never scanned, or SUSPECT-band, which are indistinguishable from outside the resolver — omits the block with block_errors.malware = "malware_intelligence_unavailable". A caller who cannot be told "this is clean" is told "we cannot answer", never "this is clean".

Provenance. When composition runs, provenance gains three keys, and a fourth when there is something to disclose:

provenance.intel_license is stamped {"product": "global_intel", "tier": "pro|enterprise", "source": "api_key:global_intel_tier"} on a licensed response.

Truncation is never silent. expanded.vulnerabilities carries total (the real count), returned, and truncated. Selection under the per-tier cap is by severity then EPSS, so a cap never drops the most exploitable rows; presentation order is then imposed by sort_deterministic() (determinism rule D5).

Array ordering (D5), precisely. sort_deterministic() orders a list of objects by the first present key in envelope.STABLE_ID_KEYS (id, signal_id, malware_signal_id, cve_id, cwe_id, purl, cpe_uri, technique_id); lists of scalars sort naturally. expanded.campaign.campaigns[] carries none of those keys and is therefore ordered by its producer instead — ascending by (date, name) — which is stable, tier-independent and reproducible, but is not the stable-ID rule the sentence above describes. It was unordered entirely until 2026-08-14. advisory is exempt from D5 altogether: LLM output is not reproducible and ordering it would imply that it is.

Budget. Each block has its own timeout, INTEL_EXPANDED_BLOCK_TIMEOUT_MS (default 3000, capped at 60000). The whole resolve is offloaded via anyio.to_thread.run_sync so composition never runs on the event loop.

Tenant-Scoped Research Triggers Mirror

Added 2026-07-26. PAI mirror of the public Tenant-Scoped Research Triggers (backend/app/routers/research_pai.py), over the same job store and dispatcher as the REST surface. Gated by the same flag, enable_intel_research_triggers (default false); returns 404 when off, in addition to the standing require_pai_access dependency on the whole /internal/v1/research/* router.

org_id comes from the PAI key (PAIKeyInfo.org_id), never from a request field — a PAI caller may not name an arbitrary org, or PAI becomes a cross-tenant write primitive. A PAI key with no resolvable org_id gets 403.

No separate Pro/Enterprise tier check. A PAI key has no subscription tier to resolve; quota is pinned at the Enterprise daily ceiling (250/day) rather than gated on a lookup that does not exist for this transport — the same precedent as this file's own Unified Intelligence Query Surface mirror capping its limit at Enterprise's max_page_size instead of inventing a separate PAI-only ceiling.

This 250/day ceiling shares the SAME per-org daily counter as REST — it is not an independent PAI-only allowance. create_job()'s quota check counts every row in intel_research_jobs for the org created that UTC day, regardless of which transport created it, so a Pro org's own REST quota is 25/day but a PAI key acting on that same org can create up to 250/day against the identical counter — heavy PAI usage can exhaust the shared counter and 429 the org's own subsequent REST calls for the rest of the day. This is a deliberate, already-tracked rollout gate, not an oversight.

job_state, not state, in the response — the same rename, and the same field-classification-collision rationale, as the REST mirror (see PUBLIC_API.html). This router duplicates a small, route-local _to_response() rather than importing research.py's, matching this codebase's established PAI-adapter-router convention.

Feature doc: 2026-07-25-tenant-scoped-research-triggers.md.

Bulk Export

Flag: enable_pai_bulk_export (default false). Every endpoint in this section returns 404 when the flag is off.

Serves pre-built, per-tier NDJSON snapshots of Phoenix datasets (backend/app/routers/bulk_export_pai.py). Artifacts are produced out-of-band by pipeline/build_bulk_exports.py and served as-is — the backend does not filter response data at request time, because the producer already wrote base and premium as physically separate files (data_shield cannot scale to millions-of-record exports).

Authentication. This surface admits PAI_INTERNAL keys only (PAI_EXPORT_SCOPES in pai_auth.py) — narrower than the rest of PAI. A phx_gintel_ (Global Intel bundle) key is rejected here even though it is admitted on /internal/v1/intel/*: global_intel is a paid customer entitlement, and this surface's licensing rationale holds only because every consumer is a first-party Phoenix platform. global_intel may lift the tier of an already-admitted key (resolve_export_tier); it never grants admission to this router.

Phase 1 has no delta endpoint. Every manifest entry carries "delta": {"supported": false} — there is no incremental/changed-since feed yet. Consumers must re-pull the full snapshot.

GET /internal/v1/export/manifest

Lists every dataset this export generation produced, tier-shaped for the caller. Free — it does not consume the bulk_export usage counter or the per-key concurrency slot described below, so discovery never costs a consumer anything (a metered discovery call would push consumers toward guessing dataset names instead of listing them).

{
  "schema_version": "1.0",
  "export_version": "v1",
  "generated_at": "2026-08-19T02:14:07Z",
  "tier": "base",
  "datasets": [
    {
      "dataset": "kev",
      "tier_required": "base",
      "accessible": true,
      "delta": {"supported": false},
      "source_freshness": {},
      "snapshot": {
        "watermark": "2026-08-19T02:00:00Z",
        "url": "/internal/v1/export/kev/snapshot",
        "records": 1284,
        "bytes": 483920,
        "sha256": "deadbeef...",
        "sha256_uncompressed": "c0ffee...",
        "cursor": "eyJkIjoia2V2IiwidCI6ImJhc2UiLCJzIjo0MTJ9",
        "snapshot_id": "kev-base-000412"
      }
    },
    {
      "dataset": "malware_packages",
      "tier_required": "premium",
      "accessible": false,
      "delta": {"supported": false},
      "source_freshness": {}
    }
  ]
}

malware_packages above is an ILLUSTRATIVE Phase 2 entry, not current output. Phase 1 registers exactly six datasets — cve_core, kev, epss, ransomware, exploitation_evidence, library_intel (pipeline/bulk_export/datasets.py) — and malware_packages is not among them, so no manifest produced by this build contains it. It is shown to illustrate the shape of an inaccessible entry: a premium dataset stays listed for a base caller with accessible: false and no snapshot block. Note source_freshness is present-but-empty rather than absent: shape_manifest_for_tier emits that key for every dataset entry, accessible or not.

GET /internal/v1/export/{dataset}/snapshot

Streams the full NDJSON snapshot for one dataset at the caller's resolved tier, gzip-compressed on disk and served with Content-Encoding: gzip (the body is not re-compressed by the app-wide GZipMiddleware — the pre-set Content-Encoding header short-circuits it).

Response headers:

HeaderMeaning
ETag"<sha256>" of the compressed artifact; use with If-None-Match. See "Verifying integrity" below before hashing anything against it
X-Phoenix-Export-Sha256-Uncompressedsha256 of the decompressed NDJSON — the digest a normal HTTP client can reproduce. Empty string on artifacts published before this field existed; treat "" as "not published", never as a mismatch
X-Phoenix-Export-CursorOpaque, resumable position (base64url; see pipeline.bulk_export.cursors). Persist this — it is what a consumer presents as from on the first delta once Phase 3 ships. Decodes (server-side only) to (dataset, tier, seq); never parse it client-side, it is not a security boundary but is not a stable format either.
X-Phoenix-Export-Snapshot-IdHuman-readable build label, {dataset}-{tier}-{seq:06d} (e.g. kev-base-000412). Convenient for logs/support tickets; not a substitute for X-Phoenix-Export-Cursor.
X-Phoenix-Export-WatermarkISO-8601 timestamp this snapshot was built as-of. Informational only — never use this as a resume position or range key; it is exactly the wall-clock range-key pattern R2 rules out (silently drops records under concurrent ingest or clock skew, indistinguishably from "nothing changed").
X-Phoenix-Export-DatasetEcho of the requested dataset name
X-Phoenix-Export-TierTier the artifact was resolved at (base / premium)
X-Phoenix-Export-RecordsRow count in the artifact
X-Phoenix-Export-Schema-Version1.0
Cache-Controlprivate, max-age=0, must-revalidate — the artifact is per-tier and per-caller, so it must not be stored by a shared cache; revalidate with If-None-Match against the ETag rather than serving a stale copy
Last-ModifiedThe artifact file's on-disk mtime, RFC 1123-formatted (email.utils.formatdate)
Content-Dispositionattachment; filename="<snapshot file>"

Conditional requests. Send If-None-Match: "<etag>"; a match returns 304 with an empty body and no re-transfer. Only If-None-Match is honoured — Last-Modified is informational and If-Modified-Since is not implemented.

Verifying integrity — read this before comparing digests. Two digests are published because no single one is reproducible by every client.

How you read the bodyDigest to compare against
Normal HTTP client (requests, httpx, Go, curl --compressed) — the library transparently decompressesX-Phoenix-Export-Sha256-Uncompressed
Raw undecoded bytes (resp.raw.read(decode_content=False), curl without --compressed, writing straight to a .gz file)ETag / the manifest's sha256

The response carries Content-Encoding: gzip, so a standard client decompresses before your code sees the bytes, and hashing what you received can never reproduce the ETag (which covers the compressed artifact). Comparing the wrong pair looks exactly like corruption. Both digests are watermark-independent: an unchanged dataset rebuilt tomorrow yields identical values, which is what makes 304 revalidation worthwhile.

Range requests. Lets a consumer resume a dropped multi-gigabyte transfer instead of restarting it. Support comes from the installed Starlette's FileResponse, and was verified live against the deployed runtime (Starlette 1.6.0): a Range: bytes=0-99 request returned 206 with exactly 100 bytes and a Content-Range header. Range handling was added to FileResponse well before 1.0, so the exact earliest supporting release is deliberately NOT stated here — only the version actually tested is. backend/requirements.txt pins starlette>=0.27.0, which does permit resolving a version older than the one verified, so an unchecked deployment must be treated as "may not support it": a Range request answered 200 with no Content-Range/Accept-Ranges has no range handling. If you must support both, treat 206 as the fast path and fall back to a full re-fetch when the response is 200.

Status codes:

ConditionCodeRationale
Flag off404House rule — never leak feature existence, same as everywhere else in PAI
No/invalid key, wrong scope (incl. phx_gintel_)401 / 403Standard PAI auth
Unknown dataset (not in the manifest and not a registered pipeline dataset)404
Dataset registered/listed but requires a higher tier than the caller holds403Deliberate divergence from the 404 default — the manifest (or the pipeline registry) already told this caller the dataset exists, so a 404 here would be actively confusing
Export generation not built yet (no manifest.json), or this dataset not built for the caller's tier503 + Retry-After: 3600Honest — not an empty 200, which would read as "dataset has no rows"
Second concurrent/rapid-reissue request for the same key429See concurrency guard below
Concurrency lease backend unreachable (cache outage)503Fails closed on this route only — see concurrency guard below for why
Match200 (or 304 on If-None-Match hit)

Metering. /internal is exempt from the global usage-tracking middleware (main.py), so this router meters itself: every snapshot request (200 or 304 — the check runs before the conditional-request branch) increments the caller's exports counter one time via UserTierService().record_usage(subject, {"exports": 1}) — the same rail user_tier_service.FEATURE_USAGE_FIELDS already maps the bulk_export feature to. The subject is the key's admin_id (falling back to key_id), matching every other PAI usage-metering call site (intel_query_pai._usage_subject). GET /manifest records nothing — discovery stays free. A metering failure (e.g. storage unavailable) is logged and swallowed; it never fails the export request itself.

Per-key concurrency guard. PAI's standard rate limit (1000 calls/minute, config.py) is the wrong shape for full-corpus streams — one consumer looping a multi-gigabyte snapshot download would saturate the backend. This router additionally caps each key to EXPORT_CONCURRENT_STREAMS_PER_KEY (currently 1) snapshot streams in flight at a time, tracked as a cache-backed counter (CacheService.increment / CacheService.increment_with_expire / CacheService.decrement) that is incremented when a stream starts and released once the response has actually finished sending. A second request for the same key while one is still in flight gets 429 Export already in progress for this key.

The guard fails closed on a cache outage: if a concurrency lease cannot be established authoritatively, the snapshot route returns 503 Export concurrency control unavailable; retry shortly. This is a deliberate divergence from the fail-open convention every interactive PAI route follows, and it is scoped to this one streaming route — GET /internal/v1/export/manifest still serves normally during an outage, so a consumer can always discover state and back off.

The reasoning: PAI's ordinary per-key rate limiter depends on the same cache, and the exports counter above is accounting-only (this router calls no UserTierService.enforce_feature_limit against it), so failing open did not degrade one control — it removed every control at once, on the only route that streams multi-hundred-megabyte to multi-gigabyte bodies. One valid or stolen key could then open unlimited concurrent streams and exhaust disk and egress capacity precisely while the platform was already degraded. Refusing bulk egress that cannot be accounted for is the correct trade here. Note this remains a per-key concurrency cap, not a volume budget: no per-day export quota is enforced in this phase.

Cross-dataset consistency — NOT guaranteed

Datasets are built independently by separate producer runs and carry independent watermarks. A kev snapshot may reference a CVE not yet present in a cve_core snapshot; a malware record may reference a package version absent from a base dataset that has not rebuilt yet.

Consumers must tolerate dangling cross-dataset references. Do not build a foreign-key constraint across two mirrored datasets — it will hold in testing and fail in production.

This is a deliberate design trade-off, not an oversight: enforcing cross-dataset consistency would require a global build barrier across every producer, serialising the pipeline. The contract is eventual convergence — any dangling reference resolves within one build cycle of the referenced dataset.

Related: docs/plans/2026-08-19-pai-bulk-export-sync-design.md (design, §7-§9), docs/plans/2026-08-19-pai-bulk-export-phase1-implementation.md (implementation plan).

Org/Team Admin Tenancy

Flag: enable_org_team_admin_scoping (default false). All endpoints in this section return 404 when the flag is off.
Auth: Global admin or org manager (is_org_manager Cognito group) for the target org — enforced by resolve_admin_scope + ensure_target_org_in_scope.
Related: docs/Individual_Feature/2026-06-29-org-team-tenancy-plan-3-tenancy-router.md

Organizations

EndpointAuthPurpose
GET /internal/v1/admin/organizationsglobal-admin or org-managerList organizations. Non-global admins see only their own org. Response also carries tier_default_seats (canonical scf_seats_{tier} map: registered 3 / pro 25 / enterprise 100) global_intel_enabled (enable_global_intel_license, the gate on granting a bundle) and global_intel_lift_enabled (the SEPARATE cumulative-lift gate, requiring both enable_global_intel_license AND enable_mpi_tier_separation). These two can disagree: with the licence flag on and MPI tier separation off, an org can be granted a bundle and rendered as licensed while every member still gets 403 on mint — clients must surface that mismatch. All three advisory fields are nullable (added 2026-08-18): a transient settings/flag lookup failure degrades the field to null rather than 500ing the listing; treat null as "unknown", never as false or an empty seat map, and do not auto-apply seat defaults you could not read. The write paths still fail loudly. Each org row gains global_intel_tier / global_intel_seats (null when the org holds no bundle row) — added 2026-08-17.
GET /internal/v1/admin/organizations (cont.)global-admin only for these fieldsAdded 2026-08-19. The response also carries unlinked_organizations — organization NAMES present in users.organization, pending_registrations.organization, scf_org_license.org_key or api_keys.org_id that have no scf_tenants row, so they cannot appear in organizations at all. Such an org can hold an SCF seat licence and issue API keys while being invisible to org administration. Each row is {org_key, user_count, pending_count, license_count, api_key_count}. Global admins only (the scan is deployment-wide and has no tenant to scope to); a non-global admin gets null, not a filtered list. Not gated on enable_org_adopt_and_merge — reporting that the org list is incomplete is not a feature. Linked orgs are subtracted server-side via normalize_org_slug (NFKC → casefold → confusable skeleton), so clients must not re-derive it with a plain string compare. unlinked_truncated is true when the 500-row candidate ceiling was hit; null means unavailable, which is not the same as false. adopt_merge_enabled reports whether the two endpoints below exist for this caller.
POST /internal/v1/admin/organizations/adoptglobal-admin + enable_org_adopt_and_mergeAdded 2026-08-19. Promotes a free-text organization NAME into a real org: tenant, metadata, default team, vuln_intel/mpi entitlements, links any orphan scf_org_license row, and adds matching users as members. 404 (not 403) when the flag is off. The name is stored RAW as scf_tenants.org_key, never replaced by the normalized slug: SCF enrollment matches that column exactly, with no case or whitespace folding, so storing the slug would 403 every key already issued under the original spelling. Additive — users.primary_tenant_id is set only where NULL, so a user already in another org keeps that membership. 400 unnormalizable name or a seat count below the tier floor (the message names the tier and floor); 409 ORG_KEY_TAKEN / SLUG_TAKEN, including on a concurrent race.
POST /internal/v1/admin/organizations/{org_id}/mergeglobal-admin + enable_org_adopt_and_mergeAdded 2026-08-19. Irreversible, no dry-run, no un-merge. Merges source_org_id INTO {org_id} — the path org survives. confirm_org_key must echo the source's RAW org_key exactly (case-sensitive); if that key cannot be resolved the endpoint returns 503 and changes nothing rather than falling back to the slug. Moves every tenant_id-bearing table (discovered from pg_catalog at runtime) plus the raw org-name columns, then deletes the source — all in one transaction, audited as org_merge in audit_log within it. scf_org_license is keyed by org_key STRING, so the source's licence row is re-keyed onto the surviving name when the target holds none; otherwise it would become unreachable and a fresh registered row would be minted in its place. When both hold one the target's governs and the dropped row's tier and seat counts are returned. 400 merging an org into itself, a confirm_org_key mismatch, or target_org_key_too_long_for:<table>.<column>; 404 source or target organization not found, including a non-UUID id (also when the flag is off); 412 migration_required:127-tenancy-fk-deferrable-for-merge.sql — a precondition, not a conflict; 503 the source org_key could not be resolved, so nothing was changed; 409 merge_conflict_unhandled:<table>. audit_log is deliberately NOT moved (audit rows are immutable, so the deleted org keeps its history; the count is returned as audit_rows_left_on_source), while users.primary_tenant_id IS repointed — it holds a tenant UUID under a non-standard column name and would otherwise dangle on the deleted tenant, silently demoting every merged user to their legacy per-user tier. Tier grants no access to this route — it is global-admin only, so an Enterprise subscriber who is not an admin is refused exactly as a Free one is.
POST /internal/v1/admin/organizationsglobal-adminCreate org. Body: { slug, display_name, plan_tier, seat_limit }. 201.
GET /internal/v1/admin/organizations/{org_id}global-admin or org-managerGet org. 404 when not found or out of scope.
PATCH /internal/v1/admin/organizations/{org_id}global-adminUpdate org metadata. Only display_name and status are applied — plan_tier/seat_limit are rejected with 400 (use the licence endpoint), and status must be active | suspended | deleted (400 otherwise). Both fields are persisted to scf_tenants and tenant_admin_metadata in one primary transaction, with the response read back inside it, so a rename cannot half-apply and cannot return a stale replica read.
POST /internal/v1/admin/organizations/{org_id}/licenseglobal-adminSet org plan tier + seat limit. Audited. Body: { tier, seat_limit, product }. product is vuln_intel | mpi; with enable_global_intel_license on it also accepts global_intel (tier restricted to pro|enterprise, 422 otherwise; plan_tier not mirrored). Flag off: 400 Invalid product. Added 2026-08-17: for product: "global_intel" only, seat_limit is clamped up to scf_seats_{tier} and the response returns the clamped, effective value — render what came back, not what was sent. vuln_intel/mpi seat handling is unchanged by this endpoint, though the admin UI now sends 25 rather than a stale hardcoded 20 at Pro for those two products as well (see CHANGELOG). Added 2026-08-26: for product: "vuln_intel" the entitlement and the org plan/seat mirror commit in one transaction, and the org's canonical identity is resolved before anything is written, so a refusal leaves no partially-applied licence — 404 no such org; 409 the org's normalised identity is already owned by another organization (scf_tenants.org_key is raw text, tenant_admin_metadata.slug is the normalised key, so keys differing only by case, spacing or confusables collide; resolve via the merge/repair workflow, no suffixed variant is minted); 422 the org key cannot be normalised into a valid identity (empty, over-long, or reserved such as admin/phoenix/default).

Teams

EndpointAuthPurpose
GET /internal/v1/admin/organizations/{org_id}/teamsglobal-admin or org-managerList teams. Non-global admins see only teams in their scope.
POST /internal/v1/admin/organizations/{org_id}/teamsglobal-adminCreate team. Body: { slug, display_name, seat_cap? }. 201. 409 SLUG_TAKEN on duplicate.
GET /internal/v1/admin/organizations/{org_id}/teams/{team_id}global-admin or org-managerGet team. Caller must have the team in scope.
POST /internal/v1/admin/organizations/{org_id}/teams/{team_id}/licenseglobal-adminSet team tier override + seat cap. Tier clamped to org tier.
DELETE /internal/v1/admin/organizations/{org_id}/teams/{team_id}global-adminDelete team. 204.

Members

GET /internal/v1/admin/organizations/{org_id}/members

Auth: Global admin or tenant_admin of that org. team_admin callers are auto-scoped to their own teams.

Query param: team_id (optional uuid) — filter by team. Overridden by auto-scoping for team_admin callers.

Response 200: { "members": [...] }

Each member carries the membership row plus the identity resolved from users (2026-08-26):

FieldTypeNotes
user_substringCognito/basic-auth subject — the membership key.
account_idinteger | nullusers.id, joined on cognito_sub. Prefer this over user_id.
user_emailstring | nullAccount email. This is the login identity — the platform stores no separate username for an established user.
user_namestring | nullusers.name; frequently blank in existing data.
user_idinteger | nullLegacy. The scf_tenant_members.user_id column, which is not reliably populated — NULL on rows whose users.id exists. Kept for compatibility only.
rolestringmember | viewer | team_admin | tenant_admin.
team_id, team_nameuuid | string | nullThe member's single resolved team.
joined_attimestampMembership creation time.

The identity join is a LEFT JOIN: a membership row whose sub has no users row is still listed, with the three identity fields null, rather than disappearing from its own org.

POST /internal/v1/admin/organizations/{org_id}/members

Auth: Global admin or tenant_admin of that org.

Add an existing user (by user_sub) to an org.

FieldTypeRequiredNotes
user_substryesCognito user sub
user_idintnoInternal DB user ID
rolestrnoDefault member. Values: member, viewer, team_admin, tenant_admin
team_iduuidnoAssign to a specific team

Errors: 400 invalid role; 403 TENANT_ADMIN_ESCALATION (tenant_admin caller cannot assign tenant_admin role); 409 seat limit.

Response 201: { "added": bool, "already_member": bool }

PATCH /internal/v1/admin/organizations/{org_id}/members/{user_sub}

Auth: Global admin or tenant_admin of that org.

Update a member's role. Request body: { "role": "member|viewer|team_admin|tenant_admin" }

Errors: 400 invalid role; 403 TENANT_ADMIN_ESCALATION; 404 member not found; 409 LAST_ADMIN (cannot demote the last tenant_admin).

DELETE /internal/v1/admin/organizations/{org_id}/members/{user_sub}

Auth: Global admin or tenant_admin of that org.

Remove a member. 409 LAST_ADMIN when removing the last tenant_admin. 204 on success.

PUT /internal/v1/admin/organizations/{org_id}/members/{user_sub}/team

Reassign a member to a team. Enterprise orgs only (403 ENTERPRISE_REQUIRED for non-enterprise).

Request body: { "team_id": uuid }

Invitations

EndpointAuthPurpose
GET /internal/v1/admin/organizations/{org_id}/invitationsglobal-admin or org-managerList invitations. team_admin auto-scoped. Query param: team_id.
POST /internal/v1/admin/organizations/{org_id}/invitationsglobal-admin or tenant_adminCreate invite. Org admins cannot create tenant_admin invites.
DELETE /internal/v1/admin/organizations/{org_id}/invitations/{invite_id}global-admin or tenant_adminRevoke invite. 204. 404 when not found.

POST /internal/v1/admin/organizations/{org_id}/users — Org-scoped user creation

Create a brand-new user and enroll them in the org. Provisions the user in Cognito (sends invite email) and inserts a users DB row.

Auth: Global admin or tenant_admin of that org. team_admin callers always receive 403 TEAM_ADMIN_DIRECT_CREATE_FORBIDDEN.

Request body

FieldTypeRequiredNotes
emailstr (email)yesNew user's email address
full_namestryesDisplay name
rolestryesmember, viewer, or team_admin. tenant_admin not permitted here.
team_iduuidnoAssign to a team immediately
industrystrnoDefault generic
passwordstrnoTemporary password; Cognito email invite sent regardless

Response 201

{ "user_sub": "cognito-sub-uuid", "role": "member", "org_id": "...", "team_id": null }

Errors

StatusCodeMeaning
400INVALID_ROLErole is not member, viewer, or team_admin
400Password does not meet Cognito policy
403TEAM_ADMIN_DIRECT_CREATE_FORBIDDENCaller is a team_admin
403TENANT_ADMIN_ESCALATIONCaller is tenant_admin and attempted to assign tenant_admin role
404org_id not found or out of caller scope
409SEAT_LIMITOrganization has no available seats
409USER_ALREADY_EXISTSEmail already registered in Cognito

Caller Scope Helpers

EndpointPurpose
GET /internal/v1/admin/org-scopeReturn caller's org-admin scope. Response: { is_global_admin, is_org_manager, tenant_id, member_role, plan_tier }. Used by UI to gate the Organization-Team tab.
GET /internal/v1/admin/member-teamsMap user_sub -> {org, team} across all tenants. Global admin only.

Admin Registrations (org-scoped)

Auth: require_admin (admin or global-admin Cognito group).

These are /api/v1/admin/ endpoints (not PAI). Documented here because their scoping behaviour changed as part of the org/team tenancy feature.

GET /api/v1/admin/registrations

List user registrations.

Scope: Global admin sees all rows. Non-global admins see only rows belonging to their primary org (get_user_primary_org). Non-global admins with no org membership receive an empty list.

Query parameters: status (optional), limit (default 100, max 1000), offset (default 0).

Response 200: Array of UserRegistrationListItem.

GET /api/v1/admin/registrations/pending

Convenience alias — equivalent to GET /registrations?status=pending. Same org-scoping rules.

Response 200: Array of UserRegistrationListItem with status=pending.

POST /api/v1/admin/registrations/{registration_id}/approve

Approve a pending registration.

Auth: require_global_admin. Unlike the GET endpoints above this is not org-scoped — only global admins may approve.

Query parameters: notes (optional str) — free-text note recorded with the approval.

Response 200: MessageResponse{ "success": true, "message": "..." }. The message is composed at runtime and names what actually happened, including the organization the user was bound to and any binding warning. Treat it as human-readable prose, not a parseable contract.

Errors:

Side effects:

Neutral Defaults

PAI calculation endpoints apply neutral defaults when parameters are omitted: