PAI is the privileged internal API plane. It is not publicly reachable and is protected by:
X-PAI-Key)X-PAI-Token) / session-JWT second factor (PAI_REQUIRE_PAIRED_TOKEN, PAI_SESSION_TOKENS_ENABLED)PAI_IP_ALLOWLIST) — skipped when empty; restrict at the network/system layer (WAF / load balancer / security group) instead/internal/v1
Internal Swagger UI (requires a PAI key, or a session JWT when PAI_SESSION_TOKENS_ENABLED is on; IP allowlist enforced only when configured):
GET /internal/docsGET /internal/openapi.jsonProvide a PAI key via one of:
X-PAI-Key: phx_pai_...X-API-Key: phx_pai_...Authorization: PAIKey phx_pai_...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.
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.
| Value | Behaviour |
|---|---|
enforce (default) | A client IP outside the allowlist is rejected with 403. Unchanged historical behaviour. |
monitor | The 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-allowlistGlobal-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/observationsGlobal-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.
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:
x-pai-token: phx_pait_...The companion is required — a missing or mismatched value returns 401 — when either:
pai_require_paired_token is true (env PAI_REQUIRE_PAIRED_TOKEN, default false), or
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.
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.
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.
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.)
{
"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).
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.
Bearer session token to this endpoint returns 401 ("Cannot mint a
session from an existing session token..."). You must always present the api-key + companion pair to
mint a session; this prevents a leaked short-lived token from self-refreshing indefinitely.401 Missing PAI key, 401 Invalid PAI key,
401 Missing PAI companion token, 401 Invalid PAI companion token,
403 Client IP not allowed) apply as usual if the pair fails validation before reaching
this endpoint.| Endpoint | Purpose |
|---|---|
POST /internal/v1/pai/session | Mint 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-pocs | Full (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-profile | Full high-profile list (no redaction) |
GET /internal/v1/enterprise-watchlist | Full watchlist entries |
POST /internal/v1/packages/intel | Internal package-version CVE + MPI malware intelligence |
POST /internal/v1/calculate-score | Custom PS-HP calculation |
POST /internal/v1/calculate-pss-score | Custom PS-OSS (PSS) calculation |
POST /internal/v1/calculate-phs-score | Custom PS-PHS calculation |
POST /internal/v1/calculate-pvs-score | Custom PS-PVS calculation |
POST /internal/v1/calculate-adqe-score | Custom ADQE calculation |
POST /internal/v1/calculate-eol-risk | Custom EOL SLR risk calculation |
GET /internal/v1/scoring-weights | PS-HP/PS-OSS weights |
GET /internal/v1/threat-actors | Threat actor intelligence |
GET /internal/v1/eol-intelligence | Full EOL intelligence |
GET /internal/v1/analysis/cwe | Full CWE analysis (mirror of /api/v1/analysis/cwe) |
GET /internal/v1/analysis/cwe-top25 | Full CWE Top 25 analysis |
GET /internal/v1/analysis/kev-cwe | Full KEV CWE analysis |
GET /internal/v1/analysis/owasp-top10 | Full OWASP Top 10 analysis |
GET /internal/v1/analysis/reference-threat-frequencies | Full, live reference threat-frequency distributions |
GET /internal/v1/analysis/cwe-intelligence | Full, unredacted merged CWE-intelligence table (mirror of /api/v1/analysis/cwe-intelligence); gated by enable_cwe_intelligence_endpoint, 404 when off |
GET /internal/v1/cves | Unrestricted CVE search — public filters plus sort, published_after/published_before, has_analysis; limit up to 1000; unredacted |
GET /internal/v1/cves/by-product | Unrestricted 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/trending | Trending CVEs (news/social mention volume) |
GET /internal/v1/cves/kev | CISA KEV catalog, no tier field filtering (limit up to 10000) |
GET /internal/v1/cves/homepage-intel | Homepage intelligence bundle |
POST /internal/v1/cves/batch | Unrestricted 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/entities | List 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.
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.
{
"ecosystem": "Maven",
"name": "com.h2database:h2",
"version": "1.3.176"
}
| Field | Notes |
|---|---|
ecosystem, name, version, purl | Normalised package identity. |
cves | Version-filtered OSV CVE/advisory records. |
vulnerabilities | Alias of cves. |
malware | MPI and compromised-package intelligence. |
malware.scan_id, malware.signals, malware.iocs, malware.raw | Internal-only MPI evidence for Phoenix-owned services. |
ps_oss_score | Derived package risk score. |
source_attribution | Data sources used in the response. |
Gated by ENABLE_ADV_FETCH (default false). All return 404 when the flag is off.
| Endpoint | Purpose |
|---|---|
GET /internal/v1/cves/{cve_id}/advisory-patches | Advisory 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) |
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/cwe | Full CWE analysis (mirror of /api/v1/analysis/cwe) |
GET /internal/v1/analysis/cwe-top25 | Full CWE Top 25 analysis |
GET /internal/v1/analysis/kev-cwe | Full KEV CWE analysis |
GET /internal/v1/analysis/owasp-top10 | Full OWASP Top 10 analysis |
GET /internal/v1/analysis/reference-threat-frequencies | Full, 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.
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-Match → 304 support.
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.
| Endpoint | Purpose |
|---|---|
GET /internal/v1/intel/entities | List 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.
?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.
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.
?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.0 → 1.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.
/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 token | Global Intel Pro | Global Intel Enterprise |
|---|---|---|
vulnerabilities | yes, capped 100 | yes, capped 500 |
licensing |
spdx_id, category, license_risk_score, license_policy | same |
exploitation | exploitation_tier + full flags/block_counts | + tte.{tte_hours,speed_pressure,ecosystem_cohort} |
malware | verdict + malware_signals + mitre | + chains + iocs + phx_neural_score |
campaign | compromised + per-campaign name/date/severity | + campaign_threat_actor, tags, repeat_offender (+ reason), incident_count_365d, days_since_last_incident, decay_phase |
advisory | unchanged — see the section above | unchanged — 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 name | Source name | Hazard |
|---|---|---|
malware.malware_signals | signals |
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_id | signal_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_ids | technique_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_counts | counts |
enrichment.circl_history.counts on the cve dataset — same inverse
hazard as license. |
exploitation.tte.tte_hours | hours |
Generic; renamed pre-emptively. |
campaign.campaigns[].campaign_threat_actor | threat_actor |
projections.from_full_cve emits threat_actor on the cve
dataset. |
provenance.intel_license | license |
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.
| Condition | Behaviour |
|---|---|
No include | Byte-identical to the pre-Plan-B response. |
| Unknown token | 400, detail naming the unknown token(s) and listing every valid token. |
enable_intel_query_surface off | 404 — 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 key | 403 {"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 exhausted | 429 {"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.
| Counter | Increments on |
|---|---|
intel_query_lookups | every billable call to /api/v1/intel/* or /internal/v1/intel/*, composed or not |
intel_expanded_lookups | additionally, 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:
block_sources — per-block source attribution.
vulnerabilities is attributed to library-intel, not
osv: its rows come from the precomputed artifact, whereas
deterministic.vulnerability.cve_ids in the same envelope is a live OSV read. Only
malware is ever live.block_freshness — "live" for a block genuinely served from a live MPI
read; the library_intelligence.json artifact's _metadata.generated_at for
anything precomputed, so a stale enrichment lane is visible instead of presented as live.block_errors — {block: reason} for any omitted block. A failed or
timed-out block is omitted with a reason, never returned empty (an empty block reads as
"we looked and found nothing" when the truth is "we could not look") and never fails the whole call.block_notes — {block: caveat}, present only when a block that
did land carries one. Today the sole entry is vulnerabilities, disclosing that it
is a precomputed snapshot which may diverge from deterministic.vulnerability.cve_ids. When
the two disagree, treat deterministic as the live answer and
expanded.vulnerabilities as a snapshot of the age given in
block_freshness.
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.
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.
POST /internal/v1/research — trigger research. Body: {"kind": "...", "target": "..."}. Returns 202.GET /internal/v1/research/{job_id} — get one job (org-scoped; another org's job reads as 404).GET /internal/v1/research — list this organisation's jobs (state, limit 1–200 default 50, offset).
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.
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/manifestLists 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.
tier — the caller's resolved export tier (base or premium; see
resolve_export_tier). Starts at base and is max-at-resolution — never
lowers, never persists a derived row. On the live request path only a
global_intel stamp lifts a caller to premium.
resolve_export_tier also reads a per-key premium export-tier stamp and the org's
mpi entitlement, but neither is reachable today: PAIKeyInfo has no
export_tier field and nothing sets one, and bulk_export_pai._caller_and_tier calls
the resolver without an entitlement_lookup. Both branches are unit-tested and dormant until a
caller supplies them — do not document them as live inputs.accessible — whether the CALLER's tier satisfies tier_required for that
dataset. A premium-only dataset stays listed for a base caller with
accessible: false rather than being hidden — hiding it would make the 403 on a snapshot
request (below) incomprehensible, and the dataset's existence is a published product, not a secret.snapshot — present only when accessible is true;
snapshot.url is always /internal/v1/export/{dataset}/snapshot.
snapshot.cursor — the same opaque, resumable position as the
X-Phoenix-Export-Cursor response header on a direct snapshot request for this
dataset+tier (kept in sync). This is what a consumer persists;
snapshot.watermark is informational only (R2 — never a wall-clock range key).snapshot.snapshot_id — human-readable label for this build
({dataset}-{tier}-{seq:06d}), matching X-Phoenix-Export-Snapshot-Id.
Convenient for logs/support, not a substitute for cursor.source_freshness — per-source freshness metadata for this dataset
(DatasetSpec.source_freshness()), most relevant for a dataset that merges multiple
upstream artifacts of differing age (e.g. exploitation_evidence).
library_intel is the first dataset to populate it, returning
{"generated_at": <library_intelligence.json's own _metadata.generated_at>};
every other dataset still returns {} — no serialiser has wired one yet
— since the field exists so each producer can populate it independently without
another manifest-shape change.503 (with Retry-After: 3600) when this export generation has not finished
building yet (manifest.json absent) — never a bare empty 200, which would read
as "no datasets exist."GET /internal/v1/export/{dataset}/snapshotStreams 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:
| Header | Meaning |
|---|---|
ETag | "<sha256>" of the compressed artifact; use with If-None-Match. See "Verifying integrity" below before hashing anything against it |
X-Phoenix-Export-Sha256-Uncompressed | sha256 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-Cursor | Opaque, 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-Id | Human-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-Watermark | ISO-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-Dataset | Echo of the requested dataset name |
X-Phoenix-Export-Tier | Tier the artifact was resolved at (base / premium) |
X-Phoenix-Export-Records | Row count in the artifact |
X-Phoenix-Export-Schema-Version | 1.0 |
Cache-Control | private, 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-Modified | The artifact file's on-disk mtime, RFC 1123-formatted (email.utils.formatdate) |
Content-Disposition | attachment; 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 body | Digest to compare against |
|---|---|
Normal HTTP client (requests, httpx, Go, curl --compressed) — the library transparently decompresses | X-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:
| Condition | Code | Rationale |
|---|---|---|
| Flag off | 404 | House rule — never leak feature existence, same as everywhere else in PAI |
No/invalid key, wrong scope (incl. phx_gintel_) | 401 / 403 | Standard 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 holds | 403 | Deliberate 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 tier | 503 + Retry-After: 3600 | Honest — not an empty 200, which would read as "dataset has no rows" |
| Second concurrent/rapid-reissue request for the same key | 429 | See concurrency guard below |
| Concurrency lease backend unreachable (cache outage) | 503 | Fails closed on this route only — see concurrency guard below for why |
| Match | 200 (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.
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).
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
| Endpoint | Auth | Purpose |
|---|---|---|
GET /internal/v1/admin/organizations | global-admin or org-manager | List 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 fields | Added 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/adopt | global-admin + enable_org_adopt_and_merge | Added 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}/merge | global-admin + enable_org_adopt_and_merge | Added 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/organizations | global-admin | Create org. Body: { slug, display_name, plan_tier, seat_limit }. 201. |
GET /internal/v1/admin/organizations/{org_id} | global-admin or org-manager | Get org. 404 when not found or out of scope. |
PATCH /internal/v1/admin/organizations/{org_id} | global-admin | Update 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}/license | global-admin | Set 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). |
| Endpoint | Auth | Purpose |
|---|---|---|
GET /internal/v1/admin/organizations/{org_id}/teams | global-admin or org-manager | List teams. Non-global admins see only teams in their scope. |
POST /internal/v1/admin/organizations/{org_id}/teams | global-admin | Create 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-manager | Get team. Caller must have the team in scope. |
POST /internal/v1/admin/organizations/{org_id}/teams/{team_id}/license | global-admin | Set team tier override + seat cap. Tier clamped to org tier. |
DELETE /internal/v1/admin/organizations/{org_id}/teams/{team_id} | global-admin | Delete team. 204. |
GET /internal/v1/admin/organizations/{org_id}/membersAuth: 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):
| Field | Type | Notes |
|---|---|---|
user_sub | string | Cognito/basic-auth subject — the membership key. |
account_id | integer | null | users.id, joined on cognito_sub. Prefer this over user_id. |
user_email | string | null | Account email. This is the login identity — the platform stores no separate username for an established user. |
user_name | string | null | users.name; frequently blank in existing data. |
user_id | integer | null | Legacy. The scf_tenant_members.user_id column, which is not reliably populated — NULL on rows whose users.id exists. Kept for compatibility only. |
role | string | member | viewer | team_admin | tenant_admin. |
team_id, team_name | uuid | string | null | The member's single resolved team. |
joined_at | timestamp | Membership 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}/membersAuth: Global admin or tenant_admin of that org.
Add an existing user (by user_sub) to an org.
| Field | Type | Required | Notes |
|---|---|---|---|
user_sub | str | yes | Cognito user sub |
user_id | int | no | Internal DB user ID |
role | str | no | Default member. Values: member, viewer, team_admin, tenant_admin |
team_id | uuid | no | Assign 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}/teamReassign a member to a team. Enterprise orgs only (403 ENTERPRISE_REQUIRED for non-enterprise).
Request body: { "team_id": uuid }
| Endpoint | Auth | Purpose |
|---|---|---|
GET /internal/v1/admin/organizations/{org_id}/invitations | global-admin or org-manager | List invitations. team_admin auto-scoped. Query param: team_id. |
POST /internal/v1/admin/organizations/{org_id}/invitations | global-admin or tenant_admin | Create invite. Org admins cannot create tenant_admin invites. |
DELETE /internal/v1/admin/organizations/{org_id}/invitations/{invite_id} | global-admin or tenant_admin | Revoke invite. 204. 404 when not found. |
POST /internal/v1/admin/organizations/{org_id}/users — Org-scoped user creationCreate 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.
| Field | Type | Required | Notes |
|---|---|---|---|
email | str (email) | yes | New user's email address |
full_name | str | yes | Display name |
role | str | yes | member, viewer, or team_admin. tenant_admin not permitted here. |
team_id | uuid | no | Assign to a team immediately |
industry | str | no | Default generic |
password | str | no | Temporary password; Cognito email invite sent regardless |
{ "user_sub": "cognito-sub-uuid", "role": "member", "org_id": "...", "team_id": null }
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_ROLE | role is not member, viewer, or team_admin |
| 400 | — | Password does not meet Cognito policy |
| 403 | TEAM_ADMIN_DIRECT_CREATE_FORBIDDEN | Caller is a team_admin |
| 403 | TENANT_ADMIN_ESCALATION | Caller is tenant_admin and attempted to assign tenant_admin role |
| 404 | — | org_id not found or out of caller scope |
| 409 | SEAT_LIMIT | Organization has no available seats |
| 409 | USER_ALREADY_EXISTS | Email already registered in Cognito |
| Endpoint | Purpose |
|---|---|
GET /internal/v1/admin/org-scope | Return 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-teams | Map user_sub -> {org, team} across all tenants. Global admin only. |
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/registrationsList 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/pendingConvenience 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}/approveApprove 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:
404 — no registration with that id.400 — registration is not pending (Cannot approve registration with status: <status>).502 — Cognito account provisioning failed. The registration is left pending and the call is safe to retry.Side effects:
approved and persists it via save_user_registration, which also writes the tenancy columns (tenant_id, requested_team_id, requested_role, intent) added by migration 102.users row, and clears the stored password.enable_approval_org_binding, default off): creates scf_tenants + a default team + tenant_admin/team_admin membership in one transaction and sets users.primary_tenant_id, so the org licence resolves. Every approved registrant gets their own org; a slug clash creates a numbered variant rather than joining an existing tenant. Binding is best-effort — a failure is reported in the response message and recorded on the registration, but never fails the approval. With the flag off, no tenancy rows are written.scf_seat_licensing_enabled is on. The licence key is the bound tenant's canonical org_key when binding succeeded and resolved one. When binding did not run or did not bind (flag off, no organization on the registration, or a binding failure), the key falls back to normalize_org_slug(registration.organization) — the same normalized form the tenancy tables use, never the raw form entry. Licensing is skipped entirely in two cases: the organization has no canonical slug (empty or reserved), or binding succeeded but the bound tenant's org_key could not be resolved — deriving one from the form entry there would licence a different key than the tenant actually uses.log_admin_action, and queues background notifications.PAI calculation endpoints apply neutral defaults when parameters are omitted:
null and excluded via renormalization.unknown status.